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:
@@ -63,8 +63,29 @@ for (const sql of [
|
||||
"ALTER TABLE subscriptions ADD COLUMN url_ssr TEXT",
|
||||
"ALTER TABLE subscriptions ADD COLUMN raw_config_clash TEXT",
|
||||
"ALTER TABLE subscriptions ADD COLUMN raw_config_ssr TEXT",
|
||||
// Migration: fetched nodes are drag-sortable within their subscription
|
||||
"ALTER TABLE fetched_nodes ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
]) {
|
||||
try { db.exec(sql); } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Guest links: each link has its own token and a whitelist of node names.
|
||||
// The whitelist references nodes by name (not id) so it survives re-fetch,
|
||||
// which deletes and re-inserts fetched_nodes rows.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS guest_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guest_link_nodes (
|
||||
guest_id INTEGER NOT NULL,
|
||||
node_name TEXT NOT NULL,
|
||||
FOREIGN KEY (guest_id) REFERENCES guest_links(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
95
server/src/routes/guests.ts
Normal file
95
server/src/routes/guests.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import db from '../db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Load the whitelisted node names for a guest link. */
|
||||
function getNodeNames(guestId: number): string[] {
|
||||
const rows = db.prepare('SELECT node_name FROM guest_link_nodes WHERE guest_id = ?').all(guestId) as any[];
|
||||
return rows.map(r => r.node_name);
|
||||
}
|
||||
|
||||
/** Replace a guest link's whitelist with the given node names. */
|
||||
function setNodeNames(guestId: number, names: string[]) {
|
||||
const del = db.prepare('DELETE FROM guest_link_nodes WHERE guest_id = ?');
|
||||
const ins = db.prepare('INSERT INTO guest_link_nodes (guest_id, node_name) VALUES (?, ?)');
|
||||
const tx = db.transaction(() => {
|
||||
del.run(guestId);
|
||||
for (const name of names) {
|
||||
if (typeof name === 'string' && name.trim()) ins.run(guestId, name);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
// GET /api/guests — list links with whitelist count
|
||||
router.get('/', (_req, res) => {
|
||||
const links = db.prepare('SELECT * FROM guest_links ORDER BY id').all() as any[];
|
||||
const counts = db.prepare(
|
||||
'SELECT guest_id, COUNT(*) as c FROM guest_link_nodes GROUP BY guest_id'
|
||||
).all() as any[];
|
||||
const countMap = new Map(counts.map(r => [r.guest_id, r.c]));
|
||||
res.json(links.map(l => ({ ...l, node_count: countMap.get(l.id) || 0 })));
|
||||
});
|
||||
|
||||
// GET /api/guests/available-nodes — all selectable nodes (static + per subscription).
|
||||
// MUST be before /:id.
|
||||
router.get('/available-nodes', (_req, res) => {
|
||||
const staticNodes = db.prepare(
|
||||
'SELECT id, name, type FROM static_nodes ORDER BY sort_order, id'
|
||||
).all();
|
||||
const subs = db.prepare('SELECT id, name FROM subscriptions ORDER BY id').all() as any[];
|
||||
const subscriptions = subs.map(sub => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
nodes: db.prepare(
|
||||
'SELECT id, name, type, server, port FROM fetched_nodes WHERE subscription_id = ? ORDER BY sort_order, id'
|
||||
).all(sub.id),
|
||||
}));
|
||||
res.json({ static: staticNodes, subscriptions });
|
||||
});
|
||||
|
||||
// POST /api/guests — create a new guest link
|
||||
router.post('/', (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name || !String(name).trim()) return res.status(400).json({ error: 'name is required' });
|
||||
const token = crypto.randomUUID();
|
||||
const result = db.prepare(
|
||||
'INSERT INTO guest_links (name, token, created_at) VALUES (?, ?, ?)'
|
||||
).run(String(name).trim(), token, new Date().toISOString());
|
||||
res.json({ id: result.lastInsertRowid, token });
|
||||
});
|
||||
|
||||
// GET /api/guests/:id — full detail including whitelist
|
||||
router.get('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const link = db.prepare('SELECT * FROM guest_links WHERE id = ?').get(id) as any;
|
||||
if (!link) return res.status(404).json({ error: 'not found' });
|
||||
res.json({ ...link, node_names: getNodeNames(link.id) });
|
||||
});
|
||||
|
||||
// PUT /api/guests/:id — update name / enabled / whitelist
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const link = db.prepare('SELECT * FROM guest_links WHERE id = ?').get(id) as any;
|
||||
if (!link) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { name, enabled, node_names } = req.body;
|
||||
db.prepare('UPDATE guest_links SET name = ?, enabled = ? WHERE id = ?').run(
|
||||
name ?? link.name,
|
||||
enabled ?? link.enabled,
|
||||
id
|
||||
);
|
||||
if (Array.isArray(node_names)) setNodeNames(link.id, node_names);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/guests/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM guest_links WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -14,6 +14,19 @@ function renameSurgeLine(surgeLine: string, oldName: string, newName: string): s
|
||||
|
||||
// --- Fetched nodes ---
|
||||
|
||||
// PUT /api/nodes/fetched/reorder — MUST be before /fetched/:id.
|
||||
// ids are the fetched_node ids of a single subscription, in the desired order.
|
||||
router.put('/fetched/reorder', (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
|
||||
const stmt = db.prepare('UPDATE fetched_nodes SET sort_order = ? WHERE id = ?');
|
||||
const reorder = db.transaction(() => {
|
||||
ids.forEach((id: number, index: number) => stmt.run(index, id));
|
||||
});
|
||||
reorder();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PUT /api/nodes/fetched/batch — MUST be before /fetched/:id
|
||||
router.put('/fetched/batch', (req, res) => {
|
||||
const { ids, enabled } = req.body;
|
||||
@@ -44,6 +57,18 @@ router.get('/static', (_req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// PUT /api/nodes/static/reorder — MUST be before /static/:id
|
||||
router.put('/static/reorder', (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
|
||||
const stmt = db.prepare('UPDATE static_nodes SET sort_order = ? WHERE id = ?');
|
||||
const reorder = db.transaction(() => {
|
||||
ids.forEach((id: number, index: number) => stmt.run(index, id));
|
||||
});
|
||||
reorder();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/nodes/static
|
||||
router.post('/static', (req, res) => {
|
||||
const { uri, name: customName } = req.body;
|
||||
|
||||
@@ -74,9 +74,11 @@ router.post('/:id/fetch', async (req, res) => {
|
||||
fetchOptional(sub.url_ssr),
|
||||
]);
|
||||
|
||||
// Save existing enabled states by node name
|
||||
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
|
||||
// Save existing enabled states and sort order by node name (survives re-fetch)
|
||||
const existingNodes = db.prepare('SELECT name, enabled, sort_order FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
|
||||
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
|
||||
const orderMap = new Map(existingNodes.map((n: any) => [n.name, n.sort_order]));
|
||||
const maxOrder = existingNodes.reduce((m: number, n: any) => Math.max(m, n.sort_order ?? 0), -1);
|
||||
|
||||
// Parse nodes from primary Surge config
|
||||
const nodes = parseSubscriptionContent(rawConfig);
|
||||
@@ -85,11 +87,14 @@ router.post('/:id/fetch', async (req, res) => {
|
||||
const replace = db.transaction(() => {
|
||||
db.prepare('DELETE FROM fetched_nodes WHERE subscription_id = ?').run(id);
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO fetched_nodes (subscription_id, name, type, server, port, surge_line, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO fetched_nodes (subscription_id, name, type, server, port, surge_line, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
// New nodes (no prior order) are appended after existing ones, in parse order
|
||||
let nextOrder = maxOrder + 1;
|
||||
for (const node of nodes) {
|
||||
const enabled = enabledMap.get(node.name) ?? 1;
|
||||
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled);
|
||||
const sortOrder = orderMap.has(node.name) ? orderMap.get(node.name) : nextOrder++;
|
||||
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled, sortOrder);
|
||||
}
|
||||
db.prepare(
|
||||
'UPDATE subscriptions SET raw_config = ?, raw_config_clash = ?, raw_config_ssr = ?, last_fetch = ?, node_count = ? WHERE id = ?'
|
||||
@@ -106,7 +111,7 @@ router.post('/:id/fetch', async (req, res) => {
|
||||
// GET /api/subscriptions/:id/nodes
|
||||
router.get('/:id/nodes', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const nodes = db.prepare('SELECT * FROM fetched_nodes WHERE subscription_id = ? ORDER BY id').all(id);
|
||||
const nodes = db.prepare('SELECT * FROM fetched_nodes WHERE subscription_id = ? ORDER BY sort_order, id').all(id);
|
||||
res.json(nodes);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import YAML from 'yaml';
|
||||
import db from '../db.js';
|
||||
import { uriToClashProxy, type ClashProxy } from '../parsers/toClash.js';
|
||||
|
||||
export function generateClashConfig(): string {
|
||||
/**
|
||||
* @param whitelist When provided (guest links), only nodes whose name is in the
|
||||
* set are included, independent of their global enabled state.
|
||||
*/
|
||||
export function generateClashConfig(whitelist?: Set<string>): string {
|
||||
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'
|
||||
).get() as any;
|
||||
@@ -25,7 +29,9 @@ export function generateClashConfig(): string {
|
||||
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
|
||||
|
||||
const staticRows = db.prepare(
|
||||
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
whitelist
|
||||
? 'SELECT uri, name FROM static_nodes ORDER BY sort_order, id'
|
||||
: 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as { uri: string; name: string }[];
|
||||
|
||||
const userRules = db.prepare(
|
||||
@@ -37,6 +43,12 @@ export function generateClashConfig(): string {
|
||||
const removedNames = new Set<string>();
|
||||
const filteredProxies = proxies.filter(p => {
|
||||
if (!p || typeof p !== 'object') return true;
|
||||
// Guest links: keep only whitelisted node names
|
||||
if (whitelist) {
|
||||
if (typeof p.name === 'string' && whitelist.has(p.name)) return true;
|
||||
if (typeof p.name === 'string') removedNames.add(p.name);
|
||||
return false;
|
||||
}
|
||||
const key = `${p.server}:${p.port}`;
|
||||
if (disabledSet.has(key)) {
|
||||
if (typeof p.name === 'string') removedNames.add(p.name);
|
||||
@@ -48,6 +60,7 @@ export function generateClashConfig(): string {
|
||||
const staticProxies: ClashProxy[] = [];
|
||||
const staticNames: string[] = [];
|
||||
for (const row of staticRows) {
|
||||
if (whitelist && !whitelist.has(row.name)) continue;
|
||||
const proxy = uriToClashProxy(row.uri);
|
||||
if (!proxy) continue;
|
||||
proxy.name = row.name;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export function generateSurgeConfig(hostUrl: string): string {
|
||||
/**
|
||||
* @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'
|
||||
@@ -10,14 +15,18 @@ export function generateSurgeConfig(hostUrl: string): string {
|
||||
return '# No subscription config available. Add and fetch a subscription first.';
|
||||
}
|
||||
|
||||
// Collect enabled fetched nodes (exclude vless — Surge doesn't support it)
|
||||
// Collect fetched nodes (exclude vless — Surge doesn't support it; tuic uses `tuic-v5`)
|
||||
const fetchedNodes = db.prepare(
|
||||
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY subscription_id, id'
|
||||
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 enabled static nodes (these go FIRST, exclude vless)
|
||||
// Collect static nodes (these go FIRST, exclude vless)
|
||||
const staticNodes = db.prepare(
|
||||
'SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY sort_order, id'
|
||||
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
|
||||
@@ -26,8 +35,10 @@ export function generateSurgeConfig(hostUrl: string): string {
|
||||
).all() as any[];
|
||||
|
||||
// Static nodes first, then fetched nodes
|
||||
const staticLines = staticNodes.map((n: any) => n.surge_line);
|
||||
const fetchedLines = fetchedNodes.map((n: any) => n.surge_line);
|
||||
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());
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export function generateSSRConfig(): string {
|
||||
/**
|
||||
* @param whitelist When provided (guest links), only nodes whose name is in the
|
||||
* set are included, independent of their global enabled state.
|
||||
*/
|
||||
export function generateSSRConfig(whitelist?: Set<string>): string {
|
||||
const sub = db.prepare(
|
||||
'SELECT raw_config_ssr FROM subscriptions WHERE enabled = 1 AND raw_config_ssr IS NOT NULL ORDER BY id LIMIT 1'
|
||||
).get() as any;
|
||||
@@ -16,30 +20,52 @@ export function generateSSRConfig(): string {
|
||||
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
|
||||
|
||||
const staticNodes = db.prepare(
|
||||
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
whitelist
|
||||
? 'SELECT uri, name FROM static_nodes ORDER BY sort_order, id'
|
||||
: 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as { uri: string; name: string }[];
|
||||
|
||||
// Decode base64 content → URI list
|
||||
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
|
||||
const rawLines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
|
||||
|
||||
// Filter out disabled fetched nodes by server:port matching
|
||||
const filteredLines = disabledSet.size > 0
|
||||
// Filter fetched nodes: by whitelist (guest) name, else by disabled server:port
|
||||
const filteredLines = whitelist
|
||||
? rawLines.filter((line: string) => {
|
||||
const sp = extractServerPort(line);
|
||||
return !sp || !disabledSet.has(`${sp.server}:${sp.port}`);
|
||||
const name = extractName(line);
|
||||
return name !== null && whitelist.has(name);
|
||||
})
|
||||
: rawLines;
|
||||
: disabledSet.size > 0
|
||||
? 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)
|
||||
.filter(n => n.uri && (!whitelist || whitelist.has(n.name)))
|
||||
.map(n => setUriName(n.uri, n.name));
|
||||
filteredLines.unshift(...staticLines);
|
||||
|
||||
return Buffer.from(filteredLines.join('\n')).toString('base64');
|
||||
}
|
||||
|
||||
/** Extract the display name (fragment / ps) from a proxy URI. Returns null if unparseable. */
|
||||
function extractName(uri: string): string | null {
|
||||
try {
|
||||
if (uri.startsWith('vmess://')) {
|
||||
const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString());
|
||||
return typeof json.ps === 'string' ? json.ps : null;
|
||||
}
|
||||
const hashIdx = uri.indexOf('#');
|
||||
if (hashIdx < 0) return null;
|
||||
return decodeURIComponent(uri.slice(hashIdx + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract server and port from a proxy URI. Returns null if unparseable. */
|
||||
function extractServerPort(uri: string): { server: string; port: number } | null {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user