- router.param('id'): all :id path params must be positive ints
(tickets/features/polls/auth/users; external/bans/notifications already
had parseInt - now consistent)
- notifications: type enum + webhook URL format + SSRF (isPrivateUrl
exported) + events whitelist + active boolean check on PUT
- bans: type enum + player_name length
- external: all-tickets type/status enums, bans status/type enums,
page/limit floor protection, ticket field length caps, clients active
boolean + id validation
- verified: 28 checks (syntax + validation coverage)
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
/*
|
|
* MC Report System
|
|
* Copyright (C) 2026 Sea Network Technology Studio
|
|
* Author: CangLan <admin@sea-studio.top>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const { v4: uuid } = require('uuid');
|
|
const { query, getRow } = require('../db');
|
|
const { generateToken, authenticate } = require('../middleware/auth');
|
|
const { sendEmail } = require('../mailer');
|
|
const { verify: verifyCaptcha } = require('./captcha');
|
|
const { validateLengths } = require('../middleware/security');
|
|
|
|
const router = express.Router();
|
|
|
|
// 统一校验 :id 路径参数(必须是正整数)
|
|
router.param('id', (req, res, next, id) => {
|
|
if (!/^\d+$/.test(id)) return res.status(400).json({ error: '无效的ID' });
|
|
next();
|
|
});
|
|
|
|
async function getSiteUrl() {
|
|
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
|
|
return row?.v || 'http://localhost:3100';
|
|
}
|
|
|
|
router.post('/send-verify-code', async (req, res) => {
|
|
try {
|
|
const { email } = req.body;
|
|
if (!email) return res.status(400).json({ error: '请输入邮箱' });
|
|
|
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
const codeId = uuid();
|
|
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, 'email_code', {
|
|
username: email.split('@')[0], game_name: '', game_uid: '',
|
|
code,
|
|
});
|
|
if (!sent) {
|
|
// SMTP 未配置/发送失败(dev 模式): 保留验证码记录, 前端直接显示验证码;
|
|
// 注册校验通过后会自动删除, 不会出现"验证码正确却报错"的问题
|
|
return res.status(200).json({ code_id: codeId, code, dev: true });
|
|
}
|
|
res.json({ code_id: codeId, message: '验证码已发送' });
|
|
} catch (err) { console.error(err); res.status(500).json({ error: '服务器内部错误' }); }
|
|
});
|
|
|
|
router.post('/register', validateLengths({
|
|
username: 20, password: 100, email: 100, game_name: 50, game_uid: 50,
|
|
}), async (req, res) => {
|
|
try {
|
|
const { username, password, email, game_name, game_uid, captcha_id, captcha_answer, source, code_id, code } = req.body;
|
|
|
|
if (!username || !password || !email || !game_name) {
|
|
return res.status(400).json({ error: '所有字段均为必填' });
|
|
}
|
|
// 来源必须是已启用的动态来源(不设默认)
|
|
let srcValid = false;
|
|
if (source) {
|
|
try {
|
|
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
|
|
srcValid = !!srcRow;
|
|
} catch { srcValid = true; } // sources 表不存在时兼容旧部署
|
|
}
|
|
if (source && !srcValid) return res.status(400).json({ error: '无效的来源' });
|
|
|
|
if (!code_id || !code) return res.status(400).json({ error: '请先完成邮箱验证' });
|
|
const codeRow = await getRow("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 10 MINUTE", [code_id]);
|
|
if (!codeRow || codeRow.answer !== code) return res.status(400).json({ error: '验证码无效或已过期' });
|
|
await query('DELETE FROM captchas WHERE id = ?', [code_id]);
|
|
if (username.length < 2 || username.length > 20) {
|
|
return res.status(400).json({ error: '用户名需2-20个字符' });
|
|
}
|
|
if (password.length < 6) {
|
|
return res.status(400).json({ error: '密码至少6位' });
|
|
}
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
return res.status(400).json({ error: '邮箱格式不正确' });
|
|
}
|
|
|
|
if (captcha_id && captcha_answer) {
|
|
const captchaOk = await verifyCaptcha(captcha_id, captcha_answer);
|
|
if (!captchaOk) {
|
|
return res.status(400).json({ error: '人机验证失败' });
|
|
}
|
|
}
|
|
|
|
const existingUser = await getRow('SELECT id FROM users WHERE username = ?', [username]);
|
|
const existingEmail = await getRow('SELECT id FROM users WHERE email = ?', [email]);
|
|
if (existingUser || existingEmail) {
|
|
return res.status(400).json({ error: '用户名或邮箱已被使用' });
|
|
}
|
|
if (game_name && source) {
|
|
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source]);
|
|
if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' });
|
|
}
|
|
// 多来源兼容: 该游戏名可能已被其他账号绑定为副身份
|
|
try {
|
|
const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
|
|
if (dupIdent) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
|
|
} catch {}
|
|
|
|
const hashed = await bcrypt.hash(password, 10);
|
|
const r = await query(
|
|
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, source) VALUES (?,?,?,?,?,?,?,?)',
|
|
[username, hashed, email, game_name, game_uid||'', 1, 1, source || '']
|
|
);
|
|
try {
|
|
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
|
[r.insertId, source || '', game_name, game_uid || '']);
|
|
} catch {}
|
|
res.status(201).json({ message: '注册成功,请登录' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.post('/verify-email', async (req, res) => {
|
|
try {
|
|
const { code } = req.body;
|
|
if (!code) return res.status(400).json({ error: '请输入验证码' });
|
|
|
|
const user = await getRow('SELECT * FROM users WHERE verify_token = ? AND verify_expires > NOW()', [code]);
|
|
if (!user) return res.status(400).json({ error: '验证码无效或已过期' });
|
|
|
|
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
|
await query('INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)', [user.id, user.username, 'verify_email', 'user', user.id, '邮箱验证通过']);
|
|
|
|
res.json({ message: '邮箱验证成功,现在可以登录了' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.post('/login', async (req, res) => {
|
|
try {
|
|
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) return res.status(401).json({ error: '用户名或密码错误' });
|
|
|
|
const valid = await bcrypt.compare(password, user.password);
|
|
if (!valid) return res.status(401).json({ error: '用户名或密码错误' });
|
|
|
|
if (!user.active) return res.status(403).json({ error: '账号未激活,请先验证邮箱' });
|
|
|
|
const token = generateToken(user);
|
|
await query(
|
|
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
|
|
[user.id, user.username, 'login', 'user', user.id, '登录系统']
|
|
);
|
|
|
|
res.json({
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
game_name: user.game_name,
|
|
game_uid: user.game_uid,
|
|
role: user.role,
|
|
source: user.source,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.get('/me', authenticate, async (req, res) => {
|
|
try {
|
|
const user = await getRow(
|
|
'SELECT id, username, email, game_name, game_uid, role, email_verified, source, created_at FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
if (!user) return res.status(404).json({ error: '用户不存在' });
|
|
user.identities = await query('SELECT id, source, game_name, game_uid, created_at FROM user_identities WHERE user_id = ? ORDER BY id', [req.user.id]);
|
|
res.json(user);
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
// ---- 多来源身份管理(一个账号可同时绑定网易端 + 皮肤站) ----
|
|
router.get('/identities', authenticate, async (req, res) => {
|
|
try {
|
|
const rows = await query('SELECT id, source, game_name, game_uid, created_at FROM user_identities WHERE user_id = ? ORDER BY id', [req.user.id]);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.post('/identities', authenticate, async (req, res) => {
|
|
try {
|
|
const { source, game_name, game_uid } = req.body;
|
|
if (!game_name) return res.status(400).json({ error: '游戏名不能为空' });
|
|
// 来源动态校验(启用中的来源; sources 表缺失时兼容)
|
|
if (source) {
|
|
try {
|
|
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
|
|
if (!srcRow) return res.status(400).json({ error: '无效的来源' });
|
|
} catch {}
|
|
}
|
|
|
|
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source || '']);
|
|
if (dup) return res.status(400).json({ error: '该来源已绑定,可先删除再重新绑定' });
|
|
|
|
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
|
|
if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
|
|
|
|
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
|
[req.user.id, source || '', game_name, game_uid || '']);
|
|
res.status(201).json({ id: r.insertId, message: '绑定成功' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.delete('/identities/:id', authenticate, async (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const ident = await getRow('SELECT * FROM user_identities WHERE id = ? AND user_id = ?', [id, req.user.id]);
|
|
if (!ident) return res.status(404).json({ error: '身份不存在' });
|
|
const count = await getRow('SELECT COUNT(*) as c FROM user_identities WHERE user_id = ?', [req.user.id]);
|
|
if (count.c <= 1) return res.status(400).json({ error: '至少保留一个身份' });
|
|
await query('DELETE FROM user_identities WHERE id = ?', [id]);
|
|
res.json({ message: '已解绑' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.put('/profile', authenticate, async (req, res) => {
|
|
try {
|
|
const fields = {};
|
|
const values = [];
|
|
if (req.body.game_name) {
|
|
fields.game_name = req.body.game_name;
|
|
}
|
|
if (req.body.game_uid) {
|
|
fields.game_uid = req.body.game_uid;
|
|
}
|
|
if (req.body.email) {
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
|
|
return res.status(400).json({ error: '邮箱格式不正确' });
|
|
}
|
|
const dup = await getRow('SELECT id FROM users WHERE email = ? AND id != ?', [req.body.email, req.user.id]);
|
|
if (dup) return res.status(400).json({ error: '邮箱已被其他账号使用' });
|
|
fields.email = req.body.email;
|
|
}
|
|
|
|
if (Object.keys(fields).length === 0) {
|
|
return res.status(400).json({ error: '无更新内容' });
|
|
}
|
|
|
|
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
|
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.user.id]);
|
|
|
|
const updated = await getRow(
|
|
'SELECT id, username, email, game_name, game_uid, role FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
const token = generateToken(updated);
|
|
res.json({ message: '更新成功', token, user: updated });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.put('/password', authenticate, async (req, res) => {
|
|
try {
|
|
const { oldPassword, newPassword } = req.body;
|
|
if (!oldPassword || !newPassword) {
|
|
return res.status(400).json({ error: '请提供新旧密码' });
|
|
}
|
|
if (newPassword.length < 6) {
|
|
return res.status(400).json({ error: '新密码至少6位' });
|
|
}
|
|
|
|
const user = await getRow('SELECT * FROM users WHERE id = ?', [req.user.id]);
|
|
const valid = await bcrypt.compare(oldPassword, user.password);
|
|
if (!valid) return res.status(400).json({ error: '旧密码错误' });
|
|
|
|
const hashed = await bcrypt.hash(newPassword, 10);
|
|
await query('UPDATE users SET password = ? WHERE id = ?', [hashed, req.user.id]);
|
|
res.json({ message: '密码修改成功' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.post('/forgot-password', async (req, res) => {
|
|
try {
|
|
const { email } = req.body;
|
|
if (!email) return res.status(400).json({ error: '请输入邮箱' });
|
|
const user = await getRow('SELECT * FROM users WHERE email = ?', [email]);
|
|
if (!user) return res.json({ message: '如果邮箱已注册,重置链接已发送' });
|
|
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, 'reset_password', {
|
|
username: user.username, game_name: user.game_name, game_uid: user.game_uid,
|
|
reset_link: `${site?.v||'http://localhost:3100'}#/reset?token=${resetToken}`,
|
|
});
|
|
res.json({ message: '如果邮箱已注册,重置链接已发送' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
router.post('/reset-password', async (req, res) => {
|
|
try {
|
|
const { token, password } = req.body;
|
|
if (!token || !password) return res.status(400).json({ error: '参数不完整' });
|
|
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
|
const user = await getRow("SELECT * FROM users WHERE reset_token = ? AND reset_expires > NOW()", [token]);
|
|
if (!user) return res.status(400).json({ error: '重置链接无效或已过期' });
|
|
const hashed = await bcrypt.hash(password, 10);
|
|
await query("UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?", [hashed, user.id]);
|
|
res.json({ message: '密码重置成功,请登录' });
|
|
} catch (err) {
|
|
console.error('[auth]', err);
|
|
res.status(500).json({ error: '服务器内部错误' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|