geoip.ts122 lines · main
1import { existsSync } from 'node:fs';
2
3import { open, type CityResponse, type Reader } from 'maxmind';
4
5import { env } from '../env.js';
6import { log } from './logger.js';
7
8export interface GeoLookup {
9 city: string | null;
10 region: string | null;
11 country: string | null;
12 /** MaxMind location centroid — used for nearest-city fallback when city is null. */
13 latitude: number | null;
14 longitude: number | null;
15 /** Approximate accuracy radius in km (MaxMind). */
16 accuracyRadiusKm: number | null;
17}
18
19let readerPromise: Promise<Reader<CityResponse> | null> | null = null;
20
21/** Common install locations when BRIVEN_GEOIP_DB_PATH is unset. */
22const GEOIP_CANDIDATES = [
23 '/var/lib/GeoIP/GeoLite2-City.mmdb',
24 '/usr/share/GeoIP/GeoLite2-City.mmdb',
25 '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
26 '/data/geoip/GeoLite2-City.mmdb',
27 '/app/data/GeoLite2-City.mmdb',
28];
29
30function resolveGeoipPath(): string | null {
31 if (env.BRIVEN_GEOIP_DB_PATH?.trim()) return env.BRIVEN_GEOIP_DB_PATH.trim();
32 for (const p of GEOIP_CANDIDATES) {
33 if (existsSync(p)) return p;
34 }
35 return null;
36}
37
38async function getReader(): Promise<Reader<CityResponse> | null> {
39 if (!readerPromise) {
40 const path = resolveGeoipPath();
41 if (!path) {
42 readerPromise = Promise.resolve(null);
43 log.warn('geoip_db_missing', {
44 message:
45 'No GeoLite2-City.mmdb — set BRIVEN_GEOIP_DB_PATH so Auth emails can show city/country',
46 });
47 return null;
48 }
49 readerPromise = open<CityResponse>(path)
50 .then((r) => {
51 log.info('geoip_db_open_ok', { path });
52 return r;
53 })
54 .catch((err: unknown) => {
55 log.warn('geoip_db_open_failed', {
56 path,
57 message: err instanceof Error ? err.message : String(err),
58 });
59 return null;
60 });
61 }
62 return readerPromise;
63}
64
65function isPrivateIp(ip: string): boolean {
66 if (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') return true;
67 if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
68 if (ip.startsWith('169.254.') || ip.startsWith('fc') || ip.startsWith('fe80:')) return true;
69 if (ip.startsWith('172.')) {
70 const second = Number(ip.split('.')[1]);
71 if (second >= 16 && second <= 31) return true;
72 }
73 return false;
74}
75
76// SELF-HOSTED ONLY (flndrn decision, 2026-07-05): geo lookups NEVER leave this
77// server. There is deliberately no third-party fallback (the old ip-api.com HTTP
78// call was removed) — an IP that the local GeoLite2 DB can't resolve returns null
79// and the caller records the raw IP with geo left blank ("pending") until the
80// GeoLite2-City .mmdb file is installed at BRIVEN_GEOIP_DB_PATH.
81export async function lookupIp(ip: string | null | undefined): Promise<GeoLookup | null> {
82 if (!ip) return null;
83 if (isPrivateIp(ip)) return null;
84 const reader = await getReader();
85 if (!reader) return null;
86 try {
87 const response = reader.get(ip);
88 if (response) {
89 const city = response.city?.names?.en ?? null;
90 const region = response.subdivisions?.[0]?.names?.en ?? null;
91 const country =
92 response.country?.names?.en ??
93 response.registered_country?.names?.en ??
94 null;
95 const latitude =
96 typeof response.location?.latitude === 'number'
97 ? response.location.latitude
98 : null;
99 const longitude =
100 typeof response.location?.longitude === 'number'
101 ? response.location.longitude
102 : null;
103 const accuracyRadiusKm =
104 typeof response.location?.accuracy_radius === 'number'
105 ? response.location.accuracy_radius
106 : null;
107 if (city || region || country || (latitude != null && longitude != null)) {
108 return {
109 city,
110 region,
111 country,
112 latitude,
113 longitude,
114 accuracyRadiusKm,
115 };
116 }
117 }
118 } catch {
119 // Local MaxMind DB read failure — return null; caller stores the raw IP only.
120 }
121 return null;
122}