diff --git a/messages/de.json b/messages/de.json index 81fd5f333..413ecb79f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 288535375..c2bf9e148 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4433313fa..530ef26a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 07dcf3613..503f55f6e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8722951d1..850acef23 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 1b3521565..71e383c3c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index d9138e901..ec988a145 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -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)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.geoProviderHost).toBe("ip-api.com"); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index a69981a7d..01177527e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -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, }); }); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 8f20921a2..52bc60302 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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 diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 3d7302146..e14543a84 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -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 diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 2a3e53a53..72583f105 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -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 diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index cf1b8c2f4..9ae5c8b58 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -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"); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 1a059bb9b..2896b8d62 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -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"; }