feat: MC Report System - MySQL + Express + Vanilla JS SPA
This commit is contained in:
37
backend/middleware/auth.js
Normal file
37
backend/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { getConfig } = require('../db');
|
||||
|
||||
function getSecret() {
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
return cfg?.jwt_secret || process.env.JWT_SECRET || 'mc-dev-secret';
|
||||
} catch { return process.env.JWT_SECRET || 'mc-dev-secret'; }
|
||||
}
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username, role: user.role, game_name: user.game_name, game_uid: user.game_uid, email: user.email }, getSecret(), { expiresIn: '72h' });
|
||||
}
|
||||
|
||||
function verifyToken(token) { return jwt.verify(token, getSecret()); }
|
||||
|
||||
function authenticate(req, res, next) {
|
||||
const h = req.headers.authorization;
|
||||
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '请先登录' });
|
||||
try { req.user = verifyToken(h.split(' ')[1]); next(); } catch { return res.status(401).json({ error: '登录已过期' }); }
|
||||
}
|
||||
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
if (!roles.includes(req.user.role)) return res.status(403).json({ error: '权限不足' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function optionalAuth(req, res, next) {
|
||||
const h = req.headers.authorization;
|
||||
if (h && h.startsWith('Bearer ')) { try { req.user = verifyToken(h.split(' ')[1]); } catch {} }
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { generateToken, verifyToken, authenticate, requireRole, optionalAuth };
|
||||
145
backend/middleware/security.js
Normal file
145
backend/middleware/security.js
Normal file
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
function getApiKey() {
|
||||
if (cachedApiKey === null) cachedApiKey = readApiKey();
|
||||
return cachedApiKey;
|
||||
}
|
||||
function clearApiKeyCache() { cachedApiKey = null; }
|
||||
|
||||
const xssOptions = {
|
||||
whiteList: {},
|
||||
stripIgnoreTag: true,
|
||||
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
|
||||
};
|
||||
|
||||
function sanitize(value) {
|
||||
if (typeof value !== 'string') return value;
|
||||
return xss(value, xssOptions).trim();
|
||||
}
|
||||
|
||||
function sanitizeBody(req, res, next) {
|
||||
if (req.body) {
|
||||
for (const key of Object.keys(req.body)) {
|
||||
if (typeof req.body[key] === 'string') {
|
||||
req.body[key] = sanitize(req.body[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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: 10,
|
||||
message: { error: '提交过于频繁,请稍后再试' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const ticketAnonLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
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'] !== key) return res.status(401).json({ error: '无效的 API 密钥' });
|
||||
next();
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
75
backend/middleware/upload.js
Normal file
75
backend/middleware/upload.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'data', 'uploads');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const ALLOWED_MIME = {
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/png': ['.png'],
|
||||
'image/gif': ['.gif'],
|
||||
'image/webp': ['.webp'],
|
||||
'video/mp4': ['.mp4'],
|
||||
'video/webm': ['.webm'],
|
||||
};
|
||||
|
||||
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm'];
|
||||
|
||||
function validateMagicBytes(buffer, mime) {
|
||||
const head = buffer.slice(0, 12);
|
||||
const sigs = {
|
||||
'image/jpeg': () => head[0] === 0xFF && head[1] === 0xD8,
|
||||
'image/png': () => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47,
|
||||
'image/gif': () => head.toString('ascii', 0, 6) === 'GIF89a' || head.toString('ascii', 0, 6) === 'GIF87a',
|
||||
'image/webp': () => head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP',
|
||||
'video/mp4': () => buffer.includes(Buffer.from('ftyp')),
|
||||
'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3,
|
||||
};
|
||||
if (!sigs[mime]) return false;
|
||||
try { return sigs[mime](); } catch { return false; }
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, crypto.randomUUID() + ext);
|
||||
},
|
||||
});
|
||||
|
||||
function fileFilter(req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
return cb(new Error('不支持的文件类型,仅允许: ' + ALLOWED_EXT.join(', ')));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024,
|
||||
files: 5,
|
||||
},
|
||||
});
|
||||
|
||||
function finalizeUpload(req, res, next) {
|
||||
if (!req.files || req.files.length === 0) return next();
|
||||
for (const file of req.files) {
|
||||
const buf = fs.readFileSync(file.path);
|
||||
if (!ALLOWED_MIME[file.mimetype]) {
|
||||
fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: `不支持的文件类型: ${file.mimetype}` });
|
||||
}
|
||||
if (!validateMagicBytes(buf, file.mimetype)) {
|
||||
fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: '文件内容与声明类型不符,可能是恶意文件' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME };
|
||||
Reference in New Issue
Block a user