/* * MC Report System * Copyright (C) 2026 Sea Network Technology Studio * Author: CangLan * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const fs = require('fs'); const path = require('path'); const { isInstalled, getConfig } = require('./db'); const { sanitizeBody, generalLimiter, loginLimiter, registerLimiter, ticketLimiter, captchaLimiter, apiKeyGuard, methodGuard } = require('./middleware/security'); const app = express(); app.set('trust proxy', 1); const PORT = process.env.PORT || 3100; const PUBLIC_DIR = path.join(__dirname, '..', 'public'); const HTML_PATH = path.join(PUBLIC_DIR, 'index.html'); let cachedHtml = null; let htmlTimestamp = 0; let htmlKey = null; function getSiteName() { if (!isInstalled()) return 'MC举报系统'; try { const { getPool } = require('./db'); return getPool().then(p => p.execute("SELECT v FROM settings WHERE k='site_name'")).then(([rows]) => rows.length > 0 ? rows[0].v : 'MC举报系统').catch(() => 'MC举报系统'); } catch { return 'MC举报系统'; } } function getHtmlWithKey() { return Promise.resolve(getSiteName()).then(siteName => { const stat = fs.statSync(HTML_PATH); const mtime = stat.mtimeMs; const cfg = getConfig(); const key = cfg?.api_key || ''; if (!cachedHtml || htmlTimestamp < mtime || htmlKey !== key || cachedHtml.indexOf(siteName) === -1) { let html = fs.readFileSync(HTML_PATH, 'utf-8'); const redirect = isInstalled() ? '' : ''; html = html.replace('', `${redirect}`); cachedHtml = html; htmlTimestamp = mtime; htmlKey = key; } return cachedHtml; }); } app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"], fontSrc: ["'self'", "https://cdnjs.cloudflare.com"], imgSrc: ["'self'", "data:", "blob:"], mediaSrc: ["'self'"], connectSrc: ["'self'"], scriptSrcAttr: ["'unsafe-inline'"], }, }, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: { policy: 'cross-origin' }, })); app.use(cors({ origin: (origin, cb) => { const allowed = process.env.CORS_ORIGIN; if (!allowed || allowed === '*') return cb(null, true); if (allowed === origin) return cb(null, true); const host = origin || ''; if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true); cb(null, false); }, credentials: true, methods: ['GET','POST','PUT','DELETE'], allowedHeaders: ['Content-Type','Authorization','x-api-key'], })); app.disable('x-powered-by'); app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(sanitizeBody); app.use(generalLimiter); app.use('/api', apiKeyGuard); app.use('/api', (req, res, next) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); next(); }); const staticOpts = { maxAge: 0, setHeaders: (res, p) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); }, }; app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), staticOpts)); app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), staticOpts)); const installRoutes = require('./routes/install'); app.use('/api/install', installRoutes); // 业务路由挂在独立 Router 上: 动态添加的路由在 Router 内部按序匹配, // 不会被注册在 Router 之后的 404 兜底抢先(修复安装后"接口不存在") const businessRouter = express.Router(); app.use('/api', businessRouter); let businessLoaded = false; function loadBusinessRoutes() { if (businessLoaded) return; businessLoaded = true; const authRoutes = require('./routes/auth'); businessRouter.use('/auth/login', methodGuard(['POST']), loginLimiter); businessRouter.use('/auth/register', methodGuard(['POST']), registerLimiter); businessRouter.use('/auth', authRoutes); businessRouter.use('/captcha', methodGuard(['GET']), captchaLimiter, require('./routes/captcha')); businessRouter.use('/tickets', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets')); businessRouter.use('/reports', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets')); businessRouter.use('/users', methodGuard(['GET','POST','PUT']), generalLimiter, require('./routes/users')); businessRouter.use('/settings', methodGuard(['GET','PUT']), generalLimiter, require('./routes/settings')); businessRouter.use('/export', methodGuard(['GET']), generalLimiter, require('./routes/export')); businessRouter.use('/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads')); businessRouter.use('/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications')); businessRouter.use('/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls')); businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans')); businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs')); businessRouter.use('/sources', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/sources')); businessRouter.use('/external', require('./routes/external')); businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); wrapAsyncRouter(businessRouter); } if (isInstalled()) loadBusinessRoutes(); global.__loadBusinessRoutes = loadBusinessRoutes; // Express 4 不自动捕获 async handler 的 rejection, 一旦 Promise 拒绝会 uncaughtException 崩掉整个进程。 // 对所有已挂载路由做递归包装: rejection → next(err) → 统一错误中间件(500 JSON)。 function wrapAsyncRouter(router) { if (!router || router.__asyncWrapped) return; router.__asyncWrapped = true; for (const layer of router.stack) { if (layer.route) { for (const rl of layer.route.stack) { const h = rl.handle; if (typeof h === 'function' && h.length <= 3) { rl.handle = (req, res, next) => { try { const p = h(req, res, next); if (p && p.catch) p.catch(next); } catch (e) { next(e); } }; } } } else { const h = layer.handle; if (typeof h === 'function' && h.stack) { wrapAsyncRouter(h); continue; } if (typeof h === 'function' && h.length <= 3) { layer.handle = (req, res, next) => { try { const p = h(req, res, next); if (p && p.catch) p.catch(next); } catch (e) { next(e); } }; } } } } wrapAsyncRouter(businessRouter); wrapAsyncRouter(installRoutes); // 顶层 async 路由同样包装 for (const layer of app._router.stack) { if (layer.route && layer.route.path === '/' && layer.route.stack[0]) { const h = layer.route.stack[0].handle; if (typeof h === 'function' && h.length <= 3) { layer.route.stack[0].handle = (req, res, next) => { try { const p = h(req, res, next); if (p && p.catch) p.catch(next); } catch (e) { next(e); } }; } } } app.get('/api/health', (req, res) => res.json({ status: 'ok', installed: isInstalled() })); app.get('/', async (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.type('html').send(await getHtmlWithKey()); }); app.get('*', async (req, res) => { if (req.path.startsWith('/api')) return res.status(404).json({ error: '接口不存在' }); res.setHeader('Cache-Control', 'no-cache'); res.type('html').send(await getHtmlWithKey()); }); app.use((err, req, res, next) => { if (err.message === 'SYSTEM_NOT_INSTALLED') return res.status(503).json({ error: '系统未安装' }); if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件大小超过限制(50MB)' }); if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message }); if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' }); console.error(err); try { require('./logger').logSystem('error', 'server', err.message || '服务器内部错误', { path: req.path, method: req.method, stack: String(err.stack).slice(0, 1000) }); } catch {} res.status(500).json({ error: '服务器内部错误' }); }); app.listen(PORT, () => { if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`); else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`); }).on('error', (err) => { console.error('Failed to start:', err.message); process.exit(1); });