Publish Container / build (push) Successful in 16m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
557 lines
22 KiB
JavaScript
557 lines
22 KiB
JavaScript
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
|
||
import rateLimit from 'express-rate-limit'
|
||
import qrcode from 'qrcode'
|
||
import {
|
||
generateTotpSecret,
|
||
verifyTotpCode,
|
||
generateRecoveryCodes,
|
||
} from '../auth.js'
|
||
import { parseCookies, buildAbsoluteUrl } from '../helpers.js'
|
||
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS, PASSWORD_RESET_TOKEN_TTL_MS, EMAIL_VERIFY_TOKEN_TTL_MS } from '../config.js'
|
||
import { state } from '../state.js'
|
||
import {
|
||
queueStudyUsersWrite,
|
||
queueContactSubmissionsWrite,
|
||
queueStudySessionsWrite,
|
||
normalizeContactEmailStatus,
|
||
normalizeMessageType,
|
||
} from '../data.js'
|
||
import {
|
||
normalizeStudyUsername,
|
||
isValidStudyUsername,
|
||
hashStudyPassword,
|
||
verifyStudyPassword,
|
||
studyPasswordNeedsRehash,
|
||
hashPasswordResetToken,
|
||
hashEmailVerifyToken,
|
||
findStudyUserByUsername,
|
||
getStudyAvatarUrl,
|
||
createStudySession,
|
||
setStudySessionCookie,
|
||
clearStudySessionCookie,
|
||
requireStudyAuth,
|
||
invalidateAllStudySessions,
|
||
createStudyTotpPendingToken,
|
||
consumeStudyTotpPendingToken,
|
||
generateEmailOtp,
|
||
storeEmailOtp,
|
||
verifyEmailOtp,
|
||
getStudyUserFromRequest,
|
||
getAccessibleStudySlugs,
|
||
} from '../study-helpers.js'
|
||
import {
|
||
sendEmailOtp,
|
||
sendStudyWelcomeEmail,
|
||
sendStudyVerifyEmail,
|
||
sendStudyPasswordResetEmail,
|
||
sendStudyPasswordChangedEmail,
|
||
syncContactToResend,
|
||
getCanonicalBaseUrl,
|
||
} from '../email.js'
|
||
|
||
const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer')
|
||
|
||
const studyAuthRateLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 20,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
|
||
skipSuccessfulRequests: true,
|
||
})
|
||
|
||
export function register(app) {
|
||
app.get('/api/study-auth/status', (req, res) => {
|
||
const user = getStudyUserFromRequest(req)
|
||
res.json({
|
||
authenticated: Boolean(user),
|
||
username: user?.username ?? '',
|
||
displayName: user?.displayName ?? '',
|
||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||
// Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link
|
||
// still lands on the admin sign-in, so it grants no access by itself.
|
||
adminShortcut: user?.adminShortcut === true,
|
||
// Studies the student can open: enrolled ones plus any they have finished
|
||
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
||
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
||
emailVerified: user?.emailVerified === true,
|
||
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||
twoFaMethod: user?.twoFaMethod ?? null,
|
||
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-auth/signup', studyAuthRateLimiter, async (req, res) => {
|
||
const username = normalizeStudyUsername(req.body?.username)
|
||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||
const subscribe = req.body?.subscribe === true
|
||
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||
|
||
if (!isValidStudyUsername(username)) {
|
||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||
return
|
||
}
|
||
|
||
if (typeof password !== 'string' || password.length < 8 || password.length > 200) {
|
||
res.status(400).json({ message: 'Password must be 8-200 characters.' })
|
||
return
|
||
}
|
||
|
||
if (findStudyUserByUsername(username)) {
|
||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||
return
|
||
}
|
||
|
||
if (state.studyUsers.length >= MAX_STUDY_USERS) {
|
||
res.status(503).json({ message: 'Account registration is temporarily unavailable. Please try again later.' })
|
||
return
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const rawVerifyToken = randomUUID()
|
||
const user = {
|
||
id: randomUUID(),
|
||
username,
|
||
passwordHash: hashStudyPassword(password),
|
||
displayName,
|
||
subscribeNewsletter: subscribe,
|
||
studyRemindersEnabled: true,
|
||
remindersDefaultedAt: now,
|
||
pendingEmailChange: null,
|
||
emailVerified: false,
|
||
pendingEmailVerification: {
|
||
tokenHash: hashEmailVerifyToken(rawVerifyToken),
|
||
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
|
||
},
|
||
enrolledStudySlugs: [],
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
lastLoginAt: now,
|
||
}
|
||
|
||
state.studyUsers.push(user)
|
||
queueStudyUsersWrite()
|
||
|
||
if (subscribe) {
|
||
const submission = {
|
||
id: randomUUID(),
|
||
submittedAt: now,
|
||
name: displayName || username,
|
||
email: username,
|
||
message: '',
|
||
messageType: 'general',
|
||
subscribe: true,
|
||
archived: false,
|
||
emailStatus: normalizeContactEmailStatus(null, true),
|
||
}
|
||
state.contactSubmissions.unshift(submission)
|
||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||
queueContactSubmissionsWrite()
|
||
syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err))
|
||
}
|
||
|
||
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
|
||
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawVerifyToken)}`)
|
||
sendStudyVerifyEmail(username, displayName || username, verifyUrl).catch(err => console.error('[study-signup] verify email error:', err))
|
||
|
||
const sessionToken = createStudySession(user.id)
|
||
setStudySessionCookie(res, sessionToken)
|
||
res.json({
|
||
ok: true,
|
||
username: user.username,
|
||
displayName: user.displayName,
|
||
subscribeNewsletter: user.subscribeNewsletter,
|
||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||
avatarUrl: getStudyAvatarUrl(user.username),
|
||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||
const username = normalizeStudyUsername(req.body?.username)
|
||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||
const user = findStudyUserByUsername(username)
|
||
|
||
if (!user) {
|
||
verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path
|
||
res.status(401).json({ message: 'Invalid email or password.' })
|
||
return
|
||
}
|
||
|
||
if (!verifyStudyPassword(password, user.passwordHash)) {
|
||
res.status(401).json({ message: 'Invalid email or password.' })
|
||
return
|
||
}
|
||
if (studyPasswordNeedsRehash(user.passwordHash)) {
|
||
user.passwordHash = hashStudyPassword(password)
|
||
queueStudyUsersWrite()
|
||
}
|
||
|
||
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
|
||
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
|
||
const pendingToken = createStudyTotpPendingToken(user.id)
|
||
res.json({ totpRequired: true, pendingToken, method: 'app' })
|
||
return
|
||
}
|
||
if (twoFaMethod === 'email') {
|
||
const code = generateEmailOtp()
|
||
storeEmailOtp(user.id, code)
|
||
const pendingToken = createStudyTotpPendingToken(user.id)
|
||
sendEmailOtp(user.username, code).catch(err => console.error('[email-otp] login send error:', err))
|
||
res.json({ totpRequired: true, pendingToken, method: 'email' })
|
||
return
|
||
}
|
||
|
||
user.lastLoginAt = new Date().toISOString()
|
||
user.updatedAt = user.lastLoginAt
|
||
user.reengagementSentAt = {}
|
||
queueStudyUsersWrite()
|
||
|
||
const sessionToken = createStudySession(user.id)
|
||
setStudySessionCookie(res, sessionToken)
|
||
res.json({
|
||
ok: true,
|
||
username: user.username,
|
||
displayName: user.displayName ?? '',
|
||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||
avatarUrl: getStudyAvatarUrl(user.username),
|
||
enrolledStudySlugs: getAccessibleStudySlugs(user),
|
||
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-auth/totp-verify', studyAuthRateLimiter, (req, res) => {
|
||
const { pendingToken, code } = req.body ?? {}
|
||
const userId = consumeStudyTotpPendingToken(pendingToken)
|
||
if (!userId) {
|
||
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
|
||
return
|
||
}
|
||
const user = state.studyUsers.find(u => u.id === userId)
|
||
const twoFaMethodCheck = user?.twoFaMethod ?? (user?.totpSecret && user?.totpVerified ? 'app' : null)
|
||
if (!user || !twoFaMethodCheck) {
|
||
res.status(400).json({ message: '2FA is not configured for this account.' })
|
||
return
|
||
}
|
||
|
||
const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : ''
|
||
const twoFaMethod = twoFaMethodCheck
|
||
|
||
function completeLogin(extra = {}) {
|
||
user.lastLoginAt = new Date().toISOString()
|
||
user.updatedAt = user.lastLoginAt
|
||
user.reengagementSentAt = {}
|
||
queueStudyUsersWrite()
|
||
const sessionToken = createStudySession(user.id)
|
||
setStudySessionCookie(res, sessionToken)
|
||
res.json({ ok: true, ...extra, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [] })
|
||
}
|
||
|
||
if (twoFaMethod === 'email') {
|
||
const result = verifyEmailOtp(user.id, codeStr)
|
||
if (result === 'ok') { completeLogin(); return }
|
||
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please sign in again to receive a new code.' }); return }
|
||
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please sign in again.' }); return }
|
||
res.status(401).json({ message: 'Invalid code. Check your email and try again.' })
|
||
return
|
||
}
|
||
|
||
if (verifyTotpCode(user.totpSecret, codeStr)) {
|
||
completeLogin()
|
||
return
|
||
}
|
||
|
||
if (Array.isArray(user.totpRecoveryCodes) && user.totpRecoveryCodes.length > 0) {
|
||
const normalised = codeStr.replace(/-/g, '').toUpperCase()
|
||
const matchIdx = user.totpRecoveryCodes.findIndex(h => {
|
||
try { return createHash('sha256').update(normalised).digest('hex') === h } catch { return false }
|
||
})
|
||
if (matchIdx !== -1) {
|
||
user.totpRecoveryCodes.splice(matchIdx, 1)
|
||
completeLogin({ usedRecoveryCode: true, remainingRecoveryCodes: user.totpRecoveryCodes.length })
|
||
return
|
||
}
|
||
}
|
||
|
||
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
|
||
})
|
||
|
||
app.post('/api/study-auth/totp-setup-init', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const secret = generateTotpSecret()
|
||
const label = user.username
|
||
const issuer = 'Verse by Verse with Nate'
|
||
const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
|
||
const qrDataUrl = await qrcode.toDataURL(uri)
|
||
user.totpSecretPending = secret
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ qrDataUrl, secret })
|
||
})
|
||
|
||
app.post('/api/study-auth/totp-setup-confirm', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const { code } = req.body ?? {}
|
||
if (!user.totpSecretPending) {
|
||
res.status(400).json({ message: 'No 2FA setup in progress. Start setup first.' })
|
||
return
|
||
}
|
||
if (!verifyTotpCode(user.totpSecretPending, typeof code === 'string' ? code.replace(/\s/g, '') : '')) {
|
||
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
|
||
return
|
||
}
|
||
const recoveryCodes = generateRecoveryCodes()
|
||
user.totpSecret = user.totpSecretPending
|
||
user.totpVerified = true
|
||
user.twoFaMethod = 'app'
|
||
user.totpEnabledAt = new Date().toISOString()
|
||
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
|
||
delete user.totpSecretPending
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true, recoveryCodes })
|
||
})
|
||
|
||
app.post('/api/study-auth/2fa-setup-email', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const code = generateEmailOtp()
|
||
storeEmailOtp(user.id, code)
|
||
await sendEmailOtp(user.username, code)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/study-auth/2fa-setup-email-confirm', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const { code } = req.body ?? {}
|
||
const result = verifyEmailOtp(user.id, typeof code === 'string' ? code.trim() : '')
|
||
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please request a new one.' }); return }
|
||
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please request a new code.' }); return }
|
||
if (result !== 'ok') { res.status(401).json({ message: 'Invalid code. Check your email and try again.' }); return }
|
||
user.twoFaMethod = 'email'
|
||
user.totpSecret = null
|
||
user.totpVerified = false
|
||
user.totpRecoveryCodes = []
|
||
delete user.totpSecretPending
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => {
|
||
const { pendingToken } = req.body ?? {}
|
||
const entry = state.studyTotpPendingTokens.get(pendingToken)
|
||
if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return }
|
||
const user = state.studyUsers.find(u => u.id === entry.userId)
|
||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||
const code = generateEmailOtp()
|
||
storeEmailOtp(user.id, code)
|
||
await sendEmailOtp(user.username, code)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const { password } = req.body ?? {}
|
||
if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) {
|
||
res.status(401).json({ message: 'Incorrect password.' })
|
||
return
|
||
}
|
||
user.twoFaMethod = null
|
||
user.totpSecret = null
|
||
user.totpVerified = false
|
||
user.totpRecoveryCodes = []
|
||
delete user.totpSecretPending
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/study-auth/totp-regen-recovery', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
if (!user.totpSecret || !user.totpVerified) {
|
||
res.status(400).json({ message: '2FA is not enabled.' })
|
||
return
|
||
}
|
||
const recoveryCodes = generateRecoveryCodes()
|
||
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true, recoveryCodes })
|
||
})
|
||
|
||
app.post('/api/study-auth/logout', (req, res) => {
|
||
const cookies = parseCookies(req.headers.cookie)
|
||
const token = cookies[STUDY_SESSION_COOKIE]
|
||
if (token) {
|
||
state.studySessions.delete(token)
|
||
queueStudySessionsWrite()
|
||
}
|
||
clearStudySessionCookie(res)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// Signs out every other active session for the current user (this device stays signed in).
|
||
app.post('/api/study-auth/logout-all', requireStudyAuth, (req, res) => {
|
||
const cookies = parseCookies(req.headers.cookie)
|
||
const currentToken = cookies[STUDY_SESSION_COOKIE]
|
||
invalidateAllStudySessions(req.studyUser.id, currentToken)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// Request a password-reset email. Always responds ok to avoid leaking which emails have accounts.
|
||
app.post('/api/study-auth/forgot-password', studyAuthRateLimiter, async (req, res) => {
|
||
const username = normalizeStudyUsername(req.body?.username)
|
||
if (!isValidStudyUsername(username)) {
|
||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||
return
|
||
}
|
||
|
||
const user = findStudyUserByUsername(username)
|
||
if (user) {
|
||
const rawToken = randomUUID()
|
||
user.pendingPasswordReset = {
|
||
tokenHash: hashPasswordResetToken(rawToken),
|
||
expiresAt: Date.now() + PASSWORD_RESET_TOKEN_TTL_MS,
|
||
}
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
|
||
const resetUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/reset-password?token=${encodeURIComponent(rawToken)}`)
|
||
sendStudyPasswordResetEmail(user.username, user.displayName || user.username, resetUrl)
|
||
.catch(err => console.error('[study-forgot-password] send error:', err))
|
||
}
|
||
|
||
res.json({ ok: true, message: 'If that email has an account, a reset link is on its way.' })
|
||
})
|
||
|
||
app.post('/api/study-auth/reset-password', studyAuthRateLimiter, (req, res) => {
|
||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||
|
||
if (!token) {
|
||
res.status(400).json({ message: 'Invalid or missing reset token.' })
|
||
return
|
||
}
|
||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||
return
|
||
}
|
||
|
||
const tokenHash = hashPasswordResetToken(token)
|
||
const user = state.studyUsers.find(u => {
|
||
const pending = u.pendingPasswordReset
|
||
if (!pending?.tokenHash) return false
|
||
const a = Buffer.from(tokenHash, 'utf8')
|
||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||
return a.length === b.length && timingSafeEqual(a, b)
|
||
})
|
||
|
||
if (!user) {
|
||
res.status(400).json({ message: 'This reset link is invalid. Please request a new one.' })
|
||
return
|
||
}
|
||
if (user.pendingPasswordReset.expiresAt <= Date.now()) {
|
||
user.pendingPasswordReset = null
|
||
queueStudyUsersWrite()
|
||
res.status(400).json({ message: 'This reset link has expired. Please request a new one.' })
|
||
return
|
||
}
|
||
|
||
user.passwordHash = hashStudyPassword(newPassword)
|
||
user.pendingPasswordReset = null
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
invalidateAllStudySessions(user.id)
|
||
|
||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||
.catch(err => console.error('[study-reset-password] confirmation email error:', err))
|
||
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// Verify the email address used at signup. Not auth-gated so it works from any device/browser.
|
||
app.post('/api/study-auth/verify-email', studyAuthRateLimiter, (req, res) => {
|
||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||
if (!token) {
|
||
res.status(400).json({ message: 'Invalid or missing verification token.' })
|
||
return
|
||
}
|
||
|
||
const tokenHash = hashEmailVerifyToken(token)
|
||
const user = state.studyUsers.find(u => {
|
||
const pending = u.pendingEmailVerification
|
||
if (!pending?.tokenHash) return false
|
||
const a = Buffer.from(tokenHash, 'utf8')
|
||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||
return a.length === b.length && timingSafeEqual(a, b)
|
||
})
|
||
|
||
if (!user) {
|
||
res.status(400).json({ message: 'This verification link is invalid or has already been used.' })
|
||
return
|
||
}
|
||
if (user.pendingEmailVerification.expiresAt <= Date.now()) {
|
||
res.status(400).json({ message: 'This verification link has expired. Request a new one from My Account.' })
|
||
return
|
||
}
|
||
|
||
user.emailVerified = true
|
||
user.pendingEmailVerification = null
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/study-auth/resend-verification', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
if (user.emailVerified) {
|
||
res.json({ ok: true, alreadyVerified: true })
|
||
return
|
||
}
|
||
const rawToken = randomUUID()
|
||
user.pendingEmailVerification = {
|
||
tokenHash: hashEmailVerifyToken(rawToken),
|
||
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
|
||
}
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
|
||
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawToken)}`)
|
||
sendStudyVerifyEmail(user.username, user.displayName || user.username, verifyUrl)
|
||
.catch(err => console.error('[study-resend-verification] send error:', err))
|
||
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// One-click unsubscribe from re-engagement emails (no login required)
|
||
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
|
||
const { uid, token } = req.query ?? {}
|
||
if (typeof uid !== 'string' || typeof token !== 'string') {
|
||
res.status(400).send('Invalid unsubscribe link.')
|
||
return
|
||
}
|
||
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
|
||
const expected = createHmac('sha256', secret).update(uid).digest('hex')
|
||
const expectedBuf = Buffer.from(expected, 'hex')
|
||
const actualBuf = Buffer.from(token.length === expected.length ? token : '', 'hex')
|
||
let valid = false
|
||
try { valid = timingSafeEqual(expectedBuf, actualBuf) } catch { valid = false }
|
||
if (!valid) {
|
||
res.status(400).send('Invalid or expired unsubscribe link.')
|
||
return
|
||
}
|
||
const user = state.studyUsers.find(u => u.id === uid)
|
||
if (!user) {
|
||
res.status(404).send('Account not found.')
|
||
return
|
||
}
|
||
user.studyRemindersEnabled = false
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.send('<!doctype html><html><head><meta charset="utf-8"><title>Unsubscribed</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;text-align:center;color:#333}h1{font-size:1.4rem;margin-bottom:12px}p{color:#666;line-height:1.6}</style></head><body><h1>You\'ve been unsubscribed</h1><p>You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.</p></body></html>')
|
||
})
|
||
}
|