502 lines
24 KiB
JavaScript
502 lines
24 KiB
JavaScript
// 生成并持久化随机 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 并注入 <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/wss(Web 终端)
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// P0:SPA 壳按路由注入服务端 meta(path → 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/:name(req.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)会拦掉它。
|
||
// 此页仅展示转义后的目标 URL(escapeHtml + 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);
|
||
// 版块页 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(/<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();
|