游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单 (抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest token,三个 generator 接收可选 whitelist 过滤生成。 节点排序:fetched_nodes 新增 sort_order,重抓时按名保留顺序, 选择/节点面板支持原生拖拽排序,输出按此顺序排列。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
167 lines
6.3 KiB
TypeScript
167 lines
6.3 KiB
TypeScript
import express from 'express';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import './db.js'; // Initialize database
|
|
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';
|
|
import { generateSSRConfig } from './services/ssrGenerator.js';
|
|
|
|
// Ensure surge_token exists
|
|
function ensureSurgeToken(): string {
|
|
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
|
|
if (row?.value) return row.value;
|
|
const token = crypto.randomUUID();
|
|
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('surge_token', ?)").run(token);
|
|
return token;
|
|
}
|
|
ensureSurgeToken();
|
|
|
|
const app = express();
|
|
const PORT = parseInt(process.env.PORT || '3456', 10);
|
|
|
|
app.use(express.json());
|
|
|
|
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;
|
|
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; admin token or guest link)
|
|
app.get('/surge/:token', (req, res) => {
|
|
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 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',
|
|
});
|
|
res.send(config);
|
|
});
|
|
|
|
// Clash endpoint (no auth, token-protected path; admin token or guest link)
|
|
app.get('/clash/:token', (req, res) => {
|
|
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',
|
|
});
|
|
res.send(config);
|
|
});
|
|
|
|
// SSR endpoint (no auth, token-protected path; admin token or guest link)
|
|
app.get('/ssr/:token', (req, res) => {
|
|
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',
|
|
});
|
|
res.send(config);
|
|
});
|
|
|
|
// Auth routes (no auth required)
|
|
app.post('/api/auth/login', (req, res) => {
|
|
const { password } = req.body;
|
|
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
|
|
|
if (!configRow?.value) {
|
|
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('password', ?)").run(password);
|
|
return res.json({ ok: true });
|
|
}
|
|
|
|
if (password === configRow.value) {
|
|
return res.json({ ok: true });
|
|
}
|
|
|
|
res.status(401).json({ error: 'wrong password' });
|
|
});
|
|
|
|
app.get('/api/auth/status', (_req, res) => {
|
|
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
|
res.json({ hasPassword: !!configRow?.value });
|
|
});
|
|
|
|
// Auth middleware for other /api routes
|
|
app.use('/api', (req, res, next) => {
|
|
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
|
if (!configRow?.value) return next();
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || authHeader !== `Bearer ${configRow.value}`) {
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
next();
|
|
});
|
|
|
|
// API routes
|
|
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) => {
|
|
const subs = db.prepare('SELECT COUNT(*) as count FROM subscriptions WHERE enabled = 1').get() as any;
|
|
const fetchedEnabled = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes WHERE enabled = 1').get() as any;
|
|
const fetchedTotal = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes').get() as any;
|
|
const staticEnabled = db.prepare('SELECT COUNT(*) as count FROM static_nodes WHERE enabled = 1').get() as any;
|
|
const staticTotal = db.prepare('SELECT COUNT(*) as count FROM static_nodes').get() as any;
|
|
const rulesCount = db.prepare('SELECT COUNT(*) as count FROM rules WHERE enabled = 1').get() as any;
|
|
|
|
res.json({
|
|
subscriptions: subs.count,
|
|
nodes: {
|
|
fetched: { enabled: fetchedEnabled.count, total: fetchedTotal.count },
|
|
static: { enabled: staticEnabled.count, total: staticTotal.count },
|
|
},
|
|
rules: rulesCount.count,
|
|
});
|
|
});
|
|
|
|
// Serve static frontend files
|
|
const webDist = path.join(__dirname, '..', '..', 'web', 'dist');
|
|
app.use(express.static(webDist));
|
|
app.get('*', (req, res, 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'));
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
const token = ensureSurgeToken();
|
|
console.log(`Sub Router running at http://127.0.0.1:${PORT}`);
|
|
console.log(`Surge subscription: http://127.0.0.1:${PORT}/surge/${token}`);
|
|
console.log(`Admin panel: http://127.0.0.1:${PORT}`);
|
|
});
|