MikiVL b404a81003 feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能
- 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口)
- 新增邮箱绑定、找回密码、个人中心功能
- 新增管理员路由与后台组件
- AI 模型设置、登录弹窗、侧边栏交互优化
- 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:09:13 +08:00

253 lines
12 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, sendRegisterCode } 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))
}
// 待注册信息临时存储key 为邮箱)
const registerPending = new Map<string, {
username: string
passwordHash: string
code: string
expiry: number
sentAt: number
}>()
// 发送注册验证码
authRouter.post('/register/send-code', async (c) => {
const { username, password, email } = await c.req.json<{ username: string; password: string; email: string }>()
if (!username || !password || !email) 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)
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
// 检查用户名是否已存在
const existingUser = db.select().from(users).where(eq(users.username, username)).all()
if (existingUser.length > 0) return c.json({ error: '用户名已存在' }, 409)
// 检查邮箱是否已被验证用户使用
const existingEmail = db.select().from(users).where(eq(users.email, email)).all()
if (existingEmail.some(u => u.emailVerified)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
// 60秒冷却
const pending = registerPending.get(email)
if (pending && Date.now() - pending.sentAt < 60 * 1000) {
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
}
const passwordHash = await bcrypt.hash(password, 10)
const code = randomCode()
registerPending.set(email, { username, passwordHash, code, expiry: Date.now() + 10 * 60 * 1000, sentAt: Date.now() })
try {
await sendRegisterCode(email, code)
} catch (err) {
console.error('[email]', err)
registerPending.delete(email)
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
})
// 完成注册(验证验证码)
authRouter.post('/register', async (c) => {
const { email, code } = await c.req.json<{ email: string; code: string }>()
if (!email || !code) return c.json({ error: '参数不完整' }, 400)
const pending = registerPending.get(email)
if (!pending) return c.json({ error: '请先发送验证码' }, 400)
if (Date.now() > pending.expiry) {
registerPending.delete(email)
return c.json({ error: '验证码已过期,请重新发送' }, 400)
}
if (pending.code !== code.trim()) return c.json({ error: '验证码错误' }, 400)
// 再次检查用户名/邮箱唯一性(防止并发)
const existingUser = db.select().from(users).where(eq(users.username, pending.username)).all()
if (existingUser.length > 0) {
registerPending.delete(email)
return c.json({ error: '用户名已被注册,请更换用户名重新发送验证码' }, 409)
}
const id = nanoid()
db.insert(users).values({
id, username: pending.username, passwordHash: pending.passwordHash,
email, emailVerified: true, cloudEnabled: false, createdAt: Date.now(),
}).run()
registerPending.delete(email)
const token = jwt.sign({ userId: id, username: pending.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
return c.json({ token, user: { id, username: pending.username, cloudEnabled: false, role: 'user', email, emailVerified: true, 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)
if (user.banned) return c.json({ error: '账号已被封禁,请联系管理员' }, 403)
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, role: user.role, 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, role: user.role, 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, role: user.role, 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 })
})