Files
publisher-dashboard/apps/api-server/src/utils/geo.ts
RevIQ 319edf70db Fix IP address not being set on sessions from localhost
The extractClientIP() function only checked proxy headers (X-Forwarded-For,
CF-Connecting-IP, etc.) which don't exist when running locally without a proxy.

Changes:
- Add clientIP field to APIContext
- Use Bun's server.requestIP() to get client IP from direct socket connection
- Update getGeoInfo() to accept fallback IP parameter
- Pass context.clientIP to getGeoInfo() in auth procedures

Now sessions will have IP address set even for local development (::1 or 127.0.0.1).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:08:21 +08:00

168 lines
4.3 KiB
TypeScript

import type { CityResponse, Reader } from "maxmind";
import { open } from "maxmind";
export interface GeoInfo {
ip: string | null;
city: string | null;
region: string | null;
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;
/**
* 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.
*
* @param headers - Request headers to extract proxy IP headers from
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
*/
export const getGeoInfo = (
headers: Headers,
fallbackIP?: string | null,
): GeoInfo => {
// Try proxy headers first, then fall back to direct connection IP
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
// Try Cloudflare geo headers first
const cfCountry = headers.get("CF-IPCountry");
const cfCity = headers.get("CF-IPCity");
const cfRegion = headers.get("CF-Region");
if (cfCountry || cfCity || cfRegion) {
return {
ip,
city: cfCity ?? null,
region: cfRegion ?? null,
country: cfCountry ?? null,
};
}
// Fall back to GeoIP database lookup
if (ip) {
return { ip, ...lookupGeoFromIP(ip) };
}
return { ip: null, city: null, region: null, country: null };
};
/**
* Extract User-Agent from request headers.
*/
export const getUserAgent = (headers: Headers): string => {
return headers.get("User-Agent") ?? "Unknown";
};