Files
rainblogweb/routes/email.js
T

149 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const nodemailer = require('nodemailer');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
// M2:验证码重发/校验限流——15 分钟窗口内每 IP 最多 10 次(防 8 位验证码爆破 + 邮箱轰炸)
const verifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: '操作过于频繁,请 15 分钟后再试' },
});
function getTransporter() {
const host = db.getSetting('smtp_host');
if (!host) return null;
return 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') },
// L4:保留 rejectUnauthorized: false(不改行为)——内网 SMTP 多为自签证书,改 true 会大面积失败。
// 风险:SMTP 凭据在 TLS 握手时可能被中间人嗅探。生产环境建议改 true 并将自签证书加入系统 CA。
tls: { rejectUnauthorized: false },
});
}
function emailTemplate(title, body) {
const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4';
return `<!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:520px;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}
.btn{display:inline-block;padding:12px 32px;background:${color};color:#fff;text-decoration:none;border-radius:100px;font-weight:500;font-size:14px;margin:16px 0}
.footer{padding:16px 24px;text-align:center;font-size:12px;color:#79747e;border-top:1px solid #e7e0ec}
.code{font-size:28px;letter-spacing:6px;text-align:center;padding:16px;background:#f5f5f5;border-radius:12px;font-family:monospace;margin:16px 0}
</style></head><body><div class="container">
<div class="header"><h1>${siteName}</h1></div>
<div class="body">${body}</div>
<div class="footer">${siteName} &middot; 自动发送请勿回复</div>
</div></body></html>`;
}
router.post('/test', authMiddleware, adminOnly, async (req, res) => {
const transporter = getTransporter();
if (!transporter) return res.status(400).json({ error: 'SMTP 未配置' });
const to = req.body.email || req.user.email;
if (!to) return res.status(400).json({ error: '没有收件人,请在个人中心设置邮箱或在请求中指定 email 参数' });
try {
await transporter.sendMail({
from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`,
to,
subject: 'RainWeb 邮件配置测试',
html: emailTemplate('测试邮件', '<p>如果您收到此邮件,说明 SMTP 配置正确。</p>'),
});
res.json({ message: '测试邮件已发送至 ' + to });
} catch (e) {
console.error('邮件发送失败:', e.message);
res.status(500).json({ error: '邮件发送失败' });
}
});
// Re-send verification code
router.post('/send-verify', verifyLimiter, async (req, res) => {
const { email, username } = req.body;
if (!email || !username) return res.status(400).json({ error: '参数不完整' });
// 先取出旧 pending 记录(其中存有注册时加密的密码),避免删除后无法重建;
// M2:只接受 15 分钟内的有效记录(过期视为已失效并拒绝,防旧码无限重发)
const pending = db.get(
`SELECT * FROM pending_users WHERE email = ? AND created_at > datetime('now','-15 minutes')`,
[email]);
if (!pending) return res.status(400).json({ error: '未找到待验证的注册信息,请重新注册' });
// 先确认 SMTP 已配置,避免删除旧记录后无法发送新验证码
const transporter = getTransporter();
if (!transporter) return res.status(400).json({ error: 'SMTP 未配置,请联系管理员' });
// 生成新的验证码,删除旧记录后重新插入新的 pending 记录
const code = crypto.randomInt(10000000, 100000000).toString();
db.run('DELETE FROM pending_users WHERE email = ?', [email]);
db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)',
[pending.username, pending.password, email, code]);
try {
const siteName = db.getSetting('site_name') || 'RainWeb';
const color = db.getSetting('primary_color') || '#6750a4';
await 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} &middot; 自动发送请勿回复</div>
</div></body></html>`,
});
res.json({ message: '验证码已发送' });
} catch (e) {
console.error('邮件发送失败:', e.message);
res.status(500).json({ error: '邮件发送失败' });
}
});
// Complete registration with verification code
router.post('/complete-register', verifyLimiter, async (req, res) => {
const { code, username, password } = req.body;
if (!code || !username || !password) return res.status(400).json({ error: '参数不完整' });
// M2pending TTL——仅接受创建 15 分钟内的验证码,超时删除并拒绝(防 8 位码无限期可试 + 表无限增长)
const pending = db.get(
`SELECT * FROM pending_users WHERE token = ? AND username = ? AND created_at > datetime('now','-15 minutes')`,
[code, username]);
if (!pending) {
// 顺手清理所有过期残留
db.run(`DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')`);
return res.status(400).json({ error: '验证码错误或已过期' });
}
if (!bcrypt.compareSync(password, pending.password)) return res.status(400).json({ error: '密码不匹配' });
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
if (existing) return res.status(400).json({ error: '用户名已存在' });
db.run('INSERT INTO users (username, password, email, email_verified) VALUES (?, ?, ?, 1)',
[username, pending.password, pending.email]);
db.run('DELETE FROM pending_users WHERE token = ?', [code]);
res.json({ message: '注册成功,请登录' });
});
module.exports = router;
module.exports.getTransporter = getTransporter;
module.exports.emailTemplate = emailTemplate;