refactor(geoip): reconcile geoip system (#31)
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {__clearIpCache, DEFAULT_CC, FETCH_TIMEOUT_MS, formatGeoipLocation, getCountryCode} from './IpUtils';
|
||||
|
||||
vi.mock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: 'geoip:8080'}}}));
|
||||
|
||||
describe('getCountryCode(ip)', () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
__clearIpCache();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns US if GEOIP_HOST is not set', async () => {
|
||||
vi.doMock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: ''}}}));
|
||||
const {getCountryCode: modFn, __clearIpCache: reset} = await import('./IpUtils');
|
||||
reset();
|
||||
const cc = await modFn('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('returns US for invalid IP', async () => {
|
||||
const cc = await getCountryCode('not_an_ip');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('accepts bracketed IPv6', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('[2001:db8::1]');
|
||||
expect(cc).toBe('SE');
|
||||
});
|
||||
|
||||
it('returns uppercase alpha-2 on success', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe('SE');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back on non-2xx', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: false, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('falls back on invalid body', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('USA')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('falls back on network error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('uses cache for repeated lookups within TTL', async () => {
|
||||
const spyNow = vi.spyOn(Date, 'now');
|
||||
spyNow.mockReturnValueOnce(1_000);
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('gb')});
|
||||
const r1 = await getCountryCode('1.1.1.1');
|
||||
expect(r1).toBe('GB');
|
||||
spyNow.mockReturnValueOnce(2_000);
|
||||
const r2 = await getCountryCode('1.1.1.1');
|
||||
expect(r2).toBe('GB');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts after timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortingFetch = vi.fn<typeof fetch>(
|
||||
(_url, opts) =>
|
||||
new Promise((_res, rej) => {
|
||||
opts?.signal?.addEventListener('abort', () =>
|
||||
rej(Object.assign(new Error('AbortError'), {name: 'AbortError'})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = abortingFetch;
|
||||
const p = getCountryCode('8.8.8.8');
|
||||
vi.advanceTimersByTime(FETCH_TIMEOUT_MS + 5);
|
||||
await expect(p).resolves.toBe(DEFAULT_CC);
|
||||
expect(abortingFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGeoipLocation(result)', () => {
|
||||
it('returns Unknown Location when no parts are available', () => {
|
||||
const label = formatGeoipLocation({
|
||||
countryCode: DEFAULT_CC,
|
||||
normalizedIp: '1.1.1.1',
|
||||
reason: null,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
});
|
||||
expect(label).toBe('Unknown Location');
|
||||
});
|
||||
|
||||
it('concatenates available city, region, and country', () => {
|
||||
const label = formatGeoipLocation({
|
||||
countryCode: 'US',
|
||||
normalizedIp: '1.1.1.1',
|
||||
reason: null,
|
||||
city: 'San Francisco',
|
||||
region: 'CA',
|
||||
countryName: 'United States',
|
||||
});
|
||||
expect(label).toBe('San Francisco, CA, United States');
|
||||
});
|
||||
});
|
||||
@@ -25,42 +25,43 @@ import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const DEFAULT_CC = 'US';
|
||||
export const FETCH_TIMEOUT_MS = 1500;
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
const REVERSE_DNS_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
|
||||
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
|
||||
export interface GeoipResult {
|
||||
countryCode: string;
|
||||
countryCode: string | null;
|
||||
normalizedIp: string | null;
|
||||
reason: string | null;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
countryName: string | null;
|
||||
}
|
||||
|
||||
interface CacheVal {
|
||||
type CacheEntry = {
|
||||
result: GeoipResult;
|
||||
exp: number;
|
||||
}
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheVal>();
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
|
||||
|
||||
export function __clearIpCache(): void {
|
||||
cache.clear();
|
||||
geoipCache.clear();
|
||||
}
|
||||
|
||||
export function __resetMaxmindReader(): void {
|
||||
maxmindReader = null;
|
||||
maxmindReaderPromise = null;
|
||||
}
|
||||
|
||||
export function extractClientIp(req: Request): string | null {
|
||||
const xff = (req.headers.get('X-Forwarded-For') ?? '').trim();
|
||||
if (!xff) {
|
||||
return null;
|
||||
}
|
||||
const first = xff.split(',')[0]?.trim() ?? '';
|
||||
if (!xff) return null;
|
||||
const [first] = xff.split(',');
|
||||
if (!first) return null;
|
||||
return normalizeIpString(first);
|
||||
}
|
||||
|
||||
@@ -72,24 +73,33 @@ export function requireClientIp(req: Request): string {
|
||||
return ip;
|
||||
}
|
||||
|
||||
function buildFallbackResult(clean: string, reason: string | null): GeoipResult {
|
||||
export async function lookupGeoip(req: Request): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(ip: string): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(input: string | Request): Promise<GeoipResult> {
|
||||
const ip = typeof input === 'string' ? input : extractClientIp(input);
|
||||
if (!ip) {
|
||||
return buildFallbackResult('');
|
||||
}
|
||||
return lookupGeoipFromString(ip);
|
||||
}
|
||||
|
||||
function buildFallbackResult(clean: string): GeoipResult {
|
||||
return {
|
||||
countryCode: DEFAULT_CC,
|
||||
normalizedIp: clean,
|
||||
reason,
|
||||
countryCode: null,
|
||||
normalizedIp: clean || null,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
async function ensureMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
if (maxmindReader) return maxmindReader;
|
||||
|
||||
if (!maxmindReaderPromise) {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
return Promise.reject(new Error('Missing MaxMind DB path'));
|
||||
throw new Error('Missing MaxMind DB path');
|
||||
}
|
||||
|
||||
maxmindReaderPromise = maxmind
|
||||
@@ -107,127 +117,79 @@ async function getMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
return maxmindReaderPromise;
|
||||
}
|
||||
|
||||
function getSubdivisionLabel(record?: CityResponse): string | null {
|
||||
function stateLabel(record?: CityResponse): string | null {
|
||||
const subdivision = record?.subdivisions?.[0];
|
||||
if (!subdivision) return null;
|
||||
return subdivision.iso_code || subdivision.names?.en || null;
|
||||
return subdivision.names?.en || subdivision.iso_code || null;
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
return buildFallbackResult(clean, 'maxmind_db_missing');
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = await getMaxmindReader();
|
||||
const reader = await ensureMaxmindReader();
|
||||
const record = reader.get(clean);
|
||||
if (!record) {
|
||||
return buildFallbackResult(clean, 'maxmind_not_found');
|
||||
}
|
||||
if (!record) return buildFallbackResult(clean);
|
||||
|
||||
const isoCode = record.country?.iso_code;
|
||||
const countryCode = isoCode ? isoCode.toUpperCase() : null;
|
||||
|
||||
const countryCode = (record.country?.iso_code ?? DEFAULT_CC).toUpperCase();
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: clean,
|
||||
reason: null,
|
||||
city: record.city?.names?.en ?? null,
|
||||
region: getSubdivisionLabel(record),
|
||||
countryName: record.country?.names?.en ?? countryDisplayName(countryCode) ?? null,
|
||||
region: stateLabel(record),
|
||||
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message ?? 'unknown';
|
||||
Logger.warn({error, maxmind_db_path: dbPath}, 'MaxMind lookup failed');
|
||||
return buildFallbackResult(clean, `maxmind_error:${message}`);
|
||||
const message = (error as Error).message ?? 'unknown';
|
||||
Logger.warn({error, maxmind_db_path: dbPath, message}, 'MaxMind lookup failed');
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupIpinfo(clean: string): Promise<GeoipResult> {
|
||||
const host = Config.geoip.host;
|
||||
if (!host) {
|
||||
return buildFallbackResult(clean, 'geoip_host_missing');
|
||||
}
|
||||
|
||||
const url = `http://${host}/lookup?ip=${encodeURIComponent(clean)}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await globalThis.fetch!(url, {
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'text/plain'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return buildFallbackResult(clean, `non_ok:${res.status}`);
|
||||
}
|
||||
|
||||
const text = (await res.text()).trim().toUpperCase();
|
||||
const countryCode = isAsciiUpperAlpha2(text) ? text : DEFAULT_CC;
|
||||
const reason = isAsciiUpperAlpha2(text) ? null : 'invalid_response';
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: clean,
|
||||
reason,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: countryDisplayName(countryCode),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message ?? 'unknown';
|
||||
return buildFallbackResult(clean, `error:${message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCountryCodeDetailed(ip: string): Promise<GeoipResult> {
|
||||
const clean = normalizeIpString(ip);
|
||||
if (!isIPv4(clean) && !isIPv6(clean)) {
|
||||
return buildFallbackResult(clean, 'invalid_ip');
|
||||
}
|
||||
|
||||
const cached = cache.get(clean);
|
||||
async function resolveGeoip(clean: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.exp) {
|
||||
const cached = geoipCache.get(clean);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const provider = Config.geoip.provider;
|
||||
const result = provider === 'maxmind' ? await lookupMaxmind(clean) : await lookupIpinfo(clean);
|
||||
|
||||
cache.set(clean, {result, exp: now + CACHE_TTL_MS});
|
||||
const result = await lookupMaxmind(clean);
|
||||
geoipCache.set(clean, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCountryCode(ip: string): Promise<string> {
|
||||
const result = await getCountryCodeDetailed(ip);
|
||||
return result.countryCode;
|
||||
}
|
||||
async function lookupGeoipFromString(value: string): Promise<GeoipResult> {
|
||||
const clean = normalizeIpString(value);
|
||||
if (!isIPv4(clean) && !isIPv6(clean)) {
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
export async function getCountryCodeFromReq(req: Request): Promise<string> {
|
||||
const ip = extractClientIp(req);
|
||||
if (!ip) return DEFAULT_CC;
|
||||
return await getCountryCode(ip);
|
||||
return resolveGeoip(clean);
|
||||
}
|
||||
|
||||
function countryDisplayName(code: string, locale = 'en'): string | null {
|
||||
const c = code.toUpperCase();
|
||||
if (!isAsciiUpperAlpha2(c)) return null;
|
||||
const upper = code.toUpperCase();
|
||||
if (!isAsciiUpperAlpha2(upper)) return null;
|
||||
const dn = new Intl.DisplayNames([locale], {type: 'region', fallback: 'none'});
|
||||
return dn.of(c) ?? null;
|
||||
return dn.of(upper) ?? null;
|
||||
}
|
||||
|
||||
export function formatGeoipLocation(result: GeoipResult): string {
|
||||
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(', ') : UNKNOWN_LOCATION;
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
function stripBrackets(s: string): string {
|
||||
return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
|
||||
function stripBrackets(value: string): string {
|
||||
return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
|
||||
}
|
||||
|
||||
export function normalizeIpString(value: string): string {
|
||||
@@ -239,12 +201,9 @@ export function normalizeIpString(value: string): string {
|
||||
|
||||
export async function getIpAddressReverse(ip: string, cacheService?: ICacheService): Promise<string | null> {
|
||||
const cacheKey = `${REVERSE_DNS_CACHE_PREFIX}${ip}`;
|
||||
|
||||
if (cacheService) {
|
||||
const cached = await cacheService.get<string | null>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached === '' ? null : cached;
|
||||
}
|
||||
if (cached !== null) return cached === '' ? null : cached;
|
||||
}
|
||||
|
||||
let result: string | null = null;
|
||||
@@ -262,9 +221,17 @@ export async function getIpAddressReverse(ip: string, cacheService?: ICacheServi
|
||||
return result;
|
||||
}
|
||||
|
||||
function isAsciiUpperAlpha2(s: string): boolean {
|
||||
if (s.length !== 2) return false;
|
||||
const a = s.charCodeAt(0);
|
||||
const b = s.charCodeAt(1);
|
||||
return a >= 65 && a <= 90 && b >= 65 && b <= 90;
|
||||
export async function getLocationLabelFromIp(ip: string): Promise<string | null> {
|
||||
const result = await lookupGeoip(ip);
|
||||
return formatGeoipLocation(result);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal file
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 Bowser from 'bowser';
|
||||
import {Logger} from '~/Logger';
|
||||
|
||||
export interface UserAgentInfo {
|
||||
clientOs: string;
|
||||
detectedPlatform: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
|
||||
function formatName(name?: string | null): string {
|
||||
const normalized = name?.trim();
|
||||
return normalized || UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
return {
|
||||
clientOs: formatName(parser.getOSName()),
|
||||
detectedPlatform: formatName(parser.getBrowserName()),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||
clientOs: string;
|
||||
clientPlatform: string;
|
||||
} {
|
||||
const parsed = parseUserAgentSafe(args.userAgent ?? '');
|
||||
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
|
||||
return {
|
||||
clientOs: parsed.clientOs,
|
||||
clientPlatform,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user