mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #388 from MBombeck/release/v1.25.6
Release v1.25.6 — optional HTTP geolocation provider
This commit is contained in:
8 files changed
+88
-24
No files matched your search
+13
-6
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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,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
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user