a8f6024234
Replaced broken srcdoc iframe approach with direct script injection so the TFL syndicate.js runs in page context with access to the DOM it needs. Also added truthforlife.org and ajax.googleapis.com to CSP script-src, which was silently blocking the widget and its jQuery dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.9 KiB
JavaScript
172 lines
5.9 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 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 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 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)
|
|
registerStudyAuth(app)
|
|
registerStudyData(app)
|
|
registerStudyAccount(app)
|
|
registerStudyComments(app)
|
|
registerStudyCertificate(app)
|
|
registerEpisodeScripts(app)
|
|
registerContact(app)
|
|
registerQuestions(app)
|
|
registerAnalytics(app)
|
|
registerDownloads(app)
|
|
registerEpisodes(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}`)
|
|
})
|
|
})
|