9e602e29ec
Adds /api/bible-passage server route that proxies the Berean Standard Bible from Bolls.life per-verse, with in-memory caching. Study lesson scripture block now displays live BSB text instead of static passageText. Adds bibleBook field to StudyProgram so admins can set the book name explicitly when the study slug doesn't match (e.g. a study titled "ephesians-part-2"). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
7.2 KiB
JavaScript
202 lines
7.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,
|
|
loadEmailSettingsFromDisk,
|
|
loadCalendarEventsFromDisk,
|
|
loadAuditLogFromDisk,
|
|
loadWebhooksFromDisk,
|
|
createBackupSnapshot,
|
|
refreshContentCaches,
|
|
queueHitStatsWrite,
|
|
} from './server/data.js'
|
|
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js'
|
|
import {
|
|
detectBot,
|
|
sanitizeUserAgent,
|
|
shouldCountHit,
|
|
recordHit,
|
|
scheduleStudyReminders,
|
|
scheduleReengagementEmails,
|
|
} 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 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 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)
|
|
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)) {
|
|
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()
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
void scheduleReengagementEmails(sendStudyReengagementEmail)
|
|
|
|
app.listen(PORT, () => {
|
|
logResendEmailAlignmentWarnings()
|
|
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
|
})
|
|
})
|