refractor server.js
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
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: `<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 */ }
|
||||
|
||||
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
|
||||
console.error('[study-account] delete email error:', err)
|
||||
})
|
||||
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user