mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
6fe552ffdf
commit
45ca6ff24b
13 files changed
+98
-18
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user