/** * 订阅的读写。 * * 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。 * 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。 * 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责, * 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。 * * 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的 * 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。 */ import type { ConfigObject } from '../vendor/config-editor/index.ts' import { isGenerated, providerCachePath } from './inbound-groups.ts' /** 新增订阅时的默认更新间隔,秒。一天一次。 */ export const DEFAULT_INTERVAL = 86400 /** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */ export interface Subscription { name: string /** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */ type: 'http' | 'file' url: string /** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */ path: string | null /** * 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时 * 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以 * 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。 */ interval: number | null /** 健康检查。没写或 enable 不是 true 都算未开启 */ healthCheck: { url: string | null; interval: number | null } | null } function isPlainObject(value: unknown): value is ConfigObject { return typeof value === 'object' && value !== null && !Array.isArray(value) } function providersOf(data: ConfigObject): ConfigObject { return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {} } function groupsOf(data: ConfigObject): ConfigObject[] { return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject) } function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] } /** 所有用户订阅,按配置里的顺序。不含生成的副本。 */ export function listSubscriptions(data: ConfigObject): Subscription[] { const result: Subscription[] = [] for (const [name, entry] of Object.entries(providersOf(data))) { if (isGenerated(name) || !isPlainObject(entry)) continue const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null result.push({ name, type: entry.type === 'file' ? 'file' : 'http', path: typeof entry.path === 'string' ? entry.path : null, url: typeof entry.url === 'string' ? entry.url : '', interval: typeof entry.interval === 'number' ? entry.interval : null, healthCheck: hc !== null && hc.enable === true ? { url: typeof hc.url === 'string' ? hc.url : null, interval: typeof hc.interval === 'number' ? hc.interval : null, } : null, }) } return result } /** * `proxy-providers` 里的所有名字,含生成的副本。 * 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。 */ export function providerNames(data: ConfigObject): string[] { return Object.keys(providersOf(data)) } /** * 新增一个订阅。 * * `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个, * 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源 * (见 inbound-groups.ts 的 applySetup)。 * * `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。 */ export function addSubscription( data: ConfigObject, name: string, url: string, interval: number | null = DEFAULT_INTERVAL, ): ConfigObject { const next = { ...data } next['proxy-providers'] = { ...providersOf(data), [name]: { type: 'http', url, path: providerCachePath(name), ...(interval !== null ? { interval } : {}), }, } return next } /** * 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好), * 内核靠文件监视跟进变化,没有地址也没有更新间隔。 */ export function addFileSubscription(data: ConfigObject, name: string): ConfigObject { const next = { ...data } next['proxy-providers'] = { ...providersOf(data), [name]: { type: 'file', path: providerCachePath(name) }, } return next } /** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */ export function updateSubscription( data: ConfigObject, name: string, url: string, interval: number | null, ): ConfigObject { const entry = providersOf(data)[name] if (!isPlainObject(entry)) return data const updated: ConfigObject = { ...entry, url } if (interval === null) delete updated.interval else updated.interval = interval const next = { ...data } next['proxy-providers'] = { ...providersOf(data), [name]: updated, } return next } /** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */ export function removeSubscription(data: ConfigObject, name: string): ConfigObject { const providers = { ...providersOf(data) } if (!(name in providers)) return data delete providers[name] const next = { ...data } if (Object.keys(providers).length === 0) delete next['proxy-providers'] else next['proxy-providers'] = providers return next } /** * 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了, * 而空组会让指向它的入站无路可走。 * * 引用有两种,第二种容易漏: * 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的 * 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅 * 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts) */ export function subscriptionReferences(data: ConfigObject, name: string): string[] { const target = providersOf(data)[name] if (!isPlainObject(target)) return [] const copies = new Set() if (typeof target.path === 'string') { for (const [copyName, entry] of Object.entries(providersOf(data))) { if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) { copies.add(copyName) } } } return groupsOf(data) .filter((group) => { const use = stringList(group.use) return use.includes(name) || use.some((u) => copies.has(u)) }) .map((group) => String(group.name)) }