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, readUploadsMetadata, writeUploadsMetadata, loadUserNotes, loadUserProgress, getUserNotesFilePath, } 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 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, 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 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: `
${escapeHtml(emailChangeBody)}
`, ctaLabel: emailChangeCtaLabel, ctaUrl: verifyUrl, footerHtml: `Verse by Verse with Nate
`, }), }) 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 */ } sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => { console.error('[study-account] delete email error:', err) }) clearStudySessionCookie(res) res.json({ ok: true }) }) }