feat: bundle GeoIP city database

This commit is contained in:
JetSprow
2026-04-29 14:45:38 +10:00
parent 17163286a6
commit 46ce257b0b
7 changed files with 159 additions and 5 deletions

View File

@@ -21,6 +21,9 @@ ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
# Redis connection URL for local tools; Docker Compose overrides host to redis # Redis connection URL for local tools; Docker Compose overrides host to redis
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
# Optional GeoIP MMDB path. The repository includes a default City database at data/GeoLite2-City.mmdb.
GEOIP_MMDB_PATH="data/GeoLite2-City.mmdb"
# Initial admin account, used by npm run db:seed on first install # Initial admin account, used by npm run db:seed on first install
ADMIN_EMAIL="admin@jboard.local" ADMIN_EMAIL="admin@jboard.local"
ADMIN_PASSWORD="admin123" ADMIN_PASSWORD="admin123"

View File

@@ -36,6 +36,7 @@ RUN adduser --system --uid 1001 nextjs
# Next.js standalone output # Next.js standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/data ./data
# Prisma: schema + config + generated client + adapter # Prisma: schema + config + generated client + adapter
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma

View File

@@ -210,7 +210,7 @@ server {
订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。 订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。
J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并在 24 小时窗口内按城市/省份变化触发风控4 个城市警告、5 个城市暂停2 个省/地区警告、3 个省/地区暂停。管理员可在后台“订阅风控”查看关联用户、订阅、IP、地区统计并将事件标记为待处理、已确认或已解决必要时可人工恢复被暂停的订阅。Cloudflare 场景建议在 Rules -> Settings -> Managed Transforms 开启 Add visitor location headers让回源请求带上 `cf-ipcity``cf-region``cf-region-code` 等字段;未提供城市/省份字段时,系统只记录 IP不会触发地区变化规则。 J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并在 24 小时窗口内按城市/省份变化触发风控4 个城市警告、5 个城市暂停2 个省/地区警告、3 个省/地区暂停。管理员可在后台“订阅风控”查看关联用户、订阅、IP、地区统计并将事件标记为待处理、已确认或已解决必要时可人工恢复被暂停的订阅。项目内置 `data/GeoLite2-City.mmdb` 作为本地 GeoIP 城市库,默认通过 `GEOIP_MMDB_PATH=data/GeoLite2-City.mmdb` 读取;如果反代或 CDN 已传地理位置头,系统优先使用请求头,并用 MMDB 补齐缺失字段。Cloudflare 场景建议在 Rules -> Settings -> Managed Transforms 开启 Add visitor location headers让回源请求带上 `cf-ipcity``cf-region``cf-region-code` 等字段;未提供城市/省份字段且 MMDB 不可用时,系统只记录 IP不会触发地区变化规则。
### 手动 Docker 部署 ### 手动 Docker 部署

BIN
data/GeoLite2-City.mmdb Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 MiB

34
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"maxmind": "^5.0.6",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -8812,6 +8813,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/maxmind": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.6.tgz",
"integrity": "sha512-5bvd/u+kIaTqaGM+xkXjatzQw1dQfSmlLggr2W1EKMyMxSgx2woZyusLpNpZ4DdPmL+1bbJWeo4LXsi6bC0Iew==",
"license": "MIT",
"dependencies": {
"mmdb-lib": "3.0.2",
"tiny-lru": "13.0.0"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/mdast-util-from-markdown": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -9524,6 +9539,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mmdb-lib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==",
"license": "MIT",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -12286,6 +12311,15 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-lru": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz",
"integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=14"
}
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",

View File

@@ -21,6 +21,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"maxmind": "^5.0.6",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -1,6 +1,13 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { isIP } from "node:net"; import { isIP } from "node:net";
import { Reader, type CityResponse, type CountryResponse } from "maxmind";
type HeaderReader = Pick<Headers, "get">; type HeaderReader = Pick<Headers, "get">;
type GeoIpResponse = CityResponse | CountryResponse;
let geoIpReader: Reader<GeoIpResponse> | null | undefined;
export interface RequestGeoContext { export interface RequestGeoContext {
country: string | null; country: string | null;
@@ -18,6 +25,112 @@ export interface ClientRequestContext {
geo: RequestGeoContext; geo: RequestGeoContext;
} }
function emptyGeoContext(source: string | null = null): RequestGeoContext {
return {
country: null,
region: null,
regionCode: null,
city: null,
latitude: null,
longitude: null,
source,
};
}
function hasGeoValue(geo: RequestGeoContext) {
return Boolean(geo.country || geo.region || geo.regionCode || geo.city || geo.latitude || geo.longitude);
}
function resolveGeoIpDatabasePath() {
const configured = process.env.GEOIP_MMDB_PATH?.trim();
if (!configured) {
return path.join(process.cwd(), "data", "GeoLite2-City.mmdb");
}
return path.isAbsolute(configured)
? configured
: path.join(/* turbopackIgnore: true */ process.cwd(), configured);
}
function getGeoIpReader() {
if (geoIpReader !== undefined) return geoIpReader;
const databasePath = resolveGeoIpDatabasePath();
if (!existsSync(/* turbopackIgnore: true */ databasePath)) {
geoIpReader = null;
return geoIpReader;
}
try {
geoIpReader = new Reader<GeoIpResponse>(readFileSync(/* turbopackIgnore: true */ databasePath));
} catch (error) {
geoIpReader = null;
if (process.env.NODE_ENV !== "production") {
console.warn("Failed to load GeoIP MMDB database:", error);
}
}
return geoIpReader;
}
function localizedName(record: { names?: object } | null | undefined) {
if (!record?.names) return null;
const names = record.names as { "zh-CN"?: string; en?: string };
return names["zh-CN"] ?? names.en ?? Object.values(names).find((value) => typeof value === "string") ?? null;
}
function getGeoIpLocation(ip: string): RequestGeoContext {
if (ip === "unknown" || !isIP(ip)) return emptyGeoContext();
const reader = getGeoIpReader();
if (!reader) return emptyGeoContext();
const record = reader.get(ip);
if (!record) return emptyGeoContext();
const country = record.country ?? record.registered_country ?? null;
const cityRecord = "city" in record ? record.city : null;
const subdivision = "subdivisions" in record ? record.subdivisions?.[0] : null;
const location = "location" in record ? record.location : null;
const geo = {
country: country?.iso_code ?? localizedName(country),
region: localizedName(subdivision),
regionCode: subdivision?.iso_code ?? null,
city: localizedName(cityRecord),
latitude: location?.latitude == null ? null : String(location.latitude),
longitude: location?.longitude == null ? null : String(location.longitude),
source: "mmdb",
} satisfies RequestGeoContext;
return hasGeoValue(geo) ? geo : emptyGeoContext();
}
function sameCountry(headerGeo: RequestGeoContext, mmdbGeo: RequestGeoContext) {
if (!headerGeo.country || !mmdbGeo.country) return true;
return headerGeo.country.trim().toLowerCase() === mmdbGeo.country.trim().toLowerCase();
}
function mergeGeoContext(headerGeo: RequestGeoContext, mmdbGeo: RequestGeoContext): RequestGeoContext {
const useMmdb = sameCountry(headerGeo, mmdbGeo);
const merged: RequestGeoContext = {
country: headerGeo.country ?? (useMmdb ? mmdbGeo.country : null),
region: headerGeo.region ?? (useMmdb ? mmdbGeo.region : null),
regionCode: headerGeo.regionCode ?? (useMmdb ? mmdbGeo.regionCode : null),
city: headerGeo.city ?? (useMmdb ? mmdbGeo.city : null),
latitude: headerGeo.latitude ?? (useMmdb ? mmdbGeo.latitude : null),
longitude: headerGeo.longitude ?? (useMmdb ? mmdbGeo.longitude : null),
source: null,
};
const sources = new Set<string>();
if (hasGeoValue(headerGeo) && headerGeo.source) sources.add(headerGeo.source);
if (useMmdb && hasGeoValue(mmdbGeo)) sources.add("mmdb");
merged.source = sources.size > 0 ? Array.from(sources).join("+") : null;
return merged;
}
function firstHeader(headers: HeaderReader, names: string[]) { function firstHeader(headers: HeaderReader, names: string[]) {
for (const name of names) { for (const name of names) {
const value = headers.get(name)?.split(",")[0]?.trim(); const value = headers.get(name)?.split(",")[0]?.trim();
@@ -84,7 +197,7 @@ export function getClientIp(headers: HeaderReader) {
return "unknown"; return "unknown";
} }
export function getRequestGeo(headers: HeaderReader): RequestGeoContext { export function getRequestGeo(headers: HeaderReader, ip = "unknown"): RequestGeoContext {
const country = decodeHeaderValue(firstHeader(headers, [ const country = decodeHeaderValue(firstHeader(headers, [
"cf-ipcountry", "cf-ipcountry",
"x-vercel-ip-country", "x-vercel-ip-country",
@@ -130,13 +243,15 @@ export function getRequestGeo(headers: HeaderReader): RequestGeoContext {
source = "proxy"; source = "proxy";
} }
return { country, region, regionCode, city, latitude, longitude, source }; const headerGeo = { country, region, regionCode, city, latitude, longitude, source };
return mergeGeoContext(headerGeo, getGeoIpLocation(ip));
} }
export function getClientRequestContext(headers: HeaderReader): ClientRequestContext { export function getClientRequestContext(headers: HeaderReader): ClientRequestContext {
const ip = getClientIp(headers);
return { return {
ip: getClientIp(headers), ip,
userAgent: headers.get("user-agent")?.slice(0, 500) ?? null, userAgent: headers.get("user-agent")?.slice(0, 500) ?? null,
geo: getRequestGeo(headers), geo: getRequestGeo(headers, ip),
}; };
} }