refactor: online geo
Node.js CI / build (push) Has been cancelled

This commit is contained in:
lly
2026-07-23 16:20:36 +08:00
parent 86cf8076ed
commit c91156820c
3 changed files with 127 additions and 0 deletions
+4
View File
@@ -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
+38
View File
@@ -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';
+85
View File
@@ -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');