MikiVL 689c8b2d86 fix: 邮件懒加载、cloudEnabled 刷新、邮箱写入时机、验证码重发冷却
- server/lib/email.ts: transporter 改为懒加载,避免 ESM import 提升导致 EMAIL_PASS 未加载
- server/routes/auth.ts: send-verify 改为写 pendingEmail,验证成功后才写 email;新增 60s 冷却;catch 块打印错误
- server/db.ts: 新增 pendingEmail 字段及迁移
- src/stores/appStore.ts: 新增 refreshCurrentUser,启动时调用 apiGetMe 拉取真实 cloudEnabled
- src/App.tsx: 启动时调用 refreshCurrentUser
- ProfileModal/AccountModal: 加入 60s 倒计时和重新发送按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:37:05 +08:00

193 lines
9.3 KiB
TypeScript
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.

import { Hono } from 'hono'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { eq } from 'drizzle-orm'
import { db, users, inviteCodes } from '../db'
import { requireAuth } from '../middleware/auth'
import { sendVerifyCode, sendResetCode } from '../lib/email'
export const authRouter = new Hono()
function nanoid() {
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
}
function randomCode() {
return String(Math.floor(100000 + Math.random() * 900000))
}
authRouter.post('/register', async (c) => {
const { username, password } = await c.req.json<{ username: string; password: string }>()
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
if (username.length < 2 || username.length > 20) return c.json({ error: '用户名长度 2-20 位' }, 400)
if (password.length < 6) return c.json({ error: '密码至少 6 位' }, 400)
const existing = db.select().from(users).where(eq(users.username, username)).all()
if (existing.length > 0) return c.json({ error: '用户名已存在' }, 409)
const passwordHash = await bcrypt.hash(password, 10)
const id = nanoid()
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } })
})
authRouter.post('/login', async (c) => {
const { username, password } = await c.req.json<{ username: string; password: string }>()
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
const [user] = db.select().from(users).where(eq(users.username, username)).all()
if (!user) return c.json({ error: '用户名或密码错误' }, 401)
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
})
authRouter.post('/activate', requireAuth, async (c) => {
const userId = c.get('userId')
const { code } = await c.req.json<{ code: string }>()
if (!code) return c.json({ error: '邀请码不能为空' }, 400)
const [invite] = db.select().from(inviteCodes).where(eq(inviteCodes.code, code.trim().toUpperCase())).all()
if (!invite) return c.json({ error: '邀请码不存在' }, 404)
if (invite.usedByUserId) return c.json({ error: '邀请码已被使用' }, 409)
db.update(inviteCodes).set({ usedByUserId: userId, usedAt: Date.now() }).where(eq(inviteCodes.code, code.trim().toUpperCase())).run()
db.update(users).set({ cloudEnabled: true }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})
// 获取当前用户信息
authRouter.get('/me', requireAuth, async (c) => {
const userId = c.get('userId')
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
})
// 更新个人资料(昵称、头像)
authRouter.put('/me', requireAuth, async (c) => {
const userId = c.get('userId')
const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>()
const updates: Record<string, any> = {}
if (nickname !== undefined) {
if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400)
updates.nickname = nickname.trim() || null
}
if (avatar !== undefined) {
// 限制头像大小base64 约 100KB
if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400)
updates.avatar = avatar || null
}
if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400)
db.update(users).set(updates).where(eq(users.id, userId)).run()
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
})
// 发送邮箱绑定验证码
authRouter.post('/email/send-verify', requireAuth, async (c) => {
const userId = c.get('userId')
const { email } = await c.req.json<{ email: string }>()
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
// 检查邮箱是否已被其他已验证用户绑定
const existing = db.select().from(users).where(eq(users.email, email)).all()
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
// 60 秒冷却:上次发送时间在 60 秒内则拒绝
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) {
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
}
const code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000
// 只写入 pendingEmail不修改 email 字段
db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run()
try {
await sendVerifyCode(email, code)
} catch (err) {
console.error('[email]', err)
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
})
// 验证邮箱绑定验证码
authRouter.post('/email/verify', requireAuth, async (c) => {
const userId = c.get('userId')
const { code } = await c.req.json<{ code: string }>()
if (!code) return c.json({ error: '请输入验证码' }, 400)
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
// 验证成功:将 pendingEmail 写入正式 email 字段
db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})
// 忘记密码:发送重置验证码
authRouter.post('/forgot-password', async (c) => {
const { email } = await c.req.json<{ email: string }>()
if (!email) return c.json({ error: '请输入邮箱' }, 400)
const [user] = db.select().from(users).where(eq(users.email, email)).all()
// 无论是否找到用户都返回成功,避免枚举攻击
if (!user || !user.emailVerified) return c.json({ success: true })
const code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000
db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run()
try {
await sendResetCode(email, code)
} catch {
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
})
// 重置密码:验证码 + 新密码
authRouter.post('/reset-password', async (c) => {
const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>()
if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400)
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
const [user] = db.select().from(users).where(eq(users.email, email)).all()
if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404)
if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400)
const passwordHash = await bcrypt.hash(newPassword, 10)
db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run()
return c.json({ success: true })
})
// 修改密码(需要旧密码)
authRouter.post('/change-password', requireAuth, async (c) => {
const userId = c.get('userId')
const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>()
if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400)
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
const ok = await bcrypt.compare(oldPassword, user.passwordHash)
if (!ok) return c.json({ error: '当前密码错误' }, 400)
const passwordHash = await bcrypt.hash(newPassword, 10)
db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})