Files
Siteforge/server/routes/study-account.js
T
nmemmertandClaude Sonnet 4.6 e158e1e53a
Publish Container / docker (push) Failing after 10m40s
Codebase audit: remove dead code, fix broken patterns, consolidate duplicates; v2.0.27
- Remove unused prop-types dep, deprecated useOutboundLinkTracking export
- Fix missing linkClicks key in analyticsEvents initial state (B-01)
- Export ADMIN_SESSION_COOKIE from auth.js; remove local copy in admin-auth.js (D-02)
- Remove cookieFlags() wrapper in auth.js and study-helpers.js; use cookieSecureFlag() directly (U-04)
- Remove ADMIN_SESSION_TTL_MS duplicate from config.js (D-01)
- Remove DEFAULT_SEO/DEFAULT_LEGAL/DEFAULT_REDIRECT_RULES/DEFAULT_PODCAST_FEATURED_LINKS/DEFAULT_PUBLISH_STATE
  duplicates from config.js; re-export from helpers.js (D-04, fixes sitemapPaths divergence)
- Fix email.js hardcoded fallback strings to use DEFAULT_RESEND_TO/DEFAULT_RESEND_REPLY_TO (M-02)
- Remove duplicate normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl,
  sanitizeRedirectRules bodies from study-helpers.js; re-export from helpers.js (D-03)
- Fix pruneStatsByDays to import VISITOR_RETENTION_DAYS_DEFAULT from config.js (B-02)
- Fix createTitusDownloadToken to use DOWNLOAD_TOKEN_TTL_MS constant (B-03)
- Remove normalizeMessageType duplicate from study-helpers.js (U-03/D-08)
- Remove dead wantsWelcome variable in contact.js; use shouldSendWelcome (B-04)
- Replace dynamic imports in contact.js with static imports (B-07)
- Remove dead wantsWelcome=true in study-auth.js (B-05)
- Merge duplicate import blocks in analytics.js and public.js (D-05, D-06)
- Replace dynamic readFile re-import in nuggets-host-upload.js (B-06)
- Replace hardcoded SITE_URL fallbacks with getCanonicalBaseUrl() (M-06)
- Replace raw resend.emails.send in study-account.js with sendResendEmailWithRetry (M-01)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 16:10:31 -04:00

514 lines
22 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.
import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
import { mkdir, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
queueStudyCommunityWrite,
queueStudyCommentsWrite,
queueStudyCertificatesWrite,
readUploadsMetadata,
writeUploadsMetadata,
loadUserNotes,
loadUserProgress,
getUserNotesFilePath,
getUserProgressFilePath,
} from '../data.js'
import {
requireStudyAuth,
hashStudyPassword,
hashEmailChangeToken,
normalizeStudyUsername,
isValidStudyUsername,
findStudyUserByUsername,
getStudyAvatarUrl,
isStudyUserEnrolled,
normalizeStudySlug,
getStudyCatalog,
clearStudySessionCookie,
} from '../study-helpers.js'
import {
sendStudyAccountDeletedEmail,
syncContactToResend,
buildBrandedEmailHtml,
getCanonicalBaseUrl,
getResendFromAddress,
sendResendEmailWithRetry,
} from '../email.js'
import { Resend } from 'resend'
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-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const sectionMeta = {}
const content = state.cachedSiteContent
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title,
reference: section.reference,
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
}
}
}
const studyEntries = {}
for (const [noteKey, noteText] of Object.entries(notes)) {
if (!noteText?.trim()) continue
const dashIndex = noteKey.indexOf('--')
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
const quizAnswersBySection = studyProgress.quizAnswers || {}
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
if (!Array.isArray(answers) || answers.length === 0) continue
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
}
}
const studySlugs = Array.from(new Set([
...Object.keys(studyEntries),
...Object.values(studies).map(study => study.slug),
]))
const docChildren = [
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
]
for (const studySlug of studySlugs) {
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
const studyTitle = study?.title || studySlug
const studyDescription = typeof study?.description === 'string' ? study.description : ''
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
if (sectionIds.length === 0) continue
docChildren.push(new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }))
if (studyDescription) {
docChildren.push(new Paragraph({ text: studyDescription, spacing: { after: 240 } }))
}
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
docChildren.push(new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }))
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
for (const sectionId of orderedSectionIds) {
const entry = studyEntries[studySlug][sectionId]
if (!entry) continue
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
docChildren.push(new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }))
if (meta.reference) {
docChildren.push(new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }))
}
if (entry.noteText) {
docChildren.push(new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }))
for (const line of entry.noteText.split('\n')) {
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
}
}
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
docChildren.push(new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
meta.studyQuestions.forEach((question, index) => {
docChildren.push(new Paragraph({ children: [new TextRun({ text: `${index + 1}. `, bold: true }), new TextRun({ text: question })], spacing: { after: 80 } }))
})
}
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
docChildren.push(new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
entry.quizAnswers.forEach((answer, index) => {
docChildren.push(new Paragraph({ children: [new TextRun({ text: `Answer ${index + 1}: `, bold: true }), new TextRun({ text: answer })], spacing: { after: 80 } }))
})
}
}
}
if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
}
const doc = new Document({ creator: 'Verse by Verse with Nate', title: 'My Study Export', sections: [{ children: docChildren }] })
const buffer = await Packer.toBuffer(doc)
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
res.send(buffer)
})
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'New password must be 8–200 characters.' })
return
}
user.passwordHash = hashStudyPassword(newPassword)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true })
})
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const noteEntries = Object.entries(notes)
const studies = getStudyCatalog().map(study => {
const totalLessons = Array.isArray(state.cachedSiteContent?.studies)
? (state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
: 0
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
return {
slug: study.slug,
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
totalLessons,
completedLessons,
noteCount,
}
})
res.json({
profile: {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
lastStudiedDate: user.lastStudiedDate ?? null,
},
studies,
})
})
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
const user = req.studyUser
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
return
}
if (avatarUrl.startsWith('data:image/') && avatarUrl.length > 6 * 1024 * 1024) {
res.status(400).json({ message: 'Avatar data URI is too large. Please use the upload endpoint instead.' })
return
}
user.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
})
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
return
}
const isValidImageBytes = (
(buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) || // JPEG
(buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) || // PNG
(buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) || // GIF
(buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) // WEBP (RIFF)
)
if (!isValidImageBytes) {
res.status(400).json({ message: 'Upload must be a valid PNG, JPG, WEBP, or GIF image.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, url: `/uploads/${finalName}` })
} catch (err) {
console.error('[study-account-avatar-upload] upload error:', err)
res.status(500).json({ message: 'Avatar upload failed.' })
}
})
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
const reminderFrequency = req.body?.reminderFrequency
if (reminderFrequency === 'weekly' || reminderFrequency === 'immediate') {
user.reminderFrequency = reminderFrequency
}
const rawPaused = req.body?.remindersPausedUntil
if (rawPaused === null || rawPaused === '') {
user.remindersPausedUntil = null
} else if (typeof rawPaused === 'string') {
const parsed = new Date(rawPaused)
user.remindersPausedUntil = !isNaN(parsed.getTime()) && parsed.getTime() > Date.now() ? parsed.toISOString() : null
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (subscribeNewsletter) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({
ok: true,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
})
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
const user = req.studyUser
const newEmail = normalizeStudyUsername(req.body?.newEmail)
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
if (!isValidStudyUsername(newEmail)) {
res.status(400).json({ message: 'Please enter a valid email address.' })
return
}
if (newEmail === user.username) {
res.status(400).json({ message: 'That is already your current email.' })
return
}
const existing = findStudyUserByUsername(newEmail)
if (existing && existing.id !== user.id) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
const rawToken = randomUUID()
const tokenHash = hashEmailChangeToken(rawToken)
const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS
user.pendingEmailChange = { newEmail, tokenHash, expiresAt, requestedAt: new Date().toISOString() }
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (process.env.RESEND_API_KEY) {
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const baseUrl = getCanonicalBaseUrl()
const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`)
const cfg = state.cachedSiteContent ?? {}
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
await sendResendEmailWithRetry({
resend,
context: 'study-account-email-change',
payload: {
from: getResendFromAddress(),
to: [newEmail],
subject: emailChangeSubject,
text: `${emailChangeBody}\n\n${verifyUrl}`,
html: buildBrandedEmailHtml({
title: emailChangeSubject,
eyebrow: 'Account Security',
bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`,
ctaLabel: emailChangeCtaLabel,
ctaUrl: verifyUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
},
})
} catch (err) {
console.error('[study-account] email change send error:', err)
res.status(503).json({ message: 'Could not send verification email right now.' })
return
}
}
res.json({ ok: true, verificationSent: true })
})
app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
const pending = user.pendingEmailChange
if (!token || !pending || !pending.tokenHash) {
res.status(400).json({ message: 'No pending email change request found.' })
return
}
if (pending.expiresAt <= Date.now()) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(400).json({ message: 'This verification link has expired. Request a new email change.' })
return
}
const submittedHash = hashEmailChangeToken(token)
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(pending.tokenHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(400).json({ message: 'Invalid verification token.' })
return
}
const newEmail = normalizeStudyUsername(pending.newEmail)
if (!isValidStudyUsername(newEmail)) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(400).json({ message: 'Pending email address is invalid.' })
return
}
const existing = findStudyUserByUsername(newEmail)
if (existing && existing.id !== user.id) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
user.username = newEmail
user.pendingEmailChange = null
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (user.subscribeNewsletter !== false) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, username: user.username })
})
app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
res.json({
noteCount: Object.keys(notes).length,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
})
})
app.delete('/api/study-account', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const deletedEmail = user.username
const deletedDisplayName = user.displayName || user.username
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()
state.studyNotesCache.delete(user.id)
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
state.studyProgressCache.delete(user.id)
try { await unlink(getUserProgressFilePath(user.id)) } catch { /* no progress file is fine */ }
const beforeCerts = state.studyCertificates.length
state.studyCertificates = state.studyCertificates.filter(c => c.userId !== user.id)
if (state.studyCertificates.length !== beforeCerts) queueStudyCertificatesWrite()
const beforePosts = state.studyCommunityPosts.length
state.studyCommunityPosts = state.studyCommunityPosts.filter(p => p.authorUserId !== user.id)
if (state.studyCommunityPosts.length !== beforePosts) queueStudyCommunityWrite()
const beforeComments = state.studyComments.length
state.studyComments = state.studyComments.filter(c => c.userId !== user.id)
if (state.studyComments.length !== beforeComments) queueStudyCommentsWrite()
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
console.error('[study-account] delete email error:', err)
})
clearStudySessionCookie(res)
res.json({ ok: true })
})
}