- db: email_logs (sent/failed) + system_logs (info/warn/error) tables, auto-migrated for existing installs - backend/logger.js: logEmail/logSystem with try/catch (never breaks flow) - mailer: log send result (SMTP unconfigured/sent/failed + error msg) - webhook: log blocked-internal, non-2xx response, send failure - server: error middleware records 500 errors to system_logs - routes/logs.js: GET /logs/emails + /logs/system (admin/owner, filters) - UI: 系统日志 page (owner/admin) with system/email tabs + filters - API docs updated
209 lines
8.8 KiB
JavaScript
209 lines
8.8 KiB
JavaScript
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() ? '' : '<script>location.hash="#/install"</script>';
|
|
html = html.replace('</head>', `<script>window.__API_KEY__=${JSON.stringify(key)};window.__SITE_NAME__=${JSON.stringify(siteName)}</script>${redirect}</head>`);
|
|
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('/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); });
|