Files
Siteforge/server/routes/study-data.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

406 lines
15 KiB
JavaScript

import {
requireStudyAuth,
normalizeStudySlug,
normalizeLessonSectionId,
getStudySlugFromNoteId,
isStudyUserEnrolled,
isEnrollableStudySlug,
getStudyTitleBySlug,
findStudyUserById,
getStudyAvatarUrl,
getStudyCatalog,
} from '../study-helpers.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
queueStudyCommunityWrite,
loadUserNotes,
queueUserNotesWrite,
loadUserProgress,
queueUserProgressWrite,
sanitizeStudyCommunityPosts,
} from '../data.js'
import { MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER } from '../config.js'
import { randomUUID } from 'node:crypto'
const noteSaveHits = new Map() // userId -> { count, windowStart }
const NOTE_RATE_WINDOW_MS = 60 * 1000
const NOTE_RATE_MAX = 30
function checkNoteSaveRateLimit(userId) {
const now = Date.now()
const entry = noteSaveHits.get(userId) ?? { count: 0, windowStart: now }
if (now - entry.windowStart > NOTE_RATE_WINDOW_MS) { entry.count = 0; entry.windowStart = now }
entry.count += 1
noteSaveHits.set(userId, entry)
return entry.count <= NOTE_RATE_MAX
}
export function register(app) {
// ── Enrollment ────────────────────────────────────────────────────────────
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
const user = req.studyUser
res.json({
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
availableStudies: getStudyCatalog()
.map(study => ({ slug: study.slug, title: study.title })),
})
})
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
res.status(404).json({ message: 'Study not found.' })
return
}
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (!enrolled.includes(studySlug)) {
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
res.status(404).json({ message: 'Study not found.' })
return
}
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolled.includes(studySlug)) {
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
// ── Notes ─────────────────────────────────────────────────────────────────
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
res.json({ notes })
})
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!sectionId) {
res.status(400).json({ message: 'Invalid section id.' })
return
}
const user = req.studyUser
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
return
}
const notes = await loadUserNotes(user.id)
res.json({ note: notes[sectionId] ?? '' })
})
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!sectionId) {
res.status(400).json({ message: 'Invalid section id.' })
return
}
const user = req.studyUser
if (!checkNoteSaveRateLimit(user.id)) {
res.status(429).json({ message: 'Too many note saves. Please slow down.' })
return
}
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
return
}
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
const notes = await loadUserNotes(user.id)
if (!note) {
delete notes[sectionId]
} else {
const existingCount = Object.keys(notes).length
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
res.status(400).json({ message: 'Notes limit reached for this account.' })
return
}
notes[sectionId] = note
}
state.studyNotesCache.set(user.id, notes)
queueUserNotesWrite(user.id)
res.json({ ok: true, note })
})
// ── Progress ──────────────────────────────────────────────────────────────
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) {
user.firstVisitAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ studySlug, completedSectionIds })
})
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
if (!studyProgress.completedSectionIds.includes(sectionId)) {
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
// Record first completion timestamp on the user record (additive, never overwrite)
if (!user.firstCompletionAt) {
user.firstCompletionAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId)
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
// ── Quiz ──────────────────────────────────────────────────────────────────
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
const progress = await loadUserProgress(user.id)
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
res.json({ studySlug, sectionId, answers: quizAnswers })
})
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
const rawAnswers = req.body?.answers
const answers = Array.isArray(rawAnswers)
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
: []
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
studyProgress.quizAnswers[sectionId] = answers
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, sectionId, answers })
})
// ── Community ─────────────────────────────────────────────────────────────
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return
}
const posts = state.studyCommunityPosts
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
return {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return { ...reply, authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || '') }
})
: [],
}
})
res.json({ studySlug, posts })
})
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.body?.studySlug)
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!studySlug || !message) {
res.status(400).json({ message: 'Study slug and message are required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return
}
const now = new Date().toISOString()
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
const post = {
id: randomUUID(),
studySlug,
sectionId,
authorUserId: user.id,
authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: now,
replies: [],
}
state.studyCommunityPosts.unshift(post)
state.studyCommunityPosts = sanitizeStudyCommunityPosts(state.studyCommunityPosts).slice(0, 500)
queueStudyCommunityWrite()
res.json({ ok: true, post })
})
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!postId || !message) {
res.status(400).json({ message: 'Post id and message are required.' })
return
}
const post = state.studyCommunityPosts.find(item => item.id === postId)
if (!post) {
res.status(404).json({ message: 'Post not found.' })
return
}
if (!isStudyUserEnrolled(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
return
}
const reply = {
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: new Date().toISOString(),
}
post.replies = Array.isArray(post.replies) ? post.replies : []
post.replies.push(reply)
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
queueStudyCommunityWrite()
res.json({ ok: true, reply })
})
}