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

172 lines
5.9 KiB
JavaScript

import express from 'express'
import { hasVisitorConsent } from './server/helpers.js'
import { isValidAdminSession } from './server/auth.js'
import { BACKUP_INTERVAL_MS } from './server/config.js'
import { state } from './server/state.js'
import {
loadHitStatsFromDisk,
loadVisitorStatsFromDisk,
loadContactSubmissionsFromDisk,
loadReplyTemplatesFromDisk,
loadReplyHistoryFromDisk,
loadQuestionsFromDisk,
loadDraftQuestionsFromDisk,
loadStudyUsersFromDisk,
loadStudyCommunityFromDisk,
loadStudyRemindersFromDisk,
loadStudyCommentsFromDisk,
loadStudyCertificatesFromDisk,
loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
} from './server/data.js'
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail } from './server/email.js'
import {
detectBot,
sanitizeUserAgent,
shouldCountHit,
recordHit,
scheduleStudyReminders,
} from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js'
// Route registrars
import { register as registerAdminAuth } from './server/routes/admin-auth.js'
import { register as registerAdminContent } from './server/routes/admin-content.js'
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
import { register as registerStudyAuth } from './server/routes/study-auth.js'
import { register as registerStudyData } from './server/routes/study-data.js'
import { register as registerStudyAccount } from './server/routes/study-account.js'
import { register as registerStudyComments } from './server/routes/study-comments.js'
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.js'
import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerPublic } from './server/routes/public.js'
const app = express()
app.use(express.json({ limit: '10mb' }))
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"media-src 'self' https:",
"frame-src https:",
"connect-src 'self' https:",
].join('; '),
)
next()
})
// Register API routes
registerAdminAuth(app)
registerAdminContent(app)
registerAdminAssets(app)
registerStudyAuth(app)
registerStudyData(app)
registerStudyAccount(app)
registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app)
registerQuestions(app)
registerAnalytics(app)
registerDownloads(app)
registerEpisodes(app)
registerQrCodes(app)
// Hit-counting middleware (must come before public routes)
app.use((req, res, next) => {
if (shouldCountHit(req)) {
const ua = sanitizeUserAgent(req.get('user-agent'))
const botDetection = detectBot(ua)
recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
recordVisitor(req, res).catch(err => {
console.error('[visitor-stats] failed to record visitor:', err)
})
}
}
next()
})
// Public routes (robots, sitemap, static, SPA fallback)
registerPublic(app)
const PORT = Number(process.env.PORT ?? 4173)
Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
loadStudyCommentsFromDisk(),
loadStudyCertificatesFromDisk(),
loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
refreshContentCaches(),
])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})
.finally(() => {
createBackupSnapshot('startup').catch(() => {})
setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
// Purge expired study sessions every hour
setInterval(() => {
const now = Date.now()
for (const [token, session] of state.studySessions) {
if (session.expiresAt <= now) state.studySessions.delete(token)
}
}, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour
setInterval(() => {
scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
}, 60 * 60 * 1000)
void scheduleStudyReminders(sendStudyReminderEmail)
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`)
})
})