Files
mihomo-patchbay/shared/providers.ts
T
im0o 013f537eaa 订阅和规则组支持从文件导入,配置页与表单一批打磨
文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作
目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状),
成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。
file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示,
编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。

订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取
(component/resource/fetcher.go:226-228),关掉开关就是不写这个键。
配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。

规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条
代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网
直连」,只在有缺口时出现。

所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
2026-08-12 00:46:17 +08:00

185 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 订阅的读写。
*
* 配置文件里的 `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<string>()
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))
}