Files
rainblogweb/server.js
T

502 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 生成并持久化随机 JWT_SECRET(必须在路由模块加载之前执行,middleware/auth.js 模块加载时即读取 SECRET
try {
const fs = require('fs');
const crypto = require('crypto');
const cfgPath = './.env.json';
let cfg = {};
if (fs.existsSync(cfgPath)) {
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
}
if (!cfg.jwt_secret) {
cfg.jwt_secret = crypto.randomBytes(48).toString('hex');
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
}
process.env.JWT_SECRET = cfg.jwt_secret;
// RainID OIDC 机密 client_secret(仅存 .env.jsongitignoredlib/rainid.js 读取)
if (cfg.rainid_client_secret) process.env.RAINID_CLIENT_SECRET = cfg.rainid_client_secret;
} catch {}
const express = require('express');
const cors = require('cors');
const path = require('path');
const { getDb } = require('./db');
const authRoutes = require('./routes/auth');
const adminLinkRoutes = require('./routes/admin-links');
const announcementRoutes = require('./routes/announcements');
const forumRoutes = require('./routes/forum');
const blogRoutes = require('./routes/blog');
const passwordRoutes = require('./routes/passwords');
const settingsRoutes = require('./routes/settings');
const emailRoutes = require('./routes/email');
const profileRoutes = require('./routes/profile');
const userRoutes = require('./routes/users');
const captchaRoutes = require('./routes/captcha');
const uploadRoutes = require('./routes/upload');
const setupRoutes = require('./routes/setup');
const proxyRoutes = require('./routes/proxy');
const importRoutes = require('./routes/import');
const noteRoutes = require('./routes/notes');
const feedRoutes = require('./routes/feed');
const terminalRoutes = require('./routes/terminal');
const ticketRoutes = require('./routes/tickets');
const oidcRoutes = require('./routes/oidc');
const { blogSSR, forumSSR, categorySSR, sitemapXml } = require('./ssr');
const app = express();
// M4:反向代理 IP 信任——仅当环境变量 TRUST_PROXY=1 时信任一层 X-Forwarded-For
//(与 routes/terminal.js getClientIp 同语义,统一开关)。限流桶(express-rate-limit
// 按 req.ip 计数:Cloudflare/nginx 场景必须开启,否则全站共用一个桶可被刷满 DoS,
// 且直连源站可无限绕过限流。
// 权衡:开启后 req.ip 取自 X-Forwarded-For,客户端可伪造该头(除非反代覆盖/剥离),
// 因此仅应在受控反代之后启用;直连部署保持关闭(false)。
app.set('trust proxy', process.env.TRUST_PROXY === '1' ? 1 : false);
// L1:全站安全响应头中间件(须在路由之前注册,全局生效)
// 豁免:/api/proxy/fetch 与 /proxy/* 前缀代理不设置 X-Frame-Options 与 CSP——代理响应自行管理头
//routes/proxy.js 已剥离上游 X-Frame-Options/CSP 并注入 <base>+shim,若强加本站 CSP
// 会破坏被代理面板的内联脚本/样式;其余头不受影响,代理成功响应经 writeHead 整体覆盖)。
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// HSTS:仅 https 请求附加(http 直连场景附加会被浏览器忽略并产生混乱)
if (req.secure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
}
// 代理端点豁免 frame 类头(工作台 iframe 依赖代理嵌入,见 routes/proxy.js
const isProxyPath = req.path === '/api/proxy/fetch' || req.path.startsWith('/proxy/');
if (!isProxyPath) {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// CSP 保守方案:MUI sx 生成大量内联 style 属性 → 必须 style-src-attr 'unsafe-inline'
// style-src 'unsafe-inline' 放行 emotion 注入的 <style> 标签;frame-src 放行 localhost
//(工作台本地代理模式)+ https(第三方面板);connect-src 放行 ws/wssWeb 终端)
res.setHeader('Content-Security-Policy',
"default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; font-src 'self' data:; connect-src 'self' https: ws: wss:; frame-src 'self' http://localhost:* https:;");
}
next();
});
// JSON body 解析(H3 修复:SEO 重构时误删,所有 /api 的 JSON 接口依赖它,缺失导致 500/挂起)
app.use(express.json({ limit: '5mb' }));
// 面板代理前缀路由:/proxy/{slug}/xxx —— 挂载在 express.json 之后但走独立处理器,
// 透传方法/body 到目标面板(express.json 只解析 application/json,其余 content-type 流不被消费;
// 代理处理器用 req.pipe 转发原始请求流,见 routes/proxy.js prefixProxy)。
// 必须在 SPA catch-all 之前注册。
app.use('/proxy', proxyRoutes);
// Read port from .env.json config file, env var, or default
let configPort = 3001;
try {
const cfg = JSON.parse(require('fs').readFileSync('./.env.json', 'utf8'));
if (cfg.port) configPort = parseInt(cfg.port);
} catch {}
const PORT = process.env.PORT || configPort;
// HTML 转义(与 ssr.js 同款):所有设置值/用户可控字段进入 meta/页面输出前必须过此函数
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// P0:SPA 壳按路由注入服务端 metapath → title/description/canonical/og/JSON-LD/robots
// 低价值页(登录/注册/个人中心/密码箱/写作/论坛管理/嵌入/初始化/管理后台)→ noindex
const NOINDEX_PATHS = [
'/login.html', '/register.html', '/profile.html', '/passwords.html',
'/write.html', '/forum-manage.html', '/embed.html', '/setup.html',
'/admin', '/admin.html'
];
// 版主子管理台(/forum/manage 及子路径)登录后可见,前缀匹配均不收录
const NOINDEX_PREFIXES = ['/forum/manage'];
// JSON-LD 序列化安全:仅需转义 < 防止 </script> 截断(与 ssr.js 详情页同模式)
function jsonldScript(obj) {
return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
}
// 按 req.path 生成路由级 SEO 配置(所有返回值均已经过 escapeHtml,可直接拼进 HTML 属性)
function buildSeoMeta(req) {
const { getSetting, all } = require('./db');
const siteName = getSetting('site_name') || 'Rainnya Blog';
const siteDesc = getSetting('site_description') || '个人云平台';
const siteUrl = getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const base = siteUrl.replace(/\/+$/, '');
const p = (req.path.replace(/\/+$/, '') || '/');
const meta = {
siteName: escapeHtml(siteName),
title: escapeHtml(siteName),
description: escapeHtml(siteDesc),
canonical: escapeHtml(base + p),
ogType: 'website',
twitterCard: 'summary',
ogImage: escapeHtml(base + '/og-default.png'),
rssUrl: escapeHtml(base + '/feed.xml'),
noindex: NOINDEX_PATHS.includes(p) || NOINDEX_PREFIXES.some((pre) => p.startsWith(pre)),
jsonld: ''
};
if (meta.noindex) return meta;
if (p === '/') {
// 首页:WebSite JSON-LD
meta.title = escapeHtml(siteName);
meta.description = escapeHtml(getSetting('homepage_bio') || siteDesc);
meta.canonical = escapeHtml(base + '/');
meta.jsonld = jsonldScript({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: siteName,
url: base + '/'
});
} else if (p === '/blog.html') {
// 博客列表:ItemList JSON-LD(最近 20 篇)
meta.title = '博客';
meta.description = escapeHtml(`查看 ${siteName} 的全部博客文章,涵盖技术分享、生活记录与随笔,按发布时间浏览。`);
const posts = all('SELECT id, title FROM blog_posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20');
meta.jsonld = jsonldScript({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: siteName + ' 博客',
itemListElement: posts.map((post, i) => ({
'@type': 'ListItem',
position: i + 1,
url: base + '/blog/' + post.id,
name: post.title
}))
});
} else if (p === '/forum.html') {
// 论坛列表:ItemList JSON-LD(最近 20 帖)
meta.title = '论坛';
meta.description = escapeHtml(`参与 ${siteName} 论坛讨论,与社区成员交流技术、分享经验、提出问题。`);
const posts = all('SELECT id, title FROM forum_posts ORDER BY created_at DESC LIMIT 20');
meta.jsonld = jsonldScript({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: siteName + ' 论坛',
itemListElement: posts.map((post, i) => ({
'@type': 'ListItem',
position: i + 1,
url: base + '/forum/' + post.id,
name: post.title
}))
});
} else if (p === '/archive.html') {
meta.title = '文章归档';
meta.description = escapeHtml(`按月份归档 ${siteName} 全部已发布博客文章,快速定位历史内容。`);
} else if (p.indexOf('/tag/') === 0) {
// 标签页:/tag/:namereq.path 保留 %xx 编码,解码用于展示)
let tagName = '';
try { tagName = decodeURIComponent(p.slice(5)); } catch {}
meta.title = escapeHtml('标签:' + tagName);
meta.description = escapeHtml(tagName
? `浏览 ${siteName} 中标签为「${tagName}」的博客文章列表。`
: siteDesc);
meta.canonical = escapeHtml(base + p);
}
return meta;
}
// Serve index.html with dynamic settings injection (must be before static to take precedence)
function serveIndex(req, res) {
// Phase 6:读取前端构建产物 dist/index.html(保留站点设置占位符运行时替换)
// 必须 no-store:构建产物 assets 带 hash,旧 HTML 引用旧 assets 会 404 白屏
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
const indexPath = path.join(__dirname, 'public', 'dist', 'index.html');
const fs = require('fs');
if (fs.existsSync(indexPath)) {
let html = fs.readFileSync(indexPath, 'utf8');
try {
const { getSetting } = require('./db');
const siteName = getSetting('site_name') || 'Rainnya Blog';
const siteDesc = getSetting('site_description') || '个人云平台';
const siteFavicon = getSetting('site_favicon') || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>';
html = html.replace(/\$\{site_name\}/g, escapeHtml(siteName));
html = html.replace(/\$\{site_description\}/g, escapeHtml(siteDesc));
html = html.replace(/\$\{site_favicon\}/g, siteFavicon);
// P0:路由级 meta——覆盖模板默认 title/description/og:type,其余叠加(避免重复标签)
const seo = buildSeoMeta(req);
if (seo.noindex) res.setHeader('X-Robots-Tag', 'noindex');
html = html.replace(/<title>[^<]*<\/title>/, `<title>${seo.title}</title>`);
html = html.replace(
/<meta name="description" content="[^"]*">/,
`<meta name="description" content="${seo.description}">`
);
html = html.replace(
/<meta property="og:type" content="[^"]*">/,
`<meta property="og:type" content="${seo.ogType}">`
);
const seoMetas = [
`<link rel="canonical" href="${seo.canonical}">`,
`<meta property="og:title" content="${seo.title}">`,
`<meta property="og:description" content="${seo.description}">`,
`<meta property="og:url" content="${seo.canonical}">`,
`<meta property="og:image" content="${seo.ogImage}">`,
`<meta property="og:site_name" content="${seo.siteName}">`,
`<meta name="twitter:card" content="${seo.twitterCard}">`,
`<meta name="twitter:title" content="${seo.title}">`,
`<meta name="twitter:description" content="${seo.description}">`,
`<link rel="alternate" type="application/rss+xml" title="${seo.siteName} RSS" href="${seo.rssUrl}">`
];
if (seo.noindex) seoMetas.push('<meta name="robots" content="noindex">');
if (seo.jsonld) seoMetas.push(seo.jsonld);
html = html.replace('</head>', seoMetas.join('\n ') + '\n</head>');
} catch {}
res.send(html);
} else {
res.status(500).send('Index file not found. Please reinstall the application or run `npm run build`.');
}
}
app.get('/', serveIndex);
// 构建产物资源:vite base='/' 使产物引用 /assets/*,实际文件在 public/dist/assets/
// (文件名带 hash,可长期缓存)
app.use('/assets', express.static(path.join(__dirname, 'public', 'dist', 'assets'), {
maxAge: '365d',
immutable: true,
}));
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: 0,
setHeaders(res, path) {
if (path.endsWith('.html') || path.endsWith('.js')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}
}));
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), {
setHeaders(res, p) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
if (!/\.(png|jpe?g|gif|webp|pdf|zip|txt|md)$/i.test(p)) {
res.setHeader('Content-Disposition', 'attachment');
}
}
}));
app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/admin-links', adminLinkRoutes);
app.use('/api/announcements', announcementRoutes);
app.use('/api/forum', forumRoutes);
app.use('/api/blog', blogRoutes);
app.use('/api/passwords', passwordRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/email', emailRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/users', userRoutes);
app.use('/api/captcha', captchaRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/setup', setupRoutes);
app.use('/api/proxy', proxyRoutes);
app.use('/api/import', importRoutes);
app.use('/api/notes', noteRoutes);
app.use('/api/terminal', terminalRoutes);
app.use('/api/tickets', ticketRoutes);
// Version & Update
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
app.get('/api/version', (req, res) => res.json({ version }));
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err.message);
res.status(500).json({ error: '服务器内部错误' });
});
// 分享链接 → SPA 完整版:/blog/:id/share 或 /forum/:id/share 302 到列表页带 share 参数,
// 前端检测 ?share=<id> 后客户端 navigate 到详情(React Router 内部跳转,不触发整页刷新)。
// 不能直接 302 到 /blog/:id —— 那会再次命中下方 SSR 路由(blogSSR),导致死循环。
// 路由必须注册在 app.get('*') SPA catch-all 之前;/blog/:id 与 /blog/:id/share 路径段数不同,互不干扰。
app.get('/blog/:id/share', (req, res) => {
const post = require('./db').get('SELECT id FROM blog_posts WHERE id = ? AND published = 1', [req.params.id]);
if (!post) return res.status(404).send('文章不存在');
res.redirect('/blog.html?share=' + post.id);
});
app.get('/forum/:id/share', (req, res) => {
const post = require('./db').get('SELECT id FROM forum_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).send('帖子不存在');
res.redirect('/forum.html?share=' + post.id);
});
// 版块分享:/forum/c/:id/share → SPA 列表页带 share_c 参数,前端客户端导航到版块页。
// 私密模式(forum_guest_visible!=='1')下与 SSR 同策略返回 404(不暴露版块存在性)。
// 须注册在 SPA catch-all 之前;与 /forum/:id、/forum/:id/share 路径段数不同,互不干扰。
app.get('/forum/c/:id/share', (req, res) => {
const db = require('./db');
if (db.getSetting('forum_guest_visible') !== '1') return res.status(404).send('版块不存在');
const cat = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
if (!cat) return res.status(404).send('版块不存在');
res.redirect('/forum.html?share_c=' + cat.id);
});
// 外链跳转确认页:前端把外链(http(s)://)点击改为 ?url=<encoded> 走这里。
// 后端兜底校验协议:非 http(s) 一律 400(相对路径是站内不应走确认页,危险协议直接拒绝)。
// 页面 noindex(不应被收录)。须注册在 SPA catch-all 之前。
app.get('/out', (req, res) => {
// 确认页需要内联 <script> 倒计时,而全站 CSP 是 script-src 'self'(无 unsafe-inline)会拦掉它。
// 此页仅展示转义后的目标 URLescapeHtml + JSON.stringify 双转义)、无第三方资源、已 noindex,
// 移除 CSP 风险极低,故豁免(其余安全头保留)。
res.removeHeader('Content-Security-Policy');
let target = String(req.query.url || '');
try { target = decodeURIComponent(target); } catch {}
if (!/^https?:\/\//i.test(target)) {
return res.status(400).type('text/plain').send('Invalid url parameter');
}
const siteName = require('./db').getSetting('site_name') || 'RainWeb';
const escapedUrl = escapeHtml(target);
const jsUrl = JSON.stringify(target); // JSON.stringify 转义引号/反斜杠,安全嵌入 JS 字符串
res.setHeader('X-Robots-Tag', 'noindex');
res.type('html').send(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>离开本站确认 - ${escapeHtml(siteName)}</title>
<style>
body{font-family:'Segoe UI',Roboto,sans-serif;background:#f3edf7;color:#1d1b20;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:16px}
.card{max-width:520px;width:100%;background:#fff;border-radius:20px;padding:32px;box-shadow:0 4px 20px rgba(0,0,0,.12);text-align:center}
h1{font-size:22px;font-weight:600;margin:0 0 8px}
.desc{font-size:14px;color:#5f5a66;line-height:1.7;margin:0 0 16px}
.url{display:block;font-size:13px;color:#6750a4;background:#f7f2fa;border:1px solid #e6e0e9;border-radius:10px;padding:12px;word-break:break-all;margin:0 0 20px}
.count{font-size:13px;color:#5f5a66;margin-bottom:20px}
.count b{color:#6750a4}
.actions{display:flex;gap:12px;justify-content:center}
button,.back{font-size:14px;padding:10px 22px;border-radius:999px;border:none;cursor:pointer;text-decoration:none;display:inline-block}
button{background:#6750a4;color:#fff}
button:disabled{opacity:.5;cursor:default}
.back{background:#e6e0e9;color:#1d1b20}
</style>
</head>
<body>
<div class="card">
<h1>即将离开本站</h1>
<p class="desc">您即将离开 ${escapeHtml(siteName)} 前往外部链接,本站不对其内容的真实性、安全性与可用性负责,请谨慎访问。</p>
<span class="url">${escapedUrl}</span>
<p class="count">将在 <b id="count">5</b> 秒后自动跳转</p>
<div class="actions">
<button id="go" onclick="location.href=${jsUrl}">立即前往</button>
<a class="back" href="/">返回本站</a>
</div>
</div>
<script>
var n = 5;
var el = document.getElementById('count');
var t = setInterval(function () {
n--;
el.textContent = n;
if (n <= 0) { clearInterval(t); location.href = ${jsUrl}; }
}, 1000);
</script>
</body>
</html>`);
});
// SEO: Server-side rendered pages for search engines
app.get('/blog/:id', blogSSR);
// 版主子管理台(登录后可见):显式注册在 /forum/:id SSR 之前,直回 SPA 壳(客户端校验权限)
app.get(['/forum/manage', '/forum/manage/:id'], (req, res) => {
serveIndex(req, res);
});
app.get('/forum/:id', forumSSR);
// 版块页 SSRP5):/forum/c/:id,须在 SPA catch-all 之前
app.get('/forum/c/:id', categorySSR);
// 前台论坛管理页退役:/forum-manage.html 直接 302 到后台 /admin/forumSPA 内路由另有 Navigate 兜底)
app.get('/forum-manage.html', (req, res) => {
res.redirect('/admin/forum');
});
app.get('/sitemap.xml', sitemapXml);
// RSS 订阅:显式路由,须在 SPA catch-all 之前(挂在 SSR 区附近)
app.use(feedRoutes);
app.get('/robots.txt', (req, res) => {
const db = require('./db');
const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host'));
const domain = siteUrl.replace(/\/$/, '');
res.type('text/plain');
// 登录/注册/密码箱等 SPA 页不 Disallow:由 serveIndex 按路由输出 X-Robots-Tag: noindex 响应头;
// 仅屏蔽管理后台 /admin/assetsJS/CSS)保持可爬
res.send(`User-agent: *
Allow: /
Disallow: /admin
Sitemap: ${domain}/sitemap.xml`);
});
// 管理后台独立应用(Phase 6):/admin 与 /admin/* → dist/admin.html(必须注册在 app.get('*') SPA fallback 之前)
// 注意:用路径数组 ['/admin', '/admin/*'] 精确匹配,避免 /adminfoo 等前缀误命中
app.get(['/admin', '/admin/*'], (req, res) => {
const adminPath = path.join(__dirname, 'public', 'dist', 'admin.html');
if (require('fs').existsSync(adminPath)) {
// 管理后台不收录:noindex meta + X-Robots-Tag 响应头
res.type('html');
res.setHeader('X-Robots-Tag', 'noindex');
let adminHtml = require('fs').readFileSync(adminPath, 'utf8');
adminHtml = adminHtml.replace(/<head>/i, '<head>\n <meta name="robots" content="noindex">');
res.send(adminHtml);
} else {
res.status(500).send('Admin build not found. Please run `npm run build`.');
}
});
// SPA fallback: serve index.html for all non-API, non-static routes
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
serveIndex(req, res);
});
// Prevent crash on unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err.message);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err.message);
});
async function start() {
try {
const fs = require('fs');
['data', 'uploads', 'uploads/avatars'].forEach(d => {
const dir = path.join(__dirname, d);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Auto-migrate old data.db to new location
const oldDb = path.join(__dirname, 'data.db');
const newDb = path.join(__dirname, 'data', 'rainweb.db');
if (fs.existsSync(oldDb) && !fs.existsSync(newDb)) {
console.log('Migrating old data.db to data/rainweb.db...');
fs.copyFileSync(oldDb, newDb);
fs.renameSync(oldDb, oldDb + '.bak');
console.log('Migration complete (old file renamed to data.db.bak)');
}
await getDb();
// RainID 单点登录启动校验:开启但 client_id/secret 缺失 → 警告并按未启用处理(fail-closed
try {
const { getClientSecret } = require('./lib/rainid');
const { getSetting } = require('./db');
if (getSetting('rainid_enabled') === '1') {
const clientId = getSetting('rainid_client_id');
if (!clientId || !getClientSecret()) {
console.warn('[RainID] rainid_enabled=1 但 client_id / rainid_client_secret 未配置,RainID 登录将不可用(fail-closed,本地登录不受影响)');
}
}
} catch {}
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`RainWeb running on port ${PORT}`);
});
// Web 终端 WS 升级:upgrade 事件不经 Express 中间件栈,
// 在此提前接管 /ws/terminal(避开 SPA catch-all),其余升级请求直接关闭。
// 安全校验(Origin/token/会话上限)均在 routes/terminal.js handleUpgrade 内完成。
server.on('upgrade', (request, socket, head) => {
// 面板代理 WS/proxy/{slug}/... 前缀 → 转发目标面板(SSRF 校验 + 握手直通)
if (proxyRoutes.handleProxyUpgrade(request, socket, head)) return;
if (!terminalRoutes.handleUpgrade(request, socket, head)) {
socket.destroy();
}
});
} catch (err) {
console.error('Failed to start:', err.message);
process.exit(1);
}
}
start();