Files
Siteforge/server/routes/study-account.js
T
nmemmert 9174331d2d Bug fixes (real breakage):
Newsletter nudge — was calling /api/study-account/profile (wrong endpoint, ignored subscription). Now correctly calls /api/study-account/preferences with PATCH.
Security:

Email regex — replaced the permissive [^\s@]+@[^\s@]+ pattern with a proper RFC-compliant regex in contact.js and downloads.js
Avatar magic bytes — server now checks actual PNG/JPEG/GIF/WEBP header bytes, not just the data URL prefix
Certificate rate limit — public /api/public/certificate/:token now has a 30 req/15min limiter
Session absolute TTL — admin sessions now have a 30-day hard cap; a stolen token can no longer be kept alive indefinitely by passive reads
Account lockout — 5 failed logins locks a study account for 1 hour
CSP headers — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers added globally
Data integrity:

Cascade delete — deleting a study account now also removes their certificates, community posts, comments, and progress file
UX / reliability:

Escape key on modals — all 3 modal groups (study index, notes, account) now close on Escape
Display name min-length — empty spaces-only names rejected; if provided, must be ≥2 chars
Note save rate limit — 30 saves/minute per user max
Analytics fetch timeout — 5s AbortController so a hanging server doesn't block the browser indefinitely
Email validation on signup — frontend catches bad email formats before hitting the server
Cleanup:

Deduplicated download forms — StudyDownloadForm and ResourceDownloadForm now share a single DownloadForm base; both are now thin wrappers
2026-06-18 09:35:44 -04:00

485 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
}
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 })
})
}