import express from 'express' import rateLimit from 'express-rate-limit' import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import { Resend } from 'resend' import qrcode from 'qrcode' import { Document, Packer, Paragraph, HeadingLevel, TextRun, AlignmentType } from 'docx' import { sanitizeSiteContent, escapeHtml, escapeXml, buildAbsoluteUrl, injectSeoIntoHtml, normalizeAssetBaseName, inferImageExtensionFromDataUrl, getClientIp, hasVisitorConsent, setConsentCookie, splitName, parseCookies, } from './server/helpers.js' import { isAdminPasswordConfigured, isValidAdminSession, requireAdminAuth, setAdminSessionCookie, clearAdminSessionCookie, createAdminSession, deleteAdminSession, validateAdminPasswordSetup, isAdminPasswordValid, isTotpEnabled, loadTotpState, saveTotpState, generateTotpSecret, getTotpUri, verifyTotpCode, generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createPendingSession, consumePendingSession, } from './server/auth.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DEFAULT_DATA_DIR = path.join(__dirname, 'data') const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : '' const DATA_DIR = configuredDataDir ? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(__dirname, configuredDataDir)) : DEFAULT_DATA_DIR const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json') const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json') const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json') const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes') const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress') const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json') const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images') validateAdminPasswordSetup() const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE ? path.resolve(__dirname, process.env.TITUS_STUDY_FILE) : path.join(__dirname, 'A_Study_of_Titus.pdf') const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf' const DEFAULT_REDIRECT_RULES = [ { id: 'spotify', path: '/spotify', target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl', statusCode: 301, }, { id: 'apple', path: '/apple', target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate', statusCode: 301, }, { id: 'amazon', path: '/amazon', target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate', statusCode: 301, }, ] function normalizeRedirectPath(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` const normalized = withSlash.replace(/\/+/g, '/') if (normalized === '/') return '' if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' return normalized } function sanitizeUrl(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' if (trimmed.startsWith('/')) return trimmed if (/^https?:\/\//i.test(trimmed)) return trimmed return '' } function getSectionReleaseTime(section) { if (!section || typeof section !== 'object') return Number.NaN const candidateValues = [ section.releasedAt, section.releaseDate, section.availableAt, section.publishAt, ] for (const candidate of candidateValues) { if (typeof candidate !== 'string' || !candidate.trim()) continue const releaseTime = Date.parse(candidate) if (Number.isFinite(releaseTime)) return releaseTime } return Number.NaN } function isSectionReleased(section) { const releaseTime = getSectionReleaseTime(section) if (!Number.isFinite(releaseTime)) return false return releaseTime <= Date.now() } function redactUnreleasedSection(section) { if (!section || typeof section !== 'object') return section if (isSectionReleased(section)) return section return { ...section, passageText: '', commentary: '', greekNotes: [], studyQuestions: [], audioEmbedUrl: '', } } function filterSiteContentByReleaseDate(siteContent) { if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent const filteredStudies = Array.isArray(siteContent.studies) ? siteContent.studies.map(study => { if (!study || typeof study !== 'object') return study const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : [] return { ...study, sections } }) : siteContent.studies const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections) ? siteContent.colossiansStudySections.map(redactUnreleasedSection) : siteContent.colossiansStudySections return { ...siteContent, studies: filteredStudies, colossiansStudySections: filteredLegacySections, } } function sanitizeRedirectRules(value) { const source = Array.isArray(value) ? value : [] const seen = new Set() const out = [] for (const item of source) { const pathValue = normalizeRedirectPath(item?.path) const target = sanitizeUrl(item?.target) const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 if (!pathValue || !target) continue if (seen.has(pathValue)) continue seen.add(pathValue) out.push({ id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), path: pathValue, target, statusCode, }) } return out.length > 0 ? out : DEFAULT_REDIRECT_RULES } const DEFAULT_SEO = { title: 'Verse by Verse with Nate', description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', ogTitle: 'Verse by Verse with Nate', ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.', ogImage: '/images/podcast-art.jpeg', canonicalUrl: 'https://versebyversewithnate.us/', robotsPolicy: 'index,follow', sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'], } const DEFAULT_LEGAL = { privacyTitle: 'Privacy Policy', privacyBody: [ 'We respect your privacy and collect limited data to operate and improve this site.', 'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.', 'Contact form details are used only to respond to your message and ministry communication requests.', ], termsTitle: 'Terms', termsBody: [ 'Content on this site is for informational and ministry purposes.', 'External links are provided for convenience and are subject to third-party policies.', 'By using this site, you agree to lawful use and respectful communication.', ], } const DEFAULT_PODCAST_FEATURED_LINKS = [] const DEFAULT_PUBLISH_STATE = { draftUpdatedAt: null, publishedAt: null, } const DEFAULT_PODCAST_CHECKLIST_TASKS = [ { id: 'verify_script', label: 'Verify Script', phase: 'pre' }, { id: 'read_script', label: 'Read Script', phase: 'pre' }, { id: 'record', label: 'Record', phase: 'pre' }, { id: 'mix', label: 'Mix', phase: 'pre' }, { id: 'edit', label: 'Edit', phase: 'pre' }, { id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' }, { id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' }, { id: 'update_website', label: 'Update Website', phase: 'post' }, { id: 'send_email', label: 'Send Email', phase: 'post' }, ] function buildChecklistEpisode(series, number) { const tasks = {} for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) { tasks[task.id] = false } return { id: `${series.toLowerCase()}-${number}`, series, episodeNumber: number, title: '', datePublished: '', expanded: false, tasks, } } function buildDefaultPodcastChecklist() { const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number)) const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1)) return { tasks: DEFAULT_PODCAST_CHECKLIST_TASKS, episodes: [...titusEpisodes, ...colossiansEpisodes], } } function sanitizeChecklistTask(task) { const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : '' if (!label) return null const phase = task?.phase === 'post' ? 'post' : 'pre' const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID() return { id, label, phase } } function sanitizePodcastChecklist(value) { const fallback = buildDefaultPodcastChecklist() const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {} const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks const seenTaskIds = new Set() const tasks = [] for (const item of taskInput) { const safeTask = sanitizeChecklistTask(item) if (!safeTask) continue if (seenTaskIds.has(safeTask.id)) continue seenTaskIds.add(safeTask.id) tasks.push(safeTask) } if (tasks.length === 0) { for (const task of fallback.tasks) { tasks.push({ ...task }) seenTaskIds.add(task.id) } } const taskIds = tasks.map(task => task.id) const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes const episodes = [] for (const item of episodesInput) { if (!item || typeof item !== 'object' || Array.isArray(item)) continue const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID() const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : '' const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : '' const rawEpisodeNumber = Number(item.episodeNumber) const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0 ? Math.round(rawEpisodeNumber) : null const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim()) ? item.datePublished.trim() : '' const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks) ? item.tasks : {} const taskState = {} for (const taskId of taskIds) { taskState[taskId] = sourceTasks[taskId] === true } episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, }) } if (episodes.length === 0) { return fallback } return { tasks, episodes } } const DEFAULT_REPLY_TEMPLATES = [ { id: 'thanks-for-reaching-out', label: 'Thank You Reply', subject: 'Thanks for reaching out to Verse by Verse with Nate', message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.', }, { id: 'question-received', label: 'Question Received', subject: 'Your Bible question was received', message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.', }, { id: 'testimony-thank-you', label: 'Testimony Thank You', subject: 'Thank you for sharing your testimony', message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.', }, ] let cachedSiteContent = null let cachedDraftSiteContent = null let publishState = { ...DEFAULT_PUBLISH_STATE } let draftQuestions = null let draftQuestionsWritePromise = Promise.resolve() let replyTemplates = [...DEFAULT_REPLY_TEMPLATES] let replyTemplatesWritePromise = Promise.resolve() let replyHistory = [] let replyHistoryWritePromise = Promise.resolve() let podcastChecklist = buildDefaultPodcastChecklist() let podcastChecklistWritePromise = Promise.resolve() async function loadSiteContentFile(filePath) { const raw = await readFile(filePath, 'utf8') const parsed = JSON.parse(raw) const safeSiteContent = sanitizeSiteContent(parsed?.siteContent) return { ...parsed, siteContent: safeSiteContent, } } async function checkDataDirWritable() { try { await mkdir(DATA_DIR, { recursive: true }) const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`) await writeFile(marker, 'ok', 'utf8') await unlink(marker) return { ok: true, error: null } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' } } } async function getStorageStatus() { const writable = await checkDataDirWritable() const files = {} for (const [key, filePath] of Object.entries({ adminContent: DATA_FILE, adminContentDraft: DRAFT_DATA_FILE, studyUsers: STUDY_USERS_FILE, })) { try { const fileStat = await stat(filePath) files[key] = { path: filePath, exists: true, sizeBytes: fileStat.size, mtime: fileStat.mtime.toISOString(), } } catch { files[key] = { path: filePath, exists: false, sizeBytes: 0, mtime: null, } } } return { dataDir: DATA_DIR, writable, files, } } async function refreshContentCaches() { try { const published = await loadSiteContentFile(DATA_FILE) cachedSiteContent = published.siteContent if (typeof published?.updatedAt === 'string') { publishState.publishedAt = published.updatedAt } } catch { cachedSiteContent = null } try { const draft = await loadSiteContentFile(DRAFT_DATA_FILE) cachedDraftSiteContent = draft.siteContent if (typeof draft?.updatedAt === 'string') { publishState.draftUpdatedAt = draft.updatedAt } } catch { cachedDraftSiteContent = null } } async function loadDraftQuestionsFromDisk() { return readFile(DRAFT_QUESTIONS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) { draftQuestions = parsed.slice(0, MAX_QUESTIONS) } else if (Array.isArray(parsed?.questions)) { draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS) } else { draftQuestions = null } if (typeof parsed?.updatedAt === 'string') { publishState.draftUpdatedAt = parsed.updatedAt } }) .catch(() => { draftQuestions = null }) } function ensureDraftQuestions() { if (draftQuestions !== null) return draftQuestions = questions.slice(0, MAX_QUESTIONS) } async function readUploadsMetadata() { try { const raw = await readFile(UPLOADS_META_FILE, 'utf8') return JSON.parse(raw) } catch { return {} } } function loadDownloadCountsFromDisk() { return readFile(DOWNLOAD_COUNTS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} }) .catch(() => { downloadCounts = {} }) } function queueDownloadCountsWrite() { downloadCountsWritePromise = downloadCountsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(downloadCounts, null, 2), 'utf8') }) .catch(err => { console.error('[download-counts] failed to write:', err) }) } function incrementDownloadCount(resourceKey) { downloadCounts[resourceKey] = (downloadCounts[resourceKey] ?? 0) + 1 queueDownloadCountsWrite() } async function writeUploadsMetadata(metadata) { await mkdir(DATA_DIR, { recursive: true }) await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8') } async function listUploadedAssets() { await mkdir(UPLOADS_DIR, { recursive: true }) const files = await readdir(UPLOADS_DIR) const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort() const metadata = await readUploadsMetadata() const withStats = await Promise.all(imageFiles.map(async filename => { const info = await stat(path.join(UPLOADS_DIR, filename)) return { filename, url: `/uploads/${filename}`, sizeBytes: info.size, updatedAt: info.mtime.toISOString(), tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [], } })) return withStats } async function invokeWebhook(url, action) { if (!url) { return { ok: false, message: `${action} webhook URL is not configured.` } } try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin', }), }) if (!response.ok) { return { ok: false, message: `${action} webhook failed with ${response.status}.` } } return { ok: true, message: `${action} webhook triggered.` } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` } } } const EMPTY_HIT_STATS = { totalHits: 0, realHits: 0, botHits: 0, firstHitAt: null, lastHitAt: null, byPath: {}, byPathReal: {}, byPathBot: {}, byDay: {}, byDayReal: {}, byDayBot: {}, botReasons: {}, } let hitStats = { ...EMPTY_HIT_STATS } let hitStatsWritePromise = Promise.resolve() const VISITOR_COOKIE = 'vbn_vid' const CONSENT_COOKIE = 'vbn_analytics_consent' const MAX_RECENT_VISITS = 1000 const VISITOR_RETENTION_DAYS_DEFAULT = 180 const BACKUP_RETENTION_DAYS = 30 const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const EMPTY_VISITOR_STATS = { totalVisits: 0, uniqueVisitors: 0, returningVisits: 0, firstVisitAt: null, lastVisitAt: null, visitors: {}, recentVisits: [], geoCacheByIp: {}, } const MAX_CONTACT_SUBMISSIONS = 5000 const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000) const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 const titusDownloadTokens = new Map() const MAX_QUESTIONS = 1000 const MAX_STUDY_USERS = 5000 const MAX_STUDY_ENROLLMENTS_PER_USER = 100 const MAX_STUDY_NOTES_PER_USER = 500 const MAX_STUDY_NOTE_LENGTH = 12000 const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 const STUDY_SESSION_COOKIE = 'vbn_study_session' const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 let visitorStats = { ...EMPTY_VISITOR_STATS } let visitorStatsWritePromise = Promise.resolve() let contactSubmissions = [] let contactSubmissionsWritePromise = Promise.resolve() const contactSubmitCooldownByEmail = new Map() const resendEmailSubmissionIndex = new Map() let questions = [] let questionsWritePromise = Promise.resolve() let studyUsers = [] let studyUsersWritePromise = Promise.resolve() const studyNotesCache = new Map() // userId -> { [sectionId]: string } const studyNotesWriteQueues = new Map() // userId -> Promise const studyProgressCache = new Map() // userId -> { byStudy: Record } const studyProgressWriteQueues = new Map() // userId -> Promise let studyCommunityPosts = [] let studyCommunityWritePromise = Promise.resolve() let downloadCounts = {} let downloadCountsWritePromise = Promise.resolve() let lastVisitorStatsWrite = { ok: true, at: null, error: null } let lastHitStatsWrite = { ok: true, at: null, error: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastCachePurgeStatus = { ok: true, at: null, error: null } let lastDeployHookStatus = { ok: true, at: null, error: null } const studySessions = new Map() function sanitizeReplyTemplates(value) { if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES] const out = value .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '', subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '', })) .filter(item => item.label && item.subject && item.message) return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES] } function sanitizeReplyHistory(value) { if (!Array.isArray(value)) return [] return value .filter(item => item && typeof item === 'object') .slice(0, 500) .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), submissionId: typeof item.submissionId === 'string' ? item.submissionId : '', toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '', toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '', fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us', subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '', sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(), })) } function normalizeIp(rawIp) { if (!rawIp) return 'unknown' let ip = String(rawIp).trim() if (ip.includes(',')) { ip = ip.split(',')[0].trim() } if (ip.startsWith('::ffff:')) { ip = ip.slice(7) } if (ip === '::1') { ip = '127.0.0.1' } return ip || 'unknown' } function isPrivateOrLocalIp(ip) { return ( ip === '127.0.0.1' || ip === 'localhost' || ip.startsWith('10.') || ip.startsWith('192.168.') || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80:') || ip === 'unknown' ) } function queueVisitorStatsWrite() { visitorStatsWritePromise = visitorStatsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( VISITOR_STATS_FILE, JSON.stringify({ ...visitorStats, updatedAt: new Date().toISOString(), }, null, 2), 'utf8', ) lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null } }) .catch(err => { console.error('[visitor-stats] failed to write visitor stats:', err) lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } }) } function queueContactSubmissionsWrite() { contactSubmissionsWritePromise = contactSubmissionsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( CONTACT_SUBMISSIONS_FILE, JSON.stringify({ submissions: contactSubmissions, updatedAt: new Date().toISOString(), }, null, 2), 'utf8', ) }) .catch(err => { console.error('[contact] failed to write submissions:', err) }) } function queueReplyTemplatesWrite() { replyTemplatesWritePromise = replyTemplatesWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( REPLY_TEMPLATES_FILE, JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) }) .catch(err => { console.error('[reply-templates] failed to write templates:', err) }) } function queueReplyHistoryWrite() { replyHistoryWritePromise = replyHistoryWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( REPLY_HISTORY_FILE, JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) }) .catch(err => { console.error('[reply-history] failed to write history:', err) }) } function loadContactSubmissionsFromDisk() { return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions) }) .catch(() => { contactSubmissions = [] }) } function loadReplyTemplatesFromDisk() { return readFile(REPLY_TEMPLATES_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) replyTemplates = sanitizeReplyTemplates(parsed?.templates) }) .catch(() => { replyTemplates = [...DEFAULT_REPLY_TEMPLATES] }) } function loadReplyHistoryFromDisk() { return readFile(REPLY_HISTORY_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) replyHistory = sanitizeReplyHistory(parsed?.items) }) .catch(() => { replyHistory = [] }) } function loadPodcastChecklistFromDisk() { return readFile(PODCAST_CHECKLIST_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) podcastChecklist = sanitizePodcastChecklist(parsed?.checklist) }) .catch(() => { podcastChecklist = buildDefaultPodcastChecklist() }) } function queuePodcastChecklistWrite() { const updatedAt = new Date().toISOString() podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( PODCAST_CHECKLIST_FILE, JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2), 'utf8', ) }) return podcastChecklistWritePromise } function normalizeMessageType(value) { if (value === 'question' || value === 'testimony' || value === 'topic') return value return 'general' } function createEmailDeliveryState(status = 'pending') { return { status, lastEventAt: null, lastEventType: null, resendEmailId: null, error: null, } } function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') { if (!value || typeof value !== 'object' || Array.isArray(value)) { return createEmailDeliveryState(fallbackStatus) } return { status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus, lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null, lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null, resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null, error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null, } } function normalizeContactEmailStatus(value, subscribe) { const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {} return { welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'), adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'), adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'), } } function upsertContactEmailStatus(submissionId, stream, patch) { if (!submissionId || typeof submissionId !== 'string') return if (!stream || typeof stream !== 'string') return const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString() let updated = false contactSubmissions = contactSubmissions.map(submission => { if (submission.id !== submissionId) return submission const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true) const current = normalizeEmailDeliveryState(next[stream], 'pending') next[stream] = { ...current, ...patch, lastEventAt: at, } updated = true return { ...submission, emailStatus: next, } }) if (updated) { queueContactSubmissionsWrite() } } function extractResendMessageId(result) { if (!result || typeof result !== 'object') return '' if (typeof result.id === 'string' && result.id.trim()) return result.id.trim() if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) { return result.data.id.trim() } return '' } function registerResendMessageForSubmission(submissionId, stream, sendResult) { const resendMessageId = extractResendMessageId(sendResult) if (!resendMessageId || !submissionId || !stream) return resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream }) upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId, }) } function noteContactEmailCooldown(emailAddress) { const normalized = String(emailAddress || '').trim().toLowerCase() if (!normalized) return { ok: true, retryAfterMs: 0 } const now = Date.now() const lastAt = contactSubmitCooldownByEmail.get(normalized) if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) { return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) } } contactSubmitCooldownByEmail.set(normalized, now) // Prevent unbounded growth while keeping this in-memory cache simple. if (contactSubmitCooldownByEmail.size > 8000) { const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3 for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) { if (timestamp < cutoff) { contactSubmitCooldownByEmail.delete(email) } } } return { ok: true, retryAfterMs: 0 } } function extractTagValue(tags, name) { if (!Array.isArray(tags)) return '' const target = String(name || '').trim().toLowerCase() if (!target) return '' for (const tag of tags) { if (!tag || typeof tag !== 'object') continue const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : '' const value = typeof tag.value === 'string' ? tag.value.trim() : '' if (key === target && value) return value } return '' } function mapResendEventToStatus(eventType) { const normalized = String(eventType || '').trim().toLowerCase() if (!normalized) return 'updated' if (normalized.includes('delivered')) return 'delivered' if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed' if (normalized.includes('bounce')) return 'bounced' if (normalized.includes('complain')) return 'complained' if (normalized.includes('click')) return 'clicked' if (normalized.includes('open')) return 'opened' if (normalized.includes('send')) return 'sent' return 'updated' } function getAddressDomain(addressValue) { const raw = String(addressValue || '').trim() if (!raw) return '' const candidate = raw.includes('<') && raw.includes('>') ? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim() : raw const at = candidate.lastIndexOf('@') if (at <= 0 || at === candidate.length - 1) return '' return candidate.slice(at + 1).toLowerCase() } function logResendEmailAlignmentWarnings() { const warnings = [] const fromAddress = getResendFromAddress() const replyToAddress = getResendReplyToAddress() const fromDomain = getAddressDomain(fromAddress) const replyDomain = getAddressDomain(replyToAddress) const hasApiKey = Boolean(process.env.RESEND_API_KEY) if (!hasApiKey) { warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.') } if (!fromDomain) { warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.') } if (fromDomain.endsWith('resend.dev')) { warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.') } if (fromDomain && replyDomain && fromDomain !== replyDomain) { warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.') } if (!process.env.RESEND_WEBHOOK_TOKEN) { warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.') } if (hasApiKey) { warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.') } if (warnings.length > 0) { console.warn('[email-health] Resend alignment checks:') for (const warning of warnings) { console.warn(`[email-health] - ${warning}`) } } } const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate ' const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us' const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us' const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM function shouldSendWelcomeEmail({ subscribe }) { return subscribe === true } function buildAdminReplyTemplate({ recipientName, message }) { const safeRecipientName = escapeHtml(recipientName || 'friend') const safeMessage = escapeHtml(message).replace(/\n/g, '
') return `
Verse by Verse with Nate

A Personal Reply

Hi ${safeRecipientName},

${safeMessage}

Grace and peace,
Verse by Verse with Nate

From: hello@versebyversewithnate.us

` } function buildContactWelcomeEmailTemplate({ greetingName, welcomeIntro, welcomeCurrentSeries, welcomeStartHereTitle, welcomeStartHereSummary, welcomeExpect1, welcomeExpect2, welcomeExpect3, welcomeScripture, welcomeScriptureRef, welcomeSignoff, welcomeHeading, welcomeSpotifyUrl, welcomeAppleUrl, welcomeAmazonUrl, welcomeWebsiteUrl, welcomeEpisodeUrl, welcomeImageUrl, }) { const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') return { text: `Welcome to Verse by Verse with Nate!\n\n` + `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + `${welcomeIntro}\n\n` + `${welcomeCurrentSeries}\n\n` + `Start here: ${welcomeEpisodeUrl}\n` + `${welcomeStartHereTitle}\n` + `${welcomeStartHereSummary}\n` + `Spotify: ${welcomeSpotifyUrl}\n` + `Apple Podcasts: ${welcomeAppleUrl}\n` + `Amazon Music: ${welcomeAmazonUrl}\n` + `Website: ${welcomeWebsiteUrl}\n\n` + `What to expect:\n` + `- ${welcomeExpect1}\n` + `- ${welcomeExpect2}\n` + `- ${welcomeExpect3}\n\n` + `${welcomeScripture}\n${welcomeScriptureRef}\n\n` + `${welcomeSignoff}`, html: `
` + `` + `
` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
` + `Verse by Verse with Nate` + `

Verse by Verse with Nate

` + `

Verse by verse. Nugget by nugget.

` + `
` + `

Welcome

` + `

${welcomeHeading}

` + `
` + `
` + `

${escapeHtml(welcomeIntro)}

` + `

${escapeHtml(welcomeCurrentSeries)}

` + `

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

` + `
` + `

Start here

` + `

${escapeHtml(welcomeStartHereTitle)}

` + `

${escapeHtml(welcomeStartHereSummary)}

` + `` + `` + `` + `
Listen on SpotifyApple Podcasts
` + `

Open Start Here page

` + `
` + `

What to expect

` + `

${escapeHtml(welcomeExpect1)}

` + `

${escapeHtml(welcomeExpect2)}

` + `

${escapeHtml(welcomeExpect3)}

` + `
` + `
` + `

“${escapeHtml(welcomeScripture)}”

` + `

${escapeHtml(welcomeScriptureRef)}

` + `
` + `
` + `

Find the podcast on

` + `` + `` + `` + `` + `` + `` + `` + `` + `
Spotify·Apple Podcasts·Amazon Music·Website
` + `

You’re receiving this because you subscribed to Verse by Verse with Nate.

` + `

${welcomeSignoffHtml}

` + `
` + `
` + `
`, } } function buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage, }) { return { subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`, text: `New contact form submission\n\n` + `Message Type: ${normalizedMessageType}\n` + `Name: ${trimmedName}\n` + `Email: ${trimmedEmail}\n` + `Submitted: ${submittedAt}\n\n` + `Message:\n${trimmedMessage}`, html: `
` + `
` + `
` + `
Verse by Verse with Nate
` + `

New Contact Form Submission

` + `
` + `
` + `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + `
` + `
Message
` + `
${escapeHtml(trimmedMessage)}
` + `
` + `
` + `
` + `
`, } } function getResendFromAddress() { return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM } function getResendReplyToAddress() { return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO } function getResendInboxAddress() { return process.env.RESEND_TO ?? DEFAULT_RESEND_TO } async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) { let lastError = null for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const result = await resend.emails.send(payload) if (!result?.error) return result lastError = result.error if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, 250 * attempt)) } } catch (err) { lastError = err if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, 250 * attempt)) } } } throw lastError ?? new Error(`[${context}] email send failed`) } function addContactSubmission({ name, email, message, messageType, subscribe }) { const wantsWelcome = subscribe === true const submission = { id: randomUUID(), submittedAt: new Date().toISOString(), name, email, message, messageType: normalizeMessageType(messageType), subscribe: wantsWelcome, archived: false, emailStatus: normalizeContactEmailStatus(null, wantsWelcome), } contactSubmissions.unshift(submission) contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) queueContactSubmissionsWrite() return submission } async function syncContactToResend(name, email) { if (!process.env.RESEND_API_KEY) return const { firstName, lastName } = splitName(name) const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) try { const { error: contactError } = await contactResend.contacts.create({ email, firstName, lastName, unsubscribed: false, ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), }) if (contactError) { const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false, }) if (updateError) { console.error('[resend] contact sync error:', updateError) } } } catch (err) { console.error('[resend] contact sync exception:', err) } } function createTitusDownloadToken(email) { const token = randomUUID() titusDownloadTokens.set(token, { email, expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS, }) return token } function consumeTitusDownloadToken(token) { const entry = titusDownloadTokens.get(token) if (!entry) return false titusDownloadTokens.delete(token) if (entry.expiresAt <= Date.now()) return false return true } function sanitizeUserAgent(userAgent) { if (!userAgent || typeof userAgent !== 'string') return 'unknown' return userAgent.trim().slice(0, 300) || 'unknown' } function detectBot(userAgent, pathInfo = {}) { if (!userAgent || typeof userAgent !== 'string') { return { isBot: true, reason: 'missing-user-agent' } } const ua = userAgent.toLowerCase() // Search engine crawlers if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) { return { isBot: true, reason: 'search-crawler' } } // Social media crawlers if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) { return { isBot: true, reason: 'social-crawler' } } // Headless browsers and automation if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) { return { isBot: true, reason: 'headless-browser' } } // Monitoring and uptime checkers if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) { return { isBot: true, reason: 'monitoring-tool' } } // Security scanners and tools if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) { return { isBot: true, reason: 'security-scanner' } } // HTTP clients and frameworks if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) { return { isBot: true, reason: 'http-client' } } // Common crawler keywords if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) { // But allow some common real user agents that might contain these words if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) { return { isBot: true, reason: 'bot-keyword' } } } return { isBot: false, reason: null } } async function resolveGeo(ip) { if (!ip || isPrivateOrLocalIp(ip)) { return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown', } } const cached = visitorStats.geoCacheByIp[ip] if (cached) { return cached } const providers = [ async () => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 2500) try { const response = await fetch( `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`, { signal: controller.signal }, ) if (!response.ok) return null const data = await response.json() if (data?.status !== 'success') return null return { country: data?.country || 'Unknown', state: data?.regionName || 'Unknown', county: data?.district || 'Unknown', city: data?.city || 'Unknown', } } finally { clearTimeout(timeout) } }, async () => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 2500) try { const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal }) if (!response.ok) return null const data = await response.json() if (!data?.success) return null return { country: data?.country || 'Unknown', state: data?.region || 'Unknown', county: data?.region || 'Unknown', city: data?.city || 'Unknown', } } finally { clearTimeout(timeout) } }, ] for (const provider of providers) { try { const geo = await provider() if (geo) { visitorStats.geoCacheByIp[ip] = geo queueVisitorStatsWrite() return geo } } catch { // Try next provider. } } const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown', } visitorStats.geoCacheByIp[ip] = fallback queueVisitorStatsWrite() return fallback } async function recordVisitor(req, res) { const cookies = parseCookies(req.headers.cookie) let visitorId = cookies[VISITOR_COOKIE] if (!visitorId) { visitorId = randomUUID() res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) } const nowIso = new Date().toISOString() const pathKey = normalizeHitPath(req.path) const ip = getClientIp(req) const ua = sanitizeUserAgent(req.get('user-agent')) const existingVisitor = visitorStats.visitors[visitorId] const isReturning = Boolean(existingVisitor) const geo = await resolveGeo(ip) if (!existingVisitor) { visitorStats.uniqueVisitors += 1 } else { visitorStats.returningVisits += 1 } const ipHash = createHash('sha256').update(ip).digest('hex') const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1 const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5) visitorStats.visitors[visitorId] = { visitorId, ip, ipHash, firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, lastSeenAt: nowIso, visitCount: nextVisitCount, lastPath: pathKey, returningVisitor: isReturning, location: geo, userAgents, } visitorStats.totalVisits += 1 visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso visitorStats.lastVisitAt = nowIso visitorStats.recentVisits.unshift({ at: nowIso, visitorId, ip, path: pathKey, country: geo.country, state: geo.state, county: geo.county, city: geo.city, returningVisitor: isReturning, visitCount: nextVisitCount, }) visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) queueVisitorStatsWrite() } function loadVisitorStatsFromDisk() { return readFile(VISITOR_STATS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) visitorStats = { totalVisits: Number(parsed?.totalVisits) || 0, uniqueVisitors: Number(parsed?.uniqueVisitors) || 0, returningVisits: Number(parsed?.returningVisits) || 0, firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null, lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null, visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}, recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [], geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {}, } }) .catch(() => { visitorStats = { ...EMPTY_VISITOR_STATS } }) } function buildTopLocations(list, key) { const counts = {} for (const row of list) { const val = row?.[key] || 'Unknown' counts[val] = (counts[val] ?? 0) + 1 } return Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([name, hits]) => ({ name, hits })) } function pruneStatsByDays(daysRaw) { const days = Number(daysRaw) const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 const keepRecent = visitorStats.recentVisits.filter(v => { const ts = new Date(v.at).getTime() return Number.isFinite(ts) && ts >= cutoff }) const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId)) const nextVisitors = {} for (const [id, data] of Object.entries(visitorStats.visitors)) { const lastSeen = new Date(data.lastSeenAt ?? 0).getTime() if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) { nextVisitors[id] = data } } const nextByDay = {} const nextByDayReal = {} const nextByDayBot = {} for (const [day, count] of Object.entries(hitStats.byDay)) { const ts = new Date(`${day}T00:00:00.000Z`).getTime() if (Number.isFinite(ts) && ts >= cutoff) { nextByDay[day] = count nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0 nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0 } } visitorStats.recentVisits = keepRecent visitorStats.visitors = nextVisitors visitorStats.uniqueVisitors = Object.keys(nextVisitors).length visitorStats.totalVisits = keepRecent.length visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null hitStats.byDay = nextByDay hitStats.byDayReal = nextByDayReal hitStats.byDayBot = nextByDayBot queueHitStatsWrite() queueVisitorStatsWrite() return { retentionDays, remainingVisits: visitorStats.totalVisits, remainingVisitors: visitorStats.uniqueVisitors, } } async function createBackupSnapshot(reason = 'scheduled') { try { await mkdir(BACKUP_DIR, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, '-') const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`) const payload = { createdAt: new Date().toISOString(), reason, adminContent: null, draftContent: null, podcastChecklist, publishState, hitStats, visitorStats, contactSubmissions, studyCommunityPosts, replyTemplates, replyHistory, } try { const contentRaw = await readFile(DATA_FILE, 'utf8') payload.adminContent = JSON.parse(contentRaw) } catch { payload.adminContent = null } try { const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8') payload.draftContent = JSON.parse(draftRaw) } catch { payload.draftContent = null } await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8') const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort() const maxFiles = BACKUP_RETENTION_DAYS if (files.length > maxFiles) { const toDelete = files.slice(0, files.length - maxFiles) await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {}))) } lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) } } catch (err) { lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null } console.error('[backup] failed to create snapshot:', err) } } async function listBackupFiles() { await mkdir(BACKUP_DIR, { recursive: true }) const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse() return files } async function readBackupPreview(filename) { if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { throw new Error('Invalid backup filename') } const fullPath = path.join(BACKUP_DIR, filename) const [fileInfo, raw] = await Promise.all([ stat(fullPath), readFile(fullPath, 'utf8'), ]) const parsed = JSON.parse(raw) return { filename, sizeBytes: fileInfo.size, createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null, reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown', adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null, totalHits: Number(parsed?.hitStats?.totalHits) || 0, totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0, } } async function listBackupPreviews() { const files = await listBackupFiles() const previews = await Promise.all(files.map(async filename => { try { return await readBackupPreview(filename) } catch { return { filename, sizeBytes: 0, createdAt: null, reason: 'unknown', adminUpdatedAt: null, totalHits: 0, totalVisits: 0, } } })) return previews } function sanitizeLoadedHitStats(value) { return { totalHits: Number(value?.totalHits) || 0, firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null, lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null, byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, } } function sanitizeLoadedVisitorStats(value) { return { totalVisits: Number(value?.totalVisits) || 0, uniqueVisitors: Number(value?.uniqueVisitors) || 0, returningVisits: Number(value?.returningVisits) || 0, firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null, lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null, visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}, recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [], geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {}, } } function sanitizeLoadedContactSubmissions(value) { if (!Array.isArray(value)) return [] return value .slice(0, MAX_CONTACT_SUBMISSIONS) .filter(entry => entry && typeof entry === 'object') .map(entry => ({ id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(), submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(), name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '', email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '', message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '', messageType: normalizeMessageType(entry.messageType), subscribe: entry.subscribe === true, archived: entry.archived === true, emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), })) } async function restoreFromBackup(filename) { if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { throw new Error('Invalid backup filename') } const fullPath = path.join(BACKUP_DIR, filename) const raw = await readFile(fullPath, 'utf8') const parsed = JSON.parse(raw) await createBackupSnapshot('pre-restore') if (parsed?.adminContent && typeof parsed.adminContent === 'object') { await mkdir(DATA_DIR, { recursive: true }) await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8') } if (parsed?.draftContent && typeof parsed.draftContent === 'object') { await mkdir(DATA_DIR, { recursive: true }) await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8') } if (parsed?.publishState && typeof parsed.publishState === 'object') { publishState = { draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null, publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null, } } hitStats = sanitizeLoadedHitStats(parsed?.hitStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) replyHistory = sanitizeReplyHistory(parsed?.replyHistory) podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist) queueHitStatsWrite() queueVisitorStatsWrite() queueContactSubmissionsWrite() queueReplyTemplatesWrite() queueReplyHistoryWrite() queuePodcastChecklistWrite() await Promise.all([ hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise, podcastChecklistWritePromise, ]) await refreshContentCaches() await createBackupSnapshot('post-restore') } function normalizeHitPath(pathname) { if (!pathname || pathname === '') return '/' if (pathname.length > 1 && pathname.endsWith('/')) { return pathname.slice(0, -1) } return pathname } function shouldCountHit(req) { if (req.method !== 'GET') return false if (req.path.startsWith('/api/')) return false if (req.path === '/admin' || req.path.startsWith('/admin/')) return false if (req.path === '/favicon.ico') return false // Ignore direct asset requests and only count document-like requests. const hasFileExt = path.extname(req.path) !== '' if (hasFileExt) return false const accept = req.get('accept') ?? '' return accept.includes('text/html') || accept === '*/*' || accept === '' } function queueHitStatsWrite() { hitStatsWritePromise = hitStatsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( HIT_STATS_FILE, JSON.stringify({ ...hitStats, updatedAt: new Date().toISOString(), }, null, 2), 'utf8', ) lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null } }) .catch(err => { console.error('[stats] failed to write hit stats:', err) lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } }) } function recordHit(pathname, isBot = false, botReason = null) { const nowIso = new Date().toISOString() const dayKey = nowIso.slice(0, 10) const safePath = normalizeHitPath(pathname) hitStats.totalHits += 1 hitStats.lastHitAt = nowIso hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso if (isBot) { hitStats.botHits += 1 hitStats.byPathBot[safePath] = (hitStats.byPathBot[safePath] ?? 0) + 1 hitStats.byDayBot[dayKey] = (hitStats.byDayBot[dayKey] ?? 0) + 1 if (botReason) { hitStats.botReasons[botReason] = (hitStats.botReasons[botReason] ?? 0) + 1 } } else { hitStats.realHits += 1 hitStats.byPathReal[safePath] = (hitStats.byPathReal[safePath] ?? 0) + 1 hitStats.byDayReal[dayKey] = (hitStats.byDayReal[dayKey] ?? 0) + 1 } // Keep legacy byPath and byDay for backward compatibility hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1 hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1 queueHitStatsWrite() } function buildLastNDaysStats(days) { const out = [] const today = new Date() for (let i = days - 1; i >= 0; i -= 1) { const d = new Date(today) d.setDate(today.getDate() - i) const dayKey = d.toISOString().slice(0, 10) out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 }) } return out } function loadHitStatsFromDisk() { return readFile(HIT_STATS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) hitStats = { totalHits: Number(parsed?.totalHits) || 0, realHits: Number(parsed?.realHits) || 0, botHits: Number(parsed?.botHits) || 0, firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null, lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null, byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {}, } }) .catch(() => { hitStats = { ...EMPTY_HIT_STATS } }) } 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.get('/api/admin-content', async (req, res) => { const source = req.query?.source === 'draft' ? 'draft' : 'published' if (source === 'draft' && !isValidAdminSession(req)) { res.status(401).json({ message: 'Unauthorized' }) return } try { const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE) if (source === 'published') { const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent) res.json({ ...parsed, siteContent: safeSiteContent }) return } res.json(parsed) } catch { if (source === 'draft') { res.status(404).json({ message: 'No saved draft content file yet.' }) return } res.status(404).json({ message: 'No saved admin content file yet.' }) } }) app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => { res.json({ publishState, hasDraft: Boolean(cachedDraftSiteContent), hasPublished: Boolean(cachedSiteContent), }) }) app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => { const status = await getStorageStatus() res.json(status) }) app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => { res.json({ checklist: podcastChecklist }) }) app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => { try { const safeChecklist = sanitizePodcastChecklist(req.body?.checklist) podcastChecklist = safeChecklist await queuePodcastChecklistWrite() res.json({ ok: true, checklist: safeChecklist }) } catch { res.status(500).json({ message: 'Failed to save podcast checklist.' }) } }) app.get('/api/site-config', async (_req, res) => { try { const parsed = await loadSiteContentFile(DATA_FILE) const siteContent = parsed.siteContent ?? {} res.json({ seo: siteContent.seo ?? DEFAULT_SEO, legal: siteContent.legal ?? DEFAULT_LEGAL, redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES, podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS, publishState, updatedAt: parsed.updatedAt ?? null, }) } catch { res.json({ seo: DEFAULT_SEO, legal: DEFAULT_LEGAL, redirects: DEFAULT_REDIRECT_RULES, podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS, publishState, updatedAt: null, }) } }) app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => { try { const { siteContent } = req.body ?? {} if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) { res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' }) return } const safeSiteContent = sanitizeSiteContent(siteContent) const updatedAt = new Date().toISOString() await mkdir(DATA_DIR, { recursive: true }) await writeFile( DRAFT_DATA_FILE, JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2), 'utf8', ) cachedDraftSiteContent = safeSiteContent publishState.draftUpdatedAt = updatedAt res.json({ ok: true, updatedAt }) } catch (err) { console.error('[admin-content-draft] persist error:', err) const reason = err instanceof Error ? err.message : 'Unknown write error' res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` }) } }) app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => { try { const source = cachedDraftSiteContent ? { siteContent: cachedDraftSiteContent, updatedAt: publishState.draftUpdatedAt ?? new Date().toISOString() } : await loadSiteContentFile(DRAFT_DATA_FILE) const publishedAt = new Date().toISOString() await mkdir(DATA_DIR, { recursive: true }) await writeFile( DATA_FILE, JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2), 'utf8', ) cachedSiteContent = source.siteContent publishState.publishedAt = publishedAt if (draftQuestions !== null) { questions = draftQuestions.slice(0, MAX_QUESTIONS) await mkdir(DATA_DIR, { recursive: true }) await writeFile( QUESTIONS_FILE, JSON.stringify({ questions, updatedAt: publishedAt }, null, 2), 'utf8', ) } await createBackupSnapshot('post-publish') res.json({ ok: true, publishedAt }) } catch (err) { console.error('[admin-content-publish] persist error:', err) const reason = err instanceof Error ? err.message : 'Unknown write error' res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` }) } }) app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { try { const assets = await listUploadedAssets() res.json({ assets }) } catch { res.status(500).json({ message: 'Could not list uploaded assets.' }) } }) app.post('/api/admin-assets', requireAdminAuth, async (req, res) => { try { const filename = typeof req.body?.filename === 'string' ? req.body.filename : '' const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' const ext = inferImageExtensionFromDataUrl(dataUrl) if (!ext) { res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' }) return } const base64 = dataUrl.split(',')[1] ?? '' const buffer = Buffer.from(base64, 'base64') if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) { res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' }) return } const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) const finalName = `${baseName}-${Date.now()}${ext}` await mkdir(UPLOADS_DIR, { recursive: true }) await writeFile(path.join(UPLOADS_DIR, finalName), buffer) const metadata = await readUploadsMetadata() metadata[finalName] = [] await writeUploadsMetadata(metadata) res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } }) } catch { res.status(500).json({ message: 'Upload failed.' }) } }) app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { res.status(400).json({ message: 'Invalid filename.' }) return } const tags = Array.isArray(req.body?.tags) ? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) : [] const filePath = path.join(UPLOADS_DIR, filename) await stat(filePath) const metadata = await readUploadsMetadata() metadata[filename] = tags await writeUploadsMetadata(metadata) res.json({ ok: true, tags }) } catch { res.status(404).json({ message: 'Asset not found.' }) } }) app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { res.status(400).json({ message: 'Invalid filename.' }) return } await unlink(path.join(UPLOADS_DIR, filename)) const metadata = await readUploadsMetadata() delete metadata[filename] await writeUploadsMetadata(metadata) res.json({ ok: true }) } catch { res.status(404).json({ message: 'Asset not found.' }) } }) app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => { res.json({ buildCommit: process.env.BUILD_COMMIT ?? null, buildNumber: process.env.BUILD_NUMBER ?? null, deployedAt: process.env.DEPLOYED_AT ?? null, cachePurge: lastCachePurgeStatus, deployHook: lastDeployHookStatus, }) }) app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => { const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge') lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message } if (!result.ok) { res.status(400).json({ message: result.message }) return } res.json({ ok: true, message: result.message }) }) app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => { const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy') lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message } if (!result.ok) { res.status(400).json({ message: result.message }) return } res.json({ ok: true, message: result.message }) }) function queueQuestionsWrite() { questionsWritePromise = questionsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( QUESTIONS_FILE, JSON.stringify({ questions, updatedAt: new Date().toISOString(), }, null, 2), 'utf8', ) }) .catch(err => { console.error('[questions] failed to write questions:', err) }) } function queueDraftQuestionsWrite() { if (draftQuestions === null) return draftQuestionsWritePromise = draftQuestionsWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( DRAFT_QUESTIONS_FILE, JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) publishState.draftUpdatedAt = new Date().toISOString() }) .catch(err => { console.error('[draft-questions] failed to write draft questions:', err) }) } function loadQuestionsFromDisk() { return readFile(QUESTIONS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) { questions = parsed.slice(0, MAX_QUESTIONS) } else if (Array.isArray(parsed?.questions)) { questions = parsed.questions.slice(0, MAX_QUESTIONS) } else { questions = [] } }) .catch(() => { questions = [] }) } function cookieFlags() { return process.env.NODE_ENV === 'production' ? '; Secure' : '' } function normalizeStudyUsername(value) { if (typeof value !== 'string') return '' return value.trim().toLowerCase() } function normalizeStudySlug(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' } function isValidStudyUsername(value) { return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254 } function getStudyCatalog() { const fallback = [ { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' }, ] const content = cachedSiteContent if (!content || typeof content !== 'object') return fallback if (Array.isArray(content.studies) && content.studies.length > 0) { const out = [] const seen = new Set() for (const study of content.studies) { const slug = normalizeStudySlug(study?.slug) if (!slug || seen.has(slug)) continue seen.add(slug) out.push({ slug, title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug, status: study?.status === 'planned' ? 'planned' : 'active', }) } if (out.length > 0) return out } return fallback } function isEnrollableStudySlug(studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized) return false return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned') } function getStudyTitleBySlug(studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized) return '' const study = getStudyCatalog().find(item => item.slug === normalized) return study?.title ?? '' } function isStudyUserEnrolled(user, studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized || !user) return false return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) } function hashStudyPassword(password) { return createHash('sha256').update(`study-user:${String(password)}`).digest('hex') } function hashEmailChangeToken(token) { return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') } function getCanonicalBaseUrl() { const configured = cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl return configured.trim() } function buildBrandedEmailHtml({ title, eyebrow, bodyHtml, ctaLabel, ctaUrl, footerHtml, }) { const ctaBlock = ctaLabel && ctaUrl ? `

${escapeHtml(ctaLabel)}

` : '' return ( `
` + `` + `
` + `` + `` + `` + `` + `
` + `

${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}

` + `

${escapeHtml(title)}

` + `
` + `
${bodyHtml}
` + `${ctaBlock}` + `
${footerHtml ?? ''}
` + `
` + `
` ) } async function sendStudyWelcomeEmail(email, displayName) { if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const baseUrl = getCanonicalBaseUrl() const studiesUrl = buildAbsoluteUrl(baseUrl, '/study') const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account') const bodyHtml = ( `

Welcome, ${escapeHtml(namePart)}.

` + `

Your student account is ready.

` + `

Open studies and continue learning, or manage your account details anytime.

` ) const footerHtml = `

Grace and peace,
Verse by Verse with Nate

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [email], subject: process.env.RESEND_WELCOME_SUBJECT ?? 'Welcome to Verse by Verse with Nate', text: `Welcome, ${namePart}.\n\n` + `Your student account is ready.\n\n` + `Open studies: ${studiesUrl}\n` + `Manage account: ${accountUrl}\n\n` + `Grace and peace,\nVerse by Verse with Nate`, html: buildBrandedEmailHtml({ title: 'Welcome to the Study Community', eyebrow: 'Study Account', bodyHtml, ctaLabel: 'Open Studies', ctaUrl: studiesUrl, footerHtml: footerHtml + `

Manage your account

`, }), }) if (error) console.error('[study-signup] welcome email send error:', error) } catch (err) { console.error('[study-signup] welcome email exception:', err) } } async function sendStudyAccountDeletedEmail(email, displayName) { if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const baseUrl = getCanonicalBaseUrl() const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup') const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

This confirms your study account and saved notes were deleted.

` + `

If this was not you, please contact us immediately.

` ) const footerHtml = `

Verse by Verse with Nate

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [email], subject: 'Your study account was deleted', text: `Hi ${namePart},\n\n` + `This confirms your study account and saved notes were deleted.\n\n` + `If this was not you, please contact us immediately.\n\n` + `Create a new account anytime: ${signupUrl}\n\n` + `Verse by Verse with Nate`, html: buildBrandedEmailHtml({ title: 'Study Account Deleted', eyebrow: 'Account Update', bodyHtml, ctaLabel: 'Create a New Account', ctaUrl: signupUrl, footerHtml, }), }) if (error) console.error('[study-account] delete email send error:', error) } catch (err) { console.error('[study-account] delete email exception:', err) } } function sanitizeStudyUsers(value) { if (!Array.isArray(value)) return [] const out = [] const seen = new Set() for (const item of value) { const username = normalizeStudyUsername(item?.username) const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : '' if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs) ? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) : [] const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' const subscribeNewsletter = item?.subscribeNewsletter !== false const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) ? { newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) ? normalizeStudyUsername(item.pendingEmailChange.newEmail) : '', tokenHash: typeof item.pendingEmailChange.tokenHash === 'string' ? item.pendingEmailChange.tokenHash.trim() : '', expiresAt: typeof item.pendingEmailChange.expiresAt === 'number' ? item.pendingEmailChange.expiresAt : 0, requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null, } : null seen.add(username) out.push({ id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), username, passwordHash, displayName, subscribeNewsletter, pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now() ? pendingEmailChange : null, enrolledStudySlugs, createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(), updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(), lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, }) } return out.slice(0, MAX_STUDY_USERS) } function sanitizeUserNotes(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const out = {} let count = 0 for (const [sectionId, note] of Object.entries(value)) { if (count >= MAX_STUDY_NOTES_PER_USER) break if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue if (typeof note !== 'string') continue const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH) if (!trimmed) continue out[sectionId] = trimmed count += 1 } return out } function sanitizeStudyCommunityReply(reply) { if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : '' if (!message) return null return { id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(), authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '', authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '', message, createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(), } } function sanitizeStudyCommunityPosts(value) { if (!Array.isArray(value)) return [] return value .filter(item => item && typeof item === 'object' && !Array.isArray(item)) .map(item => { const studySlug = normalizeStudySlug(item.studySlug) const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : '' const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : '' const replies = Array.isArray(item.replies) ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) : [] if (!studySlug || !message) return null return { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), studySlug, sectionId, authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '', authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '', message, createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), replies, } }) .filter(Boolean) } async function loadStudyCommunityFromDisk() { return readFile(STUDY_COMMUNITY_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed) }) .catch(() => { studyCommunityPosts = [] }) } function queueStudyCommunityWrite() { studyCommunityWritePromise = studyCommunityWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( STUDY_COMMUNITY_FILE, JSON.stringify({ posts: studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) }) .catch(err => { console.error('[study-community] failed to write discussion posts:', err) }) } function getUserNotesFilePath(userId) { // userId is a UUID — safe as a filename return path.join(STUDY_NOTES_DIR, `${userId}.json`) } async function loadUserNotes(userId) { if (studyNotesCache.has(userId)) return studyNotesCache.get(userId) try { const raw = await readFile(getUserNotesFilePath(userId), 'utf8') const notes = sanitizeUserNotes(JSON.parse(raw)) studyNotesCache.set(userId, notes) return notes } catch { const notes = {} studyNotesCache.set(userId, notes) return notes } } function queueUserNotesWrite(userId) { const prev = studyNotesWriteQueues.get(userId) ?? Promise.resolve() const next = prev .then(async () => { const notes = studyNotesCache.get(userId) ?? {} await mkdir(STUDY_NOTES_DIR, { recursive: true }) await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8') }) .catch(err => { console.error(`[study-notes] failed to write notes for user ${userId}:`, err) }) studyNotesWriteQueues.set(userId, next) } function getUserProgressFilePath(userId) { return path.join(STUDY_PROGRESS_DIR, `${userId}.json`) } function sanitizeStudyProgress(value) { const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() } if (!value || typeof value !== 'object') return defaultResult const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } if (value.byStudy && typeof value.byStudy === 'object') { for (const [studySlug, studyData] of Object.entries(value.byStudy)) { if (typeof studySlug !== 'string' || !studySlug.trim()) continue const completedSectionIds = Array.isArray(studyData?.completedSectionIds) ? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) : [] progress.byStudy[studySlug.trim().toLowerCase()] = { completedSectionIds: Array.from(new Set(completedSectionIds)), } } } return progress } async function loadUserProgress(userId) { if (studyProgressCache.has(userId)) return studyProgressCache.get(userId) try { const raw = await readFile(getUserProgressFilePath(userId), 'utf8') const progress = sanitizeStudyProgress(JSON.parse(raw)) studyProgressCache.set(userId, progress) return progress } catch { const progress = { byStudy: {}, updatedAt: new Date().toISOString() } studyProgressCache.set(userId, progress) return progress } } function queueUserProgressWrite(userId) { const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve() const next = prev .then(async () => { const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() } await mkdir(STUDY_PROGRESS_DIR, { recursive: true }) await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8') }) .catch(err => { console.error(`[study-progress] failed to write progress for user ${userId}:`, err) }) studyProgressWriteQueues.set(userId, next) } async function migrateStudyNotesIfNeeded() { try { const raw = await readFile(STUDY_NOTES_FILE, 'utf8') const parsed = JSON.parse(raw) const notesByUser = parsed?.notesByUser ?? {} const userIds = Object.keys(notesByUser) if (userIds.length === 0) return await mkdir(STUDY_NOTES_DIR, { recursive: true }) let migrated = 0 for (const [userId, notes] of Object.entries(notesByUser)) { const sanitized = sanitizeUserNotes(notes) if (Object.keys(sanitized).length === 0) continue const filePath = getUserNotesFilePath(userId) try { await readFile(filePath, 'utf8'); continue } catch { /* doesn't exist yet */ } await writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8') migrated += 1 } if (migrated > 0) console.log(`[study-notes] migrated ${migrated} users to per-user files`) } catch { /* no legacy file — nothing to migrate */ } } function queueStudyUsersWrite() { studyUsersWritePromise = studyUsersWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( STUDY_USERS_FILE, JSON.stringify({ users: studyUsers, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) }) .catch(err => { console.error('[study-users] failed to write users:', err) }) } function loadStudyUsersFromDisk() { return readFile(STUDY_USERS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) const source = Array.isArray(parsed) ? parsed : parsed?.users studyUsers = sanitizeStudyUsers(source) }) .catch(() => { studyUsers = [] }) } function findStudyUserByUsername(username) { return studyUsers.find(user => user.username === normalizeStudyUsername(username)) } function createStudySession(userId) { const token = randomUUID() studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) return token } function setStudySessionCookie(res, token) { res.append( 'Set-Cookie', `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } function clearStudySessionCookie(res) { res.append( 'Set-Cookie', `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } function getStudyUserFromRequest(req) { const cookies = parseCookies(req.headers.cookie) const token = cookies[STUDY_SESSION_COOKIE] if (!token) return null const session = studySessions.get(token) if (!session || session.expiresAt <= Date.now()) { studySessions.delete(token) return null } const user = studyUsers.find(item => item.id === session.userId) if (!user) { studySessions.delete(token) return null } session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS studySessions.set(token, session) return user } function requireStudyAuth(req, res, next) { const user = getStudyUserFromRequest(req) if (!user) { res.status(401).json({ message: 'Please sign in to save notes.' }) return } req.studyUser = user next() } function getStudySlugFromNoteId(sectionId) { if (typeof sectionId !== 'string') return '' const separatorIndex = sectionId.indexOf('--') if (separatorIndex <= 0) return '' return normalizeStudySlug(sectionId.slice(0, separatorIndex)) } function normalizeLessonSectionId(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' } // Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint const loginRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' }, skipSuccessfulRequests: true, }) const studyAuthRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many attempts. Please wait 15 minutes and try again.' }, skipSuccessfulRequests: true, }) app.get('/api/study-auth/status', (req, res) => { const user = getStudyUserFromRequest(req) res.json({ authenticated: Boolean(user), username: user?.username ?? '', displayName: user?.displayName ?? '', subscribeNewsletter: user?.subscribeNewsletter !== false, enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], }) }) app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { const username = normalizeStudyUsername(req.body?.username) const password = typeof req.body?.password === 'string' ? req.body.password : '' const subscribe = req.body?.subscribe === true const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' if (!isValidStudyUsername(username)) { res.status(400).json({ message: 'Please enter a valid email address.' }) return } if (typeof password !== 'string' || password.length < 8 || password.length > 200) { res.status(400).json({ message: 'Password must be 8-200 characters.' }) return } if (findStudyUserByUsername(username)) { res.status(409).json({ message: 'An account with that email already exists.' }) return } const now = new Date().toISOString() const user = { id: randomUUID(), username, passwordHash: hashStudyPassword(password), displayName, subscribeNewsletter: subscribe, pendingEmailChange: null, enrolledStudySlugs: [], createdAt: now, updatedAt: now, lastLoginAt: now, } studyUsers.push(user) if (studyUsers.length > MAX_STUDY_USERS) { studyUsers = studyUsers.slice(studyUsers.length - MAX_STUDY_USERS) } queueStudyUsersWrite() if (subscribe) { addContactSubmission({ name: displayName || username, email: username, message: '', messageType: 'general', subscribe: true }) syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err)) } sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err)) const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) res.json({ ok: true, username: user.username, displayName: user.displayName, subscribeNewsletter: user.subscribeNewsletter, enrolledStudySlugs: user.enrolledStudySlugs, }) }) app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { const username = normalizeStudyUsername(req.body?.username) const password = typeof req.body?.password === 'string' ? req.body.password : '' const user = findStudyUserByUsername(username) if (!user) { res.status(401).json({ message: 'Invalid email or password.' }) return } const submittedHash = hashStudyPassword(password) const expectedHash = user.passwordHash const a = Buffer.from(submittedHash, 'utf8') const b = Buffer.from(expectedHash, 'utf8') if (a.length !== b.length || !timingSafeEqual(a, b)) { res.status(401).json({ message: 'Invalid email or password.' }) return } user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt queueStudyUsersWrite() const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) res.json({ ok: true, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, enrolledStudySlugs: user.enrolledStudySlugs ?? [], }) }) app.get('/api/study-enrollment', requireStudyAuth, (req, res) => { const user = req.studyUser res.json({ enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [], availableStudies: getStudyCatalog() .filter(study => study.status !== 'planned') .map(study => ({ slug: study.slug, title: study.title })), }) }) app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug || !isEnrollableStudySlug(studySlug)) { res.status(404).json({ message: 'Study not found.' }) return } const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] if (!enrolled.includes(studySlug)) { user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() } res.json({ ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, enrolledStudySlugs: user.enrolledStudySlugs, }) }) app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug || !isEnrollableStudySlug(studySlug)) { res.status(404).json({ message: 'Study not found.' }) return } const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] if (enrolled.includes(studySlug)) { user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() } res.json({ ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, enrolledStudySlugs: user.enrolledStudySlugs, }) }) app.post('/api/study-auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const token = cookies[STUDY_SESSION_COOKIE] if (token) { studySessions.delete(token) } clearStudySessionCookie(res) res.json({ ok: true }) }) app.get('/api/study-notes', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) res.json({ notes }) }) app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => { const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } const user = req.studyUser const noteStudySlug = getStudySlugFromNoteId(sectionId) if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to access notes.' }) return } const notes = await loadUserNotes(user.id) res.json({ note: notes[sectionId] ?? '' }) }) app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => { const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } const user = req.studyUser const noteStudySlug = getStudySlugFromNoteId(sectionId) if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to save notes.' }) return } const rawNote = typeof req.body?.note === 'string' ? req.body.note : '' const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH) const notes = await loadUserNotes(user.id) if (!note) { delete notes[sectionId] } else { const existingCount = Object.keys(notes).length if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) { res.status(400).json({ message: 'Notes limit reached for this account.' }) return } notes[sectionId] = note } studyNotesCache.set(user.id, notes) queueUserNotesWrite(user.id) res.json({ ok: true, note }) }) app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug) { res.status(400).json({ message: 'Study slug is required.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to view progress.' }) return } const progress = await loadUserProgress(user.id) const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] res.json({ studySlug, completedSectionIds }) }) app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to update progress.' }) return } const progress = await loadUserProgress(user.id) const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } if (!studyProgress.completedSectionIds.includes(sectionId)) { studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId] } progress.byStudy[studySlug] = studyProgress progress.updatedAt = new Date().toISOString() studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) }) app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to update progress.' }) return } const progress = await loadUserProgress(user.id) const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId) progress.byStudy[studySlug] = studyProgress progress.updatedAt = new Date().toISOString() studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) }) app.get('/api/study-community', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '') if (!studySlug) { res.status(400).json({ message: 'Study slug is required.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to view the community.' }) return } const posts = studyCommunityPosts .filter(post => post.studySlug === studySlug) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 50) res.json({ studySlug, posts, }) }) app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.body?.studySlug) const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : '' if (!studySlug || !message) { res.status(400).json({ message: 'Study slug and message are required.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to post in the community.' }) return } const now = new Date().toISOString() const authorName = user.displayName?.trim() || user.username const post = { id: randomUUID(), studySlug, sectionId, authorUserId: user.id, authorName, message, createdAt: now, replies: [], } studyCommunityPosts.unshift(post) studyCommunityPosts = sanitizeStudyCommunityPosts(studyCommunityPosts).slice(0, 500) queueStudyCommunityWrite() res.json({ ok: true, post }) }) app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => { const user = req.studyUser const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : '' if (!postId || !message) { res.status(400).json({ message: 'Post id and message are required.' }) return } const post = studyCommunityPosts.find(item => item.id === postId) if (!post) { res.status(404).json({ message: 'Post not found.' }) return } if (!isStudyUserEnrolled(user, post.studySlug)) { res.status(403).json({ message: 'Please enroll in this study to reply in the community.' }) return } const reply = { id: randomUUID(), authorUserId: user.id, authorName: user.displayName?.trim() || user.username, message, createdAt: new Date().toISOString(), } post.replies = Array.isArray(post.replies) ? post.replies : [] post.replies.push(reply) post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? [] queueStudyCommunityWrite() res.json({ ok: true, reply }) }) app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) // Build a lookup of sectionId -> { title, reference, studyTitle } from cached content const sectionMeta = {} const content = cachedSiteContent if (content) { const studies = Array.isArray(content.studies) && content.studies.length > 0 ? content.studies : [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }] for (const study of studies) { for (const section of (study.sections ?? [])) { sectionMeta[`${study.slug}--${section.id}`] = { studyTitle: study.title, title: section.title, reference: section.reference, } } } } // Group notes by study const byStudy = {} for (const [noteKey, noteText] of Object.entries(notes)) { if (!noteText?.trim()) continue const dashIndex = noteKey.indexOf('--') const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown' if (!byStudy[studySlug]) byStudy[studySlug] = [] byStudy[studySlug].push({ noteKey, noteText }) } const docChildren = [ new Paragraph({ text: 'My Study Notes', heading: HeadingLevel.TITLE, }), new Paragraph({ children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })], spacing: { after: 400 }, }), ] for (const [studySlug, entries] of Object.entries(byStudy)) { const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug docChildren.push( new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }), ) for (const { noteKey, noteText } of entries) { const meta = sectionMeta[noteKey] const lessonTitle = meta?.title ?? noteKey const reference = meta?.reference ?? '' docChildren.push( new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }), ) if (reference) { docChildren.push( new Paragraph({ children: [new TextRun({ text: reference, italics: true, color: '555555' })], spacing: { after: 120 }, }), ) } for (const line of noteText.split('\n')) { docChildren.push( new Paragraph({ text: line.trim(), spacing: { after: 80 } }), ) } } } if (docChildren.length <= 2) { docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } })) } const doc = new Document({ creator: 'Verse by Verse with Nate', title: 'My Study Notes', sections: [{ children: docChildren }], }) const buffer = await Packer.toBuffer(doc) const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx` res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') res.send(buffer) }) app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => { const user = req.studyUser const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : '' const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : '' const currentHash = hashStudyPassword(currentPassword) const a = Buffer.from(currentHash, 'utf8') const b = Buffer.from(user.passwordHash, 'utf8') if (a.length !== b.length || !timingSafeEqual(a, b)) { res.status(401).json({ message: 'Current password is incorrect.' }) return } if (newPassword.length < 8 || newPassword.length > 200) { res.status(400).json({ message: 'New password must be 8–200 characters.' }) return } user.passwordHash = hashStudyPassword(newPassword) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true }) }) app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) const progress = await loadUserProgress(user.id) const noteEntries = Object.entries(notes) const studies = getStudyCatalog().map(study => { const totalLessons = Array.isArray(cachedSiteContent?.studies) ? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0) : 0 const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0 return { slug: study.slug, title: study.title, status: study.status, enrolled: isStudyUserEnrolled(user, study.slug), totalLessons, completedLessons, noteCount, } }) res.json({ profile: { username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, }, stats: { noteCount: Object.keys(notes).length, memberSince: user.createdAt, lastLoginAt: user.lastLoginAt, }, studies, }) }) app.post('/api/study-account/profile', requireStudyAuth, (req, res) => { const user = req.studyUser const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' user.displayName = displayName user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true, displayName: user.displayName }) }) app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => { const user = req.studyUser const subscribeNewsletter = req.body?.subscribeNewsletter === true user.subscribeNewsletter = subscribeNewsletter user.updatedAt = new Date().toISOString() queueStudyUsersWrite() if (subscribeNewsletter) { syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) } res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter }) }) app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { const user = req.studyUser const newEmail = normalizeStudyUsername(req.body?.newEmail) const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : '' if (!isValidStudyUsername(newEmail)) { res.status(400).json({ message: 'Please enter a valid email address.' }) return } if (newEmail === user.username) { res.status(400).json({ message: 'That is already your current email.' }) return } const existing = findStudyUserByUsername(newEmail) if (existing && existing.id !== user.id) { res.status(409).json({ message: 'An account with that email already exists.' }) return } const currentHash = hashStudyPassword(currentPassword) const a = Buffer.from(currentHash, 'utf8') const b = Buffer.from(user.passwordHash, 'utf8') if (a.length !== b.length || !timingSafeEqual(a, b)) { res.status(401).json({ message: 'Current password is incorrect.' }) return } const rawToken = randomUUID() const tokenHash = hashEmailChangeToken(rawToken) const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS user.pendingEmailChange = { newEmail, tokenHash, expiresAt, requestedAt: new Date().toISOString(), } user.updatedAt = new Date().toISOString() queueStudyUsersWrite() if (process.env.RESEND_API_KEY) { try { const resend = new Resend(process.env.RESEND_API_KEY) const baseUrl = getCanonicalBaseUrl() const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`) const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [newEmail], subject: 'Confirm your new email address', text: `Use this link to confirm your new account email:\n${verifyUrl}\n\n` + `If you did not request this change, ignore this message.`, html: `
` + `

Click the link below to confirm your new account email:

` + `

Confirm email change

` + `

If you did not request this change, ignore this message.

` + `
`, }) if (error) { console.error('[study-account] email change send error:', error) res.status(503).json({ message: 'Could not send verification email right now.' }) return } } catch (err) { console.error('[study-account] email change send exception:', err) res.status(503).json({ message: 'Could not send verification email right now.' }) return } } res.json({ ok: true, verificationSent: true }) }) app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => { const user = req.studyUser const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '' const pending = user.pendingEmailChange if (!token || !pending || !pending.tokenHash) { res.status(400).json({ message: 'No pending email change request found.' }) return } if (pending.expiresAt <= Date.now()) { user.pendingEmailChange = null queueStudyUsersWrite() res.status(400).json({ message: 'This verification link has expired. Request a new email change.' }) return } const submittedHash = hashEmailChangeToken(token) const a = Buffer.from(submittedHash, 'utf8') const b = Buffer.from(pending.tokenHash, 'utf8') if (a.length !== b.length || !timingSafeEqual(a, b)) { res.status(400).json({ message: 'Invalid verification token.' }) return } const newEmail = normalizeStudyUsername(pending.newEmail) if (!isValidStudyUsername(newEmail)) { user.pendingEmailChange = null queueStudyUsersWrite() res.status(400).json({ message: 'Pending email address is invalid.' }) return } const existing = findStudyUserByUsername(newEmail) if (existing && existing.id !== user.id) { user.pendingEmailChange = null queueStudyUsersWrite() res.status(409).json({ message: 'An account with that email already exists.' }) return } user.username = newEmail user.pendingEmailChange = null user.updatedAt = new Date().toISOString() queueStudyUsersWrite() if (user.subscribeNewsletter !== false) { syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) } res.json({ ok: true, username: user.username }) }) app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) res.json({ noteCount: Object.keys(notes).length, memberSince: user.createdAt, lastLoginAt: user.lastLoginAt, }) }) app.delete('/api/study-account', requireStudyAuth, async (req, res) => { const user = req.studyUser const deletedEmail = user.username const deletedDisplayName = user.displayName || user.username // Revoke all sessions for this user for (const [token, session] of studySessions) { if (session.userId === user.id) studySessions.delete(token) } // Remove from users list and persist studyUsers = studyUsers.filter(u => u.id !== user.id) queueStudyUsersWrite() // Delete notes file studyNotesCache.delete(user.id) try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ } sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => { console.error('[study-account] delete email error:', err) }) clearStudySessionCookie(res) res.json({ ok: true }) }) app.get('/api/admin-auth/status', async (req, res) => { res.json({ authenticated: isValidAdminSession(req), configured: isAdminPasswordConfigured(), totpEnabled: await isTotpEnabled(), }) }) // Step 1: verify password. If TOTP is enabled, returns a short-lived pending token. // If TOTP is not yet configured, logs straight in (backwards compatible). app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => { const password = typeof req.body?.password === 'string' ? req.body.password : '' if (!isAdminPasswordConfigured()) { res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' }) return } if (!isAdminPasswordValid(password)) { res.status(401).json({ message: 'Invalid password.' }) return } const totpOn = await isTotpEnabled() if (totpOn) { const pendingToken = createPendingSession() res.json({ totpRequired: true, pendingToken }) return } const sessionToken = createAdminSession() setAdminSessionCookie(res, sessionToken) res.json({ ok: true }) }) // Step 2a: verify TOTP code (or recovery code) after password was accepted app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => { const { pendingToken, code } = req.body ?? {} if (!consumePendingSession(pendingToken)) { res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' }) return } const state = await loadTotpState() if (!state?.secret || !state?.verified) { res.status(400).json({ message: 'TOTP is not configured.' }) return } const codeStr = typeof code === 'string' ? code.trim() : '' // Try TOTP first if (verifyTotpCode(state.secret, codeStr)) { const sessionToken = createAdminSession() setAdminSessionCookie(res, sessionToken) res.json({ ok: true }) return } // Try recovery code if (consumeRecoveryCode(state, codeStr)) { await saveTotpState(state) const sessionToken = createAdminSession() setAdminSessionCookie(res, sessionToken) res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length }) return } res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' }) }) // TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password) app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => { const secret = generateTotpSecret() const uri = getTotpUri(secret) const qrDataUrl = await qrcode.toDataURL(uri) // Store unverified secret temporarily const existing = await loadTotpState() await saveTotpState({ ...existing, secret, verified: false }) res.json({ qrDataUrl, secret }) }) // TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => { const { code } = req.body ?? {} const state = await loadTotpState() if (!state?.secret) { res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' }) return } if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) { res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' }) return } const recoveryCodes = generateRecoveryCodes() await saveTotpState({ secret: state.secret, verified: true, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode), enabledAt: new Date().toISOString(), }) res.json({ ok: true, recoveryCodes }) }) // Disable TOTP (requires active admin session) app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => { await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() }) res.json({ ok: true }) }) // Regenerate recovery codes (requires active admin session) app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => { const state = await loadTotpState() if (!state?.secret || !state?.verified) { res.status(400).json({ message: 'TOTP is not enabled.' }) return } const recoveryCodes = generateRecoveryCodes() await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) }) res.json({ ok: true, recoveryCodes }) }) app.post('/api/admin-auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const sessionToken = cookies[ADMIN_SESSION_COOKIE] deleteAdminSession(sessionToken) clearAdminSessionCookie(res) res.json({ ok: true }) }) app.put('/api/admin-content', requireAdminAuth, async (req, res) => { try { const { siteContent } = req.body ?? {} if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) { res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' }) return } const safeSiteContent = sanitizeSiteContent(siteContent) const updatedAt = new Date().toISOString() await mkdir(DATA_DIR, { recursive: true }) await writeFile( DATA_FILE, JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2), 'utf8', ) cachedSiteContent = safeSiteContent publishState.publishedAt = updatedAt res.json({ ok: true }) } catch { res.status(500).json({ message: 'Failed to persist admin content.' }) } }) app.post('/api/analytics-consent', (req, res) => { const consent = req.body?.consent === true setConsentCookie(res, consent) res.json({ ok: true, consent }) }) app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { const topPaths = Object.entries(hitStats.byPath) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([pathKey, hits]) => ({ path: pathKey, hits })) const topPathsReal = Object.entries(hitStats.byPathReal) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([pathKey, hits]) => ({ path: pathKey, hits })) const topPathsBot = Object.entries(hitStats.byPathBot) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([pathKey, hits]) => ({ path: pathKey, hits })) const last7Days = buildLastNDaysStats(7) const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: hitStats.byDayReal?.[item.day] ?? 0 })) const last7DaysBot = last7Days.map(item => ({ day: item.day, hits: hitStats.byDayBot?.[item.day] ?? 0 })) const last30Days = buildLastNDaysStats(30) const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0) const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayReal?.[item.day] ?? 0), 0) const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayBot?.[item.day] ?? 0), 0) const botReasons = Object.entries(hitStats.botReasons ?? {}) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([reason, count]) => ({ reason, count })) const recentVisitorRows = visitorStats.recentVisits.slice(0, 100) const enrollmentCountsBySlug = {} for (const user of studyUsers) { const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [] for (const studySlug of userEnrollments) { enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1 } } const enrollmentsByStudy = getStudyCatalog() .map(study => ({ slug: study.slug, title: study.title, count: enrollmentCountsBySlug[study.slug] ?? 0, })) .sort((a, b) => b.count - a.count) const studyCatalogBySlug = new Map(getStudyCatalog().map(study => [study.slug, study])) const users = studyUsers .map(user => { const enrolledStudySlugs = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const enrolledStudies = enrolledStudySlugs .map(slug => { const study = studyCatalogBySlug.get(slug) if (!study) return null return { slug: study.slug, title: study.title } }) .filter(Boolean) return { id: user.id, username: user.username, displayName: user.displayName ?? '', enrolledStudies, } }) .sort((a, b) => { if (b.enrolledStudies.length !== a.enrolledStudies.length) return b.enrolledStudies.length - a.enrolledStudies.length return a.username.localeCompare(b.username) }) const enrolledUsers = studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) res.json({ totalHits: hitStats.totalHits, realHits: hitStats.realHits ?? 0, botHits: hitStats.botHits ?? 0, firstHitAt: hitStats.firstHitAt, lastHitAt: hitStats.lastHitAt, topPaths, topPathsReal, topPathsBot, last7Days, last7DaysReal, last7DaysBot, last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal, botReasons, visitors: { totalVisits: visitorStats.totalVisits, uniqueVisitors: visitorStats.uniqueVisitors, returningVisits: visitorStats.returningVisits, firstVisitAt: visitorStats.firstVisitAt, lastVisitAt: visitorStats.lastVisitAt, topCountries: buildTopLocations(recentVisitorRows, 'country'), topStates: buildTopLocations(recentVisitorRows, 'state'), topCounties: buildTopLocations(recentVisitorRows, 'county'), topCities: buildTopLocations(recentVisitorRows, 'city'), recentVisits: recentVisitorRows, }, writeStatus: { hitStats: lastHitStatsWrite, visitorStats: lastVisitorStatsWrite, backups: lastBackupStatus, cachePurge: lastCachePurgeStatus, deployHook: lastDeployHookStatus, }, contactTotals: { totalSubmissions: contactSubmissions.length, totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length, }, studyEnrollment: { totalUsers: studyUsers.length, enrolledUsers, totalEnrollments, enrollmentsByStudy, users, }, }) }) app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => { res.json({ submissions: contactSubmissions.slice(0, 300) }) }) app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { const { id } = req.params if (typeof id !== 'string' || !id.trim()) { res.status(400).json({ message: 'Invalid submission id.' }) return } const archived = req.body?.archived === true let found = false contactSubmissions = contactSubmissions.map(item => { if (item.id !== id) return item found = true return { ...item, archived } }) if (!found) { res.status(404).json({ message: 'Submission not found.' }) return } queueContactSubmissionsWrite() res.json({ ok: true, archived }) }) app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => { res.json({ fromEmail: getResendReplyToAddress(), fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM, resendApiConfigured: Boolean(process.env.RESEND_API_KEY), canSendReplies: Boolean(process.env.RESEND_API_KEY), note: process.env.RESEND_API_KEY ? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.' : 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.', }) }) app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => { res.json({ templates: replyTemplates }) }) app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => { const nextTemplates = sanitizeReplyTemplates(req.body?.templates) replyTemplates = nextTemplates queueReplyTemplatesWrite() res.json({ ok: true, templates: replyTemplates }) }) app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => { res.json({ items: replyHistory.slice(0, 100) }) }) app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { const { id } = req.params if (typeof id !== 'string' || !id.trim()) { res.status(400).json({ message: 'Invalid submission id.' }) return } const startLength = contactSubmissions.length contactSubmissions = contactSubmissions.filter(item => item.id !== id) if (contactSubmissions.length === startLength) { res.status(404).json({ message: 'Submission not found.' }) return } queueContactSubmissionsWrite() res.json({ ok: true }) }) app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => { try { if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }) return } const { id } = req.params const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '' if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }) return } if (!subject || subject.length > 180) { res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }) return } if (!message || message.length > 6000) { res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }) return } const submission = contactSubmissions.find(entry => entry.id === id) if (!submission) { res.status(404).json({ message: 'Submission not found.' }) return } if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) { res.status(400).json({ message: 'Submission does not have a valid email address.' }) return } const recipientName = splitName(submission.name).firstName || submission.name || 'friend' const html = buildAdminReplyTemplate({ recipientName, message }) const replyToAddress = getResendReplyToAddress() const fromAddress = getResendFromAddress() const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}` const resend = new Resend(process.env.RESEND_API_KEY) const sendResult = await sendResendEmailWithRetry({ resend, context: 'admin-contact-reply', payload: { from: fromAddress || ADMIN_REPLY_FROM, to: [submission.email], subject, replyTo: replyToAddress, tags: [ { name: 'flow', value: 'admin-reply' }, { name: 'message_type', value: submission.messageType ?? 'general' }, { name: 'submission_id', value: submission.id }, ], headers: { 'X-Contact-Submission-Id': submission.id, }, text, html, }, }) registerResendMessageForSubmission(submission.id, 'adminReply', sendResult) upsertContactEmailStatus(submission.id, 'adminReply', { status: 'sent', lastEventType: 'email.sent', error: null, }) replyHistory.unshift({ id: randomUUID(), submissionId: submission.id, toEmail: submission.email, toName: submission.name, fromEmail: replyToAddress, subject, preview: message.slice(0, 500), sentAt: new Date().toISOString(), }) replyHistory = replyHistory.slice(0, 500) queueReplyHistoryWrite() res.json({ ok: true }) } catch (err) { if (typeof req.params?.id === 'string' && req.params.id.trim()) { upsertContactEmailStatus(req.params.id.trim(), 'adminReply', { status: 'failed', lastEventType: 'email.failed', error: String(err?.message ?? err ?? 'unknown error').slice(0, 600), }) } console.error('[admin-reply] send error:', err) res.status(500).json({ message: 'Failed to send reply email.' }) } }) app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => { res.json({ counts: downloadCounts }) }) app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { const seen = new Set() const subscribers = contactSubmissions .filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email)) .map(entry => ({ name: entry.name, email: entry.email, subscribedAt: entry.submittedAt, source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form', })) .sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime()) res.json({ subscribers, total: subscribers.length }) }) app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => { const seen = new Set() const rows = [['Name', 'Email', 'Subscribed At', 'Source']] contactSubmissions .filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email)) .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) .forEach(entry => { const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form' rows.push([entry.name, entry.email, entry.submittedAt, source]) }) const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n') res.setHeader('Content-Type', 'text/csv') res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`) res.send(csv) }) app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { let adminContent = null let draftContent = null try { const raw = await readFile(DATA_FILE, 'utf8') adminContent = JSON.parse(raw) } catch { adminContent = null } try { const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8') draftContent = JSON.parse(rawDraft) } catch { draftContent = null } res.json({ exportedAt: new Date().toISOString(), adminContent, draftContent, publishState, hitStats, visitorStats, contactSubmissions, replyTemplates, replyHistory, }) }) app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => { hitStats = { ...EMPTY_HIT_STATS } visitorStats = { ...EMPTY_VISITOR_STATS } queueHitStatsWrite() queueVisitorStatsWrite() createBackupSnapshot('post-clear').catch(() => {}) res.json({ ok: true }) }) app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => { const result = pruneStatsByDays(req.body?.days) createBackupSnapshot('post-prune').catch(() => {}) res.json({ ok: true, ...result }) }) app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => { await createBackupSnapshot('manual') res.json({ ok: true, backup: lastBackupStatus }) }) app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => { try { const backups = await listBackupPreviews() res.json({ backups }) } catch { res.status(500).json({ message: 'Could not list backups.' }) } }) app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => { try { const { filename } = req.body ?? {} const preview = await readBackupPreview(filename) res.json({ preview }) } catch (err) { res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' }) } }) app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => { try { const { filename } = req.body ?? {} await restoreFromBackup(filename) const backups = await listBackupPreviews() res.json({ ok: true, restored: filename, backups }) } catch (err) { res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' }) } }) 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) if (hasVisitorConsent(req) && !botDetection.isBot) { recordVisitor(req, res).catch(err => { console.error('[visitor-stats] failed to record visitor:', err) }) } } next() }) // Rate-limit contact submissions: max 5 per IP per 10 minutes const contactHits = new Map() const downloadHits = new Map() function contactRateLimit(req, res, next) { const ip = req.ip ?? 'unknown' const now = Date.now() const windowMs = 10 * 60 * 1000 const entry = contactHits.get(ip) ?? { count: 0, start: now } if (now - entry.start > windowMs) { entry.count = 0 entry.start = now } entry.count += 1 contactHits.set(ip, entry) if (entry.count > 5) { res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' }) return } next() } function studyDownloadRateLimit(req, res, next) { const ip = req.ip ?? 'unknown' const now = Date.now() const windowMs = 10 * 60 * 1000 const entry = downloadHits.get(ip) ?? { count: 0, start: now } if (now - entry.start > windowMs) { entry.count = 0 entry.start = now } entry.count += 1 downloadHits.set(ip, entry) if (entry.count > 10) { res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' }) return } next() } app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => { try { const { firstName, lastName, email, subscribe, _honey } = req.body ?? {} if (_honey) { res.json({ ok: true }) return } if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { res.status(400).json({ message: 'First name is required.' }) return } if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { res.status(400).json({ message: 'Last name is required.' }) return } if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }) return } const published = await loadSiteContentFile(DATA_FILE) const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl) if (!configuredDownloadUrl) { try { await stat(TITUS_STUDY_FILE) } catch { res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' }) return } } const trimmedFirstName = firstName.trim() const trimmedLastName = lastName.trim() const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim() const trimmedEmail = email.trim() const wantsSubscribe = subscribe !== false addContactSubmission({ name: trimmedName, email: trimmedEmail, message: 'Requested Titus study download.', messageType: 'general', subscribe: wantsSubscribe, }) if (wantsSubscribe) { await syncContactToResend(trimmedName, trimmedEmail) } incrementDownloadCount('titus-study') if (configuredDownloadUrl) { res.json({ ok: true, downloadUrl: configuredDownloadUrl }) return } const token = createTitusDownloadToken(trimmedEmail) res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` }) } catch (err) { console.error('[study-download] request error:', err) res.status(500).json({ message: 'Failed to process your request. Please try again.' }) } }) app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => { try { const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {} if (_honey) { res.json({ ok: true }) return } if (!resourceId || typeof resourceId !== 'string') { res.status(400).json({ message: 'Resource ID is required.' }) return } const published = await loadSiteContentFile(DATA_FILE) const siteContent = published?.siteContent function resolveResourceFromId(id) { if (!siteContent || typeof siteContent !== 'object') return null const customResources = Array.isArray(siteContent.customLinks) ? siteContent.customLinks.filter(link => link?.placement === 'resources') : [] if (id.startsWith('custom:')) { const customId = id.slice('custom:'.length) const match = customResources.find(link => link.id === customId) return match ? { label: match.label, url: match.url } : null } if (id.startsWith('archived:')) { const [, seriesId, ...linkIdParts] = id.split(':') const linkId = linkIdParts.join(':') const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : [] const series = archivedSeries.find(item => item.id === seriesId) const link = Array.isArray(series?.resourceLinks) ? series.resourceLinks.find(item => item.id === linkId) : null return link ? { label: link.label || series?.title, url: link.url } : null } const customMatch = customResources.find(link => link.id === id) if (customMatch) return { label: customMatch.label, url: customMatch.url } const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : [] for (const series of archivedSeries) { if (!Array.isArray(series?.resourceLinks)) continue const link = series.resourceLinks.find(item => item.id === id) if (link) return { label: link.label || series?.title, url: link.url } } return null } const resource = resolveResourceFromId(resourceId) if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) { res.status(400).json({ message: 'Resource not found.' }) return } if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { res.status(400).json({ message: 'First name is required.' }) return } if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { res.status(400).json({ message: 'Last name is required.' }) return } if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }) return } const trimmedFirstName = firstName.trim() const trimmedLastName = lastName.trim() const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim() const trimmedEmail = email.trim() const wantsSubscribe = subscribe !== false addContactSubmission({ name: trimmedName, email: trimmedEmail, message: `Requested resource download: ${resource.label ?? resource.url}`, messageType: 'general', subscribe: wantsSubscribe, }) if (wantsSubscribe) { await syncContactToResend(trimmedName, trimmedEmail) } incrementDownloadCount(`resource:${resourceId}`) res.json({ ok: true, downloadUrl: resource.url.trim() }) } catch (err) { console.error('[resource-download] request error:', err) res.status(500).json({ message: 'Failed to process your request. Please try again.' }) } }) app.get('/api/study-downloads/titus/file', async (req, res) => { const token = typeof req.query?.token === 'string' ? req.query.token : '' if (!token || !consumeTitusDownloadToken(token)) { res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' }) return } try { await stat(TITUS_STUDY_FILE) res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME) } catch { res.status(503).json({ message: 'The Titus study file is not configured yet.' }) } }) app.post('/api/contact', contactRateLimit, async (req, res) => { try { const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {} // Honeypot — silently discard if filled by a bot if (_honey) { res.json({ ok: true }) return } if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { res.status(400).json({ message: 'First name is required.' }) return } if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { res.status(400).json({ message: 'Last name is required.' }) return } if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }) return } if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) { res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }) return } if (!process.env.RESEND_API_KEY) { console.error('[contact] RESEND_API_KEY env var not set') res.status(503).json({ message: 'The contact form is not yet configured on the server.' }) return } const trimmedName = `${firstName.trim()} ${lastName.trim()}`.trim() const trimmedEmail = email.trim() const trimmedMessage = message.trim() const normalizedMessageType = normalizeMessageType(messageType) const cooldown = noteContactEmailCooldown(trimmedEmail) if (!cooldown.ok) { const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000)) res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }) return } const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', }) const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe, }) const submission = addContactSubmission({ name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, subscribe, }) // If this is a question, also add to questions array for public Q&A section if (normalizedMessageType === 'question') { const question = { id: randomUUID(), submittedAt: new Date().toISOString(), firstName: splitName(trimmedName).firstName, email: trimmedEmail, question: trimmedMessage, answer: '', answeredAt: null, isApproved: false, approvedAt: null, } questions.unshift(question) questions = questions.slice(0, MAX_QUESTIONS) if (draftQuestions !== null) { draftQuestions.unshift(question) draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS) queueDraftQuestionsWrite() } queueQuestionsWrite() } const resend = new Resend(process.env.RESEND_API_KEY) const adminInbox = getResendInboxAddress() const replyToAddress = getResendReplyToAddress() const fromAddress = getResendFromAddress() const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase() const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage, }) let welcomeSent = false if (subscribe === true) { await syncContactToResend(trimmedName, trimmedEmail) } if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) { const greetingName = splitName(trimmedName).firstName?.trim() ?? '' let publishedSiteContent = cachedSiteContent if (!publishedSiteContent) { try { const published = await loadSiteContentFile(DATA_FILE) publishedSiteContent = published?.siteContent ?? null } catch { publishedSiteContent = null } } const emailConfig = publishedSiteContent ?? {} const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim() ? emailConfig.seo.canonicalUrl.trim() : DEFAULT_SEO.canonicalUrl const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate' const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here" const welcomeHeading = greetingName ? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.` : `${escapeHtml(welcomeGreetingPrefix)}.` const welcomeIntro = emailConfig.welcomeEmailIntro?.trim() || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.' const welcomeCurrentSeries = emailConfig.welcomeEmailCurrentSeries?.trim() || 'Right now we\'re working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it\'s rooted in sound doctrine.' const welcomeStartHereTitle = emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus' const welcomeStartHereSummary = emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.' const welcomeExpect1 = emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.' const welcomeExpect2 = emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.' const welcomeExpect3 = emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.' const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.' const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB' const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate' const welcomeSpotifyUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify', ) const welcomeAppleUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple', ) const welcomeAmazonUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon', ) const welcomeWebsiteUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/', ) const welcomeEpisodeUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here', ) const welcomeImageUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg', ) const welcomeTemplate = buildContactWelcomeEmailTemplate({ greetingName, welcomeIntro, welcomeCurrentSeries, welcomeStartHereTitle, welcomeStartHereSummary, welcomeExpect1, welcomeExpect2, welcomeExpect3, welcomeScripture, welcomeScriptureRef, welcomeSignoff, welcomeHeading, welcomeSpotifyUrl, welcomeAppleUrl, welcomeAmazonUrl, welcomeWebsiteUrl, welcomeEpisodeUrl, welcomeImageUrl, }) try { const welcomeSendResult = await sendResendEmailWithRetry({ resend, context: 'contact-welcome', payload: { from: fromAddress, to: [trimmedEmail], replyTo: replyToAddress, subject: welcomeSubject, tags: [ { name: 'flow', value: 'contact-welcome' }, { name: 'message_type', value: safeMessageTypeTag }, { name: 'submission_id', value: submission.id }, ], headers: { 'List-Unsubscribe': ``, 'X-Contact-Submission-Id': submission.id, }, text: welcomeTemplate.text, html: welcomeTemplate.html, }, }) registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult) upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null, }) welcomeSent = true } catch (welcomeErr) { upsertContactEmailStatus(submission.id, 'welcome', { status: 'failed', lastEventType: 'email.failed', error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600), }) throw welcomeErr } } else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) { upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null, }) } try { const adminSendResult = await sendResendEmailWithRetry({ resend, context: 'contact-admin-notification', payload: { from: fromAddress, to: [adminInbox], replyTo: trimmedEmail, subject: adminTemplate.subject, tags: [ { name: 'flow', value: 'contact-admin' }, { name: 'message_type', value: safeMessageTypeTag }, { name: 'submission_id', value: submission.id }, ], headers: { 'X-Contact-Submission-Id': submission.id, }, text: adminTemplate.text, html: adminTemplate.html, }, }) registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult) upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null, }) } catch (adminSendErr) { upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'failed', lastEventType: 'email.failed', error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600), }) throw adminSendErr } res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME, }) } catch (err) { console.error('[contact] send error:', err) res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' }) } }) app.post('/api/resend/webhook', (req, res) => { const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : '' if (!expectedToken) { res.status(503).json({ message: 'Webhook token is not configured.' }) return } const providedToken = (req.get('x-webhook-token') || '').trim() || (req.get('x-resend-webhook-token') || '').trim() || String(req.query?.token || '').trim() || (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim() if (!providedToken || providedToken !== expectedToken) { res.status(401).json({ message: 'Unauthorized webhook.' }) return } const body = req.body && typeof req.body === 'object' ? req.body : {} const eventType = typeof body.type === 'string' ? body.type.trim() : '' const data = body.data && typeof body.data === 'object' ? body.data : {} const tags = Array.isArray(data.tags) ? data.tags : [] const resendMessageId = ( typeof data.email_id === 'string' && data.email_id.trim() ? data.email_id.trim() : (typeof data.emailId === 'string' && data.emailId.trim() ? data.emailId.trim() : (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : '')) ) const indexed = resendMessageId ? resendEmailSubmissionIndex.get(resendMessageId) : null const taggedSubmissionId = extractTagValue(tags, 'submission_id') const submissionId = indexed?.submissionId || taggedSubmissionId const flow = extractTagValue(tags, 'flow') const stream = indexed?.stream || (flow === 'contact-welcome' ? 'welcome' : '') || (flow === 'contact-admin' ? 'adminNotification' : '') || (flow === 'admin-reply' ? 'adminReply' : '') if (!submissionId || !stream) { res.json({ ok: true, ignored: true }) return } upsertContactEmailStatus(submissionId, stream, { status: mapResendEventToStatus(eventType), lastEventType: eventType || 'webhook.event', resendEmailId: resendMessageId || null, error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null, }) res.json({ ok: true }) }) app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => { const fromAddress = getResendFromAddress() const replyToAddress = getResendReplyToAddress() const fromDomain = getAddressDomain(fromAddress) const replyDomain = getAddressDomain(replyToAddress) const warnings = [] if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.') if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.') if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.') if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.') if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.') warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.') const recent = contactSubmissions.slice(0, 300) const failed = recent.filter(item => { const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true) return ['failed', 'bounced', 'complained'].includes(status.welcome.status) || ['failed', 'bounced', 'complained'].includes(status.adminNotification.status) || ['failed', 'bounced', 'complained'].includes(status.adminReply.status) }).length res.json({ resendApiConfigured: Boolean(process.env.RESEND_API_KEY), webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN), fromAddress, replyToAddress, fromDomain, replyDomain, warnings, recentSubmissionFailures: failed, trackedSubmissions: recent.length, }) }) // Get all questions (for admin) app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { res.json({ questions: draftQuestions ?? questions }) }) // Get only approved public questions (for homepage) app.get('/api/questions', (_req, res) => { const sourceQuestions = draftQuestions ?? questions const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) res.json({ questions: publicQuestions }) }) // Create a manual question (admin) app.post('/api/admin-questions', requireAdminAuth, (req, res) => { const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : '' const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '' const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : '' const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : '' const approveNow = req.body?.approve === true if (!firstName || firstName.length > 100) { res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' }) return } if (!questionText || questionText.length < 5 || questionText.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }) return } if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.status(400).json({ message: 'If provided, email must be a valid email address.' }) return } if (answerText.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }) return } ensureDraftQuestions() const now = new Date().toISOString() const created = { id: randomUUID(), submittedAt: now, firstName, email, question: questionText, answer: answerText, answeredAt: answerText ? now : null, isApproved: approveNow, approvedAt: approveNow ? now : null, } draftQuestions.unshift(created) draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS) queueDraftQuestionsWrite() res.status(201).json({ ok: true, question: created }) }) // Answer a question (admin) app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => { const { id } = req.params const { answer } = req.body ?? {} if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) { res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' }) return } ensureDraftQuestions() const question = draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }) return } question.answer = answer.trim() question.answeredAt = new Date().toISOString() queueDraftQuestionsWrite() res.json({ ok: true, question }) }) // Approve/unapprove a question (admin) app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => { const { id } = req.params const { approved } = req.body ?? {} ensureDraftQuestions() const question = draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }) return } question.isApproved = approved === true question.approvedAt = approved === true ? new Date().toISOString() : null queueDraftQuestionsWrite() res.json({ ok: true, question }) }) // Delete a question (admin) app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { const { id } = req.params ensureDraftQuestions() const index = draftQuestions.findIndex(q => q.id === id) if (index === -1) { res.status(404).json({ message: 'Question not found.' }) return } draftQuestions.splice(index, 1) queueDraftQuestionsWrite() res.json({ ok: true }) }) // ── Episodes (RSS feed proxy) ────────────────────────────────────────────── const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss' let episodesCache = null let episodesCacheAt = 0 const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes function extractCdata(raw) { const cdata = /^$/.exec(raw.trim()) return cdata ? cdata[1].trim() : raw.trim() } function parseRssItems(xml, limit = Infinity) { const items = [] const itemRegex = /([\s\S]*?)<\/item>/g let match while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { const block = match[1] const titleRaw = /([\s\S]*?)<\/title>/.exec(block)?.[1] ?? '' const title = extractCdata(titleRaw) if (!title) continue const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim() const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? '' const guid = extractCdata(guidRaw) const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? '' const link = guid.startsWith('http') ? guid : enclosureUrl const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? '' const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() items.push({ title, pubDate, link, description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), duration, episode, }) } return items } async function fetchAllEpisodes() { const now = Date.now() if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) { return episodesCache } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8000) const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) clearTimeout(timeout) if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) const xml = await response.text() const episodes = parseRssItems(xml) episodesCache = episodes episodesCacheAt = now return episodes } function toSpotifyEpisodeEmbedUrl(urlValue) { if (!urlValue) return '' try { const parsed = new URL(urlValue) if (parsed.protocol !== 'https:') return '' const host = parsed.hostname.toLowerCase() const parts = parsed.pathname.split('/').filter(Boolean) if (host === 'open.spotify.com') { if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) { return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator` } if (parts[0] === 'episode' && parts[1]) { return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator` } } } catch { return '' } return '' } function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) { const input = String(html || '') if (!input) return '' const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? '' const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g let match let firstEpisodeId = '' while ((match = blockRegex.exec(input)) !== null) { const episodeSlug = match[1] const spotifyUrl = decodeEscapedJsonUrl(match[2]) const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1] if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) { return episodeId } } if (firstEpisodeId) return firstEpisodeId const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input) return urlMatch ? (urlMatch[2] || '') : '' } function isAllowedSpotifyResolverHost(hostname) { const host = String(hostname || '').toLowerCase() return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com' } function decodeEscapedJsonUrl(value) { return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/') } app.get('/api/spotify/embed-url', async (req, res) => { const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : '' const safeInput = sanitizeUrl(incoming) if (!safeInput || safeInput.startsWith('/')) { res.status(400).json({ message: 'A valid episode URL is required.' }) return } let parsed try { parsed = new URL(safeInput) } catch { res.status(400).json({ message: 'Malformed URL.' }) return } if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) { res.status(400).json({ message: 'Unsupported episode URL host.' }) return } const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput) if (directEmbed) { res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }) return } try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8000) const response = await fetch(safeInput, { signal: controller.signal, headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html', }, }) clearTimeout(timeout) if (!response.ok) { res.status(404).json({ message: 'Could not fetch episode page.' }) return } const html = await response.text() const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput) if (!spotifyEpisodeId) { res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }) return } const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator` res.json({ embedUrl, resolvedFrom: 'page-fetch' }) } catch (err) { console.error('[spotify/embed-url] resolve error:', err.message) res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' }) } }) app.get('/api/episodes', async (_req, res) => { try { const episodes = await fetchAllEpisodes() res.json({ episodes: episodes.slice(0, 6) }) } catch (err) { console.error('[episodes] RSS fetch error:', err.message) res.json({ episodes: (episodesCache ?? []).slice(0, 6) }) } }) app.get('/api/episodes/all', async (_req, res) => { try { const episodes = await fetchAllEpisodes() res.json({ episodes }) } catch (err) { console.error('[episodes/all] RSS fetch error:', err.message) res.json({ episodes: episodesCache ?? [] }) } }) app.get('/robots.txt', async (_req, res) => { let content = cachedSiteContent if (!content) { try { const parsed = await loadSiteContentFile(DATA_FILE) content = parsed.siteContent } catch { content = {} } } const seo = content?.seo ?? DEFAULT_SEO const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical res.type('text/plain').send( [ 'User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`, ].join('\n'), ) }) app.get('/sitemap.xml', async (_req, res) => { let content = cachedSiteContent if (!content) { try { const parsed = await loadSiteContentFile(DATA_FILE) content = parsed.siteContent } catch { content = {} } } const seo = content?.seo ?? DEFAULT_SEO const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0 ? seo.sitemapPaths : DEFAULT_SEO.sitemapPaths const urls = paths .map(item => normalizeSitemapPath(item)) .filter(Boolean) .map(item => `${root}${item}`) const xml = [ '<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`), '</urlset>', ].join('\n') res.type('application/xml').send(xml) }) app.use((req, res, next) => { const rules = sanitizeRedirectRules(cachedSiteContent?.redirects) const match = rules.find(rule => rule.path === req.path) if (!match) { next() return } res.redirect(match.statusCode === 302 ? 302 : 301, match.target) }) app.use('/images', express.static(DIST_IMAGES_DIR)) app.use('/images', express.static(PUBLIC_IMAGES_DIR)) app.use('/uploads', express.static(UPLOADS_DIR)) // Per-question social share stub — serves question-specific OG tags so // platforms (X, Facebook) render a rich preview card. After the crawl // delay the page immediately redirects the human visitor to the real SPA URL. app.get('/questions/share/:id', (req, res) => { const id = req.params.id if (!id || !/^[\w-]{1,120}$/.test(id)) { res.redirect(302, '/questions') return } const sourceQuestions = draftQuestions ?? questions const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer) if (!question) { res.redirect(302, '/questions') return } const BASE = 'https://versebyversewithnate.us' const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}` const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}` const ogTitle = escapeHtml(question.question.length > 100 ? `${question.question.slice(0, 97)}\u2026` : question.question) const answerSnippet = question.answer.replace(/\n+/g, ' ').trim() const ogDescription = escapeHtml(answerSnippet.length > 200 ? `${answerSnippet.slice(0, 197)}\u2026` : answerSnippet) const ogImage = `${BASE}/images/banner.png` res.type('html').send(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <title>${ogTitle} — Verse by Verse with Nate `) }) app.use(express.static(DIST_DIR)) app.use(async (_req, res) => { try { const html = await readFile(INDEX_FILE, 'utf8') res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent)) } catch { res.status(503).send('Frontend build not found. Run "npm run build" first.') } }) const PORT = Number(process.env.PORT ?? 4173) Promise.all([ loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), loadReplyTemplatesFromDisk(), loadReplyHistoryFromDisk(), loadQuestionsFromDisk(), loadDraftQuestionsFromDisk(), loadStudyUsersFromDisk(), loadStudyCommunityFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadPodcastChecklistFromDisk(), 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 to prevent unbounded memory growth setInterval(() => { const now = Date.now() for (const [token, session] of studySessions) { if (session.expiresAt <= now) studySessions.delete(token) } }, 60 * 60 * 1000) app.listen(PORT, () => { logResendEmailAlignmentWarnings() console.log(`Portfolio app listening on http://localhost:${PORT}`) }) })