diff --git a/server/src/db.ts b/server/src/db.ts index 9676ee8..168f0e3 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index 7c0cada..0a35afd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 } + | 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) => { diff --git a/server/src/routes/guests.ts b/server/src/routes/guests.ts new file mode 100644 index 0000000..4bf8e44 --- /dev/null +++ b/server/src/routes/guests.ts @@ -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; diff --git a/server/src/routes/nodes.ts b/server/src/routes/nodes.ts index f1edb4f..2cd6394 100644 --- a/server/src/routes/nodes.ts +++ b/server/src/routes/nodes.ts @@ -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; diff --git a/server/src/routes/subscriptions.ts b/server/src/routes/subscriptions.ts index 2987d81..945531a 100644 --- a/server/src/routes/subscriptions.ts +++ b/server/src/routes/subscriptions.ts @@ -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); }); diff --git a/server/src/services/clashGenerator.ts b/server/src/services/clashGenerator.ts index ade5338..3b721ff 100644 --- a/server/src/services/clashGenerator.ts +++ b/server/src/services/clashGenerator.ts @@ -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 { 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(); 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; diff --git a/server/src/services/generator.ts b/server/src/services/generator.ts index 54a2bc8..a0827a1 100644 --- a/server/src/services/generator.ts +++ b/server/src/services/generator.ts @@ -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 { // 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()); diff --git a/server/src/services/ssrGenerator.ts b/server/src/services/ssrGenerator.ts index 2a218a7..3344db8 100644 --- a/server/src/services/ssrGenerator.ts +++ b/server/src/services/ssrGenerator.ts @@ -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 { 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 { diff --git a/web/src/App.tsx b/web/src/App.tsx index 9bd6f63..10cd7d3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import StaticNodes from './components/StaticNodes'; import NodeSelector from './components/NodeSelector'; import Rules from './components/Rules'; import Output from './components/Output'; +import Guests from './components/Guests'; import { auth, setToken } from './api'; function LoginPage({ onLogin }: { onLogin: () => void }) { @@ -147,6 +148,7 @@ export default function App() { 'node-selector': , rules: , output: , + guests: , }; return ( diff --git a/web/src/api.ts b/web/src/api.ts index 332ef31..e4c04c8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -89,6 +89,14 @@ export const nodes = { staticDelete: (id: number) => request<{ ok: boolean }>(`/nodes/static/${id}`, { method: 'DELETE', }), + staticReorder: (ids: number[]) => request<{ ok: boolean }>('/nodes/static/reorder', { + method: 'PUT', + body: JSON.stringify({ ids }), + }), + fetchedReorder: (ids: number[]) => request<{ ok: boolean }>('/nodes/fetched/reorder', { + method: 'PUT', + body: JSON.stringify({ ids }), + }), }; // Rules @@ -118,6 +126,24 @@ export const config = { regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }), }; +// Guest links +export const guests = { + list: () => request('/guests'), + availableNodes: () => request<{ static: any[]; subscriptions: any[] }>('/guests/available-nodes'), + create: (name: string) => request<{ id: number; token: string }>('/guests', { + method: 'POST', + body: JSON.stringify({ name }), + }), + get: (id: number) => request(`/guests/${id}`), + update: (id: number, data: any) => request<{ ok: boolean }>(`/guests/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => request<{ ok: boolean }>(`/guests/${id}`, { + method: 'DELETE', + }), +}; + // Stats export const stats = { get: () => request('/stats'), diff --git a/web/src/components/Guests.tsx b/web/src/components/Guests.tsx new file mode 100644 index 0000000..6543505 --- /dev/null +++ b/web/src/components/Guests.tsx @@ -0,0 +1,376 @@ +import { useState, useEffect, useMemo } from 'react'; +import { guests as api } from '../api'; + +interface NodeItem { name: string; type: string; server?: string } +interface NodeGroup { label: string; nodes: NodeItem[] } + +export default function Guests() { + const [links, setLinks] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [available, setAvailable] = useState<{ static: any[]; subscriptions: any[] }>({ static: [], subscriptions: [] }); + const [selectedNames, setSelectedNames] = useState>(new Set()); + const [editName, setEditName] = useState(''); + const [regexInput, setRegexInput] = useState(''); + const [newName, setNewName] = useState(''); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(''); + + const loadLinks = () => api.list().then(setLinks).catch(console.error); + useEffect(() => { + loadLinks(); + api.availableNodes().then(setAvailable).catch(console.error); + }, []); + + useEffect(() => { + if (selectedId == null) { setDetail(null); return; } + api.get(selectedId).then(d => { + setDetail(d); + setEditName(d.name); + setSelectedNames(new Set(d.node_names || [])); + }).catch(console.error); + }, [selectedId]); + + // Flatten available nodes into render groups + const groups: NodeGroup[] = useMemo(() => { + const g: NodeGroup[] = []; + if (available.static.length > 0) { + g.push({ label: '自定义节点', nodes: available.static.map((n: any) => ({ name: n.name, type: n.type })) }); + } + for (const sub of available.subscriptions) { + if (sub.nodes.length > 0) { + g.push({ label: sub.name, nodes: sub.nodes.map((n: any) => ({ name: n.name, type: n.type, server: n.server })) }); + } + } + return g; + }, [available]); + + const allNames = useMemo(() => groups.flatMap(g => g.nodes.map(n => n.name)), [groups]); + + const toggleName = (name: string) => { + setSelectedNames(prev => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); else next.add(name); + return next; + }); + }; + + const selectAll = (enabled: boolean) => { + setSelectedNames(prev => { + const next = new Set(prev); + for (const n of allNames) { if (enabled) next.add(n); else next.delete(n); } + return next; + }); + }; + + const regexBatch = (enabled: boolean) => { + if (!regexInput.trim()) return; + let re: RegExp; + try { re = new RegExp(regexInput, 'i'); } catch { alert('无效的正则表达式'); return; } + setSelectedNames(prev => { + const next = new Set(prev); + for (const n of allNames) { if (re.test(n)) { if (enabled) next.add(n); else next.delete(n); } } + return next; + }); + }; + + const regexMatchCount = useMemo(() => { + if (!regexInput) return null; + try { const re = new RegExp(regexInput, 'i'); return allNames.filter(n => re.test(n)).length; } + catch { return -1; } + }, [regexInput, allNames]); + + const handleCreate = async () => { + if (!newName.trim()) return; + const { id } = await api.create(newName.trim()); + setNewName(''); + await loadLinks(); + setSelectedId(id); + }; + + const handleSave = async () => { + if (selectedId == null) return; + setSaving(true); + try { + await api.update(selectedId, { + name: editName.trim() || detail.name, + node_names: [...selectedNames], + }); + await loadLinks(); + const d = await api.get(selectedId); + setDetail(d); + } finally { + setSaving(false); + } + }; + + const handleToggleEnabled = async (link: any) => { + await api.update(link.id, { enabled: link.enabled ? 0 : 1 }); + loadLinks(); + }; + + const handleDelete = async (link: any) => { + if (!confirm(`删除游客链接「${link.name}」?该链接将立即失效。`)) return; + await api.delete(link.id); + if (selectedId === link.id) setSelectedId(null); + loadLinks(); + }; + + const copy = (key: string, url: string) => { + navigator.clipboard.writeText(url); + setCopied(key); + setTimeout(() => setCopied(''), 2000); + }; + + const origin = window.location.origin; + const selectedCount = allNames.filter(n => selectedNames.has(n)).length; + + return ( +
+

游客链接

+

为他人生成独立订阅链接,仅返回勾选的白名单节点。可随时编辑,链接保持不变。

+ +
+ {/* Left: link list */} +
+
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} + style={{ flex: 1, fontSize: 12 }} + /> + +
+
+ {links.map(link => ( +
setSelectedId(link.id)} + style={{ + border: '1px solid', + borderColor: selectedId === link.id ? 'var(--accent)' : 'var(--border)', + background: selectedId === link.id ? 'var(--bg-active)' : 'var(--bg-input)', + borderRadius: 'var(--radius)', + padding: '10px 12px', + cursor: 'pointer', + opacity: link.enabled ? 1 : 0.5, + }} + > +
+ {link.name} + e.stopPropagation()} + onChange={() => handleToggleEnabled(link)} + title="启用/禁用此链接" + /> +
+
+ {link.node_count} 个节点 +
+
+ ))} + {links.length === 0 && ( +
+ 暂无游客链接 +
+ )} +
+
+ + {/* Right: editor */} +
+ {!detail ? ( +
+ 选择左侧链接进行编辑,或新建一个 +
+ ) : ( + <> + {/* Name + delete */} +
+ setEditName(e.target.value)} + style={{ width: 220, fontSize: 13 }} + /> + + +
+ + {/* URLs */} + {(['surge', 'clash', 'ssr'] as const).map(kind => { + const url = `${origin}/${kind}/${detail.token}`; + const label = kind === 'surge' ? 'SURGE' : kind === 'clash' ? 'CLASH / STASH' : 'SSR / QX / 小火箭'; + return ( +
+ {label} + {url} + +
+ ); + })} + + {/* Batch controls (same as 节点选择) */} +
+
+ + + + {selectedCount}/{allNames.length} 已选 + +
+
+ setRegexInput(e.target.value)} + style={{ width: 260, fontSize: 12 }} + /> + + + {regexInput && ( + + {regexMatchCount === -1 ? '无效正则' : `匹配 ${regexMatchCount} 个`} + + )} +
+
+ + {/* Node list grouped */} + + + + + + + + + + + {groups.map(group => ( + + ))} + {allNames.length === 0 && ( + + + + )} + +
白名单名称协议服务器
+ 暂无可选节点,请先添加静态节点或抓取订阅 +
+ + )} +
+
+
+ ); +} + +function FragmentGroup({ group, selectedNames, onToggle }: { + group: NodeGroup; + selectedNames: Set; + onToggle: (name: string) => void; +}) { + return ( + <> + + + {group.label} · {group.nodes.length} + + + {group.nodes.map((node, i) => { + const checked = selectedNames.has(node.name); + return ( + + + onToggle(node.name)} + /> + + + {node.name} + + + + {node.type} + + + + {node.server || '—'} + + + ); + })} + + ); +} + +const styles = { + title: { + fontFamily: 'var(--font-mono)' as const, + fontSize: 16, + fontWeight: 600 as const, + color: 'var(--text-primary)', + marginBottom: 4, + }, + subtitle: { + fontSize: 12, + color: 'var(--text-secondary)', + marginBottom: 20, + }, + urlRow: { + display: 'flex' as const, + gap: 8, + alignItems: 'center' as const, + marginBottom: 6, + }, + urlLabel: { + fontFamily: 'var(--font-mono)' as const, + fontSize: 10, + color: 'var(--text-muted)', + width: 120, + flexShrink: 0, + }, + urlCode: { + flex: 1, + fontFamily: 'var(--font-mono)' as const, + fontSize: 12, + color: 'var(--accent)', + userSelect: 'all' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + background: 'var(--bg-input)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: '6px 10px', + }, +}; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 331b0ac..162ecff 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { stats as statsApi } from '../api'; -type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output'; +type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output' | 'guests'; const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [ { key: 'subscriptions', label: '订阅', icon: '⟐' }, @@ -9,6 +9,7 @@ const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [ { key: 'node-selector', label: '选择', icon: '☰' }, { key: 'rules', label: '规则', icon: '⧖' }, { key: 'output', label: '输出', icon: '▸' }, + { key: 'guests', label: '游客', icon: '◐' }, ]; interface LayoutProps { diff --git a/web/src/components/NodeSelector.tsx b/web/src/components/NodeSelector.tsx index 69e6a3f..e0f94cf 100644 --- a/web/src/components/NodeSelector.tsx +++ b/web/src/components/NodeSelector.tsx @@ -6,6 +6,7 @@ export default function NodeSelector() { const [selectedSub, setSelectedSub] = useState(null); const [nodeList, setNodeList] = useState([]); const [regexInput, setRegexInput] = useState(''); + const [dragIndex, setDragIndex] = useState(null); useEffect(() => { subsApi.list().then(data => { @@ -52,6 +53,21 @@ export default function NodeSelector() { } }; + const handleDrop = async (targetIndex: number) => { + if (dragIndex === null || dragIndex === targetIndex) { setDragIndex(null); return; } + const reordered = [...nodeList]; + const [moved] = reordered.splice(dragIndex, 1); + reordered.splice(targetIndex, 0, moved); + setNodeList(reordered); + setDragIndex(null); + try { + await nodesApi.fetchedReorder(reordered.map(n => n.id)); + } catch (err) { + console.error(err); + if (selectedSub) subsApi.nodes(selectedSub).then(setNodeList); + } + }; + const enabledCount = nodeList.filter(n => n.enabled).length; return ( @@ -129,9 +145,13 @@ export default function NodeSelector() { )} {/* Node list */} +

+ 拖动 ⠿ 可调整节点顺序,输出配置会按此顺序排列 +

+ @@ -140,10 +160,25 @@ export default function NodeSelector() { - {nodeList.map(node => ( - + {nodeList.map((node, idx) => ( + e.preventDefault()} + onDrop={() => handleDrop(idx)} + style={{ + opacity: node.enabled ? 1 : 0.5, + background: dragIndex === idx ? 'var(--bg-active)' : undefined, + }} + > +
启用 名称 协议
setDragIndex(idx)} + onDragEnd={() => setDragIndex(null)} + style={{ cursor: 'grab', textAlign: 'center', color: 'var(--text-muted)' }} + title="拖动排序" + > + ⠿ + - + {subs.length === 0 ? '请先添加订阅源' : '请先抓取订阅源节点' diff --git a/web/src/components/StaticNodes.tsx b/web/src/components/StaticNodes.tsx index 5f37881..07facb4 100644 --- a/web/src/components/StaticNodes.tsx +++ b/web/src/components/StaticNodes.tsx @@ -7,6 +7,7 @@ export default function StaticNodes() { const [customName, setCustomName] = useState(''); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); + const [dragIndex, setDragIndex] = useState(null); const load = () => api.staticList().then(setNodeList).catch(console.error); useEffect(() => { load(); }, []); @@ -47,10 +48,27 @@ export default function StaticNodes() { } }; + const handleDrop = async (targetIndex: number) => { + if (dragIndex === null || dragIndex === targetIndex) { setDragIndex(null); return; } + const reordered = [...nodeList]; + const [moved] = reordered.splice(dragIndex, 1); + reordered.splice(targetIndex, 0, moved); + setNodeList(reordered); + setDragIndex(null); + try { + await api.staticReorder(reordered.map(n => n.id)); + } catch (err) { + console.error(err); + load(); + } + }; + return (

静态节点

-

粘贴 ss:// / vmess:// / trojan:// / vless:// URI 自动解析

+

+ 粘贴 ss:// / vmess:// / trojan:// / vless:// / hysteria2:// / tuic:// / anytls:// / socks5:// / socks5-tls:// URI 自动解析 +

setUri(e.target.value)} onKeyDown={handleKeyDown} @@ -72,6 +90,7 @@ export default function StaticNodes() { + @@ -80,8 +99,22 @@ export default function StaticNodes() { - {nodeList.map(node => ( - + {nodeList.map((node, idx) => ( + e.preventDefault()} + onDrop={() => handleDrop(idx)} + style={{ background: dragIndex === idx ? 'var(--bg-active)' : undefined }} + > +
状态 名称 协议
setDragIndex(idx)} + onDragEnd={() => setDragIndex(null)} + style={{ cursor: 'grab', textAlign: 'center', color: 'var(--text-muted)' }} + title="拖动排序" + > + ⠿ + - + 暂无静态节点