fix: 修复 Clash/SSR 订阅生成器的多个问题

- 按 server:port 过滤 disabled 节点,使节点开关对 Clash/SSR 生效
- 静态节点使用自定义名称(而非 URI 内嵌名),且置于列表最前
- Clash 配置注入自定义规则到 rules: 段顶部
- Clash/SSR 端点 Content-Disposition filename 改为 IPLC.MAX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:28:16 +08:00
parent 013c0f14c4
commit 2df229473a
3 changed files with 239 additions and 41 deletions

View File

@@ -51,7 +51,7 @@ app.get('/clash/:token', (req, res) => {
const config = generateClashConfig(); const config = generateClashConfig();
res.set({ res.set({
'Content-Type': 'text/plain; charset=utf-8', 'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=clash.yaml', 'Content-Disposition': 'attachment; filename=IPLC.MAX.yaml',
}); });
res.send(config); res.send(config);
}); });
@@ -62,7 +62,7 @@ app.get('/ssr/:token', (req, res) => {
const config = generateSSRConfig(); const config = generateSSRConfig();
res.set({ res.set({
'Content-Type': 'text/plain; charset=utf-8', 'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=proxy.txt', 'Content-Disposition': 'attachment; filename=IPLC.MAX.txt',
}); });
res.send(config); res.send(config);
}); });

View File

@@ -1,6 +1,10 @@
import db from '../db.js'; import db from '../db.js';
import { uriToClashLine } from '../parsers/toClash.js'; import { uriToClashLine } from '../parsers/toClash.js';
function escYaml(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function generateClashConfig(): string { export function generateClashConfig(): string {
const sub = db.prepare( const sub = db.prepare(
'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1' 'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1'
@@ -10,29 +14,175 @@ export function generateClashConfig(): string {
return '# No Clash config available. Add a subscription with a Clash URL and fetch it.'; return '# No Clash config available. Add a subscription with a Clash URL and fetch it.';
} }
// Get disabled fetched nodes for filtering (by server:port)
const disabledNodes = db.prepare(
'SELECT server, port FROM fetched_nodes WHERE enabled = 0'
).all() as { server: string; port: number }[];
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
const staticNodes = db.prepare( const staticNodes = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[]; ).all() as { uri: string; name: string }[];
if (staticNodes.length === 0) { const userRules = db.prepare(
return sub.raw_config_clash; 'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
).all() as { type: string; value: string; action: string; comment: string | null }[];
let config = sub.raw_config_clash;
// Filter out disabled fetched nodes
if (disabledSet.size > 0) {
config = filterDisabledProxies(config, disabledSet);
} }
// Inject static nodes with custom names
if (staticNodes.length > 0) {
const clashLines = staticNodes const clashLines = staticNodes
.map((n: any) => uriToClashLine(n.uri)) .map(n => withCustomName(uriToClashLine(n.uri), n.name))
.filter((l): l is string => l !== null); .filter((l): l is string => l !== null);
const staticNames = staticNodes.map(n => n.name);
const staticNames = staticNodes.map((n: any) => n.name as string); if (clashLines.length > 0) {
config = injectProxies(config, clashLines);
if (clashLines.length === 0) { config = injectProxyGroupNames(config, staticNames);
return sub.raw_config_clash; }
}
// Inject user rules
if (userRules.length > 0) {
config = injectClashRules(config, userRules);
} }
let config = injectProxies(sub.raw_config_clash, clashLines);
config = injectProxyGroupNames(config, staticNames);
return config; return config;
} }
/** Replace the name field in a Clash flow-style proxy line with a custom name. */
function withCustomName(line: string | null, name: string): string | null {
if (!line) return null;
return line.replace(/name: "[^"]*"/, `name: "${escYaml(name)}"`);
}
/**
* Remove proxies whose server:port matches a disabled node.
* Also removes those proxy names from all proxy-group proxies lists.
*/
function filterDisabledProxies(config: string, disabledSet: Set<string>): string {
const lines = config.split('\n');
const result: string[] = [];
const removedNames = new Set<string>();
let inProxies = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^proxies:/.test(line)) {
inProxies = true;
result.push(line);
continue;
}
if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ') && !line.startsWith('-')) {
inProxies = false;
}
if (inProxies && trimmed.startsWith('-')) {
const serverMatch = trimmed.match(/server:\s*([^\s,}]+)/);
const portMatch = trimmed.match(/port:\s*(\d+)/);
if (serverMatch && portMatch) {
const key = `${serverMatch[1].replace(/,+$/, '')}:${portMatch[1].replace(/,+$/, '')}`;
if (disabledSet.has(key)) {
const nameMatch = trimmed.match(/name:\s*"([^"]*)"/);
if (nameMatch) removedNames.add(nameMatch[1]);
continue; // skip this proxy
}
}
}
result.push(line);
}
if (removedNames.size > 0) {
return removeNamesFromProxyGroups(result.join('\n'), removedNames);
}
return result.join('\n');
}
/** Remove specific proxy names from all proxy-group proxies lists. */
function removeNamesFromProxyGroups(config: string, names: Set<string>): string {
// Handle inline array format: proxies: [name1, name2, ...]
config = config.replace(/proxies:\s*\[([^\]]*)\]/g, (_, inner: string) => {
const items = inner.split(',').map(s => s.trim()).filter(s => s && !names.has(s));
return `proxies: [${items.join(', ')}]`;
});
// Handle block list format:
// proxies:
// - SomeName
const lines = config.split('\n');
const result: string[] = [];
let inProxyGroups = false;
let inProxiesList = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^proxy-groups:/.test(line)) {
inProxyGroups = true;
inProxiesList = false;
result.push(line);
continue;
}
if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ') && !line.startsWith('-') && !/^proxy-groups/.test(line)) {
inProxyGroups = false;
inProxiesList = false;
}
if (inProxyGroups) {
if (/^\s+-\s+name:/.test(line)) {
inProxiesList = false;
}
if (/^\s+proxies:\s*$/.test(line)) {
inProxiesList = true;
result.push(line);
continue;
}
if (inProxiesList && /^\s+-\s+/.test(line)) {
const name = trimmed.replace(/^-\s+/, '');
if (names.has(name)) continue;
}
}
result.push(line);
}
return result.join('\n');
}
/** Inject user rules at the top of the rules: section. */
function injectClashRules(
config: string,
rules: { type: string; value: string; action: string; comment: string | null }[]
): string {
const ruleLines = rules.map(r => {
const line = ` - ${r.type},${r.value},${r.action}`;
return r.comment ? `${line} # ${r.comment}` : line;
});
const lines = config.split('\n');
const result: string[] = [];
for (const line of lines) {
result.push(line);
if (/^rules:/.test(line.trim())) {
result.push(' # --- 自定义规则 ---');
for (const rl of ruleLines) result.push(rl);
result.push(' # --- 订阅规则 ---');
}
}
return result.join('\n');
}
/** /**
* Inject static node YAML lines into the proxies: section. * Inject static node YAML lines into the proxies: section.
* Inserts immediately after the `proxies:` key line. * Inserts immediately after the `proxies:` key line.
@@ -48,7 +198,6 @@ function injectProxies(config: string, clashLines: string[]): string {
if (!injected && trimmed === 'proxies:') { if (!injected && trimmed === 'proxies:') {
result.push(lines[i]); result.push(lines[i]);
// Inject right after the `proxies:` key
for (const cl of clashLines) result.push(cl); for (const cl of clashLines) result.push(cl);
injected = true; injected = true;
continue; continue;
@@ -126,40 +275,35 @@ function injectProxyGroupNames(config: string, names: string[]): string {
} }
if (inSelectGroup) { if (inSelectGroup) {
// Handle inline array: proxies: [node1, node2, ...] // Handle inline array: proxies: [node1, node2, ...] → prepend static names
if (/^\s+proxies:\s*\[/.test(line)) { if (/^\s+proxies:\s*\[/.test(line)) {
// Append names before the closing ] const openBracket = line.indexOf('[');
const closeBracket = line.lastIndexOf(']'); const closeBracket = line.lastIndexOf(']');
if (closeBracket !== -1) { if (openBracket !== -1 && closeBracket !== -1) {
const before = line.slice(0, closeBracket); const prefix = line.slice(0, openBracket + 1);
const after = line.slice(closeBracket); const existing = line.slice(openBracket + 1, closeBracket).trim();
result.push(before + ', ' + names.join(', ') + after); const sep = existing ? ', ' : '';
result.push(prefix + names.join(', ') + sep + existing + ']');
} else { } else {
result.push(line); result.push(line);
} }
continue; continue;
} }
// Handle block sequence: ` proxies:` followed by ` - name` items // Handle block sequence: ` proxies:` → inject static names before existing items
if (/^\s+proxies:\s*$/.test(line)) { if (/^\s+proxies:\s*$/.test(line)) {
inProxiesSublist = true; inProxiesSublist = true;
result.push(line); result.push(line);
// Peek at next line to determine indentation
const nextLine = lines[i + 1];
const indentMatch = nextLine?.match(/^(\s+)-\s/);
const indent = indentMatch?.[1] ?? ' ';
for (const name of names) result.push(`${indent}- ${name}`);
continue; continue;
} }
if (inProxiesSublist) { if (inProxiesSublist) {
// Check if next line is still a proxies list item
const nextLine = lines[i + 1];
const isLastItem = !nextLine || !/^\s{4,}-\s/.test(nextLine);
result.push(line); result.push(line);
if (isLastItem && /^\s{4,}-\s/.test(line)) {
// Inject names after last item
const indent = line.match(/^(\s+)/)?.[1] || ' ';
for (const name of names) result.push(`${indent}- ${name}`);
inProxiesSublist = false;
}
continue; continue;
} }
} }

View File

@@ -9,18 +9,72 @@ export function generateSSRConfig(): string {
return ''; return '';
} }
// Get disabled fetched nodes for filtering (by server:port)
const disabledNodes = db.prepare(
'SELECT server, port FROM fetched_nodes WHERE enabled = 0'
).all() as { server: string; port: number }[];
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
const staticNodes = db.prepare( const staticNodes = db.prepare(
'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[]; ).all() as { uri: string; name: string }[];
// Decode base64 content → URI list // Decode base64 content → URI list
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString(); const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
const lines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean); const rawLines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
// Append static node URIs // Filter out disabled fetched nodes by server:port matching
for (const node of staticNodes) { const filteredLines = disabledSet.size > 0
if (node.uri) lines.push(node.uri); ? rawLines.filter((line: string) => {
const sp = extractServerPort(line);
return !sp || !disabledSet.has(`${sp.server}:${sp.port}`);
})
: rawLines;
// Prepend static node URIs with custom names (static nodes go first)
const staticLines = staticNodes
.filter(n => n.uri)
.map(n => setUriName(n.uri, n.name));
filteredLines.unshift(...staticLines);
return Buffer.from(filteredLines.join('\n')).toString('base64');
} }
return Buffer.from(lines.join('\n')).toString('base64'); /** Extract server and port from a proxy URI. Returns null if unparseable. */
function extractServerPort(uri: string): { server: string; port: number } | null {
try {
if (uri.startsWith('ss://') || uri.startsWith('trojan://')) {
const url = new URL(uri);
return { server: url.hostname, port: parseInt(url.port, 10) };
}
if (uri.startsWith('vmess://')) {
const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString());
return { server: json.add, port: parseInt(json.port, 10) };
}
if (uri.startsWith('ssr://')) {
// SSR: ssr://base64(host:port:protocol:method:obfs:base64pass/?params)
const inner = Buffer.from(uri.replace('ssr://', ''), 'base64').toString();
const [main] = inner.split('/?');
const parts = main.split(':');
return { server: parts[0], port: parseInt(parts[1], 10) };
}
} catch {}
return null;
}
/** Re-encode a proxy URI with a custom display name. */
function setUriName(uri: string, name: string): string {
try {
if (uri.startsWith('vmess://')) {
const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString());
json.ps = name;
return 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64');
}
// ss://, trojan://, ssr:// use #fragment for the display name
const hashIdx = uri.indexOf('#');
const base = hashIdx >= 0 ? uri.slice(0, hashIdx) : uri;
return base + '#' + encodeURIComponent(name);
} catch {
return uri;
}
} }