Files
Siteforge/server.js
T
nmemmert 1e4fe5f0e3 v1.1.0 — RSS feed, PWA, lesson comments, progress tracking + streaks
RSS Feed
- /feed.xml proxies the Anchor feed under the site's canonical domain
- Rewrites channel <link> and atom:link self-ref to the site URL
- Served with 30-min Cache-Control, reuses the existing episode cache

PWA
- vite-plugin-pwa installed; Workbox service worker auto-generated on build
- manifest.json inlined in vite.config.ts (name, icons, theme, standalone)
- pwa-192.png and pwa-512.png generated from existing book_icon.png
- StaleWhileRevalidate for /api/episodes and /api/questions; CacheFirst for images
- API, feed.xml, and uploads routes excluded from navigate fallback

Lesson Comments
- Import and wire StudySectionComments into ColossiansStudySectionPage
- Replaces the CommunityBoard in the Lesson Discussion section
- All routes and moderation already existed; only the render was missing

Progress Tracking + Streaks
- Mark-complete handler now records lastStudiedDate, currentStreak, longestStreak on the user record
- Streak increments on consecutive calendar days, resets on a gap
- /api/study-account/overview now returns streak fields
- Account page: 4-stat summary row (notes, streak 🔥, longest streak, member since)
- Per-study progress bars showing completedLessons/totalLessons with gold → green fill at 100%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:19:25 -04:00

178 lines
6.2 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 registerAdminBackup } from './server/routes/admin-backup.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 registerInboundEmail } from './server/routes/inbound-email.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 registerFeed } from './server/routes/feed.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' https://www.truthforlife.org https://ajax.googleapis.com",
"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)
registerAdminBackup(app)
registerStudyAuth(app)
registerStudyData(app)
registerStudyAccount(app)
registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app)
registerInboundEmail(app)
registerQuestions(app)
registerAnalytics(app)
registerDownloads(app)
registerEpisodes(app)
registerFeed(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}`)
})
})