geo-country.ts89 lines · main
1import { headers } from 'next/headers';
2import ip3country from 'ip3country';
3
4/**
5 * Self-hosted IP → country lookup for the public /contact page.
6 *
7 * Uses ip3country — a tiny (<300KB), zero-dependency, embedded IP2Location
8 * LITE table. Everything happens in-process: no external API call, no
9 * account, no per-request network hop, so it can run inside a server
10 * component on every render without leaking the visitor's IP to a third
11 * party.
12 *
13 * The detected country drives the LOCKED "country" field on the contact
14 * form — it is shown read-only and submitted alongside the message so an
15 * operator can see where a request came from. It is a hint, never a hard
16 * gate: when the IP can't be resolved (localhost, IPv6, private ranges,
17 * unknown blocks) the field falls back to "unknown" and stays locked.
18 */
19
20export interface DetectedCountry {
21 /** ISO 3166-1 alpha-2 country code, e.g. "US". */
22 code: string;
23 /** Human-readable English country name, e.g. "United States". */
24 name: string;
25}
26
27// init() builds the lookup tables and is relatively CPU-heavy, so do it
28// once per process and reuse it across requests.
29let initialized = false;
30function ensureInit(): void {
31 if (!initialized) {
32 ip3country.init();
33 initialized = true;
34 }
35}
36
37/** First, left-most IP in a comma-separated `x-forwarded-for` chain. */
38function firstForwardedIp(value: string | null): string | null {
39 if (!value) return null;
40 const first = value.split(',')[0]?.trim();
41 return first && first.length > 0 ? first : null;
42}
43
44// ip3country resolves IPv4 dot-decimal addresses; anything else (IPv6,
45// garbage) is skipped so we never surface a wrong country.
46function isIpv4(ip: string): boolean {
47 return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip);
48}
49
50/** Map an ISO 3166-1 alpha-2 code to an English display name. */
51function codeToName(code: string): string {
52 try {
53 const display = new Intl.DisplayNames(['en'], { type: 'region' });
54 return display.of(code) ?? code;
55 } catch {
56 return code;
57 }
58}
59
60/**
61 * Resolve the current request's visitor country from request headers.
62 * Reads the FIRST ip in `x-forwarded-for` (Traefik forwards the real
63 * client there), falling back to `x-real-ip`. Returns null when the
64 * country can't be determined (localhost, IPv6, unknown block) — the
65 * caller then renders a locked "unknown" field.
66 *
67 * Server-only: relies on `next/headers`.
68 */
69export async function detectCountry(): Promise<DetectedCountry | null> {
70 let ip: string | null = null;
71 try {
72 const h = await headers();
73 ip = firstForwardedIp(h.get('x-forwarded-for')) ?? firstForwardedIp(h.get('x-real-ip'));
74 } catch {
75 return null;
76 }
77 if (!ip || !isIpv4(ip)) return null;
78
79 ensureInit();
80 let code: string | null = null;
81 try {
82 code = ip3country.lookupStr(ip);
83 } catch {
84 return null;
85 }
86 if (!code) return null;
87
88 return { code, name: codeToName(code) };
89}