mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Maintainer-voice sweep across `src/`. ~190 references to the maintainer by first name in code comments and JSDoc neutralised to "the maintainer", "the live tenant", "users", or "the user" depending on context. Three sed passes covering ~50 distinct patterns (`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.). Test fixtures (`displayName: "Marc B."`, `username: "marc"`, `<HeroStrip userName="Marc">`) kept as opaque test data per the brief. CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a German bullet with its English translation by historical design — the in-app copy is English-default and the user docs are English, so the mixed-language style was a holdover from the early-marathon notes. A one-off node script extracted the `_..._` English paragraph from each bilingual bullet and rewrote the entry as that text. ~44 bullets reduced; one Docs bullet that lost a leading word in the regex fixed by hand. CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md` stays in place for multi-agent compatibility (the conventions doc is shared with Codex / Cursor / etc.). Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete; v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20 items the marathon landed; `.planning/phase-W4-report.md` documents the per-commit decisions, test deltas, and the 23 items deferred to v1.4.23 with reasoning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
/**
|
|
* IP geolocation lookup for audit-log enrichment.
|
|
*
|
|
* Default provider is ipwho.is (HTTPS, free, no key). Both the response
|
|
* shape from ipwho.is and the fallback ip-api.com pro endpoint are
|
|
* accepted, so swapping providers via IP_GEO_LOOKUP_URL only requires
|
|
* matching one of those response shapes.
|
|
*
|
|
* Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by
|
|
* deployments that do not want any IP egress to a third-party service
|
|
* (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress).
|
|
*
|
|
* v1.4.16 A8a: the body is decoded as UTF-8 explicitly via
|
|
* `TextDecoder('utf-8')` instead of `Response.json()`, and an
|
|
* `Accept-Language: de, en;q=0.5` hint is sent so providers return
|
|
* native city names ("Nürnberg") rather than ASCII folds
|
|
* ("Nuremberg"). The previous path lost umlauts in production for at
|
|
* least one maintainer-flagged login row that rendered as "Nrnberg" in
|
|
* /admin/login-overview — see `docs/audit/v1416-summary.md` once cut.
|
|
*/
|
|
|
|
interface IpwhoIsResponse {
|
|
success?: boolean;
|
|
city?: string;
|
|
country_code?: string;
|
|
}
|
|
|
|
interface IpApiProResponse {
|
|
status?: "success" | "fail";
|
|
city?: string;
|
|
countryCode?: string;
|
|
}
|
|
|
|
type GeoResponse = IpwhoIsResponse & IpApiProResponse;
|
|
|
|
const PRIVATE_IP =
|
|
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
|
|
|
|
const DEFAULT_GEO_URL = "https://ipwho.is";
|
|
|
|
function buildLookupUrl(ip: string): string {
|
|
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
|
|
/\/+$/,
|
|
"",
|
|
);
|
|
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)}`;
|
|
}
|
|
return `${base}/${encodeURIComponent(ip)}`;
|
|
}
|
|
|
|
/**
|
|
* Decode the raw response bytes as UTF-8, then JSON-parse. Bypasses
|
|
* `Response.json()` so the result is independent of the upstream
|
|
* `Content-Type: application/json; charset=…` value — relevant in
|
|
* production where ipwho.is sits behind Cloudflare and an intermediate
|
|
* proxy can re-serve the body without preserving the charset hint.
|
|
* Falls back to whatever decoding errors `TextDecoder` produces (which
|
|
* substitute U+FFFD rather than dropping bytes), so a malformed
|
|
* response surfaces as a parse failure instead of silent character
|
|
* loss.
|
|
*/
|
|
async function readUtf8Json(res: Response): Promise<unknown> {
|
|
const buf = await res.arrayBuffer();
|
|
const text = new TextDecoder("utf-8").decode(buf);
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
export async function lookupIpLocation(
|
|
ip: string | null,
|
|
): Promise<string | null> {
|
|
if (!ip || PRIVATE_IP.test(ip)) return null;
|
|
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
|
|
|
|
try {
|
|
const res = await fetch(buildLookupUrl(ip), {
|
|
signal: AbortSignal.timeout(3000),
|
|
headers: {
|
|
// ipwho.is and ip-api both honour Accept-Language for the
|
|
// city field. Without the hint, ip-api falls back to the
|
|
// English ASCII fold ("Nuremberg" instead of "Nürnberg").
|
|
// German first because the user-base is DACH-skewed; English
|
|
// as a graceful fallback for cities that don't have a German
|
|
// name. Quality 0.5 on the en fallback keeps providers from
|
|
// tie-breaking the wrong way.
|
|
"Accept-Language": "de, en;q=0.5",
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
if (!res.ok) return null;
|
|
|
|
const data = (await readUtf8Json(res)) as GeoResponse;
|
|
const ok =
|
|
data.success === true ||
|
|
data.status === "success" ||
|
|
(data.city && (data.country_code ?? data.countryCode));
|
|
if (!ok) return null;
|
|
|
|
const city = data.city;
|
|
const country = data.country_code ?? data.countryCode;
|
|
if (!city || !country) return null;
|
|
|
|
return `${city}, ${country}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|