Files
proxy-station/server/src/routes/rules.ts
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

140 lines
6.4 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.

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { asc, eq, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { BUILTIN_POLICIES, ruleInputSchema, type RuleDto } from '@proxy-station/shared';
import { db } from '../db/index.js';
import { policyGroups, rules, rulesets } from '../db/schema.js';
import { seedRulesFromTemplate } from '../db/seed.js';
function loadRules(): RuleDto[] {
const rows = db.select().from(rules).orderBy(asc(rules.seq)).all();
const sets = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
return rows.map((r) => {
const rs = r.value ? sets.get(r.value) : undefined;
return {
...r,
params: JSON.parse(r.params),
rulesetName: rs?.name ?? null,
rulesetUrl: rs?.surgeUrl ?? null,
};
});
}
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
function policyError(policy: string): string | null {
if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null;
const group = db.select().from(policyGroups).where(eq(policyGroups.name, policy)).get();
return group?.enabled ? null : `策略「${policy}」不存在或已停用:请选择内置策略或启用的策略组`;
}
function inputError(input: z.output<typeof ruleInputSchema>, id?: string): string | null {
const err = policyError(input.policy);
if (err) return err;
if (input.type === 'FINAL' && db.select().from(rules).all().some((r) => r.type === 'FINAL' && r.id !== id)) return '只能有一条 FINAL 规则';
if (['RULE-SET', 'DOMAIN-SET'].includes(input.type) && !db.select().from(rulesets).where(eq(rulesets.id, input.value!)).get()) return '规则集不存在';
return null;
}
export const rulesRoute = new Hono()
.get('/', (c) => c.json(loadRules()))
.post('/', zValidator('json', ruleInputSchema), (c) => {
const input = c.req.valid('json');
const err = inputError(input);
if (err) return c.json({ error: err }, 400);
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
const id = nanoid(10);
db.transaction(() => {
if (input.type !== 'FINAL') db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
const seq = input.type === 'FINAL' ? Math.max(-1, ...db.select().from(rules).all().map((r) => r.seq)) + 1 : 0;
db.insert(rules)
.values({
id,
seq,
type: input.type,
value: input.value ?? null,
policy: input.policy,
params: JSON.stringify(input.params),
enabled: input.enabled,
source: 'custom',
})
.run();
});
return c.json({ id }, 201);
})
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
const { ids } = c.req.valid('json');
const rows = db.select().from(rules).all();
const known = new Set(rows.map((r) => r.id));
if (ids.length !== rows.length || new Set(ids).size !== ids.length || ids.some((id) => !known.has(id))) return c.json({ error: '排序必须包含全部规则且每条仅出现一次' }, 400);
const finals = rows.filter((r) => r.type === 'FINAL');
if (finals.length !== 1 || !finals[0].enabled || ids.at(-1) !== finals[0].id) return c.json({ error: '启用的唯一 FINAL 必须位于末尾' }, 400);
db.transaction(() => ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run()));
return c.json({ ok: true });
})
.post('/reset', (c) => {
seedRulesFromTemplate(db, { keepCustom: true });
return c.json({ ok: true, rules: loadRules().length });
})
.put('/:id', zValidator('json', ruleInputSchema), (c) => {
const id = c.req.param('id');
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
if (!existing) return c.json({ error: '规则不存在' }, 404);
const input = c.req.valid('json');
if (existing.type === 'FINAL' && input.type !== 'FINAL') return c.json({ error: '不能将 FINAL 改成其他规则类型' }, 400);
if (existing.type !== 'FINAL' && input.type === 'FINAL') return c.json({ error: '请编辑已有 FINAL,不能改变普通规则的类型为 FINAL' }, 400);
const err = inputError(input, id);
if (err) return c.json({ error: err }, 400);
db.update(rules)
.set({
type: input.type,
value: input.value ?? null,
policy: input.policy,
params: JSON.stringify(input.params),
enabled: input.enabled,
})
.where(eq(rules.id, id))
.run();
return c.json({ ok: true });
})
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
if (!row) return c.json({ error: '规则不存在' }, 404);
if (row.type === 'FINAL' && !c.req.valid('json').enabled) return c.json({ error: '不能停用 FINAL' }, 400);
const res = db
.update(rules)
.set({ enabled: c.req.valid('json').enabled })
.where(eq(rules.id, c.req.param('id')))
.run();
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
return c.json({ ok: true });
})
.delete('/:id', (c) => {
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
if (!row) return c.json({ error: '规则不存在' }, 404);
if (row.type === 'FINAL') return c.json({ error: '不能删除 FINAL' }, 400);
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
return c.json({ ok: true });
});
export const rulesetsRoute = new Hono()
.get('/', (c) => {
const rows = db.select().from(rulesets).all();
return c.json(rows);
})
.get('/:id/content', async (c) => {
const rs = db.select().from(rulesets).where(eq(rulesets.id, c.req.param('id'))).get();
if (!rs) return c.json({ error: '规则集不存在' }, 404);
const refresh = c.req.query('refresh') === '1';
try {
const { fetchRulesetContent } = await import('../services/rulesets/fetch.js');
const { content, fetchedAt, stale } = await fetchRulesetContent(rs.surgeUrl, { refresh });
const lineCount = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length;
return c.json({ content, fetchedAt, stale, lineCount });
} catch (e: any) {
return c.json({ error: `拉取失败:${e.message}` }, 502);
}
});