diff --git a/README.md b/README.md index 474c75b3f..2d664a25d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ Optional: set `API_URL` to change the base URL used by internal UI API calls. Relative paths are served under `BASE_PATH`; absolute URLs are proxied through the local `/api` route. For example, `API_URL=/internal-api` or `API_URL=https://api.example.com/api`. +IP geolocation uses the IP9 online API first and falls back to the local GeoLite2 database when the +online request fails. Set `IP_LOCATION_API_URL` to override the endpoint and +`IP_LOCATION_API_TIMEOUT` to change the request timeout in milliseconds (default: `2000`). + The connection URL format: ```bash diff --git a/src/lib/detect.test.ts b/src/lib/detect.test.ts index 804c84029..cad375004 100644 --- a/src/lib/detect.test.ts +++ b/src/lib/detect.test.ts @@ -12,10 +12,14 @@ vi.mock('is-localhost-ip', () => ({ beforeEach(() => { vi.resetAllMocks(); + vi.unstubAllGlobals(); delete process.env.CLIENT_IP_HEADER; delete process.env.IGNORE_IP; + delete process.env.IP_LOCATION_API_TIMEOUT; + delete process.env.IP_LOCATION_API_URL; delete process.env.SKIP_LOCATION_HEADERS; + delete globalThis.maxmind; }); test('getIpAddress: Custom header', () => { @@ -70,6 +74,40 @@ test('getLocation: treats localhost check errors as non-local', async () => { }); }); +test('getLocation: uses online location lookup before the local database', async () => { + isLocalhost.default.mockResolvedValue(false); + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ret: 200, + data: { + country_code: 'cn', + prov: '北京市', + city: '北京', + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetch); + + await expect(getLocation('58.30.0.0', new Headers(), true)).resolves.toEqual({ + country: 'CN', + region: 'CN-BJ', + city: '北京', + }); + expect(fetch).toHaveBeenCalledOnce(); +}); + +test('getLocation: falls back to the local database when online lookup fails', async () => { + isLocalhost.default.mockResolvedValue(false); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + + await expect(getLocation('8.8.8.8', new Headers(), true)).resolves.toMatchObject({ + country: 'US', + }); +}); + test('hasBlockedIp: returns false for malformed client ip with cidr block', () => { process.env.IGNORE_IP = '10.0.0.0/8'; diff --git a/src/lib/detect.ts b/src/lib/detect.ts index fb36ba212..ad9e22ff4 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -8,6 +8,45 @@ import { getIpAddress, stripPort } from '@/lib/ip'; import { safeDecodeURIComponent } from '@/lib/url'; const MAXMIND = 'maxmind'; +const IP_LOCATION_API_URL = 'https://ip9.com.cn/get'; +const IP_LOCATION_API_TIMEOUT = 2000; + +const CHINA_REGION_CODES = { + 北京: 'BJ', + 天津: 'TJ', + 河北: 'HE', + 山西: 'SX', + 内蒙古: 'NM', + 辽宁: 'LN', + 吉林: 'JL', + 黑龙江: 'HL', + 上海: 'SH', + 江苏: 'JS', + 浙江: 'ZJ', + 安徽: 'AH', + 福建: 'FJ', + 江西: 'JX', + 山东: 'SD', + 河南: 'HA', + 湖北: 'HB', + 湖南: 'HN', + 广东: 'GD', + 广西: 'GX', + 海南: 'HI', + 重庆: 'CQ', + 四川: 'SC', + 贵州: 'GZ', + 云南: 'YN', + 西藏: 'XZ', + 陕西: 'SN', + 甘肃: 'GS', + 青海: 'QH', + 宁夏: 'NX', + 新疆: 'XJ', + 台湾: 'TW', + 香港: 'HK', + 澳门: 'MO', +}; const PROVIDER_HEADERS = [ // Umami custom headers (cloud mode only) @@ -84,6 +123,46 @@ async function isLocalIp(ip: string) { } } +function getChinaRegionCode(province: string) { + const name = province?.trim(); + const region = Object.entries(CHINA_REGION_CODES).find(([key]) => name?.startsWith(key))?.[1]; + + return region ? getRegionCode('CN', region) : undefined; +} + +async function getOnlineLocation(ip: string) { + try { + const url = new URL(process.env.IP_LOCATION_API_URL || IP_LOCATION_API_URL); + const timeout = Number(process.env.IP_LOCATION_API_TIMEOUT) || IP_LOCATION_API_TIMEOUT; + + url.searchParams.set('ip', ip); + + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(timeout), + }); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + const country = result?.data?.country_code?.trim()?.toUpperCase(); + + if (result?.ret !== 200 || !/^[A-Z]{2}$/.test(country)) { + return null; + } + + return { + country, + region: country === 'CN' ? getChinaRegionCode(result.data.prov) : undefined, + city: result.data.city?.trim() || undefined, + }; + } catch { + return null; + } +} + export async function getLocation(ip: string = '', headers: Headers, skipHeaders: boolean) { const cleanIp = stripPort(ip); @@ -109,6 +188,12 @@ export async function getLocation(ip: string = '', headers: Headers, skipHeaders } } + const onlineLocation = await getOnlineLocation(cleanIp); + + if (onlineLocation) { + return onlineLocation; + } + // Database lookup if (!globalThis[MAXMIND]) { const dir = path.join(process.cwd(), 'geo');