fix(admin): name the configured geo provider in the system snapshot

The admin system snapshot hardcoded "ipwho.is" for the offline-geo-off
state, so an operator who points IP_GEO_LOOKUP_URL at another provider
still read the wrong host. Resolve the actual provider host from the env
and surface it through /api/version, so the snapshot reads
"Online (ip-api.com)" — the host it really queries.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 18:08:57 +02:00
1 parent 6fe552ffdf
commit 45ca6ff24b
13 files changed
+98 -18

No files matched your search

+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geodaten",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Fallback (ipwho.is) — MAXMIND_LICENSE_KEY in CI setzen",
"offlineGeoFallback": "Online ({host}) — MAXMIND_LICENSE_KEY in CI setzen",
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
"umamiTitle": "Umami",
"umamiDescription": "Web-Analyse mit Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Gebaut am",
"snapshotOfflineGeo": "Geodaten",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
"snapshotOfflineGeoOff": "Online ({host})",
"versionTileTitle": "Version",
"versionTileVersion": "Laufende Version",
"versionTileBuildSha": "Build-SHA",
+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geo data",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Fallback (ipwho.is) — set MAXMIND_LICENSE_KEY in CI",
"offlineGeoFallback": "Online ({host}) — set MAXMIND_LICENSE_KEY in CI",
"apiGlobal": "Allow API endpoints and API tokens.",
"umamiTitle": "Umami",
"umamiDescription": "Web analytics with Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Built at",
"snapshotOfflineGeo": "Geo data",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
"snapshotOfflineGeoOff": "Online ({host})",
"versionTileTitle": "Version",
"versionTileVersion": "Running version",
"versionTileBuildSha": "Build SHA",
+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Datos geográficos",
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
"offlineGeoFallback": "Respaldo (ipwho.is) — configura MAXMIND_LICENSE_KEY en CI",
"offlineGeoFallback": "En línea ({host}) — configura MAXMIND_LICENSE_KEY en CI",
"apiGlobal": "Permitir endpoints de API y tokens de API.",
"umamiTitle": "Umami",
"umamiDescription": "Analítica web con Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Compilado el",
"snapshotOfflineGeo": "Datos geográficos",
"snapshotOfflineGeoOn": "Sin conexión (GeoLite2)",
"snapshotOfflineGeoOff": "Respaldo (ipwho.is)",
"snapshotOfflineGeoOff": "En línea ({host})",
"versionTileTitle": "Versión",
"versionTileVersion": "Versión en ejecución",
"versionTileBuildSha": "SHA de compilación",
+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Données géographiques",
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
"offlineGeoFallback": "Fallback (ipwho.is) — définissez MAXMIND_LICENSE_KEY dans la CI",
"offlineGeoFallback": "En ligne ({host}) — définissez MAXMIND_LICENSE_KEY dans la CI",
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
"umamiTitle": "Umami",
"umamiDescription": "Analyse web avec Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Compilé le",
"snapshotOfflineGeo": "Données géographiques",
"snapshotOfflineGeoOn": "Hors ligne (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
"snapshotOfflineGeoOff": "En ligne ({host})",
"versionTileTitle": "Version",
"versionTileVersion": "Version en cours",
"versionTileBuildSha": "SHA de build",
+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dati geografici",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Fallback (ipwho.is) — imposta MAXMIND_LICENSE_KEY in CI",
"offlineGeoFallback": "Online ({host}) — imposta MAXMIND_LICENSE_KEY in CI",
"apiGlobal": "Consentire endpoint API e token API.",
"umamiTitle": "Umami",
"umamiDescription": "Analisi web con Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Compilato il",
"snapshotOfflineGeo": "Dati geografici",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
"snapshotOfflineGeoOff": "Online ({host})",
"versionTileTitle": "Versione",
"versionTileVersion": "Versione in esecuzione",
"versionTileBuildSha": "SHA build",
+2 -2
View File
@@ -5437,7 +5437,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dane geograficzne",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Awaryjne (ipwho.is) — ustaw MAXMIND_LICENSE_KEY w CI",
"offlineGeoFallback": "Online ({host}) — ustaw MAXMIND_LICENSE_KEY w CI",
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
"umamiTitle": "Umami",
"umamiDescription": "Analityka internetowa z Umami",
@@ -5673,7 +5673,7 @@
"snapshotBuiltAt": "Skompilowano",
"snapshotOfflineGeo": "Dane geograficzne",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Awaryjne (ipwho.is)",
"snapshotOfflineGeoOff": "Online ({host})",
"versionTileTitle": "Wersja",
"versionTileVersion": "Uruchomiona wersja",
"versionTileBuildSha": "SHA kompilacji",
@@ -5,8 +5,10 @@ vi.mock("@/lib/api-handler", () => ({
}));
const offlineGeoReadyMock = vi.fn(() => false);
const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is");
vi.mock("@/lib/geo", () => ({
offlineGeoReady: () => offlineGeoReadyMock(),
resolveGeoProviderHost: () => resolveGeoProviderHostMock(),
}));
import { GET } from "../route";
@@ -16,6 +18,7 @@ beforeEach(() => {
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
delete process.env.NEXT_PUBLIC_APP_VERSION;
offlineGeoReadyMock.mockReturnValue(false);
resolveGeoProviderHostMock.mockReturnValue("ipwho.is");
});
interface VersionEnvelope {
@@ -28,6 +31,7 @@ interface VersionEnvelope {
changelog: string;
docs: string;
offlineGeoEnabled: boolean;
geoProviderHost: string;
};
}
@@ -95,4 +99,11 @@ describe("GET /api/version", () => {
const body = (await response.json()) as VersionEnvelope;
expect(body.data.offlineGeoEnabled).toBe(true);
});
it("surfaces the resolved online geo provider host", async () => {
resolveGeoProviderHostMock.mockReturnValue("ip-api.com");
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.geoProviderHost).toBe("ip-api.com");
});
});
+8 -4
View File
@@ -1,6 +1,6 @@
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { offlineGeoReady } from "@/lib/geo";
import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo";
import packageJson from "../../../../package.json";
// v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo
@@ -32,9 +32,11 @@ export const dynamic = "force-dynamic";
*
* `offlineGeoEnabled` is `true` when the GeoLite2-City MMDB is present
* at the configured `GEOLITE2_DIR` and no `.empty` marker is set.
* When `false` the runtime falls back to the online `ipwho.is`
* provider and the resolver emits a one-shot admin notification on
* first use.
* When `false` the runtime falls back to the online provider named by
* `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default
* `ipwho.is`) and the resolver emits a one-shot admin notification on
* first use. `geoProviderHost` lets the admin status surface name the
* real provider instead of assuming the default.
*/
export const GET = apiHandler(async () => {
// v1.4.43 B11 — prefer the build-arg-injected env var so the runtime
@@ -50,6 +52,7 @@ export const GET = apiHandler(async () => {
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
const offlineGeoEnabled = offlineGeoReady();
const geoProviderHost = resolveGeoProviderHost();
return apiSuccess({
version,
@@ -60,5 +63,6 @@ export const GET = apiHandler(async () => {
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
docs: "https://docs.healthlog.dev",
offlineGeoEnabled,
geoProviderHost,
});
});
+4
View File
@@ -500,6 +500,10 @@ export interface PublicVersion {
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
// Host of the online geo provider the runtime falls back to when the
// offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is).
// Optional so legacy /api/version responses still satisfy the shape.
geoProviderHost?: string;
}
/** Shared `/api/version` reader — the system-status section + overview
@@ -169,7 +169,9 @@ export function SystemStatusSection() {
value={
version.offlineGeoEnabled
? t("admin.offlineGeoEnabled")
: t("admin.offlineGeoFallback")
: t("admin.offlineGeoFallback", {
host: version.geoProviderHost ?? "ipwho.is",
})
}
className={
version.offlineGeoEnabled
@@ -106,7 +106,9 @@ export function SystemStatusSummary() {
value={
version.offlineGeoEnabled
? t("admin.overview.snapshotOfflineGeoOn")
: t("admin.overview.snapshotOfflineGeoOff")
: t("admin.overview.snapshotOfflineGeoOff", {
host: version.geoProviderHost ?? "ipwho.is",
})
}
className={
version.offlineGeoEnabled
+31
View File
@@ -425,3 +425,34 @@ describe("lookupIpGeo carrier resolution (v1.25.8)", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe("resolveGeoProviderHost (task #162)", () => {
it("returns the default ipwho.is host when IP_GEO_LOOKUP_URL is unset", async () => {
const { resolveGeoProviderHost } = await import("../geo");
expect(resolveGeoProviderHost()).toBe("ipwho.is");
});
it("returns the configured provider host", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
const { resolveGeoProviderHost } = await import("../geo");
expect(resolveGeoProviderHost()).toBe("ip-api.com");
});
it("preserves an explicit port in the host", async () => {
process.env.IP_GEO_LOOKUP_URL = "https://geo.example.com:8443/lookup";
const { resolveGeoProviderHost } = await import("../geo");
expect(resolveGeoProviderHost()).toBe("geo.example.com:8443");
});
it("falls back to the default host for a blank env value", async () => {
process.env.IP_GEO_LOOKUP_URL = " ";
const { resolveGeoProviderHost } = await import("../geo");
expect(resolveGeoProviderHost()).toBe("ipwho.is");
});
it("falls back to the default host for an unparseable env value", async () => {
process.env.IP_GEO_LOOKUP_URL = "not a url";
const { resolveGeoProviderHost } = await import("../geo");
expect(resolveGeoProviderHost()).toBe("ipwho.is");
});
});
+26
View File
@@ -121,6 +121,32 @@ const PRIVATE_IP =
// online lookup misses, and skipped entirely when the databases are absent.
const DEFAULT_GEO_URL = "https://ipwho.is";
// The host portion of DEFAULT_GEO_URL — the provider name the admin status
// surface shows when the offline tier is absent and the runtime resolves
// locations online.
const DEFAULT_GEO_HOST = "ipwho.is";
/**
* Resolve the online geo provider HOST the runtime actually queries.
*
* Reads `IP_GEO_LOOKUP_URL` and returns its hostname (e.g. `ip-api.com`),
* falling back to the default `ipwho.is` when the env is unset, blank, or
* not a parseable URL. Pure and safe — never throws.
*
* `/api/version` surfaces this so the admin System-Snapshot can name the
* real provider instead of a hardcoded "ipwho.is": an operator who points
* `IP_GEO_LOOKUP_URL` at another provider sees that provider's host.
*/
export function resolveGeoProviderHost(): string {
const raw = process.env.IP_GEO_LOOKUP_URL?.trim();
if (!raw) return DEFAULT_GEO_HOST;
try {
return new URL(raw).host || DEFAULT_GEO_HOST;
} catch {
return DEFAULT_GEO_HOST;
}
}
function geoLiteDir(): string {
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
}