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, loadEmailSettingsFromDisk, loadCalendarEventsFromDisk, loadAuditLogFromDisk, loadWebhooksFromDisk, loadStudySessionsFromDisk, queueStudySessionsWrite, loadNewsletterIssuesFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, } from './server/data.js' import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js' import { detectBot, sanitizeUserAgent, shouldCountHit, recordHit, scheduleStudyReminders, scheduleReengagementEmails, scheduleWeeklyDigestEmails, backfillCompletedStudies, } 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, runScheduledSeriesTransition } 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 registerAdminDashboard } from './server/routes/admin-dashboard.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 registerCalendarEvents } from './server/routes/calendar-events.js' import { startReminderScheduler } from './server/reminder.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 registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { register as registerFeed } from './server/routes/feed.js' import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerBiblePassage } from './server/routes/bible-passage.js' import { register as registerNewsletter } from './server/routes/newsletter.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('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()') if (process.env.NODE_ENV === 'production') { res.setHeader('Strict-Transport-Security', 'max-age=31536000') } 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) registerAdminDashboard(app) registerStudyAuth(app) registerStudyData(app) registerStudyAccount(app) registerStudyComments(app) registerStudyCertificate(app) registerEpisodeScripts(app) registerContact(app) registerCalendarEvents(app) registerInboundEmail(app) registerQuestions(app) registerAnalytics(app) registerDownloads(app) registerEpisodes(app) registerNuggets(app) registerNuggetsHostUpload(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) registerNewsletter(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)) { try { recordVisitor(req, res) } catch (err) { console.error('[visitor-stats] failed to record visitor:', err) } } } next() }) // Public routes (robots, sitemap, static, SPA fallback) registerPublic(app) // JSON error responses for API routes instead of Express's default HTML page. app.use((err, req, res, _next) => { if (res.headersSent) return const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500 if (status >= 500) console.error('[error]', req.method, req.path, err) if (req.path.startsWith('/api/')) { res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') }) return } res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.') }) 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(), loadEmailSettingsFromDisk(), loadCalendarEventsFromDisk(), loadAuditLogFromDisk(), loadWebhooksFromDisk(), loadStudySessionsFromDisk(), loadNewsletterIssuesFromDisk(), refreshContentCaches(), ]) .catch(err => { console.error('[stats] failed to load persisted stats:', err) }) .then(() => backfillCompletedStudies()) .then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) }) .catch(err => { console.error('[study] completed-study backfill failed:', err) }) .finally(() => { createBackupSnapshot('startup').catch(() => {}) setInterval(() => { createBackupSnapshot('scheduled').catch(() => {}) }, BACKUP_INTERVAL_MS) startReminderScheduler() startNuggetsSeafileWatcher() // Purge expired study sessions every hour setInterval(() => { const now = Date.now() let pruned = false for (const [token, session] of state.studySessions) { if (session.expiresAt <= now) { state.studySessions.delete(token); pruned = true } } if (pruned) queueStudySessionsWrite() }, 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) scheduleStudyReminders(sendStudyReminderEmail).catch(err => { console.error('[study-reminders] failed to schedule reminders:', err) }) // Send re-engagement emails to inactive study users every hour setInterval(() => { scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => { console.error('[study-reengagement] failed to schedule emails:', err) }) }, 60 * 60 * 1000) scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => { console.error('[study-reengagement] failed to schedule emails:', err) }) // Send weekly lesson digest emails every hour (only actually fires every 7 days per user) setInterval(() => { scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => { console.error('[study-digest] failed to schedule weekly digest:', err) }) }, 60 * 60 * 1000) scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => { console.error('[study-digest] failed to schedule weekly digest:', err) }) // Check for a scheduled series transition every minute setInterval(() => { runScheduledSeriesTransition().catch(err => { console.error('[series-scheduler] error:', err) }) }, 60 * 1000) runScheduledSeriesTransition().catch(err => { console.error('[series-scheduler] error:', err) }) const server = app.listen(PORT, () => { logResendEmailAlignmentWarnings() console.log(`Siteforge listening on http://localhost:${PORT}`) }) // On every deploy the running container gets SIGTERM'd and replaced. // All the queueXWrite() helpers in data.js debounce disk writes onto // per-resource promise chains — without draining those here, a redeploy // can kill the process mid-write and silently lose the last few // analytics events (or any other pending write) of that container's life. function collectPendingWrites() { return [ state.hitStatsWritePromise, state.visitorStatsWritePromise, state.contactSubmissionsWritePromise, state.questionsWritePromise, state.draftQuestionsWritePromise, state.replyTemplatesWritePromise, state.replyHistoryWritePromise, state.podcastChecklistWritePromise, state.studyUsersWritePromise, state.studyCommunityWritePromise, state.studyRemindersWritePromise, state.studyCommentsWritePromise, state.studyCertificatesWritePromise, state.episodeScriptsWritePromise, state.downloadCountsWritePromise, state.episodePlaysWritePromise, state.analyticsEventsWritePromise, state.qrCodesWritePromise, state.emailSettingsWritePromise, state.calendarEventsWritePromise, state.auditLogWritePromise, state.webhooksWritePromise, state.studySessionsWritePromise, ...state.studyNotesWriteQueues.values(), ...state.studyProgressWriteQueues.values(), ] } let shuttingDown = false async function gracefulShutdown(signal) { if (shuttingDown) return shuttingDown = true console.log(`[shutdown] ${signal} received — closing server and flushing pending writes...`) server.close() const forceExit = setTimeout(() => { console.error('[shutdown] timed out waiting for pending writes; exiting anyway') process.exit(1) }, 10_000) try { await Promise.all(collectPendingWrites()) console.log('[shutdown] all pending writes flushed') } catch (err) { console.error('[shutdown] error while flushing writes:', err) } finally { clearTimeout(forceExit) process.exit(0) } } process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) process.on('SIGINT', () => gracefulShutdown('SIGINT')) })