feat: clash/ss suport

This commit is contained in:
2026-04-13 22:19:30 +08:00
parent 699b038a6c
commit 35e95ee777
11 changed files with 665 additions and 129 deletions

View File

@@ -8,7 +8,9 @@
"Bash(scp server/src/routes/surge.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/server/src/routes/surge.ts)", "Bash(scp server/src/routes/surge.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/server/src/routes/surge.ts)",
"Bash(scp web/src/api.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/api.ts)", "Bash(scp web/src/api.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/api.ts)",
"Bash(scp web/src/components/Output.tsx ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/components/Output.tsx)", "Bash(scp web/src/components/Output.tsx ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/components/Output.tsx)",
"Bash(scp:*)" "Bash(scp:*)",
"Bash(./node_modules/.bin/tsc --noEmit)",
"Bash(../node_modules/.bin/tsc --noEmit)"
] ]
} }
} }

View File

@@ -4,28 +4,31 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
Sub Router is a Surge proxy subscription management system. It aggregates nodes from upstream subscriptions, allows adding custom static nodes and rules, and generates a combined Surge configuration file served at `/surge`. Sub Router is a Surge proxy subscription management system. It aggregates nodes from upstream subscriptions, allows adding custom static nodes and rules, and generates a combined Surge configuration file served at `/surge/:token`.
## Commands ## Commands
```bash ```bash
# Development (starts both server and web dev server concurrently) # Development (starts both server and web dev server concurrently)
npm run dev pnpm dev
# Server only (port 3456, hot-reload via tsx watch) # Server only (port 3456, hot-reload via tsx watch)
npm run dev:server pnpm dev:server
# Frontend only (port 5173, proxies /api and /surge to :3456) # Frontend only (port 5173, proxies /api and /surge to :3456)
npm run dev:web pnpm dev:web
# Build frontend for production # Build frontend for production
npm run build:web pnpm build:web
# Production start (serves built frontend + API on port 3456) # Production start (serves built frontend + API on port 3456)
npm run start pnpm start
# Type check frontend # Type check frontend
cd web && npx tsc --noEmit cd web && pnpm exec tsc --noEmit
# Type check server
cd server && pnpm exec tsc --noEmit
# Docker # Docker
docker compose up -d docker compose up -d
@@ -39,7 +42,7 @@ docker compose up -d
1. User adds upstream subscription URLs → backend fetches and parses nodes into `fetched_nodes` table 1. User adds upstream subscription URLs → backend fetches and parses nodes into `fetched_nodes` table
2. User adds static nodes via URI (ss://, vmess://, trojan://) → parsed and stored in `static_nodes` table 2. User adds static nodes via URI (ss://, vmess://, trojan://) → parsed and stored in `static_nodes` table
3. `GET /surge``generator.ts` rebuilds the Surge config: 3. `GET /surge/:token``generator.ts` rebuilds the Surge config:
- Takes first enabled subscription's `raw_config` as base template - Takes first enabled subscription's `raw_config` as base template
- **Replaces** `[Proxy]` section entirely with only enabled nodes (static first, then fetched) - **Replaces** `[Proxy]` section entirely with only enabled nodes (static first, then fetched)
- **Rebuilds** `[Proxy Group]` select groups with only enabled node names - **Rebuilds** `[Proxy Group]` select groups with only enabled node names
@@ -48,11 +51,23 @@ docker compose up -d
### Auth model ### Auth model
`/surge` endpoint requires no auth (for Surge client access). All `/api/*` routes require Bearer token auth, except `/api/auth/login` and `/api/auth/status`. Password is stored in the `config` table. Token is the raw password sent via `Authorization: Bearer <password>`. Two separate credentials exist:
- **Admin password**: Protects all `/api/*` routes (except `/api/auth/login` and `/api/auth/status`). Sent as `Authorization: Bearer <password>`. On first login with no password set, the submitted password becomes the password.
- **Surge token**: UUID stored in `config` table as `surge_token`. Embedded in the Surge subscription URL itself (`/surge/:token`). Returns 404 for wrong tokens. Regeneratable via `POST /api/config/surge-token`.
### API routes
- `POST /api/auth/login`, `GET /api/auth/status` — auth (no auth required)
- `GET /api/stats` — node/rule counts summary
- `/api/subscriptions``subscriptions.ts` (CRUD + `/:id/fetch`, `/:id/nodes`)
- `/api/nodes``nodes.ts` (fetched node toggle, static node CRUD)
- `/api/rules``rules.ts` (CRUD + `/reorder`)
- `/api/config``surge.ts` (`/surge-token` GET/POST, `/preview` GET)
### Database ### Database
SQLite with WAL mode at `server/data/sub-router.db`. Five tables: `subscriptions`, `fetched_nodes` (FK to subscriptions, CASCADE delete), `static_nodes`, `rules`, `config` (KV store for password). SQLite with WAL mode at `server/data/sub-router.db`. Five tables: `subscriptions`, `fetched_nodes` (FK to subscriptions, CASCADE delete), `static_nodes`, `rules`, `config` (KV store for `password` and `surge_token`).
### Parsers (`server/src/parsers/`) ### Parsers (`server/src/parsers/`)
@@ -61,14 +76,28 @@ Convert protocol URIs to Surge proxy lines. Each parser returns `{ name, type, s
- **SS**: No TLS wrapping (Surge doesn't support SS over TLS; SS 2022 has built-in encryption) - **SS**: No TLS wrapping (Surge doesn't support SS over TLS; SS 2022 has built-in encryption)
- **VMess/Trojan**: `skip-cert-verify=false` (strict TLS verification) - **VMess/Trojan**: `skip-cert-verify=false` (strict TLS verification)
On re-fetch, enabled state is preserved by node name: existing name→enabled mapping is saved before delete, then reapplied to new rows.
When renaming a static node (`PUT /api/nodes/static/:id`), the `surge_line` prefix is also updated to match the new name.
### Frontend ### Frontend
Five panels: Subscriptions (CRUD + fetch trigger), Static Nodes (paste URI, custom naming, double-click to rename), Node Selector (per-subscription toggle with regex batch operations), Rules (CRUD + drag reorder), Output (subscription URL + config preview). Dark cyberpunk theme with CSS variables, JetBrains Mono font. Five panels: Subscriptions (CRUD + fetch trigger), Static Nodes (paste URI, custom naming, double-click to rename), Node Selector (per-subscription toggle with regex batch operations), Rules (CRUD + drag reorder), Output (subscription URL + config preview). Dark cyberpunk theme with CSS variables, JetBrains Mono font.
### Generator limitation
`rebuildProxyGroup` in `generator.ts` only updates the **first** `= select,` group (due to `handled = true` flag). If a Surge template has multiple select groups, only the first gets its node list replaced.
## Route ordering matters ## Route ordering matters
In `server/src/routes/`: specific paths like `/reorder` and `/fetched/batch` must be registered **before** parameterized `/:id` routes to avoid being captured by Express route matching. In `server/src/routes/`: specific paths like `/reorder` and `/fetched/batch` must be registered **before** parameterized `/:id` routes to avoid being captured by Express route matching.
## Legacy file
`index.js` at the repo root is a pre-rewrite single-file prototype. It is not used by the current system and can be ignored.
## Deployment ## Deployment
The `Content-Disposition` header on `/surge` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Docker mounts `./data` volume for SQLite persistence. Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation). Multi-stage Docker build: frontend built on `node:22-slim`, output copied into production image alongside the server. The `./data` volume maps to `server/data/` inside the container for SQLite persistence.
The `Content-Disposition` header on `/surge/:token` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation).

View File

@@ -57,4 +57,14 @@ db.exec(`
); );
`); `);
// Migration: add Clash and SSR URL/config columns to subscriptions
for (const sql of [
"ALTER TABLE subscriptions ADD COLUMN url_clash TEXT",
"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",
]) {
try { db.exec(sql); } catch { /* column already exists */ }
}
export default db; export default db;

View File

@@ -8,6 +8,8 @@ import rulesRouter from './routes/rules.js';
import surgeRouter from './routes/surge.js'; import surgeRouter from './routes/surge.js';
import db from './db.js'; import db from './db.js';
import { generateSurgeConfig } from './services/generator.js'; import { generateSurgeConfig } from './services/generator.js';
import { generateClashConfig } from './services/clashGenerator.js';
import { generateSSRConfig } from './services/ssrGenerator.js';
// Ensure surge_token exists // Ensure surge_token exists
function ensureSurgeToken(): string { function ensureSurgeToken(): string {
@@ -24,15 +26,17 @@ const PORT = parseInt(process.env.PORT || '3456', 10);
app.use(express.json()); app.use(express.json());
function verifySurgeToken(token: string): boolean {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
return !!row?.value && token === row.value;
}
// Surge endpoint (no auth, token-protected path) // Surge endpoint (no auth, token-protected path)
app.get('/surge/:token', (req, res) => { app.get('/surge/:token', (req, res) => {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any; if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
if (!row?.value || req.params.token !== row.value) {
return res.status(404).send('Not Found');
}
const host = req.headers.host || 'localhost:3456'; const host = req.headers.host || 'localhost:3456';
const protocol = req.secure ? 'https' : 'http'; const protocol = req.secure ? 'https' : 'http';
const hostUrl = `${protocol}://${host}/surge/${row.value}`; const hostUrl = `${protocol}://${host}/surge/${req.params.token}`;
const config = generateSurgeConfig(hostUrl); const config = generateSurgeConfig(hostUrl);
res.set({ res.set({
'Content-Type': 'text/plain; charset=utf-8', 'Content-Type': 'text/plain; charset=utf-8',
@@ -41,6 +45,28 @@ app.get('/surge/:token', (req, res) => {
res.send(config); res.send(config);
}); });
// Clash endpoint (no auth, token-protected path)
app.get('/clash/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateClashConfig();
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=clash.yaml',
});
res.send(config);
});
// SSR endpoint (no auth, token-protected path)
app.get('/ssr/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateSSRConfig();
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=proxy.txt',
});
res.send(config);
});
// Auth routes (no auth required) // Auth routes (no auth required)
app.post('/api/auth/login', (req, res) => { app.post('/api/auth/login', (req, res) => {
const { password } = req.body; const { password } = req.body;
@@ -104,7 +130,7 @@ app.get('/api/stats', (_req, res) => {
const webDist = path.join(__dirname, '..', '..', 'web', 'dist'); const webDist = path.join(__dirname, '..', '..', 'web', 'dist');
app.use(express.static(webDist)); app.use(express.static(webDist));
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/surge')) return next(); if (req.path.startsWith('/api') || req.path.startsWith('/surge') || req.path.startsWith('/clash') || req.path.startsWith('/ssr')) return next();
res.sendFile(path.join(webDist, 'index.html')); res.sendFile(path.join(webDist, 'index.html'));
}); });

View File

@@ -0,0 +1,79 @@
/**
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash YAML flow-style proxy lines.
* Returns a string like ` - {name: "NodeName", type: ss, server: ..., port: ..., ...}`
* or null if the URI is unsupported/invalid.
*/
export function uriToClashLine(uri: string): string | null {
try {
if (uri.startsWith('ss://')) return ssToClash(uri);
if (uri.startsWith('vmess://')) return vmessToClash(uri);
if (uri.startsWith('trojan://')) return trojanToClash(uri);
return null;
} catch {
return null;
}
}
/** Escape a string value for use inside double-quoted YAML flow style */
function esc(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function ssToClash(uri: string): string | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
// userinfo is base64(method:password)
const decoded = Buffer.from(url.username, 'base64').toString();
const firstColon = decoded.indexOf(':');
if (firstColon === -1) return null;
const cipher = decoded.slice(0, firstColon);
const password = decoded.slice(firstColon + 1);
return ` - {name: "${esc(name)}", type: ss, server: ${server}, port: ${port}, cipher: ${cipher}, password: "${esc(password)}"}`;
}
function vmessToClash(uri: string): string | null {
const b64 = uri.replace('vmess://', '');
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
const name = json.ps || 'VMess';
const server = json.add;
const port = json.port;
const uuid = json.id;
if (!server || !port || !uuid) return null;
let line = ` - {name: "${esc(name)}", type: vmess, server: ${server}, port: ${port}, uuid: ${uuid}, alterId: 0, cipher: auto`;
if (json.tls === 'tls') {
line += ', tls: true';
if (json.sni) line += `, sni: ${json.sni}`;
}
if (json.net === 'ws') {
line += ', network: ws';
const wsOpts: string[] = [];
if (json.path) wsOpts.push(`path: "${esc(json.path)}"`);
if (json.host) wsOpts.push(`headers: {Host: ${json.host}}`);
if (wsOpts.length > 0) {
line += `, ws-opts: {${wsOpts.join(', ')}}`;
}
}
line += '}';
return line;
}
function trojanToClash(uri: string): string | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
const password = url.username;
const sni = url.searchParams.get('sni') || server;
return ` - {name: "${esc(name)}", type: trojan, server: ${server}, port: ${port}, password: "${esc(password)}", sni: ${sni}}`;
}

View File

@@ -12,23 +12,37 @@ router.get('/', (_req, res) => {
// POST /api/subscriptions // POST /api/subscriptions
router.post('/', (req, res) => { router.post('/', (req, res) => {
const { name, url } = req.body; const { name, url, url_clash, url_ssr } = req.body;
if (!name || !url) { if (!name || !url) {
return res.status(400).json({ error: 'name and url are required' }); return res.status(400).json({ error: 'name and url are required' });
} }
const result = db.prepare('INSERT INTO subscriptions (name, url) VALUES (?, ?)').run(name, url); const result = db.prepare(
'INSERT INTO subscriptions (name, url, url_clash, url_ssr) VALUES (?, ?, ?, ?)'
).run(name, url, url_clash || null, url_ssr || null);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
}); });
// PUT /api/subscriptions/:id // PUT /api/subscriptions/:id
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, url, enabled } = req.body; const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id);
if (!sub) return res.status(404).json({ error: 'not found' }); if (!sub) return res.status(404).json({ error: 'not found' });
db.prepare('UPDATE subscriptions SET name = ?, url = ?, enabled = ? WHERE id = ?') const { name, url, enabled } = req.body;
.run(name ?? (sub as any).name, url ?? (sub as any).url, enabled ?? (sub as any).enabled, id); // Only update Clash/SSR URLs if explicitly included in request body
const url_clash = 'url_clash' in req.body ? (req.body.url_clash || null) : sub.url_clash;
const url_ssr = 'url_ssr' in req.body ? (req.body.url_ssr || null) : sub.url_ssr;
db.prepare(
'UPDATE subscriptions SET name = ?, url = ?, url_clash = ?, url_ssr = ?, enabled = ? WHERE id = ?'
).run(
name ?? sub.name,
url ?? sub.url,
url_clash,
url_ssr,
enabled ?? sub.enabled,
id
);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -50,11 +64,21 @@ router.post('/:id/fetch', async (req, res) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const rawConfig = await response.text(); const rawConfig = await response.text();
const fetchOptional = (url: string | null) =>
url
? fetch(url).then(r => (r.ok ? r.text() : null)).catch(() => null)
: Promise.resolve(null);
const [rawClash, rawSsr] = await Promise.all([
fetchOptional(sub.url_clash),
fetchOptional(sub.url_ssr),
]);
// Save existing enabled states by node name // Save existing enabled states by node name
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[]; const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled])); const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
// Parse nodes // Parse nodes from primary Surge config
const nodes = parseSubscriptionContent(rawConfig); const nodes = parseSubscriptionContent(rawConfig);
// Replace nodes in transaction // Replace nodes in transaction
@@ -67,8 +91,9 @@ router.post('/:id/fetch', async (req, res) => {
const enabled = enabledMap.get(node.name) ?? 1; const enabled = enabledMap.get(node.name) ?? 1;
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled); insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled);
} }
db.prepare('UPDATE subscriptions SET raw_config = ?, last_fetch = ?, node_count = ? WHERE id = ?') db.prepare(
.run(rawConfig, new Date().toISOString(), nodes.length, id); 'UPDATE subscriptions SET raw_config = ?, raw_config_clash = ?, raw_config_ssr = ?, last_fetch = ?, node_count = ? WHERE id = ?'
).run(rawConfig, rawClash, rawSsr, new Date().toISOString(), nodes.length, id);
}); });
replace(); replace();

View File

@@ -0,0 +1,171 @@
import db from '../db.js';
import { uriToClashLine } from '../parsers/toClash.js';
export function generateClashConfig(): 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;
if (!sub?.raw_config_clash) {
return '# No Clash config available. Add a subscription with a Clash URL and fetch it.';
}
const staticNodes = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
if (staticNodes.length === 0) {
return sub.raw_config_clash;
}
const clashLines = staticNodes
.map((n: any) => uriToClashLine(n.uri))
.filter((l): l is string => l !== null);
const staticNames = staticNodes.map((n: any) => n.name as string);
if (clashLines.length === 0) {
return sub.raw_config_clash;
}
let config = injectProxies(sub.raw_config_clash, clashLines);
config = injectProxyGroupNames(config, staticNames);
return config;
}
/**
* Inject static node YAML lines into the proxies: section.
* Inserts immediately after the `proxies:` key line.
* If no `proxies:` key exists, inserts a new proxies block before `proxy-groups:`.
*/
function injectProxies(config: string, clashLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let injected = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!injected && trimmed === 'proxies:') {
result.push(lines[i]);
// Inject right after the `proxies:` key
for (const cl of clashLines) result.push(cl);
injected = true;
continue;
}
// If proxies: never appeared, inject before proxy-groups:
if (!injected && trimmed === 'proxy-groups:') {
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
result.push('');
injected = true;
}
result.push(lines[i]);
}
// Fallback: append at end if neither key was found
if (!injected) {
result.push('');
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
}
return result.join('\n');
}
/**
* Inject static node names into every `type: select` proxy-group's proxies sub-list.
* Handles both block sequence (` - name`) and inline array (`proxies: [a, b]`) formats.
*/
function injectProxyGroupNames(config: string, names: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let inProxyGroups = false;
let inSelectGroup = false;
let inProxiesSublist = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect top-level section changes
if (/^[a-zA-Z]/.test(line) && line.includes(':')) {
const key = line.split(':')[0].trim();
if (key === 'proxy-groups') {
inProxyGroups = true;
inSelectGroup = false;
inProxiesSublist = false;
} else if (!line.startsWith(' ') && !line.startsWith('-')) {
inProxyGroups = false;
inSelectGroup = false;
inProxiesSublist = false;
}
}
if (!inProxyGroups) {
result.push(line);
continue;
}
// New group entry: reset state
if (/^\s*-\s+name:/.test(line)) {
inSelectGroup = false;
inProxiesSublist = false;
result.push(line);
continue;
}
// Detect `type: select` within a group
if (trimmed === 'type: select') {
inSelectGroup = true;
result.push(line);
continue;
}
if (inSelectGroup) {
// Handle inline array: proxies: [node1, node2, ...]
if (/^\s+proxies:\s*\[/.test(line)) {
// Append names before the closing ]
const closeBracket = line.lastIndexOf(']');
if (closeBracket !== -1) {
const before = line.slice(0, closeBracket);
const after = line.slice(closeBracket);
result.push(before + ', ' + names.join(', ') + after);
} else {
result.push(line);
}
continue;
}
// Handle block sequence: ` proxies:` followed by ` - name` items
if (/^\s+proxies:\s*$/.test(line)) {
inProxiesSublist = true;
result.push(line);
continue;
}
if (inProxiesSublist) {
// Check if next line is still a proxies list item
const nextLine = lines[i + 1];
const isLastItem = !nextLine || !/^\s{4,}-\s/.test(nextLine);
result.push(line);
if (isLastItem && /^\s{4,}-\s/.test(line)) {
// Inject names after last item
const indent = line.match(/^(\s+)/)?.[1] || ' ';
for (const name of names) result.push(`${indent}- ${name}`);
inProxiesSublist = false;
}
continue;
}
}
result.push(line);
}
return result.join('\n');
}

View File

@@ -0,0 +1,26 @@
import db from '../db.js';
export function generateSSRConfig(): 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;
if (!sub?.raw_config_ssr) {
return '';
}
const staticNodes = db.prepare(
'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
// Decode base64 content → URI list
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
const lines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
// Append static node URIs
for (const node of staticNodes) {
if (node.uri) lines.push(node.uri);
}
return Buffer.from(lines.join('\n')).toString('base64');
}

View File

@@ -50,9 +50,9 @@ export const auth = {
// Subscriptions // Subscriptions
export const subscriptions = { export const subscriptions = {
list: () => request<any[]>('/subscriptions'), list: () => request<any[]>('/subscriptions'),
create: (name: string, url: string) => request<{ id: number }>('/subscriptions', { create: (name: string, url: string, urlClash?: string, urlSsr?: string) => request<{ id: number }>('/subscriptions', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, url }), body: JSON.stringify({ name, url, url_clash: urlClash || null, url_ssr: urlSsr || null }),
}), }),
update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, { update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
method: 'PUT', method: 'PUT',

View File

@@ -4,10 +4,14 @@ import { config as configApi } from '../api';
export default function Output() { export default function Output() {
const [preview, setPreview] = useState(''); const [preview, setPreview] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false); const [copiedSurge, setCopiedSurge] = useState(false);
const [copiedClash, setCopiedClash] = useState(false);
const [copiedSsr, setCopiedSsr] = useState(false);
const [surgeToken, setSurgeToken] = useState(''); const [surgeToken, setSurgeToken] = useState('');
const surgeUrl = surgeToken ? `${window.location.origin}/surge/${surgeToken}` : ''; const surgeUrl = surgeToken ? `${window.location.origin}/surge/${surgeToken}` : '';
const clashUrl = surgeToken ? `${window.location.origin}/clash/${surgeToken}` : '';
const ssrUrl = surgeToken ? `${window.location.origin}/ssr/${surgeToken}` : '';
const loadToken = async () => { const loadToken = async () => {
try { try {
@@ -17,7 +21,7 @@ export default function Output() {
}; };
const handleRegenerate = async () => { const handleRegenerate = async () => {
if (!confirm('重新生成后,旧的订阅链接将失效Surge 客户端需要更新订阅地址。确定继续?')) return; if (!confirm('重新生成后,旧的订阅链接将失效Surge / Clash / SSR 三条链接同时更新)。确定继续?')) return;
try { try {
const data = await configApi.regenerateSurgeToken(); const data = await configApi.regenerateSurgeToken();
setSurgeToken(data.token); setSurgeToken(data.token);
@@ -42,93 +46,88 @@ export default function Output() {
loadPreview(); loadPreview();
}, []); }, []);
const handleCopy = () => { const makeCopyHandler = (url: string, setter: (v: boolean) => void) => () => {
navigator.clipboard.writeText(surgeUrl); navigator.clipboard.writeText(url);
setCopied(true); setter(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setter(false), 2000);
}; };
return ( return (
<div> <div>
<h2 style={styles.title}></h2> <h2 style={styles.title}></h2>
<p style={styles.subtitle}>Surge </p> <p style={styles.subtitle}>Surge / Clash / SSR </p>
{/* Subscription URL */} {/* Surge URL */}
<div style={{ <UrlCard
background: 'var(--bg-input)', label="SURGE 订阅链接"
border: '1px solid var(--border)', url={surgeUrl}
borderRadius: 'var(--radius)', copied={copiedSurge}
padding: 16, onCopy={makeCopyHandler(surgeUrl, setCopiedSurge)}
marginBottom: 20, onRegenerate={handleRegenerate}
}}> />
<div style={{
fontSize: 10, {/* Clash URL */}
fontFamily: 'var(--font-mono)', <UrlCard
color: 'var(--text-muted)', label="CLASH / STASH 订阅链接"
textTransform: 'uppercase', url={clashUrl}
letterSpacing: '0.1em', copied={copiedClash}
marginBottom: 8, onCopy={makeCopyHandler(clashUrl, setCopiedClash)}
}}> />
Surge
</div> {/* SSR URL */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <UrlCard
<code style={{ label="SSR / QX / 小火箭 订阅链接"
flex: 1, url={ssrUrl}
fontFamily: 'var(--font-mono)', copied={copiedSsr}
fontSize: 13, onCopy={makeCopyHandler(ssrUrl, setCopiedSsr)}
color: 'var(--accent)', />
userSelect: 'all',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{surgeUrl || '加载中...'}
</code>
<button className="small" onClick={handleCopy} disabled={!surgeUrl}>
{copied ? '已复制' : '复制'}
</button>
<button className="small" onClick={handleRegenerate} disabled={!surgeUrl}>
</button>
</div>
</div>
{/* Preview */} {/* Preview */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ <span style={styles.sectionLabel}>SURGE </span>
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}>
</span>
<button className="small" onClick={loadPreview} disabled={loading}> <button className="small" onClick={loadPreview} disabled={loading}>
{loading ? '加载中...' : '刷新预览'} {loading ? '加载中...' : '刷新预览'}
</button> </button>
</div> </div>
<pre style={{ <pre style={styles.pre}>
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)',
fontSize: 11,
lineHeight: 1.6,
color: 'var(--text-secondary)',
overflow: 'auto',
maxHeight: 'calc(100vh - 360px)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{preview || '(empty)'} {preview || '(empty)'}
</pre> </pre>
</div> </div>
); );
} }
function UrlCard({
label,
url,
copied,
onCopy,
onRegenerate,
}: {
label: string;
url: string;
copied: boolean;
onCopy: () => void;
onRegenerate?: () => void;
}) {
return (
<div style={styles.card}>
<div style={styles.cardLabel}>{label}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={styles.urlCode}>{url || '加载中...'}</code>
<button className="small" onClick={onCopy} disabled={!url}>
{copied ? '已复制' : '复制'}
</button>
{onRegenerate && (
<button className="small" onClick={onRegenerate} disabled={!url}>
</button>
)}
</div>
</div>
);
}
const styles = { const styles = {
title: { title: {
fontFamily: 'var(--font-mono)' as const, fontFamily: 'var(--font-mono)' as const,
@@ -142,4 +141,50 @@ const styles = {
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
marginBottom: 20, marginBottom: 20,
}, },
card: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 12,
},
cardLabel: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
marginBottom: 8,
},
urlCode: {
flex: 1,
fontFamily: 'var(--font-mono)' as const,
fontSize: 13,
color: 'var(--accent)',
userSelect: 'all' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
},
sectionLabel: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
},
pre: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)' as const,
fontSize: 11,
lineHeight: 1.6,
color: 'var(--text-secondary)',
overflow: 'auto',
maxHeight: 'calc(100vh - 480px)',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-all' as const,
},
}; };

View File

@@ -3,18 +3,46 @@ import { subscriptions as api } from '../api';
export default function Subscriptions() { export default function Subscriptions() {
const [subs, setSubs] = useState<any[]>([]); const [subs, setSubs] = useState<any[]>([]);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [fetching, setFetching] = useState<number | null>(null); const [fetching, setFetching] = useState<number | null>(null);
// Form state
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [formUrlClash, setFormUrlClash] = useState('');
const [formUrlSsr, setFormUrlSsr] = useState('');
const load = () => api.list().then(setSubs).catch(console.error); const load = () => api.list().then(setSubs).catch(console.error);
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const handleAdd = async () => { const openForm = (sub?: any) => {
if (!name.trim() || !url.trim()) return; setEditingId(sub?.id ?? null);
await api.create(name.trim(), url.trim()); setFormName(sub?.name ?? '');
setName(''); setFormUrl(sub?.url ?? '');
setUrl(''); setFormUrlClash(sub?.url_clash ?? '');
setFormUrlSsr(sub?.url_ssr ?? '');
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditingId(null);
};
const handleSubmit = async () => {
if (!formName.trim() || !formUrl.trim()) return;
if (editingId !== null) {
await api.update(editingId, {
name: formName.trim(),
url: formUrl.trim(),
url_clash: formUrlClash.trim() || null,
url_ssr: formUrlSsr.trim() || null,
});
} else {
await api.create(formName.trim(), formUrl.trim(), formUrlClash.trim(), formUrlSsr.trim());
}
closeForm();
load(); load();
}; };
@@ -46,22 +74,63 @@ export default function Subscriptions() {
<h2 style={styles.title}></h2> <h2 style={styles.title}></h2>
<p style={styles.subtitle}></p> <p style={styles.subtitle}></p>
{/* Add form */} {/* Add button */}
<div style={styles.form}> {!formOpen && (
<button className="primary" style={{ marginBottom: 16 }} onClick={() => openForm()}>
+
</button>
)}
{/* Add / Edit form */}
{formOpen && (
<div style={styles.formCard}>
<div style={styles.formTitle}>
{editingId !== null ? '编辑订阅' : '添加订阅'}
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}></label>
<input <input
placeholder="名称" placeholder="名称"
value={name} value={formName}
onChange={e => setName(e.target.value)} onChange={e => setFormName(e.target.value)}
style={{ width: 160 }} style={{ width: '100%' }}
/> />
<input
placeholder="订阅 URL"
value={url}
onChange={e => setUrl(e.target.value)}
style={{ flex: 1 }}
/>
<button className="primary" onClick={handleAdd}></button>
</div> </div>
<div style={styles.fieldGroup}>
<label style={styles.label}>Surge URL <span style={{ color: 'var(--accent)' }}>*</span></label>
<input
placeholder="https://..."
value={formUrl}
onChange={e => setFormUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}>Clash / Stash URL <span style={styles.optional}></span></label>
<input
placeholder="https://..."
value={formUrlClash}
onChange={e => setFormUrlClash(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={styles.fieldGroup}>
<label style={styles.label}>SSR / QX / URL <span style={styles.optional}></span></label>
<input
placeholder="https://..."
value={formUrlSsr}
onChange={e => setFormUrlSsr(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={closeForm}></button>
<button className="primary" onClick={handleSubmit}>
{editingId !== null ? '保存' : '添加'}
</button>
</div>
</div>
)}
{/* Table */} {/* Table */}
<table> <table>
@@ -69,10 +138,10 @@ export default function Subscriptions() {
<tr> <tr>
<th style={{ width: 50 }}></th> <th style={{ width: 50 }}></th>
<th></th> <th></th>
<th>URL</th> <th style={{ width: 120 }}></th>
<th style={{ width: 80 }}></th> <th style={{ width: 80 }}></th>
<th style={{ width: 140 }}></th> <th style={{ width: 140 }}></th>
<th style={{ width: 140 }}></th> <th style={{ width: 170 }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -90,9 +159,15 @@ export default function Subscriptions() {
{sub.name} {sub.name}
</td> </td>
<td> <td>
<span className="ellipsis" style={{ maxWidth: 300, display: 'inline-block' }}> <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sub.url} <span style={styles.badge} title={sub.url}>SURGE</span>
</span> {sub.url_clash && (
<span style={{ ...styles.badge, ...styles.badgeClash }} title={sub.url_clash}>CLASH</span>
)}
{sub.url_ssr && (
<span style={{ ...styles.badge, ...styles.badgeSsr }} title={sub.url_ssr}>SSR</span>
)}
</div>
</td> </td>
<td style={{ fontFamily: 'var(--font-mono)' }}> <td style={{ fontFamily: 'var(--font-mono)' }}>
{sub.node_count || '—'} {sub.node_count || '—'}
@@ -102,6 +177,9 @@ export default function Subscriptions() {
</td> </td>
<td> <td>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button className="small" onClick={() => openForm(sub)}>
</button>
<button <button
className="small" className="small"
onClick={() => handleFetch(sub.id)} onClick={() => handleFetch(sub.id)}
@@ -142,9 +220,54 @@ const styles = {
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
marginBottom: 20, marginBottom: 20,
}, },
form: { formCard: {
display: 'flex' as const, background: 'var(--bg-input)',
gap: 8, border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 20, marginBottom: 20,
display: 'flex' as const,
flexDirection: 'column' as const,
gap: 12,
},
formTitle: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 12,
color: 'var(--text-muted)',
textTransform: 'uppercase' as const,
letterSpacing: '0.08em',
},
fieldGroup: {
display: 'flex' as const,
flexDirection: 'column' as const,
gap: 4,
},
label: {
fontSize: 11,
color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)' as const,
},
optional: {
color: 'var(--text-muted)',
fontSize: 10,
},
badge: {
fontSize: 10,
fontFamily: 'var(--font-mono)' as const,
padding: '1px 5px',
borderRadius: 3,
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
cursor: 'default' as const,
userSelect: 'none' as const,
},
badgeClash: {
color: '#4ade80',
borderColor: '#4ade8040',
},
badgeSsr: {
color: '#60a5fa',
borderColor: '#60a5fa40',
}, },
}; };