feat: 新增游客订阅链接与节点拖动排序

游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单
(抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest
token,三个 generator 接收可选 whitelist 过滤生成。

节点排序:fetched_nodes 新增 sort_order,重抓时按名保留顺序,
选择/节点面板支持原生拖拽排序,输出按此顺序排列。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 23:43:28 +08:00
parent c3d8e1a961
commit fb7a30489e
14 changed files with 737 additions and 44 deletions

View File

@@ -6,6 +6,7 @@ import subscriptionsRouter from './routes/subscriptions.js';
import nodesRouter from './routes/nodes.js';
import rulesRouter from './routes/rules.js';
import surgeRouter from './routes/surge.js';
import guestsRouter from './routes/guests.js';
import db from './db.js';
import { generateSurgeConfig } from './services/generator.js';
import { generateClashConfig } from './services/clashGenerator.js';
@@ -26,18 +27,36 @@ const PORT = parseInt(process.env.PORT || '3456', 10);
app.use(express.json());
function verifySurgeToken(token: string): boolean {
type TokenResolution =
| { kind: 'main' }
| { kind: 'guest'; whitelist: Set<string> }
| null;
/**
* Resolve a subscription token to either the admin token (full config) or an
* enabled guest link (config restricted to its whitelisted node names).
*/
function resolveToken(token: string): TokenResolution {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
return !!row?.value && token === row.value;
if (row?.value && token === row.value) return { kind: 'main' };
const guest = db.prepare('SELECT id FROM guest_links WHERE token = ? AND enabled = 1').get(token) as any;
if (guest) {
const names = db.prepare('SELECT node_name FROM guest_link_nodes WHERE guest_id = ?').all(guest.id) as any[];
return { kind: 'guest', whitelist: new Set(names.map(n => n.node_name)) };
}
return null;
}
// Surge endpoint (no auth, token-protected path)
// Surge endpoint (no auth, token-protected path; admin token or guest link)
app.get('/surge/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const host = req.headers.host || 'localhost:3456';
const protocol = req.secure ? 'https' : 'http';
const hostUrl = `${protocol}://${host}/surge/${req.params.token}`;
const config = generateSurgeConfig(hostUrl);
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateSurgeConfig(hostUrl, whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.conf',
@@ -45,10 +64,12 @@ app.get('/surge/:token', (req, res) => {
res.send(config);
});
// Clash endpoint (no auth, token-protected path)
// Clash endpoint (no auth, token-protected path; admin token or guest link)
app.get('/clash/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateClashConfig();
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateClashConfig(whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.yaml',
@@ -56,10 +77,12 @@ app.get('/clash/:token', (req, res) => {
res.send(config);
});
// SSR endpoint (no auth, token-protected path)
// SSR endpoint (no auth, token-protected path; admin token or guest link)
app.get('/ssr/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateSSRConfig();
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateSSRConfig(whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.txt',
@@ -106,6 +129,7 @@ app.use('/api/subscriptions', subscriptionsRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/rules', rulesRouter);
app.use('/api/config', surgeRouter);
app.use('/api/guests', guestsRouter);
// Stats endpoint
app.get('/api/stats', (_req, res) => {