diff --git a/.gitignore b/.gitignore index 5e515ad..b4953c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,10 @@ node_modules/ -data/config.json -data/uploads/* -data/*.db -data/*.db-* +# 本地运行配置与上传文件(含数据库密码,严禁入库) +data/ +# 日志与临时文件 *.log -*.log -o.log -e.log -err.log -out.log +*.tmp.js +*.tmp.sh +*.bak +# 会话记录 +session-*.md diff --git a/backend/db.js b/backend/db.js index b5187ff..e8eaa05 100644 --- a/backend/db.js +++ b/backend/db.js @@ -307,6 +307,9 @@ async function seedTemplates(db) { ['verify_email', '邮箱验证', '【{{site_name}}】请验证您的邮箱', '
您好 {{username}}(游戏名: {{game_name}},UID: {{game_uid}}),
感谢注册!请点击以下链接验证邮箱:
链接有效期24小时。
— {{site_name}}
', '变量: {{site_name}}, {{username}}, {{game_name}}, {{game_uid}}, {{verify_link}}'], + ['email_code', '邮箱验证码', '【{{site_name}}】您的邮箱验证码', + '您好,
您的邮箱验证码为:
{{code}}
验证码10分钟内有效。如非本人操作,请忽略此邮件。
— {{site_name}}
', + '变量: {{site_name}}, {{code}}'], ['ticket_created', '工单创建通知', '【{{site_name}}】{{ticket_type}}已提交 #{{ticket_id}}', '{{reporter_game_name}}(UID: {{reporter_game_uid}}),您的{{ticket_type}}已提交。
工单编号: #{{ticket_id}}
标题: {{ticket_title}}
— {{site_name}}
', '变量: {{site_name}}, {{reporter_game_name}}, {{ticket_type}}, {{ticket_id}}, {{ticket_title}}, {{tracking_link}}'], @@ -319,6 +322,9 @@ async function seedTemplates(db) { ['ticket_transferred', '工单已转交', '【{{site_name}}】工单 #{{ticket_id}} 已转交', '工单 #{{ticket_id}}({{ticket_title}})已由 {{from_user}} 转交给 {{to_user}}。
— {{site_name}}
', '变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{from_user}}, {{to_user}}'], + ['reset_password', '密码重置', '【{{site_name}}】密码重置请求', + '您好 {{username}},
我们收到了您的密码重置请求。请点击以下链接设置新密码:
链接1小时内有效。如非本人操作,请忽略此邮件。
— {{site_name}}
', + '变量: {{site_name}}, {{username}}, {{reset_link}}'], ]; for (const t of temps) { diff --git a/backend/middleware/security.js b/backend/middleware/security.js index e64897d..89d612b 100644 --- a/backend/middleware/security.js +++ b/backend/middleware/security.js @@ -22,9 +22,20 @@ function getApiKey() { function clearApiKeyCache() { cachedApiKey = null; } const xssOptions = { - whiteList: {}, + 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) { @@ -85,6 +96,7 @@ const ticketLimiter = rateLimit({ const ticketAnonLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, + skip: (req) => !!(req.headers.authorization), message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' }, standardHeaders: true, legacyHeaders: false, diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js index d81ea51..c07e7eb 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': () => buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70, + '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; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 6e792cd..b3b1396 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -24,9 +24,9 @@ router.post('/send-verify-code', async (req, res) => { await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE"); await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [codeId, email, code]); - const sent = await sendEmail(email, 'verify_email', { + const sent = await sendEmail(email, 'email_code', { username: email.split('@')[0], game_name: '', game_uid: '', - verify_link: code, + code, }); if (!sent) { await query('DELETE FROM captchas WHERE id = ?', [codeId]); @@ -229,9 +229,9 @@ router.post('/forgot-password', async (req, res) => { const resetToken = uuid(); await query("UPDATE users SET reset_token = ?, reset_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?", [resetToken, user.id]); const site = await getRow("SELECT v FROM settings WHERE k='site_url'"); - await sendEmail(email, 'verify_email', { + await sendEmail(email, 'reset_password', { username: user.username, game_name: user.game_name, game_uid: user.game_uid, - verify_link: `${site?.v||'http://localhost:3100'}#/reset?token=${resetToken}`, + reset_link: `${site?.v||'http://localhost:3100'}#/reset?token=${resetToken}`, }); res.json({ message: '如果邮箱已注册,重置链接已发送' }); } catch (err) { diff --git a/backend/routes/bans.js b/backend/routes/bans.js index 005c3dd..5cd6ca1 100644 --- a/backend/routes/bans.js +++ b/backend/routes/bans.js @@ -53,9 +53,12 @@ router.post('/', authenticate, requireRole('owner'), async (req, res) => { router.put('/:id', authenticate, requireRole('owner'), async (req, res) => { const fields = {}; - if (req.body.status) fields.status = req.body.status; - if (req.body.reason) fields.reason = req.body.reason; - if (req.body.duration) fields.duration = req.body.duration; + if (req.body.status) { + if (!['active','expired','appealed','lifted'].includes(req.body.status)) return res.status(400).json({ error: '无效的状态值' }); + fields.status = req.body.status; + } + if (req.body.reason !== undefined) fields.reason = req.body.reason; + if (req.body.duration !== undefined) fields.duration = req.body.duration; if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); const sets = Object.keys(fields).map(k => `${k} = ?`).join(', '); await query(`UPDATE bans SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]); diff --git a/backend/routes/install.js b/backend/routes/install.js index c051a18..eb246f9 100644 --- a/backend/routes/install.js +++ b/backend/routes/install.js @@ -12,6 +12,7 @@ router.get('/status', (req, res) => { router.post('/check-db', async (req, res) => { const { host, port, user, password, database } = req.body; + if (!/^[A-Za-z0-9_]+$/.test(database || '')) return res.status(400).json({ error: '数据库名仅允许字母、数字、下划线' }); try { const conn = await mysql.createConnection({ host: host || 'localhost', @@ -42,6 +43,9 @@ router.post('/complete', async (req, res) => { if (!db_host || !db_user || !db_name || !admin_username || !admin_password || !admin_email) { return res.status(400).json({ error: '必填字段不完整' }); } + if (!/^[A-Za-z0-9_]+$/.test(db_name || '')) return res.status(400).json({ error: '数据库名仅允许字母、数字、下划线' }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(admin_email)) return res.status(400).json({ error: '邮箱格式不正确' }); + if (admin_password.length < 6) return res.status(400).json({ error: '密码至少6位' }); try { const conn = await mysql.createConnection({ @@ -90,7 +94,9 @@ router.post('/complete', async (req, res) => { external_api_key: externalKey, }); - res.json({ ok: true, message: '安装完成,请重新启动服务器' }); + try { if (typeof global.__loadBusinessRoutes === 'function') global.__loadBusinessRoutes(); } catch {} + + res.json({ ok: true, message: '安装完成,无需重启,系统已就绪' }); } catch (e) { res.status(500).json({ error: '安装失败: ' + e.message }); } diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index 4d6c4d1..6186269 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -5,7 +5,7 @@ const { authenticate, requireRole, optionalAuth } = require('../middleware/auth' const { upload, finalizeUpload } = require('../middleware/upload'); const { sendEmail } = require('../mailer'); const { sendWebhook } = require('../webhook'); -const { validateLengths } = require('../middleware/security'); +const { validateLengths, ticketAnonLimiter } = require('../middleware/security'); const router = express.Router(); @@ -101,7 +101,7 @@ router.get('/:id', authenticate, async (req, res) => { res.json(t); }); -router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({ +router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({ title:100, reporter_game_name:50, reporter_game_uid:50, target_game_name:50, target_game_uid:50, reason:300, description:5000, }), async (req, res) => { const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, parent_ticket_id } = req.body; @@ -185,7 +185,10 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) if (req.user.role === 'admin' && t.assigned_to && t.assigned_to !== req.user.id) return res.status(403).json({ error: '只能修改自己负责的工单' }); if (t.status === 'closed' && req.user.role !== 'owner') return res.status(400).json({ error: '已关闭的工单仅服主可操作' }); const fields = {}; - if (req.body.status) fields.status = req.body.status; + if (req.body.status) { + if (!['pending','processing','awaiting_info','appealing','resolved','rejected','closed'].includes(req.body.status)) return res.status(400).json({ error: '无效的状态值' }); + 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; diff --git a/backend/server.js b/backend/server.js index 0fdc7ff..81d9ec3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,6 +17,7 @@ const HTML_PATH = path.join(PUBLIC_DIR, 'index.html'); let cachedHtml = null; let htmlTimestamp = 0; +let htmlKey = null; function getSiteName() { if (!isInstalled()) return 'MC举报系统'; @@ -27,17 +28,18 @@ function getSiteName() { } function getHtmlWithKey() { - return getSiteName().then(siteName => { + return Promise.resolve(getSiteName()).then(siteName => { const stat = fs.statSync(HTML_PATH); const mtime = stat.mtimeMs; - if (!cachedHtml || htmlTimestamp < mtime || cachedHtml.indexOf(siteName) === -1) { + const cfg = getConfig(); + const key = cfg?.api_key || ''; + if (!cachedHtml || htmlTimestamp < mtime || htmlKey !== key || 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; + htmlKey = key; } return cachedHtml; }); @@ -65,11 +67,9 @@ app.use(cors({ 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')); + const host = origin || ''; + if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true); + cb(null, false); }, credentials: true, methods: ['GET','POST','PUT','DELETE'], @@ -100,7 +100,11 @@ app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), staticOpts)); const installRoutes = require('./routes/install'); app.use('/api/install', installRoutes); -if (isInstalled()) { +let businessLoaded = false; +function loadBusinessRoutes() { + if (businessLoaded) return; + businessLoaded = true; + const authRoutes = require('./routes/auth'); app.use('/api/auth/login', methodGuard(['POST']), loginLimiter); app.use('/api/auth/register', methodGuard(['POST']), registerLimiter); @@ -124,6 +128,9 @@ if (isInstalled()) { app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); } +if (isInstalled()) loadBusinessRoutes(); +global.__loadBusinessRoutes = loadBusinessRoutes; + app.get('/api/health', (req, res) => res.json({ status: 'ok', installed: isInstalled() })); app.get('/', async (req, res) => { diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 6376bc7..0000000 --- a/opencode.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "instructions": ["AGENTS.md"] -} diff --git a/public/js/app.js b/public/js/app.js index 06c1b2a..757d1a9 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -109,7 +109,7 @@ const App = { pub.classList.remove('hidden'); document.getElementById('top-bar').classList.remove('hidden'); this.updateTopAuth(); - const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : HomePage; + const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : page === 'forgot' ? ForgotPage : page === 'reset' ? ResetPage : page === 'verify' ? VerifyPage : HomePage; comp.render(param).then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(param); }).catch(() => { pub.innerHTML = '请刷新页面重试
| # | 类型 | 标题 | 状态 | 时间 |
|---|---|---|---|---|
| ${tk.id} | ${U.badge(tk.type,'type')} | ${U.esc(tk.title)} | ${U.badge(tk.status,'status')} | ${U.date(tk.created_at)} |
未找到工单
| # | 类型 | 标题 | 状态 | 时间 | 操作 |
|---|---|---|---|---|---|
| ${tk.id} | ${U.badge(tk.type,'type')} | ${U.esc(tk.title)} | ${U.badge(tk.status,'status')} | ${U.date(tk.created_at)} |
未找到工单
配置已保存。请重启服务器后刷新页面。
系统已就绪,正在跳转登录...