Files
sub-router/server/src/services/generator.ts
YANG JIANKUAN fb7a30489e feat: 新增游客订阅链接与节点拖动排序
游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单
(抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest
token,三个 generator 接收可选 whitelist 过滤生成。

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 23:43:28 +08:00

179 lines
5.7 KiB
TypeScript

import db from '../db.js';
/**
* @param whitelist When provided (guest links), only nodes whose name is in the
* set are included, independent of their global enabled state. When omitted,
* the normal enabled-only behavior applies.
*/
export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): string {
// Get first enabled subscription's raw_config as base template
const sub = db.prepare(
'SELECT raw_config FROM subscriptions WHERE enabled = 1 AND raw_config IS NOT NULL ORDER BY id LIMIT 1'
).get() as any;
if (!sub?.raw_config) {
return '# No subscription config available. Add and fetch a subscription first.';
}
// Collect fetched nodes (exclude vless — Surge doesn't support it; tuic uses `tuic-v5`)
const fetchedNodes = db.prepare(
whitelist
? "SELECT surge_line FROM fetched_nodes WHERE type != 'vless' ORDER BY subscription_id, sort_order, id"
: "SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY subscription_id, sort_order, id"
).all() as any[];
// Collect static nodes (these go FIRST, exclude vless)
const staticNodes = db.prepare(
whitelist
? "SELECT surge_line FROM static_nodes WHERE type != 'vless' ORDER BY sort_order, id"
: "SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY sort_order, id"
).all() as any[];
// Collect enabled rules
const userRules = db.prepare(
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
// Static nodes first, then fetched nodes
const lineName = (l: string) => l.split(' = ')[0].trim();
const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
const staticLines = staticNodes.map((n: any) => n.surge_line).filter(keep);
const fetchedLines = fetchedNodes.map((n: any) => n.surge_line).filter(keep);
const allNodeLines = [...staticLines, ...fetchedLines];
const allNodeNames = allNodeLines.map((l: string) => l.split(' = ')[0].trim());
// Build rule lines
const ruleLines = userRules.map((r: any) => {
const line = `${r.type},${r.value},${r.action}`;
return r.comment ? `${line} // ${r.comment}` : line;
});
let config = sub.raw_config;
// Replace [Proxy] section with only enabled nodes
config = rebuildProxySection(config, staticLines, fetchedLines);
// Rebuild [Proxy Group] select groups with only enabled node names
config = rebuildProxyGroup(config, allNodeNames);
// Inject user rules at the beginning of [Rule] section
if (ruleLines.length > 0) {
config = injectRules(config, ruleLines);
}
// Rewrite MANAGED-CONFIG URL
config = config.replace(
/^#!MANAGED-CONFIG\s+\S+/m,
`#!MANAGED-CONFIG ${hostUrl}`
);
return config;
}
/**
* Replace the entire [Proxy] section content with only enabled nodes.
* Static nodes go first, then fetched nodes.
*/
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let inProxySection = false;
let proxyHeaderEmitted = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
if (inProxySection && !proxyHeaderEmitted) {
// Emit our rebuilt proxy content before leaving the section
emitProxyContent(result, staticLines, fetchedLines);
proxyHeaderEmitted = true;
}
inProxySection = trimmed === '[Proxy]';
result.push(line);
if (inProxySection) {
// Emit all enabled nodes right after [Proxy] header
emitProxyContent(result, staticLines, fetchedLines);
proxyHeaderEmitted = true;
}
continue;
}
if (inProxySection) {
// Skip original proxy lines (we replaced them)
continue;
}
result.push(line);
}
// If [Proxy] was the last section
if (inProxySection && !proxyHeaderEmitted) {
emitProxyContent(result, staticLines, fetchedLines);
}
return result.join('\n');
}
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
if (staticLines.length > 0) {
result.push('# --- 自定义节点 ---');
staticLines.forEach(l => result.push(l));
result.push('');
}
if (fetchedLines.length > 0) {
result.push('# --- 订阅节点 ---');
fetchedLines.forEach(l => result.push(l));
result.push('');
}
}
/**
* Rebuild [Proxy Group] select groups to contain only the enabled node names.
*/
function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
if (allNodeNames.length === 0) return config;
const lines = config.split('\n');
const result: string[] = [];
let inProxyGroupSection = false;
let handled = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inProxyGroupSection = trimmed === '[Proxy Group]';
}
if (inProxyGroupSection && !handled && trimmed.includes('= select,')) {
// Rebuild: keep group name and "= select," prefix, replace node list
const eqSelect = trimmed.indexOf('= select,');
const prefix = trimmed.slice(0, eqSelect + '= select,'.length);
result.push(prefix + ' ' + allNodeNames.join(', '));
handled = true;
continue;
}
result.push(line);
}
return result.join('\n');
}
function injectRules(config: string, ruleLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
for (const line of lines) {
result.push(line);
if (line.trim() === '[Rule]') {
result.push('# --- 自定义规则 ---');
ruleLines.forEach(r => result.push(r));
result.push('');
}
}
return result.join('\n');
}