Files
Siteforge/server.js
T
2026-06-01 12:56:26 -04:00

5053 lines
178 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_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
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 <hello@versebyversewithnate.us>'
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, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
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, '<br/>')
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:
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
`</td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
}
}
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:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
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
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
: ''
return (
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
`${ctaBlock}` +
`</td></tr>` +
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`
)
}
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 = (
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
`<p style="margin:0 0 16px;">Your student account is ready.</p>` +
`<p style="margin:0 0 16px;">Open studies and continue learning, or manage your account details anytime.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
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 + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
}),
})
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 = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">This confirms your study account and saved notes were deleted.</p>` +
`<p style="margin:0 0 16px;">If this was not you, please contact us immediately.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
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)
}
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-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 8200 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 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
return {
slug: study.slug,
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
totalLessons,
completedLessons: noteCount,
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 <hello@versebyversewithnate.us>',
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:
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
`<p>Click the link below to confirm your new account email:</p>` +
`<p><a href="${escapeHtml(verifyUrl)}">Confirm email change</a></p>` +
`<p>If you did not request this change, ignore this message.</p>` +
`</div>`,
})
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': `<mailto:${replyToAddress}?subject=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 = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\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</title>
<meta name="description" content="${ogDescription}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
<meta property="og:title" content="${ogTitle}"/>
<meta property="og:description" content="${ogDescription}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${ogTitle}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${ogTitle}"/>
<meta name="twitter:description" content="${ogDescription}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
</head>
<body></body>
</html>`)
})
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}`)
})
})