Files
MC_Report/backend/middleware/upload.js
canglan 6e9101a506 fix: deploy crash + multiple bug fixes + cleanup
- server.js: fix getSiteName() returning string when not installed causing
  'getSiteName(...).then is not a function' crash on homepage (deploy blocker)
- server.js: auto-load business routes after install completes (no restart needed),
  HTML cache keyed by api_key
- install: validate db name/email/password, trigger route loading after complete
- app.js: fix forgot/reset/verify pages rendering HomePage (missing page mapping)
- verify.js: support URL token auto-verification for external registration links
- auth: new email_code template for 6-digit code, reset_password template
  (forgot-password was using verify_email template)
- upload.js: fix MP4 magic-bytes check using undefined buf variable
- tickets.js: status enum validation, anonymous submission rate limit
- security.js: XSS whitelist preserves email template HTML, strips scripts,
  blocks javascript:/data: hrefs; CORS reject returns 403
- bans.js: allow clearing reason/duration, status enum validation
- users.js: fix req.user.role ReferenceError in create user modal
- home.js: tracking results now have detail view button
- .gitignore: ignore data/ (db credentials), logs, session files, temp scripts
2026-08-16 21:21:25 +08:00

80 lines
2.7 KiB
JavaScript

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': () => head[4] === 0x66 && head[5] === 0x74 && head[6] === 0x79 && head[7] === 0x70,
'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();
const filePaths = [];
try {
for (const file of req.files) {
const fd = fs.openSync(file.path, 'r');
const buf = Buffer.alloc(256);
fs.readSync(fd, buf, 0, 256, 0);
fs.closeSync(fd);
filePaths.push(file.path);
if (!ALLOWED_MIME[file.mimetype]) throw new Error(`不支持的文件类型: ${file.mimetype}`);
if (!validateMagicBytes(buf, file.mimetype)) throw new Error('文件内容与声明类型不符,可能是恶意文件');
}
next();
} catch (e) {
for (const fp of filePaths) { try { fs.unlinkSync(fp); } catch {} }
return res.status(400).json({ error: e.message || '文件校验失败' });
}
}
module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME };