fluxer/packages/geoip/src/GeoipLookup.tsx

150 lines
4.2 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | 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<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
maxmindReaderPromise = maxmind
.open<CityResponse>(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<GeoipResult> {
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<GeoipResult> {
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<GeoipResult> {
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<string> = [];
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;
}