订阅和规则组支持从文件导入,配置页与表单一批打磨

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

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

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

所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
2026-08-12 00:46:17 +08:00
parent 7d08f37248
commit 013f537eaa
19 changed files with 871 additions and 101 deletions
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { isProviderFilePath } from './provider-files.ts'
describe('isProviderFilePath', () => {
test('订阅缓存和规则清单的标准形状放行', () => {
expect(isProviderFilePath('./providers/subs.yaml')).toBe(true)
expect(isProviderFilePath('./providers/rules/ads.txt')).toBe(true)
expect(isProviderFilePath('./providers/rules/直连清单.yaml')).toBe(true)
})
test('目录逃逸和绝对路径全拦', () => {
expect(isProviderFilePath('./providers/../config.yaml')).toBe(false)
expect(isProviderFilePath('providers/subs.yaml')).toBe(false)
expect(isProviderFilePath('/etc/mihomo/config.yaml')).toBe(false)
expect(isProviderFilePath('./providers/rules/../../x.yaml')).toBe(false)
expect(isProviderFilePath('./providers/a/b.yaml')).toBe(false)
})
test('mrs 是二进制格式,不走文本上传', () => {
expect(isProviderFilePath('./providers/rules/geo.mrs')).toBe(false)
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* 上传的线路/规则清单文件允许写到哪。
*
* 服务端的写文件端点只认这两种形状——它们正是 providerCachePath()(订阅缓存)
* 和 ruleProviderPath()(规则组清单)生成的路径。白名单式校验:文件名只许
* 字母数字下划线连字符,杜绝 ../、绝对路径和多余的目录层级。
* mrs 不在扩展名里:那是二进制格式,文本上传通道传不了。
*/
export function isProviderFilePath(path: string): boolean {
return /^\.\/providers\/(rules\/)?[\p{L}\p{N}_-]+\.(yaml|txt)$/u.test(path)
}
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test'
import {
DEFAULT_INTERVAL,
addFileSubscription,
addSubscription,
listSubscriptions,
providerNames,
@@ -40,6 +41,8 @@ describe('读出订阅列表', () => {
expect(listSubscriptions(base())).toEqual([
{
name: 'subs',
type: 'http',
path: './providers/subs.yaml',
url: URL,
interval: 86400,
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
@@ -92,6 +95,19 @@ describe('新增订阅', () => {
(data['proxy-providers'] as ConfigObject).subs,
)
})
it('关掉自动更新时不写 interval 键', () => {
const next = addSubscription({}, 'subs2', URL, null)
const entry = (next['proxy-providers'] as ConfigObject).subs2 as ConfigObject
expect('interval' in entry).toBe(false)
})
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
const next = addFileSubscription({}, 'subs2')
expect(next['proxy-providers']).toEqual({
subs2: { type: 'file', path: './providers/subs2.yaml' },
})
})
})
describe('编辑订阅', () => {
@@ -112,6 +128,12 @@ describe('编辑订阅', () => {
const data = base()
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
})
it('关掉自动更新时把已有的 interval 键去掉', () => {
const next = updateSubscription(base(), 'subs', URL, null)
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
expect('interval' in entry).toBe(false)
})
})
describe('删除订阅', () => {
+43 -5
View File
@@ -18,8 +18,16 @@ export const DEFAULT_INTERVAL = 86400
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription {
name: string
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
type: 'http' | 'file'
url: string
/** 更新间隔,秒。null 表示配置里没写 */
/** 线路文件的路径(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
@@ -49,6 +57,8 @@ export function listSubscriptions(data: ConfigObject): Subscription[] {
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:
@@ -77,12 +87,37 @@ export function providerNames(data: ConfigObject): string[] {
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
* (见 inbound-groups.ts 的 applySetup)。
*
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
*/
export function addSubscription(data: ConfigObject, name: string, url: string): ConfigObject {
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: DEFAULT_INTERVAL },
[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
}
@@ -92,14 +127,17 @@ export function updateSubscription(
data: ConfigObject,
name: string,
url: string,
interval: number,
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]: { ...entry, url, interval },
[name]: updated,
}
return next
}