Compare commits
5 Commits
c3d8e1a961
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b4285bc82 | |||
| d708240acf | |||
| 1eb0359b8f | |||
| 3bdba3e50f | |||
| fb7a30489e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
server/data/*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,5 +1,8 @@
|
||||
FROM node:22-slim AS frontend
|
||||
RUN corepack enable pnpm
|
||||
# Pin pnpm: corepack otherwise auto-fetches the latest (pnpm 11), which no longer
|
||||
# reads the "pnpm.onlyBuiltDependencies" field in package.json and fails the build
|
||||
# on ignored install scripts (esbuild / better-sqlite3). v10 still honors that field.
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.34.3 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json ./
|
||||
RUN pnpm config set registry https://registry.npmmirror.com && pnpm install
|
||||
@@ -7,7 +10,10 @@ COPY web/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-slim AS production
|
||||
RUN corepack enable pnpm
|
||||
# Pin pnpm: corepack otherwise auto-fetches the latest (pnpm 11), which no longer
|
||||
# reads the "pnpm.onlyBuiltDependencies" field in package.json and fails the build
|
||||
# on ignored install scripts (esbuild / better-sqlite3). v10 still honors that field.
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.34.3 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Switch to Aliyun mirror for faster apt downloads in China
|
||||
|
||||
206
pnpm-lock.yaml
generated
Normal file
206
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,206 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
concurrently:
|
||||
specifier: ^9.1.0
|
||||
version: 9.2.4
|
||||
|
||||
packages:
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
concurrently@9.2.4:
|
||||
resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
shell-quote@1.9.0:
|
||||
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-color@8.1.1:
|
||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
concurrently@9.2.4:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.9.0
|
||||
supports-color: 8.1.1
|
||||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
shell-quote@1.9.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-color@8.1.1:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
@@ -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) => {
|
||||
|
||||
20
server/src/parsers/anytls.ts
Normal file
20
server/src/parsers/anytls.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { readInsecure } from './util.js';
|
||||
|
||||
// anytls://password@host:port?sni=...&insecure=1#name
|
||||
export function parseAnytls(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'AnyTLS';
|
||||
|
||||
const password = decodeURIComponent(url.username);
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || '';
|
||||
|
||||
let line = `${name} = anytls, ${server}, ${port}, password=${password}`;
|
||||
if (sni) line += `, sni=${sni}`;
|
||||
line += `, skip-cert-verify=${readInsecure(params)}`;
|
||||
|
||||
return { name, type: 'anytls', server, port, surgeLine: line };
|
||||
}
|
||||
34
server/src/parsers/hysteria2.ts
Normal file
34
server/src/parsers/hysteria2.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { readInsecure } from './util.js';
|
||||
|
||||
// hysteria2://auth@host:port?sni=...&insecure=1&obfs=salamander&obfs-password=...#name
|
||||
// hy2:// is an accepted alias for the scheme.
|
||||
export function parseHysteria2(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
|
||||
|
||||
// The auth string lives in the userinfo. For userpass auth it is "username:password".
|
||||
const user = decodeURIComponent(url.username);
|
||||
const pass = decodeURIComponent(url.password);
|
||||
const auth = pass ? `${user}:${pass}` : user;
|
||||
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || '';
|
||||
const obfs = params.get('obfs') || '';
|
||||
const obfsPassword = params.get('obfs-password') || params.get('obfs_password') || '';
|
||||
// pinSHA256 means the node uses a self-signed / SNI-masqueraded cert verified by
|
||||
// fingerprint rather than the standard CA chain. Surge's server-cert-fingerprint-sha256
|
||||
// proved unreliable here (it still validates against the SNI hostname and rejects the
|
||||
// cert), so in that case we skip standard verification — matching the effective behavior
|
||||
// of clients like Shadowrocket that connect fine via pinSHA256.
|
||||
const hasPin = !!(params.get('pinSHA256') || params.get('pinsha256'));
|
||||
|
||||
let line = `${name} = hysteria2, ${server}, ${port}, password=${auth}`;
|
||||
if (sni) line += `, sni=${sni}`;
|
||||
if (obfs === 'salamander' && obfsPassword) line += `, salamander-password=${obfsPassword}`;
|
||||
line += `, skip-cert-verify=${readInsecure(params) || hasPin}`;
|
||||
|
||||
return { name, type: 'hysteria2', server, port, surgeLine: line };
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { parseSS, type ParsedNode } from './ss.js';
|
||||
import { parseVMess } from './vmess.js';
|
||||
import { parseTrojan } from './trojan.js';
|
||||
import { parseVless } from './vless.js';
|
||||
import { parseSocks5, parseSocks5Tls } from './socks.js';
|
||||
import { parseHysteria2 } from './hysteria2.js';
|
||||
import { parseTuic } from './tuic.js';
|
||||
import { parseAnytls } from './anytls.js';
|
||||
|
||||
export type { ParsedNode };
|
||||
|
||||
@@ -11,6 +15,13 @@ export function parseNodeUri(uri: string): ParsedNode | null {
|
||||
if (uri.startsWith('vmess://')) return parseVMess(uri);
|
||||
if (uri.startsWith('trojan://')) return parseTrojan(uri);
|
||||
if (uri.startsWith('vless://')) return parseVless(uri);
|
||||
if (uri.startsWith('hysteria2://') || uri.startsWith('hy2://')) return parseHysteria2(uri);
|
||||
if (uri.startsWith('tuic://')) return parseTuic(uri);
|
||||
if (uri.startsWith('anytls://')) return parseAnytls(uri);
|
||||
// socks5-over-tls variants must be checked before plain socks
|
||||
if (uri.startsWith('socks5-tls://') || uri.startsWith('socks5+tls://') || uri.startsWith('socks+tls://'))
|
||||
return parseSocks5Tls(uri);
|
||||
if (uri.startsWith('socks5://') || uri.startsWith('socks://')) return parseSocks5(uri);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
51
server/src/parsers/socks.ts
Normal file
51
server/src/parsers/socks.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { readInsecure } from './util.js';
|
||||
|
||||
export function parseSocks5(uri: string): ParsedNode {
|
||||
return parseSocks(uri, false);
|
||||
}
|
||||
|
||||
export function parseSocks5Tls(uri: string): ParsedNode {
|
||||
return parseSocks(uri, true);
|
||||
}
|
||||
|
||||
// socks5://user:pass@host:port#name | socks://host:port#name (no auth)
|
||||
// socks5-tls://user:pass@host:port?sni=...&skip-cert-verify=1#name
|
||||
function parseSocks(uri: string, tls: boolean): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const type = tls ? 'socks5-tls' : 'socks5';
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || (tls ? 'SOCKS5-TLS' : 'SOCKS5');
|
||||
|
||||
let username = decodeURIComponent(url.username);
|
||||
let password = decodeURIComponent(url.password);
|
||||
|
||||
// Shadowrocket-style: userinfo is base64(username:password) with no separate password part
|
||||
if (username && !password && !username.includes(':')) {
|
||||
try {
|
||||
const decoded = Buffer.from(username, 'base64').toString();
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx !== -1) {
|
||||
username = decoded.slice(0, idx);
|
||||
password = decoded.slice(idx + 1);
|
||||
}
|
||||
} catch {
|
||||
// keep original username
|
||||
}
|
||||
}
|
||||
|
||||
// username/password are positional in Surge; emit both only when auth is present
|
||||
let line = `${name} = ${type}, ${server}, ${port}`;
|
||||
if (username || password) {
|
||||
line += `, ${username}, ${password}`;
|
||||
}
|
||||
|
||||
if (tls) {
|
||||
const sni = url.searchParams.get('sni') || url.searchParams.get('tls-host') || '';
|
||||
if (sni) line += `, sni=${sni}`;
|
||||
line += `, skip-cert-verify=${readInsecure(url.searchParams)}`;
|
||||
}
|
||||
|
||||
return { name, type, server, port, surgeLine: line };
|
||||
}
|
||||
@@ -6,6 +6,12 @@ export interface ParsedNode {
|
||||
surgeLine: string;
|
||||
}
|
||||
|
||||
// Surge 使用与标准 Shadowsocks 不同的加密方式命名,需做别名映射
|
||||
const ENCRYPT_METHOD_ALIASES: Record<string, string> = {
|
||||
'chacha20-poly1305': 'chacha20-ietf-poly1305',
|
||||
'xchacha20-poly1305': 'xchacha20-ietf-poly1305',
|
||||
};
|
||||
|
||||
export function parseSS(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
@@ -14,11 +20,18 @@ export function parseSS(uri: string): ParsedNode {
|
||||
|
||||
const decoded = Buffer.from(url.username, 'base64').toString();
|
||||
const firstColon = decoded.indexOf(':');
|
||||
const method = decoded.slice(0, firstColon);
|
||||
const rawMethod = decoded.slice(0, firstColon);
|
||||
const method = ENCRYPT_METHOD_ALIASES[rawMethod] ?? rawMethod;
|
||||
const password = decoded.slice(firstColon + 1);
|
||||
|
||||
// SS 2022 has built-in encryption, Surge does not support SS over TLS
|
||||
const surgeLine = `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`;
|
||||
|
||||
return { name, type: 'ss', server, port, surgeLine };
|
||||
// Surge's `ss` proxy type has no WebSocket/TLS transport support at all (unlike
|
||||
// trojan/vmess/vless) — this Xray-style ss+ws+tls link can never work in Surge,
|
||||
// so it's tagged 'ss-ws' and excluded from Surge output (see generator.ts).
|
||||
// Clash/SSR generators re-parse the raw uri independently and are unaffected.
|
||||
const type = url.searchParams.get('type') === 'ws' ? 'ss-ws' : 'ss';
|
||||
|
||||
return { name, type, server, port, surgeLine };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash proxy objects.
|
||||
* Convert proxy URIs to Clash (mihomo) proxy objects.
|
||||
* Returns a JS object suitable for inclusion in a Clash YAML `proxies:` array,
|
||||
* or null if the URI is unsupported/invalid.
|
||||
*/
|
||||
import { readInsecure, normalizeWsPath } from './util.js';
|
||||
|
||||
export interface ClashProxy {
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -16,6 +18,12 @@ export function uriToClashProxy(uri: string): ClashProxy | null {
|
||||
if (uri.startsWith('ss://')) return ssToClash(uri);
|
||||
if (uri.startsWith('vmess://')) return vmessToClash(uri);
|
||||
if (uri.startsWith('trojan://')) return trojanToClash(uri);
|
||||
if (uri.startsWith('hysteria2://') || uri.startsWith('hy2://')) return hysteria2ToClash(uri);
|
||||
if (uri.startsWith('tuic://')) return tuicToClash(uri);
|
||||
if (uri.startsWith('anytls://')) return anytlsToClash(uri);
|
||||
if (uri.startsWith('socks5-tls://') || uri.startsWith('socks5+tls://') || uri.startsWith('socks+tls://'))
|
||||
return socks5ToClash(uri, true);
|
||||
if (uri.startsWith('socks5://') || uri.startsWith('socks://')) return socks5ToClash(uri, false);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -66,7 +74,7 @@ function vmessToClash(uri: string): ClashProxy | null {
|
||||
if (json.net === 'ws') {
|
||||
proxy.network = 'ws';
|
||||
const wsOpts: Record<string, unknown> = {};
|
||||
if (json.path) wsOpts.path = json.path;
|
||||
if (json.path) wsOpts.path = normalizeWsPath(json.path);
|
||||
if (json.host) wsOpts.headers = { Host: json.host };
|
||||
if (Object.keys(wsOpts).length > 0) proxy['ws-opts'] = wsOpts;
|
||||
}
|
||||
@@ -84,3 +92,103 @@ function trojanToClash(uri: string): ClashProxy | null {
|
||||
|
||||
return { name, type: 'trojan', server, port, password, sni };
|
||||
}
|
||||
|
||||
function socks5ToClash(uri: string, tls: boolean): ClashProxy | null {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
if (!server || !port) return null;
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || (tls ? 'SOCKS5-TLS' : 'SOCKS5');
|
||||
|
||||
let username = decodeURIComponent(url.username);
|
||||
let password = decodeURIComponent(url.password);
|
||||
if (username && !password && !username.includes(':')) {
|
||||
try {
|
||||
const decoded = Buffer.from(username, 'base64').toString();
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx !== -1) {
|
||||
username = decoded.slice(0, idx);
|
||||
password = decoded.slice(idx + 1);
|
||||
}
|
||||
} catch {
|
||||
// keep original username
|
||||
}
|
||||
}
|
||||
|
||||
const proxy: ClashProxy = { name, type: 'socks5', server, port, udp: true };
|
||||
if (username) proxy.username = username;
|
||||
if (password) proxy.password = password;
|
||||
if (tls) {
|
||||
proxy.tls = true;
|
||||
const sni = url.searchParams.get('sni') || url.searchParams.get('tls-host');
|
||||
if (sni) proxy.sni = sni;
|
||||
proxy['skip-cert-verify'] = readInsecure(url.searchParams);
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
function hysteria2ToClash(uri: string): ClashProxy | null {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
if (!server || !port) return null;
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
|
||||
|
||||
const user = decodeURIComponent(url.username);
|
||||
const pass = decodeURIComponent(url.password);
|
||||
const auth = pass ? `${user}:${pass}` : user;
|
||||
const params = url.searchParams;
|
||||
|
||||
const proxy: ClashProxy = { name, type: 'hysteria2', server, port, password: auth };
|
||||
const sni = params.get('sni');
|
||||
if (sni) proxy.sni = sni;
|
||||
const obfs = params.get('obfs');
|
||||
const obfsPassword = params.get('obfs-password') || params.get('obfs_password');
|
||||
if (obfs) {
|
||||
proxy.obfs = obfs;
|
||||
if (obfsPassword) proxy['obfs-password'] = obfsPassword;
|
||||
}
|
||||
proxy['skip-cert-verify'] = readInsecure(params);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
function tuicToClash(uri: string): ClashProxy | null {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
if (!server || !port) return null;
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'TUIC';
|
||||
|
||||
const uuid = decodeURIComponent(url.username);
|
||||
const password = decodeURIComponent(url.password);
|
||||
const params = url.searchParams;
|
||||
|
||||
const proxy: ClashProxy = { name, type: 'tuic', server, port, uuid, password };
|
||||
const sni = params.get('sni');
|
||||
if (sni) proxy.sni = sni;
|
||||
const alpn = (params.get('alpn') || 'h3').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (alpn.length) proxy.alpn = alpn;
|
||||
const cc = params.get('congestion_control') || params.get('congestion-controller');
|
||||
if (cc) proxy['congestion-controller'] = cc;
|
||||
const udpMode = params.get('udp_relay_mode') || params.get('udp-relay-mode');
|
||||
if (udpMode) proxy['udp-relay-mode'] = udpMode;
|
||||
proxy['skip-cert-verify'] = readInsecure(params);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
function anytlsToClash(uri: string): ClashProxy | null {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
if (!server || !port) return null;
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'AnyTLS';
|
||||
|
||||
const password = decodeURIComponent(url.username);
|
||||
const params = url.searchParams;
|
||||
|
||||
const proxy: ClashProxy = { name, type: 'anytls', server, port, password };
|
||||
const sni = params.get('sni');
|
||||
if (sni) proxy.sni = sni;
|
||||
proxy['skip-cert-verify'] = readInsecure(params);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { normalizeWsPath } from './util.js';
|
||||
|
||||
export function parseTrojan(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
@@ -9,7 +10,13 @@ export function parseTrojan(uri: string): ParsedNode {
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || server;
|
||||
|
||||
const surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`;
|
||||
let surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`;
|
||||
|
||||
if (params.get('type') === 'ws') {
|
||||
const path = params.get('path') || '/';
|
||||
const host = params.get('host') || sni;
|
||||
surgeLine += `, ws=true, ws-path=${normalizeWsPath(path)}, ws-headers=Host:${host}`;
|
||||
}
|
||||
|
||||
return { name, type: 'trojan', server, port, surgeLine };
|
||||
}
|
||||
|
||||
26
server/src/parsers/tuic.ts
Normal file
26
server/src/parsers/tuic.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { readInsecure } from './util.js';
|
||||
|
||||
// TUIC v5: tuic://uuid:password@host:port?sni=...&alpn=h3&congestion_control=bbr&allow_insecure=1#name
|
||||
export function parseTuic(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'TUIC';
|
||||
|
||||
const uuid = decodeURIComponent(url.username);
|
||||
const password = decodeURIComponent(url.password);
|
||||
const params = url.searchParams;
|
||||
const alpn = params.get('alpn') || 'h3';
|
||||
const sni = params.get('sni') || '';
|
||||
|
||||
// Surge uses the type name `tuic-v5` for TUIC v5 (uuid+password); plain `tuic` is v4 (token).
|
||||
// There is NO `version` field — using `tuic` + `version=5` makes Surge parse the line as v4 and
|
||||
// reject it ("token must be provided"). Field order mirrors Surge Mac 6.6.0's own GUI export.
|
||||
let line = `${name} = tuic-v5, ${server}, ${port}, password=${password}, uuid=${uuid}`;
|
||||
if (alpn) line += `, alpn=${alpn}`;
|
||||
line += `, skip-cert-verify=${readInsecure(params)}`;
|
||||
if (sni) line += `, sni=${sni}`;
|
||||
|
||||
return { name, type: 'tuic', server, port, surgeLine: line };
|
||||
}
|
||||
16
server/src/parsers/util.ts
Normal file
16
server/src/parsers/util.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Surge (and Clash) require the WebSocket path to be an absolute path; ensure a leading slash. */
|
||||
export function normalizeWsPath(path: string): string {
|
||||
if (!path) return path;
|
||||
return path.startsWith('/') ? path : '/' + path;
|
||||
}
|
||||
|
||||
/** Read a "skip certificate verification / insecure TLS" flag from the common query-param spellings. */
|
||||
export function readInsecure(params: URLSearchParams): boolean {
|
||||
const v =
|
||||
params.get('insecure') ??
|
||||
params.get('allowInsecure') ??
|
||||
params.get('allow_insecure') ??
|
||||
params.get('skip-cert-verify') ??
|
||||
params.get('skipVerify');
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
import { normalizeWsPath } from './util.js';
|
||||
|
||||
export function parseVMess(uri: string): ParsedNode {
|
||||
const b64 = uri.replace('vmess://', '');
|
||||
@@ -19,7 +20,7 @@ export function parseVMess(uri: string): ParsedNode {
|
||||
|
||||
if (json.net === 'ws') {
|
||||
line += ', ws=true';
|
||||
if (json.path) line += `, ws-path=${json.path}`;
|
||||
if (json.path) line += `, ws-path=${normalizeWsPath(json.path)}`;
|
||||
if (json.host) line += `, ws-headers=Host:${json.host}`;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,22 @@ router.post('/surge-token', (_req, res) => {
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
// GET /api/config/custom - get custom config snippet
|
||||
router.get('/custom', (_req, res) => {
|
||||
const row = db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
|
||||
res.json({ content: row?.value || '' });
|
||||
});
|
||||
|
||||
// PUT /api/config/custom - save custom config snippet
|
||||
router.put('/custom', (req, res) => {
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'content must be a string' });
|
||||
}
|
||||
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('custom_config', ?)").run(content);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/config/preview - preview generated config
|
||||
router.get('/preview', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
|
||||
44
server/src/scripts/regen-surge-lines.ts
Normal file
44
server/src/scripts/regen-surge-lines.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// One-off migration: regenerate static_nodes.surge_line from their stored uri
|
||||
// using the current parsers. Static nodes cache surge_line at add-time, so a
|
||||
// parser fix (e.g. hysteria2 skip-cert-verify) does not reach already-stored
|
||||
// nodes until they are re-parsed. The custom node name is preserved.
|
||||
//
|
||||
// Run inside the container: pnpm exec tsx src/scripts/regen-surge-lines.ts
|
||||
import db from '../db.js';
|
||||
import { parseNodeUri } from '../parsers/index.js';
|
||||
|
||||
function renameSurgeLine(surgeLine: string, oldName: string, newName: string): string {
|
||||
if (surgeLine.startsWith(oldName + ' = ')) {
|
||||
return newName + surgeLine.slice(oldName.length);
|
||||
}
|
||||
return surgeLine;
|
||||
}
|
||||
|
||||
const rows = db.prepare('SELECT id, name, uri, surge_line, type FROM static_nodes').all() as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
uri: string;
|
||||
surge_line: string;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
const update = db.prepare('UPDATE static_nodes SET surge_line = ?, type = ? WHERE id = ?');
|
||||
let changed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const node = parseNodeUri(row.uri);
|
||||
if (!node) {
|
||||
console.warn(`[skip] id=${row.id} "${row.name}" — unparseable uri`);
|
||||
continue;
|
||||
}
|
||||
const newLine = renameSurgeLine(node.surgeLine, node.name, row.name);
|
||||
if (newLine !== row.surge_line || node.type !== row.type) {
|
||||
update.run(newLine, node.type, row.id);
|
||||
changed++;
|
||||
console.log(`[updated] id=${row.id} "${row.name}"`);
|
||||
console.log(` old: ${row.surge_line}`);
|
||||
console.log(` new: ${newLine}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${changed}/${rows.length} static node(s) regenerated.`);
|
||||
@@ -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,21 @@ 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)
|
||||
// Exclude vless and ss-ws (Surge doesn't support either transport)
|
||||
const UNSUPPORTED = "type NOT IN ('vless', 'ss-ws')";
|
||||
|
||||
// Collect fetched nodes (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 ${UNSUPPORTED} ORDER BY subscription_id, sort_order, id`
|
||||
: `SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND ${UNSUPPORTED} 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)
|
||||
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 ${UNSUPPORTED} ORDER BY sort_order, id`
|
||||
: `SELECT surge_line FROM static_nodes WHERE enabled = 1 AND ${UNSUPPORTED} ORDER BY sort_order, id`
|
||||
).all() as any[];
|
||||
|
||||
// Collect enabled rules
|
||||
@@ -25,9 +37,18 @@ export function generateSurgeConfig(hostUrl: string): string {
|
||||
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as any[];
|
||||
|
||||
// Custom config sections ([Proxy]/[Rule]/... snippets), admin links only —
|
||||
// guest links must not receive admin-machine specifics like local interfaces
|
||||
const customRow = whitelist
|
||||
? null
|
||||
: db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
|
||||
const customSections = parseCustomSections(customRow?.value || '');
|
||||
|
||||
// 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());
|
||||
|
||||
@@ -40,14 +61,22 @@ export function generateSurgeConfig(hostUrl: string): string {
|
||||
let config = sub.raw_config;
|
||||
|
||||
// Replace [Proxy] section with only enabled nodes
|
||||
config = rebuildProxySection(config, staticLines, fetchedLines);
|
||||
config = rebuildProxySection(config, staticLines, fetchedLines, customSections.get('Proxy') || []);
|
||||
|
||||
// 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);
|
||||
// (custom config rules go first — they take highest priority)
|
||||
const customRuleLines = customSections.get('Rule') || [];
|
||||
if (ruleLines.length > 0 || customRuleLines.length > 0) {
|
||||
config = injectRules(config, ruleLines, customRuleLines);
|
||||
}
|
||||
|
||||
// Merge remaining custom sections (anything other than [Proxy]/[Rule])
|
||||
for (const [section, lines] of customSections) {
|
||||
if (section === 'Proxy' || section === 'Rule') continue;
|
||||
config = injectIntoSection(config, section, lines);
|
||||
}
|
||||
|
||||
// Rewrite MANAGED-CONFIG URL
|
||||
@@ -60,10 +89,34 @@ export function generateSurgeConfig(hostUrl: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire [Proxy] section content with only enabled nodes.
|
||||
* Static nodes go first, then fetched nodes.
|
||||
* Parse a custom config snippet into section name → lines.
|
||||
* Lines before any [Section] header are ignored; blank lines are dropped
|
||||
* (comments are kept so users can annotate their rules).
|
||||
*/
|
||||
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
|
||||
export function parseCustomSections(text: string): Map<string, string[]> {
|
||||
const sections = new Map<string, string[]>();
|
||||
let current: string[] | null = null;
|
||||
|
||||
for (const raw of text.split('\n')) {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const name = trimmed.slice(1, -1).trim();
|
||||
current = sections.get(name) || [];
|
||||
sections.set(name, current);
|
||||
continue;
|
||||
}
|
||||
if (!trimmed || !current) continue;
|
||||
current.push(trimmed);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire [Proxy] section content with only enabled nodes.
|
||||
* Static nodes go first, then fetched nodes, then custom config proxies.
|
||||
*/
|
||||
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[], customLines: string[]): string {
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let inProxySection = false;
|
||||
@@ -75,14 +128,14 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
// Emit our rebuilt proxy content before leaving the section
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
inProxySection = trimmed === '[Proxy]';
|
||||
result.push(line);
|
||||
if (inProxySection) {
|
||||
// Emit all enabled nodes right after [Proxy] header
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
continue;
|
||||
@@ -98,13 +151,13 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
|
||||
|
||||
// If [Proxy] was the last section
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
|
||||
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[], customLines: string[]) {
|
||||
if (staticLines.length > 0) {
|
||||
result.push('# --- 自定义节点 ---');
|
||||
staticLines.forEach(l => result.push(l));
|
||||
@@ -115,6 +168,11 @@ function emitProxyContent(result: string[], staticLines: string[], fetchedLines:
|
||||
fetchedLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
if (customLines.length > 0) {
|
||||
result.push('# --- 自定义配置 ---');
|
||||
customLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,18 +208,74 @@ function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function injectRules(config: string, ruleLines: string[]): string {
|
||||
function injectRules(config: string, ruleLines: string[], customRuleLines: 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('');
|
||||
if (customRuleLines.length > 0) {
|
||||
result.push('# --- 自定义配置规则 ---');
|
||||
customRuleLines.forEach(r => result.push(r));
|
||||
result.push('');
|
||||
}
|
||||
if (ruleLines.length > 0) {
|
||||
result.push('# --- 自定义规则 ---');
|
||||
ruleLines.forEach(r => result.push(r));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject custom lines at the top of an arbitrary [Section]; appends the
|
||||
* section at the end of the config if the template doesn't have it.
|
||||
* A custom `key = value` line overrides (removes) same-key lines the
|
||||
* template already has in that section, e.g. doh-server in [General].
|
||||
*/
|
||||
function injectIntoSection(config: string, section: string, customLines: string[]): string {
|
||||
if (customLines.length === 0) return config;
|
||||
|
||||
const header = `[${section}]`;
|
||||
const keyOf = (l: string) => l.includes('=') && !l.startsWith('#') ? l.split('=')[0].trim() : null;
|
||||
const overrideKeys = new Set(customLines.map(keyOf).filter(Boolean));
|
||||
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let found = false;
|
||||
let inSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inSection = trimmed === header;
|
||||
result.push(line);
|
||||
if (inSection && !found) {
|
||||
found = true;
|
||||
result.push('# --- 自定义配置 ---');
|
||||
customLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSection) {
|
||||
const key = keyOf(trimmed);
|
||||
if (key && overrideKeys.has(key)) continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
result.push('', header, '# --- 自定义配置 ---');
|
||||
customLines.forEach(l => result.push(l));
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1134
web/pnpm-lock.yaml
generated
Normal file
1134
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ import Subscriptions from './components/Subscriptions';
|
||||
import StaticNodes from './components/StaticNodes';
|
||||
import NodeSelector from './components/NodeSelector';
|
||||
import Rules from './components/Rules';
|
||||
import CustomConfig from './components/CustomConfig';
|
||||
import Output from './components/Output';
|
||||
import Guests from './components/Guests';
|
||||
import { auth, setToken } from './api';
|
||||
|
||||
function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
@@ -146,7 +148,9 @@ export default function App() {
|
||||
'static-nodes': <StaticNodes />,
|
||||
'node-selector': <NodeSelector />,
|
||||
rules: <Rules />,
|
||||
'custom-config': <CustomConfig />,
|
||||
output: <Output />,
|
||||
guests: <Guests />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
@@ -116,6 +124,29 @@ export const config = {
|
||||
preview: () => request<{ config: string }>('/config/preview'),
|
||||
getSurgeToken: () => request<{ token: string }>('/config/surge-token'),
|
||||
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }),
|
||||
getCustom: () => request<{ content: string }>('/config/custom'),
|
||||
saveCustom: (content: string) => request<{ ok: boolean }>('/config/custom', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Guest links
|
||||
export const guests = {
|
||||
list: () => request<any[]>('/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<any>(`/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
|
||||
|
||||
116
web/src/components/CustomConfig.tsx
Normal file
116
web/src/components/CustomConfig.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { config as configApi } from '../api';
|
||||
|
||||
const PLACEHOLDER = `[Proxy]
|
||||
EN5-DIRECT = direct, interface = en5, allow-other-interface = false, dns-follow-interface = true
|
||||
|
||||
[Rule]
|
||||
# 目标应用固定走 en5
|
||||
PROCESS-NAME,/Applications/YourApp.app/,EN5-DIRECT
|
||||
|
||||
# 其他所有流量按照 macOS 系统默认路由出网
|
||||
FINAL,DIRECT`;
|
||||
|
||||
export default function CustomConfig() {
|
||||
const [content, setContent] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
configApi.getCustom().then(data => {
|
||||
setContent(data.content);
|
||||
setSaved(data.content);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const dirty = content !== saved;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
try {
|
||||
await configApi.saveCustom(content);
|
||||
setSaved(content);
|
||||
setMessage('已保存,将合并到 Surge 输出配置');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>自定义配置</h2>
|
||||
<p style={styles.subtitle}>
|
||||
按 Surge 配置格式书写(如 [Proxy]、[Rule]),保存后合并到最终输出的 Surge 配置中
|
||||
</p>
|
||||
|
||||
<div style={styles.hints}>
|
||||
<div>· [Proxy] 段的自定义节点会追加到输出的 [Proxy] 末尾(不加入策略组,供规则直接引用)</div>
|
||||
<div>· [Rule] 段的自定义规则注入到输出 [Rule] 的最顶部,优先级高于「规则」面板中的规则</div>
|
||||
<div>· 其他段(如 [Host])注入到对应段顶部,模板中不存在的段会追加到文件末尾</div>
|
||||
<div>· 仅对管理员订阅链接生效,游客链接不包含自定义配置</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={PLACEHOLDER}
|
||||
spellCheck={false}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
||||
<button className="primary" onClick={handleSave} disabled={saving || !dirty}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
{dirty && <span style={{ fontSize: 12, color: 'var(--warning, #e0af68)' }}>有未保存的修改</span>}
|
||||
{message && <span style={{ fontSize: 12, color: 'var(--success)' }}>{message}</span>}
|
||||
{error && <span style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: 12,
|
||||
},
|
||||
hints: {
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: '10px 14px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.8,
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: 16,
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
minHeight: 'calc(100vh - 400px)',
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-primary)',
|
||||
resize: 'vertical' as const,
|
||||
},
|
||||
};
|
||||
376
web/src/components/Guests.tsx
Normal file
376
web/src/components/Guests.tsx
Normal file
@@ -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<any[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<any>(null);
|
||||
const [available, setAvailable] = useState<{ static: any[]; subscriptions: any[] }>({ static: [], subscriptions: [] });
|
||||
const [selectedNames, setSelectedNames] = useState<Set<string>>(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<string>(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 (
|
||||
<div>
|
||||
<h2 style={styles.title}>游客链接</h2>
|
||||
<p style={styles.subtitle}>为他人生成独立订阅链接,仅返回勾选的白名单节点。可随时编辑,链接保持不变。</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
|
||||
{/* Left: link list */}
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
placeholder="新链接名称"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
/>
|
||||
<button className="primary small" onClick={handleCreate}>新建</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map(link => (
|
||||
<div
|
||||
key={link.id}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--accent)' }}>{link.name}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={!!link.enabled}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={() => handleToggleEnabled(link)}
|
||||
title="启用/禁用此链接"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{link.node_count} 个节点
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{links.length === 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, padding: 12, textAlign: 'center' }}>
|
||||
暂无游客链接
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: editor */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{!detail ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: 40, textAlign: 'center' }}>
|
||||
选择左侧链接进行编辑,或新建一个
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Name + delete */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
style={{ width: 220, fontSize: 13 }}
|
||||
/>
|
||||
<button className="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button className="small danger" onClick={() => handleDelete(detail)}>删除链接</button>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div key={kind} style={styles.urlRow}>
|
||||
<span style={styles.urlLabel}>{label}</span>
|
||||
<code style={styles.urlCode}>{url}</code>
|
||||
<button className="small" onClick={() => copy(kind, url)}>
|
||||
{copied === kind ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Batch controls (same as 节点选择) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '16px 0' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="small" onClick={() => selectAll(true)}>全选</button>
|
||||
<button className="small" onClick={() => selectAll(false)}>全不选</button>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-secondary)', marginLeft: 8 }}>
|
||||
{selectedCount}/{allNames.length} 已选
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
placeholder="正则匹配节点名(如 香港|HK)"
|
||||
value={regexInput}
|
||||
onChange={e => setRegexInput(e.target.value)}
|
||||
style={{ width: 260, fontSize: 12 }}
|
||||
/>
|
||||
<button className="small" onClick={() => regexBatch(true)}>匹配启用</button>
|
||||
<button className="small" onClick={() => regexBatch(false)}>匹配禁用</button>
|
||||
{regexInput && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{regexMatchCount === -1 ? '无效正则' : `匹配 ${regexMatchCount} 个`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node list grouped */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 50 }}>白名单</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 80 }}>协议</th>
|
||||
<th>服务器</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map(group => (
|
||||
<FragmentGroup
|
||||
key={group.label}
|
||||
group={group}
|
||||
selectedNames={selectedNames}
|
||||
onToggle={toggleName}
|
||||
/>
|
||||
))}
|
||||
{allNames.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无可选节点,请先添加静态节点或抓取订阅
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentGroup({ group, selectedNames, onToggle }: {
|
||||
group: NodeGroup;
|
||||
selectedNames: Set<string>;
|
||||
onToggle: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={4} style={{
|
||||
background: 'var(--bg-active)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{group.label} · {group.nodes.length}
|
||||
</td>
|
||||
</tr>
|
||||
{group.nodes.map((node, i) => {
|
||||
const checked = selectedNames.has(node.name);
|
||||
return (
|
||||
<tr key={`${group.label}-${i}-${node.name}`} style={{ opacity: checked ? 1 : 0.55 }}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(node.name)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: checked ? 'var(--accent)' : 'var(--text-secondary)' }}>
|
||||
{node.name}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 6px',
|
||||
borderRadius: 'var(--radius)', background: 'var(--bg-active)',
|
||||
color: 'var(--text-secondary)', textTransform: 'uppercase',
|
||||
}}>
|
||||
{node.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||
{node.server || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -1,14 +1,16 @@
|
||||
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' | 'custom-config' | 'output' | 'guests';
|
||||
|
||||
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
||||
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
|
||||
{ key: 'static-nodes', label: '节点', icon: '◈' },
|
||||
{ key: 'node-selector', label: '选择', icon: '☰' },
|
||||
{ key: 'rules', label: '规则', icon: '⧖' },
|
||||
{ key: 'custom-config', label: '自定义', icon: '✎' },
|
||||
{ key: 'output', label: '输出', icon: '▸' },
|
||||
{ key: 'guests', label: '游客', icon: '◐' },
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function NodeSelector() {
|
||||
const [selectedSub, setSelectedSub] = useState<number | null>(null);
|
||||
const [nodeList, setNodeList] = useState<any[]>([]);
|
||||
const [regexInput, setRegexInput] = useState('');
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(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 */}
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
拖动 ⠿ 可调整节点顺序,输出配置会按此顺序排列
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }}></th>
|
||||
<th style={{ width: 50 }}>启用</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 80 }}>协议</th>
|
||||
@@ -140,10 +160,25 @@ export default function NodeSelector() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeList.map(node => (
|
||||
<tr key={node.id} style={{
|
||||
opacity: node.enabled ? 1 : 0.5,
|
||||
}}>
|
||||
{nodeList.map((node, idx) => (
|
||||
<tr
|
||||
key={node.id}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
style={{
|
||||
opacity: node.enabled ? 1 : 0.5,
|
||||
background: dragIndex === idx ? 'var(--bg-active)' : undefined,
|
||||
}}
|
||||
>
|
||||
<td
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(idx)}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
style={{ cursor: 'grab', textAlign: 'center', color: 'var(--text-muted)' }}
|
||||
title="拖动排序"
|
||||
>
|
||||
⠿
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -189,7 +224,7 @@ export default function NodeSelector() {
|
||||
))}
|
||||
{nodeList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
{subs.length === 0
|
||||
? '请先添加订阅源'
|
||||
: '请先抓取订阅源节点'
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function StaticNodes() {
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(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 (
|
||||
<div>
|
||||
<h2 style={styles.title}>静态节点</h2>
|
||||
<p style={styles.subtitle}>粘贴 ss:// / vmess:// / trojan:// / vless:// URI 自动解析</p>
|
||||
<p style={styles.subtitle}>
|
||||
粘贴 ss:// / vmess:// / trojan:// / vless:// / hysteria2:// / tuic:// / anytls:// / socks5:// / socks5-tls:// URI 自动解析
|
||||
</p>
|
||||
|
||||
<div style={styles.form}>
|
||||
<input
|
||||
@@ -60,7 +78,7 @@ export default function StaticNodes() {
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="粘贴节点 URI(ss:// / vmess:// / trojan:// / vless://)"
|
||||
placeholder="粘贴节点 URI(ss / vmess / trojan / vless / hysteria2 / tuic / anytls / socks5 / socks5-tls)"
|
||||
value={uri}
|
||||
onChange={e => setUri(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -72,6 +90,7 @@ export default function StaticNodes() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }}></th>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 80 }}>协议</th>
|
||||
@@ -80,8 +99,22 @@ export default function StaticNodes() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeList.map(node => (
|
||||
<tr key={node.id}>
|
||||
{nodeList.map((node, idx) => (
|
||||
<tr
|
||||
key={node.id}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
style={{ background: dragIndex === idx ? 'var(--bg-active)' : undefined }}
|
||||
>
|
||||
<td
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(idx)}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
style={{ cursor: 'grab', textAlign: 'center', color: 'var(--text-muted)' }}
|
||||
title="拖动排序"
|
||||
>
|
||||
⠿
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -145,7 +178,7 @@ export default function StaticNodes() {
|
||||
))}
|
||||
{nodeList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无静态节点
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user