diff --git a/backend/mailer.js b/backend/mailer.js index de2f35e..eb14fcf 100644 --- a/backend/mailer.js +++ b/backend/mailer.js @@ -42,17 +42,28 @@ function renderTemplate(template, vars) { return { subject, body }; } +let _transporter = null; +let _transporterKey = ''; + +function getTransporter(cfg) { + const key = `${cfg.host}:${cfg.port}:${cfg.user}`; + if (_transporter && _transporterKey === key) return _transporter; + _transporterKey = key; + _transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } }); + return _transporter; +} + async function sendEmail(to, templateCode, vars) { try { const cfg = await getSmtpConfig(); if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; } const template = await getTemplate(templateCode); if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; } - const allVars = { ...vars, site_name: await getSiteName(), site_url: await getSiteUrl() }; + const siteName = await getSiteName(); + const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() }; const { subject, body } = renderTemplate(template, allVars); - - const transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } }); - await transporter.sendMail({ from: `"${await getSiteName()}" <${cfg.from}>`, to, subject, html: body }); + const transporter = getTransporter(cfg); + await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body }); return true; } catch (err) { console.error('[Mailer]', err.message); return false; } } diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index afad4b9..0d5c9c7 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -4,8 +4,13 @@ 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'; } + const secret = cfg?.jwt_secret || process.env.JWT_SECRET; + if (secret) return secret; + throw new Error('JWT_SECRET not configured'); + } catch (e) { + if (e.message === 'JWT_SECRET not configured') throw e; + return process.env.JWT_SECRET || (() => { throw new Error('JWT_SECRET not configured'); })(); + } } function generateToken(user) { diff --git a/backend/middleware/security.js b/backend/middleware/security.js index 2fcbef2..5266d1f 100644 --- a/backend/middleware/security.js +++ b/backend/middleware/security.js @@ -33,13 +33,14 @@ function sanitize(value) { } 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]); - } + 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(); } diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js index 10ba05e..d81ea51 100644 --- a/backend/middleware/upload.js +++ b/backend/middleware/upload.js @@ -24,7 +24,7 @@ function validateMagicBytes(buffer, mime) { '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/mp4': () => buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70, 'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3, }; if (!sigs[mime]) return false; @@ -58,18 +58,22 @@ const upload = multer({ 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: '文件内容与声明类型不符,可能是恶意文件' }); + 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 || '文件校验失败' }); } - next(); } module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME }; diff --git a/backend/routes/external.js b/backend/routes/external.js index e82bf73..936bf9c 100644 --- a/backend/routes/external.js +++ b/backend/routes/external.js @@ -1,7 +1,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const { v4: uuid } = require('uuid'); -const { query, getRow, getConfig } = require('../db'); +const { query, getRow, getConfig, getPool } = require('../db'); const { generateToken, authenticate } = require('../middleware/auth'); const { sendEmail } = require('../mailer'); @@ -47,7 +47,7 @@ router.post('/auth/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); const user = await getRow('SELECT * FROM users WHERE username = ?', [username]); - if (!user || !bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: '用户名或密码错误' }); + if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: '用户名或密码错误' }); if (!user.active) return res.status(403).json({ error: '账号未激活' }); const token = generateToken(user); res.json({ token, user: { id:user.id, username:user.username, game_name:user.game_name, game_uid:user.game_uid, role:user.role } }); diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index ea46e10..7ed3aae 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -185,6 +185,7 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) const fields = {}; if (req.body.status) fields.status = req.body.status; if (req.body.priority) fields.priority = req.body.priority; + if (req.body.priority && !['low','medium','high','urgent'].includes(req.body.priority)) return res.status(400).json({ error: '无效的优先级' }); if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note; if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); const sets = Object.keys(fields).map(k => `${k} = ?`).join(', '); diff --git a/backend/server.js b/backend/server.js index 3bdacd3..04591e4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -125,7 +125,7 @@ app.use((err, req, res, next) => { res.status(500).json({ error: '服务器内部错误' }); }); -if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`); -else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`); - -app.listen(PORT); +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); }); diff --git a/backend/webhook.js b/backend/webhook.js index e4db5f9..eb98811 100644 --- a/backend/webhook.js +++ b/backend/webhook.js @@ -9,7 +9,12 @@ async function sendWebhook(event, data) { const events = cfg.events || 'all'; if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue; const embed = buildEmbed(event, data); - try { await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed) }); } catch {} + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10000); + await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctrl.signal }); + clearTimeout(t); + } catch (e) { console.error('[Webhook]', cfg.name, e.message); } } } catch {} }