Merge pull request #388 from MBombeck/release/v1.25.6

Release v1.25.6 — optional HTTP geolocation provider
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-29 20:46:42 +02:00
commit 34caa1d910
8 files changed
+88 -24

No files matched your search

+13 -6
View File
@@ -223,13 +223,20 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# IP geolocation lookup -- optional
# -----------------------------------------------------------------------------
# The admin login/audit overview enriches auth events with a coarse
# "City, CC" location + carrier. The default online provider is ip-api.com.
# Override the base URL to point at a different provider; the response must
# match either the ipwho.is shape (`success` + `country_code`) or the
# ip-api.com shape (`status` + `countryCode`). HTTPS only -- a non-HTTPS URL
# is refused (audit-event IPs must never egress over plaintext HTTP). The
# "City, CC" location + carrier. The default online provider is ipwho.is
# (free, no key, HTTPS). Override the base URL to point at a different
# provider; the response must match either the ipwho.is shape (`success` +
# `country_code`) or the ip-api.com shape (`status` + `countryCode`). The
# `/<ip>` segment is appended automatically.
# IP_GEO_LOOKUP_URL="https://ip-api.com/json"
# IP_GEO_LOOKUP_URL="https://ipwho.is"
#
# By default a non-HTTPS URL is refused (audit-event IPs must never egress over
# plaintext HTTP). The free ip-api.com endpoint is HTTP-only (its HTTPS form
# needs a paid key) but is often more accurate. To use it, set BOTH the HTTP
# URL and the explicit opt-in below -- the looked-up IP then travels in clear
# over your server's own egress; this is your call to make.
# IP_GEO_LOOKUP_URL="http://ip-api.com/json"
# IP_GEO_ALLOW_INSECURE="true"
#
# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
## [1.25.6] — 2026-06-29 — Optional HTTP geolocation provider
A focused configuration release. No schema change.
### Added
- A self-hoster can now opt into a plain-HTTP IP-location provider by setting `IP_GEO_ALLOW_INSECURE=true` alongside an `http://` `IP_GEO_LOOKUP_URL`. This makes the free ip-api.com endpoint usable (its HTTPS form needs a paid key, but its geolocation is often more accurate, and it includes the carrier). The default stays HTTPS-only with ipwho.is — plain HTTP is refused unless the operator explicitly opts in, since the looked-up IP then travels unencrypted over the server's own egress.
## [1.25.5] — 2026-06-29 — Mental-wellbeing polish, location lookup
A focused polish release. No schema change.
+5 -2
View File
@@ -84,13 +84,16 @@ services:
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# IP_GEO_LOOKUP_URL overrides the default ip-api.com provider (HTTPS
# only); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
# free ip-api.com endpoint) — off by default, an explicit operator choice.
# Listed here so operator values in .env reach the container (the
# `environment:` block is a whitelist — vars not listed never propagate).
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.25.5
version: 1.25.6
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.25.5",
"version": "1.25.6",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.25.5";
/* @sw-version-fallback */ "v1.25.6";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+34
View File
@@ -34,6 +34,7 @@ beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_URL;
delete process.env.IP_GEO_LOOKUP_DISABLED;
delete process.env.IP_GEO_ALLOW_INSECURE;
addWarningSpy.mockClear();
vi.resetModules();
});
@@ -122,6 +123,39 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
expect(url.startsWith("https://")).toBe(true);
});
it("calls a plain-HTTP provider only when IP_GEO_ALLOW_INSECURE=true", async () => {
// v1.25.6 — a self-hoster who explicitly opts in can use the free
// HTTP-only ip-api.com endpoint; the helper then issues the HTTP request
// verbatim and parses its `status`/`countryCode` shape.
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
process.env.IP_GEO_ALLOW_INSECURE = "true";
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ status: "success", city: "Bochum", countryCode: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Bochum, DE");
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("http://ip-api.com/json/8.8.8.8");
});
it("still refuses plain HTTP when the opt-in is any value other than 'true'", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
process.env.IP_GEO_ALLOW_INSECURE = "1"; // not the literal "true"
const fetchSpy = vi
.fn()
.mockResolvedValue(jsonOk({ status: "success", countryCode: "DE" }));
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url.startsWith("https://")).toBe(true);
});
it("accepts the ip-api pro response shape (status:'success', countryCode)", async () => {
process.env.IP_GEO_LOOKUP_URL = "https://pro.ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue(
+25 -13
View File
@@ -30,14 +30,15 @@
* skip the offline tier — local dev without the MMDBs still works and
* falls straight back to the online provider.
*
* Default provider for the online lookup is ip-api.com (HTTPS JSON
* endpoint). ipwho.is was the v1.18.10 default but rejects datacentre /
* free-plan server egress with a 403, so it never resolved on prod. Both
* the ipwho.is shape (`success`/`country_code`) and the ip-api.com shape
* Default provider for the online lookup is ipwho.is (free, no key, HTTPS).
* Both the ipwho.is shape (`success`/`country_code`) and the ip-api.com shape
* (`status`/`countryCode`) are accepted, so swapping providers via
* `IP_GEO_LOOKUP_URL` only requires matching one of those response shapes.
* A non-ok HTTP status (403/429/5xx) is surfaced on the wide event rather
* than swallowed, so a future provider rejection is visible.
* The URL must be HTTPS by default; a self-hoster can opt into a plain-HTTP
* provider (e.g. the free, HTTP-only, often-more-accurate ip-api.com endpoint)
* with `IP_GEO_ALLOW_INSECURE=true` — see `buildLookupUrl`. A non-ok HTTP
* status (403/429/5xx) is surfaced on the wide event rather than swallowed,
* so a future provider rejection is visible.
*
* Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online lookup
* entirely — used by deployments that do not want any IP egress to a
@@ -300,14 +301,25 @@ function buildLookupUrl(ip: string): string {
/\/+$/,
"",
);
if (!base.startsWith("https://")) {
// V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any
// configuration that would do so by upgrading to https; if the operator
// has explicitly opted into HTTP via env, we still refuse and return a
// dummy URL the parser will fail on.
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
if (base.startsWith("https://")) {
return `${base}/${encodeURIComponent(ip)}`;
}
return `${base}/${encodeURIComponent(ip)}`;
// v1.25.6 — plain HTTP is refused BY DEFAULT (V3 audit: never leak the
// looked-up IP over an unencrypted hop). A self-hoster who deliberately
// wants a free HTTP-only provider — e.g. the free ip-api.com endpoint,
// whose HTTPS form needs a paid key but whose geolocation is often more
// accurate — opts in explicitly with `IP_GEO_ALLOW_INSECURE=true`. The
// trade-off (the IP travels in clear over the server's own egress) is the
// operator's to make; the default stays HTTPS-only for everyone else.
if (
base.startsWith("http://") &&
process.env.IP_GEO_ALLOW_INSECURE === "true"
) {
return `${base}/${encodeURIComponent(ip)}`;
}
// Any other scheme — or HTTP without the explicit opt-in — is refused with a
// dummy URL the parser fails on (a clean miss, never a plaintext request).
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
}
/**