/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting'; import {isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress'; import maxmind, {type CityResponse, type Reader} from 'maxmind'; export const UNKNOWN_LOCATION = 'Unknown Location'; export interface GeoipResult { countryCode: string | null; normalizedIp: string | null; city: string | null; region: string | null; countryName: string | null; } type CacheEntry = { result: GeoipResult; expiresAt: number; }; const CACHE_TTL_MS = 10 * 60 * 1000; const geoipCache = new Map(); let maxmindReader: Reader | null = null; let maxmindReaderPromise: Promise> | null = null; function buildFallbackResult(normalizedIp: string): GeoipResult { return { countryCode: null, normalizedIp: normalizedIp || null, city: null, region: null, countryName: null, }; } async function ensureReader(dbPath: string): Promise> { if (maxmindReader) return maxmindReader; if (!maxmindReaderPromise) { maxmindReaderPromise = maxmind .open(dbPath) .then((reader) => { maxmindReader = reader; return reader; }) .catch((error) => { maxmindReaderPromise = null; throw error; }); } return maxmindReaderPromise; } function stateLabel(record?: CityResponse): string | null { const subdivision = record?.subdivisions?.[0]; if (!subdivision) return null; return subdivision.names?.en || subdivision.iso_code || null; } function countryDisplayName(code: string, locale = 'en'): string | null { if (!isAsciiUpperAlpha2(code)) return null; return getRegionDisplayName(code, {locale}) ?? null; } function isAsciiUpperAlpha2(value: string): boolean { return ( value.length === 2 && value.charCodeAt(0) >= 65 && value.charCodeAt(0) <= 90 && value.charCodeAt(1) >= 65 && value.charCodeAt(1) <= 90 ); } async function lookupMaxmind(clean: string, dbPath: string): Promise { try { const reader = await ensureReader(dbPath); const record = reader.get(clean); if (!record) return buildFallbackResult(clean); const isoCode = record.country?.iso_code; const countryCode = isoCode ? isoCode.toUpperCase() : null; return { countryCode, normalizedIp: clean, city: record.city?.names?.en ?? null, region: stateLabel(record), countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null, }; } catch { return buildFallbackResult(clean); } } async function resolveGeoip(clean: string, dbPath: string): Promise { const now = Date.now(); const cached = geoipCache.get(clean); if (cached && now < cached.expiresAt) { return cached.result; } const result = await lookupMaxmind(clean, dbPath); geoipCache.set(clean, {result, expiresAt: now + CACHE_TTL_MS}); return result; } export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): Promise { if (!dbPath) { return buildFallbackResult(ip); } const clean = normalizeIpString(ip); if (!isValidIp(clean)) { return buildFallbackResult(clean); } return resolveGeoip(clean, dbPath); } export function formatGeoipLocation(result: GeoipResult): string | null { const parts: Array = []; if (result.city) parts.push(result.city); if (result.region) parts.push(result.region); const countryLabel = result.countryName ?? result.countryCode; if (countryLabel) parts.push(countryLabel); return parts.length > 0 ? parts.join(', ') : null; }