Publish Container / docker (push) Successful in 14m16s
New /nuggets page for the companion show, linked from the Episodes nav dropdown. Hosts (name, role, bio, photo) show in a left column with the episode list on the right — latest episode featured, rest below. Episodes are pulled from an admin-configurable RSS feed URL (empty by default, shows "coming soon" until set) rather than hardcoded like the main show's feed, since the show doesn't have one yet. Everything is editable in /admin under a new Nuggets section: Show Settings (title, tagline, description, podcast art, RSS/platform URLs) and Hosts (add freely; removing a host archives them instead of deleting — kept on record and restorable, never lost). Extracted the RSS item-parsing logic shared with the main show's feed (server/routes/episodes.js) into server/rss-helpers.js so both feeds use the same parser instead of duplicating it. Also fixes the local Vite dev server not proxying /uploads/* to the backend — it was silently serving the SPA shell (200, text/html) instead of the actual file, so any admin-uploaded image (podcast art, host photos, etc.) appeared broken in local dev only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
213 lines
7.7 KiB
JavaScript
213 lines
7.7 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 } 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 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)
|
|
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)
|
|
|
|
// 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)
|
|
void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
|
|
|
|
app.listen(PORT, () => {
|
|
logResendEmailAlignmentWarnings()
|
|
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
|
})
|
|
})
|