Publish Container / docker (push) Successful in 15m4s
server.js was calling recordVisitor(req, res).catch() but recordVisitor returns undefined (it's synchronous). .catch() on undefined throws TypeError on every request, causing Express to return 500 for every page. Swap to try/catch since the function is synchronous. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
8.5 KiB
JavaScript
235 lines
8.5 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,
|
|
loadEmailSettingsFromDisk,
|
|
loadCalendarEventsFromDisk,
|
|
loadAuditLogFromDisk,
|
|
loadWebhooksFromDisk,
|
|
createBackupSnapshot,
|
|
refreshContentCaches,
|
|
queueHitStatsWrite,
|
|
} from './server/data.js'
|
|
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
|
|
import {
|
|
detectBot,
|
|
sanitizeUserAgent,
|
|
shouldCountHit,
|
|
recordHit,
|
|
scheduleStudyReminders,
|
|
scheduleReengagementEmails,
|
|
scheduleWeeklyDigestEmails,
|
|
} 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 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 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)
|
|
registerCalendarEvents(app)
|
|
registerInboundEmail(app)
|
|
registerQuestions(app)
|
|
registerAnalytics(app)
|
|
registerDownloads(app)
|
|
registerEpisodes(app)
|
|
registerNuggets(app)
|
|
registerNuggetsHostUpload(app)
|
|
registerFeed(app)
|
|
registerQrCodes(app)
|
|
registerBiblePassage(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)
|
|
|
|
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(),
|
|
refreshContentCaches(),
|
|
])
|
|
.catch(err => {
|
|
console.error('[stats] failed to load persisted stats:', 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()
|
|
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)
|
|
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 hour
|
|
setInterval(() => {
|
|
runScheduledSeriesTransition().catch(err => {
|
|
console.error('[series-scheduler] error:', err)
|
|
})
|
|
}, 60 * 60 * 1000)
|
|
runScheduledSeriesTransition().catch(err => {
|
|
console.error('[series-scheduler] error:', err)
|
|
})
|
|
|
|
app.listen(PORT, () => {
|
|
logResendEmailAlignmentWarnings()
|
|
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
|
})
|
|
})
|