Files
Siteforge/server/routes/study-account.js
T
nmemmert 1d43875e5a Fix 11 bugs: restore crash, draft leak, email failures, memory leaks
Critical fixes:
- sanitizeLoadedHitStats/VisitorStats: restore full state shape so a
  snapshot restore no longer crashes hit-counting middleware (missing
  byPathReal, byPathBot, byDayReal, byDayBot, botReasons, ipHashIndex)
- /questions/share/🆔 read state.questions only, not draft questions
- inbound-email: validate date with Number.isFinite before toISOString
- study-reminders: wrap each send in try/catch so one failure doesn't
  block remaining users; persist sent-markers after each success

Security:
- getClientIp: use req.ip (trust-proxy-resolved) instead of raw
  x-forwarded-for header to prevent IP spoofing
- env-snapshot.env: delete immediately after backup tar stream ends
  so secrets don't linger on disk between exports

Correctness / UX:
- contact form: email failures no longer 500 the user after the
  submission is already saved; log and fall through instead
- study-account profile: cap data URI avatar at 6 MB
- admin enrollment PATCH: validate slug against study catalog
- signup: return 503 at MAX_STUDY_USERS instead of silently dropping
  oldest accounts

Memory leaks:
- contactHits, downloadHits Maps: prune stale entries at 5000 entries
- resendEmailSubmissionIndex: trim to 2000 entries (oldest first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 07:48:54 -04:00

489 lines
21 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,
} 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 8200 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,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
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
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 })
})
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'
const { error } = await resend.emails.send({
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>`,
}),
})
if (error) {
console.error('[study-account] email change send error:', error)
res.status(503).json({ message: 'Could not send verification email right now.' })
return
}
} catch (err) {
console.error('[study-account] email change send exception:', 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 })
})
}