feat(geo): make ipwho.is the default IP-location provider again

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 20:00:53 +02:00
1 parent 2500dbeb1a
commit abb4c71d0c
2 files changed
+17 -16

No files matched your search

+7 -6
View File
@@ -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 () => {
+10 -10
View File
@@ -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 `/<ip>` so the wire
// URL is `https://ip-api.com/json/<ip>`. 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 `/<ip>` so the wire URL is `https://ipwho.is/<ip>`.
// 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";