mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: bundle GeoIP city database
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 部署
|
||||
|
||||
|
||||
BIN
data/GeoLite2-City.mmdb
Normal file
BIN
data/GeoLite2-City.mmdb
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 MiB |
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Headers, "get">;
|
||||
type GeoIpResponse = CityResponse | CountryResponse;
|
||||
|
||||
|
||||
let geoIpReader: Reader<GeoIpResponse> | 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<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[]) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user