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}}),

感谢注册!请点击以下链接验证邮箱:

{{verify_link}}

链接有效期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}}

我们收到了您的密码重置请求。请点击以下链接设置新密码:

{{reset_link}}

链接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 = '
'; }); diff --git a/public/js/pages/home.js b/public/js/pages/home.js index f0e6c43..b1feb4d 100644 --- a/public/js/pages/home.js +++ b/public/js/pages/home.js @@ -37,7 +37,7 @@ const HomePage = { r.innerHTML = '
'; try { const tickets = await API.get(`/tickets/tracking?token=${token}`); - r.innerHTML = tickets.length ? `
${tickets.map(tk=>``).join('')}
#类型标题状态时间
${tk.id}${U.badge(tk.type,'type')}${U.esc(tk.title)}${U.badge(tk.status,'status')}${U.date(tk.created_at)}
` : '

未找到工单

'; + r.innerHTML = tickets.length ? `
${tickets.map(tk=>``).join('')}
#类型标题状态时间操作
${tk.id}${U.badge(tk.type,'type')}${U.esc(tk.title)}${U.badge(tk.status,'status')}${U.date(tk.created_at)}
` : '

未找到工单

'; } catch (e) { r.innerHTML = `
${e.message}
`; } }, diff --git a/public/js/pages/install.js b/public/js/pages/install.js index e6a6a0d..48ee732 100644 --- a/public/js/pages/install.js +++ b/public/js/pages/install.js @@ -97,7 +97,8 @@ const InstallPage = { admin_game_name: document.getElementById('ia-game').value, admin_game_uid: document.getElementById('ia-uid').value, }); - document.getElementById('install-admin').innerHTML = '

安装成功!

配置已保存。请重启服务器后刷新页面。

'; + document.getElementById('install-admin').innerHTML = '

安装成功!

系统已就绪,正在跳转登录...

'; + setTimeout(() => location.hash = '#/login', 1500); } catch (ex) { document.getElementById('ia-err').textContent = ex.message; document.getElementById('ia-err').classList.remove('hidden'); diff --git a/public/js/pages/users.js b/public/js/pages/users.js index 35bcf06..bef898b 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -44,7 +44,7 @@ const UsersPage = {
-
+
diff --git a/public/js/pages/verify.js b/public/js/pages/verify.js index 704a779..3293d69 100644 --- a/public/js/pages/verify.js +++ b/public/js/pages/verify.js @@ -1,5 +1,13 @@ const VerifyPage = { async render() { + const params = new URLSearchParams((location.hash.split('?')[1] || '')); + const token = params.get('token'); + if (token) { + return `
+
验证中,请稍候...
+
返回登录
+
`; + } return `
@@ -8,6 +16,12 @@ const VerifyPage = {
`; }, mount() { + const params = new URLSearchParams((location.hash.split('?')[1] || '')); + const token = params.get('token'); + if (token) { + this.verifyToken(token); + return; + } document.getElementById('verify-form').addEventListener('submit', async e => { e.preventDefault(); const code = document.getElementById('vf-code').value.trim(); @@ -22,5 +36,15 @@ const VerifyPage = { msg.classList.remove('hidden'); } }); + }, + async verifyToken(token) { + const msg = document.getElementById('vf-msg'); + try { + const r = await API.post('/auth/verify-email', { code: token }); + msg.className = 'alert alert-s'; msg.textContent = r.message || '验证成功'; + setTimeout(() => location.hash = '#/login', 2000); + } catch (ex) { + msg.className = 'alert alert-e'; msg.textContent = ex.message; + } } };