/* * 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 xss = require('xss'); const rateLimit = require('express-rate-limit'); const fs = require('fs'); const path = require('path'); const CONFIG_PATH = path.join(__dirname, '..', '..', 'data', 'config.json'); function readApiKey() { try { if (fs.existsSync(CONFIG_PATH)) { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')).api_key || null; } } catch {} return null; } let cachedApiKey = null; let cachedApiKeyMtime = 0; function getApiKey() { try { const stat = fs.statSync(CONFIG_PATH); if (cachedApiKeyMtime === stat.mtimeMs) return cachedApiKey; cachedApiKey = readApiKey(); cachedApiKeyMtime = stat.mtimeMs; } catch { cachedApiKey = null; cachedApiKeyMtime = 0; } return cachedApiKey; } function clearApiKeyCache() { cachedApiKey = null; cachedApiKeyMtime = 0; } const xssOptions = { whiteList: { p: [], br: [], strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], a: ['href', 'title', 'target', 'rel'], span: [], div: [], ul: [], ol: [], li: [], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [], table: [], thead: [], tbody: [], tfoot: [], tr: [], th: [], td: [], caption: [], code: [], pre: [], blockquote: [], hr: [], img: ['src', 'alt', 'title', 'width', 'height'], font: ['color', 'size', 'face'], small: [], sub: [], sup: [], }, stripIgnoreTag: true, stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'], onTagAttr: (tag, name, value) => { if ((name === 'href' || name === 'src') && /^\s*(javascript|data):/i.test(value)) return ''; return; }, }; function sanitize(value) { if (typeof value !== 'string') return value; return xss(value, xssOptions).trim(); } function sanitizeBody(req, res, next) { function walk(obj) { if (!obj || typeof obj !== 'object') return; for (const key of Object.keys(obj)) { if (typeof obj[key] === 'string') obj[key] = sanitize(obj[key]); else if (typeof obj[key] === 'object') walk(obj[key]); } } walk(req.body); next(); } function validateLengths(rules) { return (req, res, next) => { for (const [field, maxLen] of Object.entries(rules)) { const val = req.body[field]; if (val && typeof val === 'string' && val.length > maxLen) { return res.status(400).json({ error: `${field} 长度不能超过 ${maxLen} 个字符` }); } } next(); }; } const loginLimiter = rateLimit({ windowMs: 60 * 1000, max: 8, message: { error: '登录尝试过于频繁,请1分钟后再试' }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, }); const registerLimiter = rateLimit({ windowMs: 60 * 1000, max: 3, message: { error: '注册请求过于频繁,请1分钟后再试' }, standardHeaders: true, legacyHeaders: false, }); const ticketLimiter = rateLimit({ windowMs: 60 * 1000, max: 500, skip: (req) => !!(req.headers.authorization), message: { error: '提交过于频繁,请稍后再试' }, standardHeaders: true, legacyHeaders: false, }); const ticketAnonLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, skip: (req) => !!(req.headers.authorization), message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' }, standardHeaders: true, legacyHeaders: false, }); const uploadLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, message: { error: '上传请求过于频繁' }, standardHeaders: true, legacyHeaders: false, }); const generalLimiter = rateLimit({ windowMs: 60 * 1000, max: 300, message: { error: '请求过于频繁' }, standardHeaders: true, legacyHeaders: false, }); const captchaLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { error: '验证码请求过于频繁' }, }); function apiKeyGuard(req, res, next) { const p = req.originalUrl; if (p === '/api/health' || p.startsWith('/api/install')) return next(); const key = getApiKey(); if (!key) return next(); if (!req.headers['x-api-key'] || req.headers['x-api-key'].length !== key.length) return res.status(401).json({ error: '无效的 API 密钥' }); if (!timingSafeEqual(req.headers['x-api-key'], key)) return res.status(401).json({ error: '无效的 API 密钥' }); next(); } function timingSafeEqual(a, b) { let diff = a.length ^ b.length; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } function methodGuard(allowed) { return (req, res, next) => { if (!allowed.includes(req.method)) { return res.status(405).json({ error: `不允许 ${req.method} 方法` }); } next(); }; } module.exports = { sanitize, sanitizeBody, validateLengths, loginLimiter, registerLimiter, ticketLimiter, ticketAnonLimiter, uploadLimiter, generalLimiter, captchaLimiter, apiKeyGuard, methodGuard, clearApiKeyCache, };