From abb4c71d0c029d8c39e7da0225cc014308c1280f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 20:00:53 +0200 Subject: [PATCH] feat(geo): make ipwho.is the default IP-location provider again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ipwho.is is free, needs no key, and resolves a city/country for the login overview out of the box. It is the default online lookup again; the offline GeoLite2 MMDB tier stays strictly optional (consulted only on an online miss, skipped when the databases are absent). Operators whose egress ipwho.is rejects can point IP_GEO_LOOKUP_URL at ip-api.com — the parser accepts both response shapes, so the swap needs no code change. --- src/lib/__tests__/geo.test.ts | 13 +++++++------ src/lib/geo.ts | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index 63af5bb27..172b2a799 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -84,14 +84,15 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { ); } - it("uses the ip-api.com HTTPS endpoint by default", async () => { - // v1.18.11 (W3): default provider is ip-api.com (ipwho.is 403s on - // datacentre egress). The wire URL must be the ip-api.com JSON path - // and the parser must accept its `status`/`countryCode` shape. + it("uses the ipwho.is HTTPS endpoint by default", async () => { + // v1.25.5: ipwho.is is the default provider again. The wire URL must be + // the ipwho.is path and the parser must accept its `success`/`country_code` + // shape. (Operators whose egress ipwho.is rejects override IP_GEO_LOOKUP_URL + // to ip-api.com — the parser still accepts that shape; covered below.) const fetchSpy = vi .fn() .mockResolvedValue( - jsonOk({ status: "success", city: "Berlin", countryCode: "DE" }), + jsonOk({ success: true, city: "Berlin", country_code: "DE" }), ); vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); @@ -99,7 +100,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE"); const url = fetchSpy.mock.calls[0]?.[0] as string; expect(url.startsWith("https://")).toBe(true); - expect(url).toBe("https://ip-api.com/json/8.8.8.8"); + expect(url).toBe("https://ipwho.is/8.8.8.8"); }); it("refuses to call non-HTTPS configured providers", async () => { diff --git a/src/lib/geo.ts b/src/lib/geo.ts index c3bfc95a9..1f15b8320 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -90,16 +90,16 @@ 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)/; -// v1.18.11 (W3) — the default online provider is ip-api.com, not ipwho.is. -// ipwho.is rejects datacentre / free-plan server egress with a 403 -// ("CORS is not supported on the Free plan"), which silently produced the -// "—" location on prod (every server-side lookup failed). The base is -// `https://ip-api.com/json`; `buildLookupUrl` appends `/` so the wire -// URL is `https://ip-api.com/json/`. The parser already accepts the -// ip-api.com shape (`status:"success"` + `countryCode`). Operators who need -// a different / keyed provider override via `IP_GEO_LOOKUP_URL`; the HTTPS -// egress guard in `buildLookupUrl` still applies to any override. -const DEFAULT_GEO_URL = "https://ip-api.com/json"; +// v1.25.5 — ipwho.is is the default online provider again (free, no key, no +// per-minute cap on the standard endpoint). The base is `https://ipwho.is`; +// `buildLookupUrl` appends `/` so the wire URL is `https://ipwho.is/`. +// The parser accepts BOTH the ipwho.is shape (`success` + `country_code`) and +// the ip-api.com shape (`status` + `countryCode`), so operators whose egress +// ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json` +// (or any keyed provider matching one of those shapes) with no code change. +// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the +// online lookup misses, and skipped entirely when the databases are absent. +const DEFAULT_GEO_URL = "https://ipwho.is"; function geoLiteDir(): string { return process.env.GEOLITE2_DIR ?? "/opt/geolite2";