Files
umami/src/lib/detect.ts
Alex Escalante 26ddfd5a80 Sanitize IP to remove port for geolocation lookup
Sanitize IP address to remove port number before geolocation lookup. This ensures proper MaxMind database resolution in setups where IP:PORT is passed by the proxy.
2025-06-30 18:40:21 -06:00

190 lines
4.7 KiB
TypeScript

import path from 'path';
import { browserName, detectOS } from 'detect-browser';
import isLocalhost from 'is-localhost-ip';
import ipaddr from 'ipaddr.js';
import maxmind from 'maxmind';
import {
DESKTOP_OS,
MOBILE_OS,
DESKTOP_SCREEN_WIDTH,
LAPTOP_SCREEN_WIDTH,
MOBILE_SCREEN_WIDTH,
IP_ADDRESS_HEADERS,
} from './constants';
const MAXMIND = 'maxmind';
export function getIpAddress(headers: Headers) {
const customHeader = process.env.CLIENT_IP_HEADER;
if (customHeader && headers.get(customHeader)) {
return headers.get(customHeader);
}
const header = IP_ADDRESS_HEADERS.find(name => {
return headers.get(name);
});
const ip = headers.get(header);
if (header === 'x-forwarded-for') {
return ip?.split(',')?.[0]?.trim();
}
if (header === 'forwarded') {
const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/);
if (match) {
return match[1];
}
}
return ip;
}
export function getDevice(screen: string, os: string) {
if (!screen) return;
const [width] = screen.split('x');
if (DESKTOP_OS.includes(os)) {
if (os === 'Chrome OS' || +width < DESKTOP_SCREEN_WIDTH) {
return 'laptop';
}
return 'desktop';
} else if (MOBILE_OS.includes(os)) {
if (os === 'Amazon OS' || +width > MOBILE_SCREEN_WIDTH) {
return 'tablet';
}
return 'mobile';
}
if (+width >= DESKTOP_SCREEN_WIDTH) {
return 'desktop';
} else if (+width >= LAPTOP_SCREEN_WIDTH) {
return 'laptop';
} else if (+width >= MOBILE_SCREEN_WIDTH) {
return 'tablet';
} else {
return 'mobile';
}
}
function getRegionCode(country: string, region: string) {
if (!country || !region) {
return undefined;
}
return region.includes('-') ? region : `${country}-${region}`;
}
function decodeHeader(s: string | undefined | null): string | undefined | null {
if (s === undefined || s === null) {
return s;
}
return Buffer.from(s, 'latin1').toString('utf-8');
}
export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
// Ignore local ips
if (await isLocalhost(ip)) {
return;
}
if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
// Cloudflare headers
if (headers.get('cf-ipcountry')) {
const country = decodeHeader(headers.get('cf-ipcountry'));
const region = decodeHeader(headers.get('cf-region-code'));
const city = decodeHeader(headers.get('cf-ipcity'));
return {
country,
region: getRegionCode(country, region),
city,
};
}
// Vercel headers
if (headers.get('x-vercel-ip-country')) {
const country = decodeHeader(headers.get('x-vercel-ip-country'));
const region = decodeHeader(headers.get('x-vercel-ip-country-region'));
const city = decodeHeader(headers.get('x-vercel-ip-city'));
return {
country,
region: getRegionCode(country, region),
city,
};
}
}
// Database lookup
if (!global[MAXMIND]) {
const dir = path.join(process.cwd(), 'geo');
global[MAXMIND] = await maxmind.open(path.resolve(dir, 'GeoLite2-City.mmdb'));
}
// When the client IP is extracted from headers, sometimes the value includes a port
const cleanIp = ip?.split(':')[0];
const result = global[MAXMIND].get(cleanIp);
if (result) {
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
const region = result.subdivisions?.[0]?.iso_code;
const city = result.city?.names?.en;
return {
country,
region: getRegionCode(country, region),
city,
};
}
}
export async function getClientInfo(request: Request, payload: Record<string, any>) {
const userAgent = payload?.userAgent || request.headers.get('user-agent');
const ip = payload?.ip || getIpAddress(request.headers);
const location = await getLocation(ip, request.headers, !!payload?.ip);
const country = location?.country;
const region = location?.region;
const city = location?.city;
const browser = browserName(userAgent);
const os = detectOS(userAgent) as string;
const device = getDevice(payload?.screen, os);
return { userAgent, browser, os, ip, country, region, city, device };
}
export function hasBlockedIp(clientIp: string) {
const ignoreIps = process.env.IGNORE_IP;
if (ignoreIps) {
const ips = [];
if (ignoreIps) {
ips.push(...ignoreIps.split(',').map(n => n.trim()));
}
return ips.find(ip => {
if (ip === clientIp) {
return true;
}
// CIDR notation
if (ip.indexOf('/') > 0) {
const addr = ipaddr.parse(clientIp);
const range = ipaddr.parseCIDR(ip);
if (addr.kind() === range[0].kind() && addr.match(range)) {
return true;
}
}
});
}
return false;
}