/** * GEO 数据库文件的本地状态:多大、多久没更新了。 * * 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端 * 在同一台机器上,直接 stat 就是。 */ import { readdir, rename, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts' import type { GeoFileStatus } from '../shared/geo.ts' import type { Env } from './env.ts' /** 配置里写了就用配置的,否则退回内核默认。 */ function urlFor(geoxUrl: Record, configKey: string): string { const configured = geoxUrl[configKey] return typeof configured === 'string' && configured !== '' ? configured : (DEFAULT_GEOX_URLS[configKey] ?? '') } export async function geoStatus( env: Env, geoxUrl: Record, ): Promise { const entries = await readdir(env.mihomoHome).catch(() => [] as string[]) return Promise.all( GEO_FILES.map(async ({ key, configKey, file }) => { // 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做 const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase()) let size: number | null = null let updatedAt: string | null = null if (actual) { const info = await stat(join(env.mihomoHome, actual)).catch(() => null) if (info) { size = info.size updatedAt = info.mtime.toISOString() } } return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt } }), ) } async function download(url: string, proxy: string | null): Promise { const res = await fetch(url, { signal: AbortSignal.timeout(180_000), redirect: 'follow', ...(proxy ? { proxy } : {}), }) if (!res.ok) throw new Error(`HTTP ${String(res.status)}`) return new Uint8Array(await res.arrayBuffer()) } /** * 下载单个 GEO 文件写进内核工作目录。 * * 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型), * 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查 * (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。 * * `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连 * GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。 */ export async function syncGeoFile( env: Env, configKey: string, geoxUrl: Record, proxy: string | null, ): Promise<{ ok: true } | { ok: false; message: string }> { const spec = GEO_FILES.find((g) => g.configKey === configKey) if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` } const url = urlFor(geoxUrl, configKey) let bytes: Uint8Array try { bytes = await download(url, proxy) } catch (viaProxy) { if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` } try { bytes = await download(url, null) } catch (direct) { return { ok: false, message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`, } } } const shapeOk = configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes) if (!shapeOk) { return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` } } // 沿用目录里现有的大小写写法,内核就是这样找文件的 const entries = await readdir(env.mihomoHome).catch(() => [] as string[]) const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file const finalPath = join(env.mihomoHome, actual) const tempPath = `${finalPath}.patchbay-tmp` try { await writeFile(tempPath, bytes) await rename(tempPath, finalPath) } catch (error) { return { ok: false, message: `写入失败:${String(error)}` } } return { ok: true } }