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; 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 getSiteName().then(siteName => { const stat = fs.statSync(HTML_PATH); const mtime = stat.mtimeMs; if (!cachedHtml || htmlTimestamp < mtime || cachedHtml.indexOf(siteName) === -1) { let html = fs.readFileSync(HTML_PATH, 'utf-8'); const cfg = getConfig(); const key = cfg?.api_key || ''; const redirect = isInstalled() ? '' : ''; html = html.replace('', `${redirect}`); cachedHtml = html; htmlTimestamp = mtime; } 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); if (!allowed) { const host = origin || ''; if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true); } cb(new Error('Not allowed by CORS')); }, 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); if (isInstalled()) { const authRoutes = require('./routes/auth'); app.use('/api/auth/login', methodGuard(['POST']), loginLimiter); app.use('/api/auth/register', methodGuard(['POST']), registerLimiter); app.use('/api/auth', authRoutes); app.use('/api/captcha', methodGuard(['GET']), captchaLimiter, require('./routes/captcha')); app.use('/api/tickets', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets')); app.use('/api/reports', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets')); app.use('/api/users', methodGuard(['GET','POST','PUT']), generalLimiter, require('./routes/users')); app.use('/api/settings', methodGuard(['GET','PUT']), generalLimiter, require('./routes/settings')); app.use('/api/export', methodGuard(['GET']), generalLimiter, require('./routes/export')); app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads')); app.use('/api/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications')); app.use('/api/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls')); app.use('/api/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); app.use('/api/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans')); app.use('/api/external', require('./routes/external')); app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); } 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); 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); });