// 生成并持久化随机 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.json,gitignored;lib/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 并注入 +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 注入的

即将离开本站

您即将离开 ${escapeHtml(siteName)} 前往外部链接,本站不对其内容的真实性、安全性与可用性负责,请谨慎访问。

${escapedUrl}

将在 5 秒后自动跳转

返回本站
`); }); // 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); // 版块页 SSR(P5):/forum/c/:id,须在 SPA catch-all 之前 app.get('/forum/c/:id', categorySSR); // 前台论坛管理页退役:/forum-manage.html 直接 302 到后台 /admin/forum(SPA 内路由另有 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;/assets(JS/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(//i, '\n '); 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();