diff --git a/server/routes.ts b/server/routes.ts index e42a6e8..25294bd 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -9,7 +9,9 @@ * 透传只开放 GET。写操作(切换节点、关连接)属于运行时面板的功能,等做到那一步再单独开, * 现在不留一个「什么都能转发」的口子。 */ +import { resolve, sep } from 'node:path' import listenerFields from '../shared/listener-fields.json' with { type: 'json' } +import { isProviderFilePath } from '../shared/provider-files.ts' import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import { hostInfo, probeExit } from './host.ts' import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts' @@ -181,6 +183,26 @@ function makeRoute(env: Env, token: string) { return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) } + // ── 清单文件 ──────────────────────────────────────── + // 从界面上传的订阅/规则清单,写进内核工作目录的 providers/ 下。 + // 路径白名单校验 + 解析后确认没出工作目录,两道都过才落盘 + if (path === '/api/provider-files' && req.method === 'POST') { + const body = (await req.json()) as { path?: string; content?: string } + if (typeof body.path !== 'string' || typeof body.content !== 'string') { + return json({ message: '请求缺少 path 或 content 字段' }, 400) + } + if (!isProviderFilePath(body.path)) { + return json({ message: `不允许写入该路径:${body.path}` }, 400) + } + const home = resolve(env.mihomoHome) + const target = resolve(home, body.path) + if (!target.startsWith(home + sep)) { + return json({ message: `不允许写入该路径:${body.path}` }, 400) + } + await Bun.write(target, body.content) + return json({ ok: true }) + } + // ── 主机 ──────────────────────────────────────────── // 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答 if (path === '/api/host' && req.method === 'GET') { diff --git a/shared/provider-files.test.ts b/shared/provider-files.test.ts new file mode 100644 index 0000000..f3465ae --- /dev/null +++ b/shared/provider-files.test.ts @@ -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) + }) +}) diff --git a/shared/provider-files.ts b/shared/provider-files.ts new file mode 100644 index 0000000..6cf9069 --- /dev/null +++ b/shared/provider-files.ts @@ -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) +} diff --git a/shared/providers.test.ts b/shared/providers.test.ts index ea36151..6788781 100644 --- a/shared/providers.test.ts +++ b/shared/providers.test.ts @@ -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('删除订阅', () => { diff --git a/shared/providers.ts b/shared/providers.ts index e09e115..6c4e3f3 100644 --- a/shared/providers.ts +++ b/shared/providers.ts @@ -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 } diff --git a/web/api.ts b/web/api.ts index 0fd8f7e..42d9a52 100644 --- a/web/api.ts +++ b/web/api.ts @@ -190,6 +190,13 @@ export const api = { return res.body }, + /** + * 把上传的线路/规则清单写进内核工作目录的 providers/ 下。 + * path 必须是 providerCachePath / ruleProviderPath 生成的形状,服务端有白名单校验。 + */ + uploadProviderFile: (path: string, content: string) => + request<{ ok: true }>('POST', '/api/provider-files', { path, content }), + /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ listenerFields: () => request('GET', '/api/meta/listener-fields'), diff --git a/web/components/DropFile.vue b/web/components/DropFile.vue new file mode 100644 index 0000000..35dbbcf --- /dev/null +++ b/web/components/DropFile.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/web/views/ConfigPage.vue b/web/views/ConfigPage.vue index 825cf49..b0d570e 100644 --- a/web/views/ConfigPage.vue +++ b/web/views/ConfigPage.vue @@ -10,6 +10,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { collectInbounds } from '@shared/inbounds.ts' import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts' import { + addFileSubscription, addSubscription, listSubscriptions, providerNames, @@ -18,6 +19,7 @@ import { updateSubscription, } from '@shared/providers.ts' import type { Subscription } from '@shared/providers.ts' +import { providerCachePath } from '@shared/inbound-groups.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts' import { ApiError, api } from '../api' import type { ProviderInfo } from '../api' @@ -27,6 +29,8 @@ import ProviderList from '../components/ProviderList.vue' import SubscriptionDetail from './SubscriptionDetail.vue' import SubscriptionForm from './SubscriptionForm.vue' import IconAdd from '~icons/ri/add-line' +import IconRefresh from '~icons/ri/refresh-line' +import IconClose from '~icons/ri/close-line' const props = defineProps<{ data: ConfigObject; revision: string }>() @@ -142,11 +146,33 @@ function openEdit() { formOpen.value = true } -async function submitForm(draft: { name: string; url: string; interval: number }) { +async function submitForm(draft: { + name: string + url: string + interval: number | null + file: { name: string; content: string } | null +}) { const editing = formEditing.value + + // 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。 + // 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件 + if (draft.file) { + try { + await api.uploadProviderFile(providerCachePath(draft.name), draft.file.content) + } catch (error) { + failure.value = + error instanceof ApiError + ? { message: error.message, hint: error.hint } + : { message: String(error), hint: '' } + return + } + } + const next = editing ? updateSubscription(clone(), editing.name, draft.url, draft.interval) - : addSubscription(clone(), draft.name, draft.url) + : draft.file + ? addFileSubscription(clone(), draft.name) + : addSubscription(clone(), draft.name, draft.url, draft.interval) await commit(next, editing ? '已保存' : `已新增 ${draft.name}`) // 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了 if (failure.value) return @@ -194,6 +220,29 @@ async function refresh() { refreshing.value = null } } + +const refreshingAll = ref(false) + +/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */ +async function refreshAll() { + if (refreshingAll.value || !kernel.value.online || subscriptions.value.length === 0) return + refreshingAll.value = true + failure.value = null + done.value = '' + const names = subscriptions.value.map((s) => s.name) + const results = await Promise.allSettled(names.map((n) => api.refreshProvider(n))) + await Promise.all([reloadRuntime(), reloadProviders()]) + const failed = names.filter((_, i) => results[i]?.status === 'rejected') + if (failed.length > 0) { + failure.value = { + message: `${failed.join('、')} 更新失败`, + hint: '其余订阅已更新。该操作仅修改内核运行状态,配置文件未被修改。', + } + } else { + done.value = `已更新全部 ${names.length} 个订阅` + } + refreshingAll.value = false +}