diff --git a/.env.example b/.env.example index dc515e8..ed5f3a8 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ ENCRYPTION_KEY="0123456789abcdef0123456789abcdef" # Redis connection URL for local tools; Docker Compose overrides host to redis 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 ADMIN_EMAIL="admin@jboard.local" ADMIN_PASSWORD="admin123" diff --git a/Dockerfile b/Dockerfile index 96a0b0b..08dd698 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ RUN adduser --system --uid 1001 nextjs # Next.js standalone output 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/data ./data # Prisma: schema + config + generated client + adapter COPY --from=builder /app/prisma ./prisma diff --git a/README.md b/README.md index a43c5ac..433a472 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ server { 订阅域名套 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 部署 diff --git a/data/GeoLite2-City.mmdb b/data/GeoLite2-City.mmdb new file mode 100644 index 0000000..d512eb4 Binary files /dev/null and b/data/GeoLite2-City.mmdb differ diff --git a/package-lock.json b/package-lock.json index 70e2ac4..5a12688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "date-fns": "^4.1.0", "ioredis": "^5.10.1", "lucide-react": "^1.8.0", + "maxmind": "^5.0.6", "next": "16.2.4", "next-auth": "^4.24.14", "next-themes": "^0.4.6", @@ -8812,6 +8813,20 @@ "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": { "version": "2.0.3", "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" } }, + "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12286,6 +12311,15 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", diff --git a/package.json b/package.json index a3cbad1..af8881c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "date-fns": "^4.1.0", "ioredis": "^5.10.1", "lucide-react": "^1.8.0", + "maxmind": "^5.0.6", "next": "16.2.4", "next-auth": "^4.24.14", "next-themes": "^0.4.6", diff --git a/src/lib/request-context.ts b/src/lib/request-context.ts index 0851727..daa86cd 100644 --- a/src/lib/request-context.ts +++ b/src/lib/request-context.ts @@ -1,6 +1,13 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { isIP } from "node:net"; +import { Reader, type CityResponse, type CountryResponse } from "maxmind"; type HeaderReader = Pick; +type GeoIpResponse = CityResponse | CountryResponse; + + +let geoIpReader: Reader | null | undefined; export interface RequestGeoContext { country: string | null; @@ -18,6 +25,112 @@ export interface ClientRequestContext { 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(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(); + 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[]) { for (const name of names) { const value = headers.get(name)?.split(",")[0]?.trim(); @@ -84,7 +197,7 @@ export function getClientIp(headers: HeaderReader) { return "unknown"; } -export function getRequestGeo(headers: HeaderReader): RequestGeoContext { +export function getRequestGeo(headers: HeaderReader, ip = "unknown"): RequestGeoContext { const country = decodeHeaderValue(firstHeader(headers, [ "cf-ipcountry", "x-vercel-ip-country", @@ -130,13 +243,15 @@ export function getRequestGeo(headers: HeaderReader): RequestGeoContext { 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 { + const ip = getClientIp(headers); return { - ip: getClientIp(headers), + ip, userAgent: headers.get("user-agent")?.slice(0, 500) ?? null, - geo: getRequestGeo(headers), + geo: getRequestGeo(headers, ip), }; }