const express = require('express') const bcrypt = require('bcryptjs') const crypto = require('crypto') const { z } = require('zod') const { nanoid } = require('nanoid') const multer = require('multer') const { countUsers, findUserByEmail, findUserByNickname, findUserById, createUser, touchUserLastLoginAt, updateUserPassword, verifyUserEmail, createEmailVerificationToken, findEmailVerificationTokenByHash, consumeEmailVerificationToken, createPasswordResetToken, findPasswordResetTokenByHash, consumePasswordResetToken, updateUserProfile, findPrimaryAdminUser, } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const { isMailerConfigured, sendEmailVerificationMail, sendPasswordResetMail } = require('../lib/mailer') const { isReservedNickname } = require('../lib/user-validation') const router = express.Router() const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000 const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000 const signupSchema = z.object({ email: z.string().email(), nickname: z.string().trim().min(2).max(40), password: z.string().min(6), }) const verifyEmailSchema = z.object({ token: z.string().min(16).max(256), }) const resendVerificationSchema = z.object({ email: z.string().email(), }) const requestPasswordResetSchema = z.object({ email: z.string().email(), }) const confirmPasswordResetSchema = z.object({ token: z.string().min(16).max(256), password: z.string().min(6), }) const changePasswordSchema = z.object({ currentPassword: z.string().min(6), nextPassword: z.string().min(6), }) const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), removeAvatar: z.union([z.string(), z.undefined()]).optional(), }) function establishSession(req, user) { return new Promise((resolve, reject) => { req.session.regenerate((regenerateError) => { if (regenerateError) return reject(regenerateError) req.session.userId = user.id req.session.isAdmin = !!user.isAdmin req.session.save((saveError) => { if (saveError) return reject(saveError) resolve() }) }) }) } async function serializeUser(user) { if (!user) return null const primaryAdmin = await findPrimaryAdminUser() const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id return { id: user.id, email: user.email, nickname: user.nickname || '', isAdmin: !!user.isAdmin, isPrimaryAdmin, isOperator: !!user.isAdmin && !isPrimaryAdmin, role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user', avatarSrc: user.avatarSrc || '', emailVerified: user.emailVerified !== false, createdAt: user.createdAt, } } function createRawToken() { return crypto.randomBytes(32).toString('hex') } function hashToken(token) { return crypto.createHash('sha256').update(String(token || '')).digest('hex') } function resolveAppOrigin(req) { const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim() if (envOrigin) return envOrigin.replace(/\/+$/, '') const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() const protocol = forwardedProto || req.protocol || 'http' const host = req.get('host') return host ? `${protocol}://${host}` : '' } async function issueEmailVerificationMail(req, user) { if (!isMailerConfigured()) { const error = new Error('mail_not_configured') error.code = 'mail_not_configured' throw error } const rawToken = createRawToken() await createEmailVerificationToken({ id: nanoid(), userId: user.id, tokenHash: hashToken(rawToken), expiresAt: Date.now() + EMAIL_VERIFICATION_TTL_MS, }) const appOrigin = resolveAppOrigin(req) const verificationUrl = `${appOrigin}/login?verifyToken=${encodeURIComponent(rawToken)}` await sendEmailVerificationMail({ to: user.email, nickname: user.nickname, verificationUrl, }) } async function issuePasswordResetMail(req, user) { if (!isMailerConfigured()) { const error = new Error('mail_not_configured') error.code = 'mail_not_configured' throw error } const rawToken = createRawToken() await createPasswordResetToken({ id: nanoid(), userId: user.id, tokenHash: hashToken(rawToken), expiresAt: Date.now() + PASSWORD_RESET_TTL_MS, }) const appOrigin = resolveAppOrigin(req) const resetUrl = `${appOrigin}/login?resetToken=${encodeURIComponent(rawToken)}` await sendPasswordResetMail({ to: user.email, nickname: user.nickname, resetUrl, }) } router.post('/signup', async (req, res) => { const parsed = signupSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { email, nickname, password } = parsed.data const exists = await findUserByEmail(email) if (exists) return res.status(409).json({ error: 'email_taken' }) if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' }) const nicknameExists = await findUserByNickname(nickname) if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' }) const passwordHash = await bcrypt.hash(password, 10) const isAdmin = (await countUsers()) === 0 if (!isAdmin && !isMailerConfigured()) { return res.status(503).json({ error: 'mail_not_configured' }) } const user = await createUser({ id: nanoid(), email, nickname, passwordHash, emailVerified: isAdmin, isAdmin, }) if (!isAdmin) { try { await issueEmailVerificationMail(req, user) return res.json({ user: null, verificationRequired: true, email: user.email, }) } catch (err) { const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) } } try { await establishSession(req, user) const touchedUser = await touchUserLastLoginAt(user.id) res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } }) router.post('/login', async (req, res) => { const parsed = z.object({ email: z.string().email(), password: z.string().min(6), }).safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { email, password } = parsed.data const user = await findUserByEmail(email) if (!user) return res.status(401).json({ error: 'invalid_credentials' }) const ok = await bcrypt.compare(password, user.passwordHash) if (!ok) return res.status(401).json({ error: 'invalid_credentials' }) if (!user.emailVerified) return res.status(403).json({ error: 'email_unverified', email: user.email }) try { await establishSession(req, user) const touchedUser = await touchUserLastLoginAt(user.id) res.json({ user: await serializeUser(touchedUser || user) }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } }) router.post('/logout', async (req, res) => { if (!req.session) return res.json({ ok: true }) req.session.destroy(() => res.json({ ok: true })) }) router.get('/me', async (req, res) => { if (!req.session || !req.session.userId) return res.json({ user: null }) const user = await touchUserLastLoginAt(req.session.userId) if (!user) return res.json({ user: null }) res.json({ user: await serializeUser(user) }) }) router.get('/meta', async (req, res) => { res.json({ hasUsers: (await countUsers()) > 0 }) }) router.post('/email/verify', async (req, res) => { const parsed = verifyEmailSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const tokenRow = await findEmailVerificationTokenByHash(hashToken(parsed.data.token)) if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) { return res.status(400).json({ error: 'invalid_or_expired_token' }) } const user = await verifyUserEmail(tokenRow.userId) if (!user) return res.status(404).json({ error: 'not_found' }) await consumeEmailVerificationToken(tokenRow.id) try { await establishSession(req, user) const touchedUser = await touchUserLastLoginAt(user.id) res.json({ user: await serializeUser(touchedUser || user) }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } }) router.post('/email/resend', async (req, res) => { const parsed = resendVerificationSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const user = await findUserByEmail(parsed.data.email) if (!user || user.emailVerified) return res.json({ ok: true }) try { await issueEmailVerificationMail(req, user) res.json({ ok: true }) } catch (err) { const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) } }) router.post('/password-reset/request', async (req, res) => { const parsed = requestPasswordResetSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const user = await findUserByEmail(parsed.data.email) if (!user) return res.json({ ok: true }) try { await issuePasswordResetMail(req, user) res.json({ ok: true }) } catch (err) { const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) } }) router.post('/password-reset/confirm', async (req, res) => { const parsed = confirmPasswordResetSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const tokenRow = await findPasswordResetTokenByHash(hashToken(parsed.data.token)) if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) { return res.status(400).json({ error: 'invalid_or_expired_token' }) } const passwordHash = await bcrypt.hash(parsed.data.password, 10) const updatedUser = await updateUserPassword({ id: tokenRow.userId, passwordHash }) if (!updatedUser) return res.status(404).json({ error: 'not_found' }) const verifiedUser = updatedUser.emailVerified ? updatedUser : await verifyUserEmail(updatedUser.id) await consumePasswordResetToken(tokenRow.id) try { await establishSession(req, verifiedUser) const touchedUser = await touchUserLastLoginAt(verifiedUser.id) res.json({ user: await serializeUser(touchedUser || verifiedUser) }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } }) router.post('/password', requireAuth, async (req, res) => { const parsed = changePasswordSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const user = await findUserById(req.session.userId) if (!user) return res.status(404).json({ error: 'not_found' }) const authUser = await findUserByEmail(user.email) if (!authUser) return res.status(404).json({ error: 'not_found' }) const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash) if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' }) const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10) const updated = await updateUserPassword({ id: authUser.id, passwordHash }) res.json({ user: await serializeUser(updated) }) }) const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => { const parsed = profileSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const user = await findUserById(req.session.userId) if (!user) return res.status(404).json({ error: 'not_found' }) if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' }) const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id) if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' }) const optimized = req.file ? await writeOptimizedImage({ file: req.file, directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82, }) : null const shouldRemoveAvatar = parsed.data.removeAvatar === '1' const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' const updated = await updateUserProfile({ id: user.id, nickname: parsed.data.nickname, avatarSrc: nextAvatarSrc, }) res.json({ user: await serializeUser(updated) }) }) module.exports = router