1d43875e5a
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>
489 lines
21 KiB
JavaScript
489 lines
21 KiB
JavaScript
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 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
|
||
}
|
||
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 })
|
||
})
|
||
}
|