149 lines
7.5 KiB
JavaScript
149 lines
7.5 KiB
JavaScript
const express = require('express');
|
||
const bcrypt = require('bcryptjs');
|
||
const crypto = require('crypto');
|
||
const db = require('../db');
|
||
const { authMiddleware } = require('../middleware/auth');
|
||
|
||
const router = express.Router();
|
||
|
||
// Verification codes in memory: userId -> { code, expires }
|
||
const verifyCodes = new Map();
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, v] of verifyCodes) if (v.expires < now) verifyCodes.delete(k);
|
||
}, 60000);
|
||
|
||
function sendCodeEmail(email, code, username) {
|
||
return new Promise((resolve, reject) => {
|
||
const host = db.getSetting('smtp_host');
|
||
if (!host) return reject(new Error('SMTP 未配置'));
|
||
const nodemailer = require('nodemailer');
|
||
const transporter = nodemailer.createTransport({
|
||
host, port: parseInt(db.getSetting('smtp_port')) || 587,
|
||
secure: parseInt(db.getSetting('smtp_port')) === 465,
|
||
auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') },
|
||
tls: { rejectUnauthorized: false },
|
||
});
|
||
const siteName = db.getSetting('site_name') || 'RainWeb';
|
||
const color = db.getSetting('primary_color') || '#6750a4';
|
||
transporter.sendMail({
|
||
from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`,
|
||
to: email,
|
||
subject: '验证码 - ' + siteName,
|
||
html: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<style>body{margin:0;padding:0;background:#f5f5f5;font-family:'Segoe UI',Roboto,sans-serif}
|
||
.container{max-width:480px;margin:40px auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08)}
|
||
.header{background:${color};padding:32px 24px;text-align:center}
|
||
.header h1{margin:0;color:#fff;font-size:22px;font-weight:500}
|
||
.body{padding:32px 24px;color:#1c1b1f;font-size:15px;line-height:1.6;text-align:center}
|
||
.code{font-size:36px;letter-spacing:8px;font-weight:700;text-align:center;padding:20px;background:#f5f5f5;border-radius:12px;font-family:monospace;margin:20px 0;color:${color}}
|
||
.footer{padding:16px 24px;text-align:center;font-size:12px;color:#79747e;border-top:1px solid #e7e0ec}
|
||
</style></head><body><div class="container">
|
||
<div class="header"><h1>${siteName}</h1></div>
|
||
<div class="body"><p style="font-size:16px">您好 ${username},</p><p>您的验证码为:</p>
|
||
<div class="code">${code}</div><p style="color:#79747e;font-size:14px">有效期 10 分钟。</p></div>
|
||
<div class="footer">${siteName} · 自动发送请勿回复</div>
|
||
</div></body></html>`,
|
||
}).then(() => resolve()).catch(reject);
|
||
});
|
||
}
|
||
|
||
router.get('/', authMiddleware, (req, res) => {
|
||
const user = db.get('SELECT id, username, email, email_verified, role, avatar, qq, nickname, website, bio, title, title_color, created_at FROM users WHERE id = ?', [req.user.id]);
|
||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||
res.json(user);
|
||
});
|
||
|
||
// Email is read-only after registration
|
||
router.put('/', authMiddleware, (req, res) => {
|
||
const { avatar, qq, bio, nickname, website } = req.body;
|
||
if (avatar !== undefined) {
|
||
// 头像仅接受站内上传路径(sha256 hash + 图片扩展名),防止外链、路径穿越与引号绕过
|
||
if (typeof avatar !== 'string' || !/^\/uploads\/avatars\/[0-9a-f]{64}\.(png|jpe?g|gif|webp)$/i.test(avatar))
|
||
return res.status(400).json({ error: '头像必须是站内路径' });
|
||
db.run('UPDATE users SET avatar = ? WHERE id = ?', [avatar, req.user.id]);
|
||
}
|
||
if (qq !== undefined) {
|
||
// QQ 号:纯数字 5-12 位,或空字符串清空
|
||
const q = String(qq).trim();
|
||
if (q !== '' && !/^\d{5,12}$/.test(q))
|
||
return res.status(400).json({ error: 'QQ 号需为 5-12 位数字' });
|
||
db.run('UPDATE users SET qq = ? WHERE id = ?', [q, req.user.id]);
|
||
}
|
||
if (bio !== undefined) {
|
||
// 个性签名:非字符串忽略,字符串 trim 后限长 200 字符
|
||
if (typeof bio === 'string') {
|
||
const b = bio.trim();
|
||
if (b.length > 200) return res.status(400).json({ error: '个性签名不能超过 200 字符' });
|
||
db.run('UPDATE users SET bio = ? WHERE id = ?', [b, req.user.id]);
|
||
}
|
||
}
|
||
if (nickname !== undefined) {
|
||
// 昵称:非字符串忽略,字符串 trim 后限长 20 字符
|
||
if (typeof nickname === 'string') {
|
||
const n = nickname.trim();
|
||
if (n.length > 20) return res.status(400).json({ error: '昵称最多 20 字' });
|
||
db.run('UPDATE users SET nickname = ? WHERE id = ?', [n, req.user.id]);
|
||
}
|
||
}
|
||
if (website !== undefined) {
|
||
// 个人博客:非字符串忽略,字符串 trim ≤200,必须 http(s) 开头;空串=清空
|
||
if (typeof website === 'string') {
|
||
const w = website.trim();
|
||
if (w !== '') {
|
||
if (w.length > 200) return res.status(400).json({ error: '个人博客不能超过 200 字' });
|
||
if (!/^https?:\/\//i.test(w)) return res.status(400).json({ error: '个人博客需以 http(s):// 开头' });
|
||
}
|
||
db.run('UPDATE users SET website = ? WHERE id = ?', [w, req.user.id]);
|
||
}
|
||
}
|
||
res.json({ message: '已更新' });
|
||
});
|
||
|
||
// Send verification code for password change
|
||
router.post('/send-pw-code', authMiddleware, async (req, res) => {
|
||
const user = db.get('SELECT id, username, email FROM users WHERE id = ?', [req.user.id]);
|
||
if (!user || !user.email) return res.status(400).json({ error: '未设置邮箱' });
|
||
const code = crypto.randomInt(10000000, 100000000).toString();
|
||
verifyCodes.set(req.user.id, { code, expires: Date.now() + 600000 });
|
||
try {
|
||
await sendCodeEmail(user.email, code, user.username);
|
||
res.json({ message: '验证码已发送至 ' + user.email });
|
||
} catch (e) {
|
||
res.status(500).json({ error: '发送失败: ' + e.message });
|
||
}
|
||
});
|
||
|
||
// Change password with verification code
|
||
router.put('/password', authMiddleware, (req, res) => {
|
||
const { code, oldPassword, newPassword } = req.body;
|
||
if (!code || !oldPassword || !newPassword) return res.status(400).json({ error: '参数不完整' });
|
||
if (newPassword.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||
|
||
const entry = verifyCodes.get(req.user.id);
|
||
if (!entry || entry.code !== code) return res.status(400).json({ error: '验证码错误或已过期' });
|
||
verifyCodes.delete(req.user.id);
|
||
|
||
const user = db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
|
||
if (!bcrypt.compareSync(oldPassword, user.password)) return res.status(401).json({ error: '原密码错误' });
|
||
db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPassword, 10), req.user.id]);
|
||
res.json({ message: '密码已修改' });
|
||
});
|
||
|
||
// Resend email verification (8-digit code) for existing user
|
||
router.post('/resend-verify', authMiddleware, async (req, res) => {
|
||
const user = db.get('SELECT id, username, email FROM users WHERE id = ?', [req.user.id]);
|
||
if (!user || !user.email) return res.status(400).json({ error: '未设置邮箱' });
|
||
if (user.email_verified) return res.status(400).json({ error: '邮箱已验证' });
|
||
const code = crypto.randomInt(10000000, 100000000).toString();
|
||
db.run('DELETE FROM pending_users WHERE email = ?', [user.email]);
|
||
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)',
|
||
[user.username, '', user.email, code]);
|
||
try {
|
||
await sendCodeEmail(user.email, code, user.username);
|
||
res.json({ message: '验证码已发送' });
|
||
} catch (e) { res.status(500).json({ error: '发送失败: ' + e.message }); }
|
||
});
|
||
|
||
module.exports = router;
|