Compare commits
4
Commits
8b9f31c77c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7691ae9a7 | ||
|
|
c91156820c | ||
|
|
86cf8076ed | ||
|
|
fa30610c0e |
@@ -5,6 +5,7 @@ Dockerfile
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
geo/GeoLite2-City.mmdb
|
||||||
.idea
|
.idea
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
+14
-17
@@ -1,29 +1,24 @@
|
|||||||
ARG NODE_IMAGE_VERSION=22-alpine
|
ARG NODE_IMAGE_VERSION=22-alpine
|
||||||
ARG PNPM_VERSION=10.15.1
|
ARG PNPM_VERSION=10.15.1
|
||||||
|
|
||||||
FROM node:${NODE_IMAGE_VERSION} AS deps
|
FROM node:${NODE_IMAGE_VERSION} AS base
|
||||||
|
|
||||||
ARG PNPM_VERSION
|
ARG PNPM_VERSION
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat \
|
RUN apk add --no-cache curl libc6-compat \
|
||||||
&& npm install --global pnpm@${PNPM_VERSION}
|
&& npm install --global pnpm@${PNPM_VERSION}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM node:${NODE_IMAGE_VERSION} AS builder
|
FROM deps AS builder
|
||||||
|
|
||||||
ARG PNPM_VERSION
|
|
||||||
ARG BASE_PATH
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
ENV BASE_PATH=${BASE_PATH}
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
@@ -34,18 +29,13 @@ ENV SKIP_BUILD_GEO=1
|
|||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM node:${NODE_IMAGE_VERSION} AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
ARG PNPM_VERSION
|
|
||||||
ARG NODE_OPTIONS
|
ARG NODE_OPTIONS
|
||||||
|
|
||||||
RUN apk add --no-cache curl libc6-compat \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
&& npm install --global pnpm@${PNPM_VERSION} \
|
|
||||||
&& addgroup --system --gid 1001 nodejs \
|
|
||||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
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-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/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/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/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
|
||||||
|
RUN sed -i 's/\r$//' ./docker-entrypoint.sh \
|
||||||
|
&& chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["pnpm", "start"]
|
CMD ["pnpm", "start"]
|
||||||
@@ -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.
|
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`.
|
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:
|
The connection URL format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://umami:umami@db:5432/umami
|
DATABASE_URL: postgresql://umami:umami@db:5432/umami
|
||||||
APP_SECRET: replace-me-with-a-random-string
|
APP_SECRET: replace-me-with-a-random-string
|
||||||
|
SKIP_LOCATION_HEADERS: 1
|
||||||
volumes:
|
volumes:
|
||||||
- ./geo/GeoLite2-City.mmdb:/app/geo/GeoLite2-City.mmdb:ro
|
- ./geo/GeoLite2-City.mmdb:/app/geo/GeoLite2-City.mmdb:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
pnpm exec prisma migrate deploy
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
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 { useCountryNames } from './useCountryNames';
|
||||||
import { useLanguageNames } from './useLanguageNames';
|
import { useLanguageNames } from './useLanguageNames';
|
||||||
import { useLocale } from './useLocale';
|
import { useLocale } from './useLocale';
|
||||||
@@ -10,6 +10,7 @@ export function useFormat() {
|
|||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
const { languageNames } = useLanguageNames(locale);
|
const { languageNames } = useLanguageNames(locale);
|
||||||
|
const regionNames = getRegionNames(locale);
|
||||||
|
|
||||||
const formatOS = (value: string): string => {
|
const formatOS = (value: string): string => {
|
||||||
return OS_NAMES[value] || value;
|
return OS_NAMES[value] || value;
|
||||||
@@ -29,7 +30,7 @@ export function useFormat() {
|
|||||||
|
|
||||||
const formatRegion = (value?: string): string => {
|
const formatRegion = (value?: string): string => {
|
||||||
const [country] = value?.split('-') || [];
|
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 => {
|
const formatCity = (value: string, country?: string): string => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import regions from '../../../public/iso-3166-2.json';
|
import { getRegionNames } from '@/lib/geo';
|
||||||
import { useCountryNames } from './useCountryNames';
|
import { useCountryNames } from './useCountryNames';
|
||||||
|
|
||||||
export function useRegionNames(locale: string) {
|
export function useRegionNames(locale: string) {
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
|
const regions = getRegionNames(locale);
|
||||||
|
|
||||||
const getRegionName = (regionCode: string, countryCode?: string) => {
|
const getRegionName = (regionCode: string, countryCode?: string) => {
|
||||||
if (!countryCode) {
|
if (!countryCode) {
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ vi.mock('is-localhost-ip', () => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
|
||||||
delete process.env.CLIENT_IP_HEADER;
|
delete process.env.CLIENT_IP_HEADER;
|
||||||
delete process.env.IGNORE_IP;
|
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 process.env.SKIP_LOCATION_HEADERS;
|
||||||
|
delete globalThis.maxmind;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getIpAddress: Custom header', () => {
|
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', () => {
|
test('hasBlockedIp: returns false for malformed client ip with cidr block', () => {
|
||||||
process.env.IGNORE_IP = '10.0.0.0/8';
|
process.env.IGNORE_IP = '10.0.0.0/8';
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import ipaddr from 'ipaddr.js';
|
|||||||
import isLocalhost from 'is-localhost-ip';
|
import isLocalhost from 'is-localhost-ip';
|
||||||
import maxmind from 'maxmind';
|
import maxmind from 'maxmind';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { getChinaRegionCode } from '@/lib/geo';
|
||||||
import { getIpAddress, stripPort } from '@/lib/ip';
|
import { getIpAddress, stripPort } from '@/lib/ip';
|
||||||
import { safeDecodeURIComponent } from '@/lib/url';
|
import { safeDecodeURIComponent } from '@/lib/url';
|
||||||
|
|
||||||
const MAXMIND = 'maxmind';
|
const MAXMIND = 'maxmind';
|
||||||
|
const IP_LOCATION_API_URL = 'https://ip9.com.cn/get';
|
||||||
|
const IP_LOCATION_API_TIMEOUT = 2000;
|
||||||
|
|
||||||
const PROVIDER_HEADERS = [
|
const PROVIDER_HEADERS = [
|
||||||
// Umami custom headers (cloud mode only)
|
// 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) {
|
export async function getLocation(ip: string = '', headers: Headers, skipHeaders: boolean) {
|
||||||
const cleanIp = stripPort(ip);
|
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
|
// Database lookup
|
||||||
if (!globalThis[MAXMIND]) {
|
if (!globalThis[MAXMIND]) {
|
||||||
const dir = path.join(process.cwd(), 'geo');
|
const dir = path.join(process.cwd(), 'geo');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user