Compare commits

...
4 Commits
Author SHA1 Message Date
lly c7691ae9a7 fix: geo show
Node.js CI / build (push) Has been cancelled
2026-07-23 16:30:02 +08:00
lly c91156820c refactor: online geo
Node.js CI / build (push) Has been cancelled
2026-07-23 16:20:36 +08:00
lly 86cf8076ed refactor: geo
Node.js CI / build (push) Has been cancelled
2026-07-23 14:30:13 +08:00
lly fa30610c0e fix: migrate
Node.js CI / build (push) Has been cancelled
2026-07-23 12:44:39 +08:00
11 changed files with 179 additions and 20 deletions
+1
View File
@@ -5,6 +5,7 @@ Dockerfile
.DS_Store
node_modules
.next
geo/GeoLite2-City.mmdb
.idea
.env
.env.*
+14 -17
View File
@@ -1,29 +1,24 @@
ARG NODE_IMAGE_VERSION=22-alpine
ARG PNPM_VERSION=10.15.1
FROM node:${NODE_IMAGE_VERSION} AS deps
FROM node:${NODE_IMAGE_VERSION} AS base
ARG PNPM_VERSION
RUN apk add --no-cache libc6-compat \
RUN apk add --no-cache curl libc6-compat \
&& npm install --global pnpm@${PNPM_VERSION}
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM node:${NODE_IMAGE_VERSION} AS builder
FROM deps AS builder
ARG PNPM_VERSION
ARG BASE_PATH
RUN apk add --no-cache libc6-compat \
&& npm install --global pnpm@${PNPM_VERSION}
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV BASE_PATH=${BASE_PATH}
@@ -34,18 +29,13 @@ ENV SKIP_BUILD_GEO=1
RUN pnpm build
FROM node:${NODE_IMAGE_VERSION} AS runner
FROM base AS runner
ARG PNPM_VERSION
ARG NODE_OPTIONS
RUN apk add --no-cache curl libc6-compat \
&& npm install --global pnpm@${PNPM_VERSION} \
&& addgroup --system --gid 1001 nodejs \
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -56,12 +46,19 @@ COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/docker/entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
RUN sed -i 's/\r$//' ./docker-entrypoint.sh \
&& chmod +x ./docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["pnpm", "start"]
+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
+1
View File
@@ -10,6 +10,7 @@ services:
environment:
DATABASE_URL: postgresql://umami:umami@db:5432/umami
APP_SECRET: replace-me-with-a-random-string
SKIP_LOCATION_HEADERS: 1
volumes:
- ./geo/GeoLite2-City.mmdb:/app/geo/GeoLite2-City.mmdb:ro
depends_on:
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
pnpm exec prisma migrate deploy
exec "$@"
+3 -2
View File
@@ -1,5 +1,5 @@
import { BROWSERS, OS_NAMES } from '@/lib/constants';
import regions from '../../../public/iso-3166-2.json';
import { getRegionNames } from '@/lib/geo';
import { useCountryNames } from './useCountryNames';
import { useLanguageNames } from './useLanguageNames';
import { useLocale } from './useLocale';
@@ -10,6 +10,7 @@ export function useFormat() {
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { languageNames } = useLanguageNames(locale);
const regionNames = getRegionNames(locale);
const formatOS = (value: string): string => {
return OS_NAMES[value] || value;
@@ -29,7 +30,7 @@ export function useFormat() {
const formatRegion = (value?: string): string => {
const [country] = value?.split('-') || [];
return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
return regionNames[value] ? `${regionNames[value]}, ${countryNames[country]}` : value;
};
const formatCity = (value: string, country?: string): string => {
+2 -1
View File
@@ -1,8 +1,9 @@
import regions from '../../../public/iso-3166-2.json';
import { getRegionNames } from '@/lib/geo';
import { useCountryNames } from './useCountryNames';
export function useRegionNames(locale: string) {
const { countryNames } = useCountryNames(locale);
const regions = getRegionNames(locale);
const getRegionName = (regionCode: string, countryCode?: string) => {
if (!countryCode) {
+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';
+42
View File
@@ -4,10 +4,13 @@ import ipaddr from 'ipaddr.js';
import isLocalhost from 'is-localhost-ip';
import maxmind from 'maxmind';
import { UAParser } from 'ua-parser-js';
import { getChinaRegionCode } from '@/lib/geo';
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 PROVIDER_HEADERS = [
// Umami custom headers (cloud mode only)
@@ -84,6 +87,39 @@ async function isLocalIp(ip: string) {
}
}
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 +145,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');
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from 'vitest';
import { getChinaRegionCode, getRegionNames } from './geo';
test('getChinaRegionCode: converts Chinese province names to ISO codes', () => {
expect(getChinaRegionCode('福建省')).toBe('CN-FJ');
});
test('getRegionNames: localizes Chinese regions for zh-CN', () => {
expect(getRegionNames('zh-CN')['CN-FJ']).toBe('福建');
expect(getRegionNames('en-US')['CN-FJ']).toBe('Fujian');
});
+57
View File
@@ -0,0 +1,57 @@
import regions from '../../public/iso-3166-2.json';
export 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 CHINA_REGION_NAMES = Object.fromEntries(
Object.entries(CHINA_REGION_CODES).map(([name, code]) => [`CN-${code}`, name]),
);
export function getChinaRegionCode(province: string) {
const name = province?.trim();
const region = Object.entries(CHINA_REGION_CODES).find(([key]) => name?.startsWith(key))?.[1];
return region ? `CN-${region}` : undefined;
}
export function getRegionNames(locale?: string): Record<string, string> {
if (locale?.toLowerCase() === 'zh-cn') {
return { ...regions, ...CHINA_REGION_NAMES };
}
return regions;
}