Add GeoIP lookup support and update device fingerprints to base58
- Add maxmind library for GeoIP database lookups when not behind Cloudflare - Extract client IP from multiple header sources (CF, X-Real-IP, X-Forwarded-For, etc.) - Change device fingerprints from UUID to base58 with device_ prefix - Add isValidDeviceFingerprint() that accepts both new and legacy formats - Colocate unit tests with source files, remove __tests__/unit directory - Add test coverage reporting to test script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import type { CityResponse, Reader } from "maxmind";
|
||||
import { open } from "maxmind";
|
||||
|
||||
export interface GeoInfo {
|
||||
ip: string | null;
|
||||
city: string | null;
|
||||
@@ -5,37 +8,152 @@ export interface GeoInfo {
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
/** Default path for GeoLite2-City database */
|
||||
const DEFAULT_GEOIP_PATH = "/usr/share/GeoIP/GeoLite2-City.mmdb";
|
||||
|
||||
// Module-level reader instance
|
||||
let geoReader: Reader<CityResponse> | null = null;
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Extract geolocation info from request headers
|
||||
* Supports Cloudflare headers in production, falls back to standard headers
|
||||
* @param headers - Request headers
|
||||
* @returns Geolocation information extracted from headers
|
||||
* Initialize the GeoIP database reader.
|
||||
* Uses GEOIP_DATABASE_PATH env var, defaults to /usr/share/GeoIP/GeoLite2-City.mmdb
|
||||
*/
|
||||
export const initGeoReader = async (): Promise<void> => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = Bun.env.GEOIP_DATABASE_PATH ?? DEFAULT_GEOIP_PATH;
|
||||
|
||||
try {
|
||||
geoReader = await open<CityResponse>(dbPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.warn(`GeoIP database not available at ${dbPath}: ${message}`);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
};
|
||||
|
||||
// ===== Test Helpers =====
|
||||
// These are exported for testing only. Do not use in production code.
|
||||
|
||||
/** @internal Reset state for testing */
|
||||
export const _resetForTesting = (): void => {
|
||||
geoReader = null;
|
||||
initialized = false;
|
||||
};
|
||||
|
||||
/** @internal Set a mock reader for testing */
|
||||
export const _setReaderForTesting = (
|
||||
reader: Reader<CityResponse> | null,
|
||||
): void => {
|
||||
geoReader = reader;
|
||||
initialized = true;
|
||||
};
|
||||
|
||||
// ===== Public API =====
|
||||
|
||||
/**
|
||||
* Extract the client IP address from request headers.
|
||||
* Checks headers in priority order:
|
||||
* 1. CF-Connecting-IP (Cloudflare)
|
||||
* 2. True-Client-IP (Cloudflare Enterprise / Akamai)
|
||||
* 3. X-Real-IP (nginx, other proxies)
|
||||
* 4. X-Forwarded-For (first IP in chain)
|
||||
* 5. X-Client-IP (some proxies)
|
||||
*/
|
||||
export const extractClientIP = (headers: Headers): string | null => {
|
||||
const cfIP = headers.get("CF-Connecting-IP");
|
||||
if (cfIP) {
|
||||
return cfIP.trim();
|
||||
}
|
||||
|
||||
const trueClientIP = headers.get("True-Client-IP");
|
||||
if (trueClientIP) {
|
||||
return trueClientIP.trim();
|
||||
}
|
||||
|
||||
const realIP = headers.get("X-Real-IP");
|
||||
if (realIP) {
|
||||
return realIP.trim();
|
||||
}
|
||||
|
||||
const forwardedFor = headers.get("X-Forwarded-For");
|
||||
if (forwardedFor) {
|
||||
const firstIP = forwardedFor.split(",")[0]?.trim();
|
||||
if (firstIP) {
|
||||
return firstIP;
|
||||
}
|
||||
}
|
||||
|
||||
const clientIP = headers.get("X-Client-IP");
|
||||
if (clientIP) {
|
||||
return clientIP.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up geo information from an IP address using the GeoIP database.
|
||||
* Returns null values if the database isn't initialized or IP is unknown.
|
||||
*/
|
||||
export const lookupGeoFromIP = (
|
||||
ip: string,
|
||||
): { city: string | null; region: string | null; country: string | null } => {
|
||||
if (!geoReader) {
|
||||
return { city: null, region: null, country: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = geoReader.get(ip);
|
||||
if (!result) {
|
||||
return { city: null, region: null, country: null };
|
||||
}
|
||||
|
||||
return {
|
||||
city: result.city?.names.en ?? null,
|
||||
region: result.subdivisions?.[0]?.names.en ?? null,
|
||||
country: result.country?.iso_code ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { city: null, region: null, country: null };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract geolocation info from request headers.
|
||||
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
||||
*/
|
||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
||||
// Try Cloudflare headers first (production)
|
||||
const cfIP = headers.get("CF-Connecting-IP");
|
||||
const ip = extractClientIP(headers);
|
||||
|
||||
// Try Cloudflare geo headers first
|
||||
const cfCountry = headers.get("CF-IPCountry");
|
||||
const cfCity = headers.get("CF-IPCity");
|
||||
const cfRegion = headers.get("CF-Region");
|
||||
|
||||
// Fallback to X-Forwarded-For or X-Real-IP
|
||||
const forwardedFor = headers.get("X-Forwarded-For");
|
||||
const realIP = headers.get("X-Real-IP");
|
||||
if (cfCountry || cfCity || cfRegion) {
|
||||
return {
|
||||
ip,
|
||||
city: cfCity ?? null,
|
||||
region: cfRegion ?? null,
|
||||
country: cfCountry ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const ip = cfIP ?? realIP ?? forwardedFor?.split(",")[0]?.trim() ?? null;
|
||||
// Fall back to GeoIP database lookup
|
||||
if (ip) {
|
||||
return { ip, ...lookupGeoFromIP(ip) };
|
||||
}
|
||||
|
||||
return {
|
||||
ip,
|
||||
city: cfCity ?? null,
|
||||
region: cfRegion ?? null,
|
||||
country: cfCountry ?? null,
|
||||
};
|
||||
return { ip: null, city: null, region: null, country: null };
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract User-Agent from request headers
|
||||
* @param headers - Request headers
|
||||
* @returns User-Agent string or "Unknown" if not present
|
||||
* Extract User-Agent from request headers.
|
||||
*/
|
||||
export const getUserAgent = (headers: Headers): string => {
|
||||
return headers.get("User-Agent") ?? "Unknown";
|
||||
|
||||
Reference in New Issue
Block a user