4366 lines
154 KiB
JavaScript
4366 lines
154 KiB
JavaScript
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 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 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()
|
||
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 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 = Array.isArray(parsed?.submissions)
|
||
? parsed.submissions.slice(0, MAX_CONTACT_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'
|
||
}
|
||
|
||
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
|
||
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
||
|
||
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 addContactSubmission({ name, email, message, messageType, subscribe }) {
|
||
const submission = {
|
||
id: randomUUID(),
|
||
submittedAt: new Date().toISOString(),
|
||
name,
|
||
email,
|
||
message,
|
||
messageType: normalizeMessageType(messageType),
|
||
subscribe: subscribe === true,
|
||
archived: false,
|
||
}
|
||
|
||
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,
|
||
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,
|
||
}))
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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 { 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:
|
||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||
`<p>Welcome, <strong>${escapeHtml(namePart)}</strong>.</p>` +
|
||
`<p>Your student account is ready.</p>` +
|
||
`<p><a href="${escapeHtml(studiesUrl)}">Open studies</a><br/><a href="${escapeHtml(accountUrl)}">Manage account</a></p>` +
|
||
`<p>Grace and peace,<br/>Verse by Verse with Nate</p>` +
|
||
`</div>`,
|
||
})
|
||
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 { 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:
|
||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||
`<p>Hi ${escapeHtml(namePart)},</p>` +
|
||
`<p>This confirms your study account and saved notes were deleted.</p>` +
|
||
`<p>If this was not you, please contact us immediately.</p>` +
|
||
`<p><a href="${escapeHtml(signupUrl)}">Create a new account</a></p>` +
|
||
`<p>Verse by Verse with Nate</p>` +
|
||
`</div>`,
|
||
})
|
||
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 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-account/export-notes', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const notes = await loadUserNotes(user.id)
|
||
|
||
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
|
||
const sectionMeta = {}
|
||
const content = cachedSiteContent
|
||
if (content) {
|
||
const studies = Array.isArray(content.studies) && content.studies.length > 0
|
||
? content.studies
|
||
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }]
|
||
for (const study of studies) {
|
||
for (const section of (study.sections ?? [])) {
|
||
sectionMeta[`${study.slug}--${section.id}`] = {
|
||
studyTitle: study.title,
|
||
title: section.title,
|
||
reference: section.reference,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Group notes by study
|
||
const byStudy = {}
|
||
for (const [noteKey, noteText] of Object.entries(notes)) {
|
||
if (!noteText?.trim()) continue
|
||
const dashIndex = noteKey.indexOf('--')
|
||
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
|
||
if (!byStudy[studySlug]) byStudy[studySlug] = []
|
||
byStudy[studySlug].push({ noteKey, noteText })
|
||
}
|
||
|
||
const docChildren = [
|
||
new Paragraph({
|
||
text: 'My Study Notes',
|
||
heading: HeadingLevel.TITLE,
|
||
}),
|
||
new Paragraph({
|
||
children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })],
|
||
spacing: { after: 400 },
|
||
}),
|
||
]
|
||
|
||
for (const [studySlug, entries] of Object.entries(byStudy)) {
|
||
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug
|
||
docChildren.push(
|
||
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
|
||
)
|
||
for (const { noteKey, noteText } of entries) {
|
||
const meta = sectionMeta[noteKey]
|
||
const lessonTitle = meta?.title ?? noteKey
|
||
const reference = meta?.reference ?? ''
|
||
docChildren.push(
|
||
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
|
||
)
|
||
if (reference) {
|
||
docChildren.push(
|
||
new Paragraph({
|
||
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
|
||
spacing: { after: 120 },
|
||
}),
|
||
)
|
||
}
|
||
for (const line of noteText.split('\n')) {
|
||
docChildren.push(
|
||
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (docChildren.length <= 2) {
|
||
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
|
||
}
|
||
|
||
const doc = new Document({
|
||
creator: 'Verse by Verse with Nate',
|
||
title: 'My Study Notes',
|
||
sections: [{ children: docChildren }],
|
||
})
|
||
|
||
const buffer = await Packer.toBuffer(doc)
|
||
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||
res.send(buffer)
|
||
})
|
||
|
||
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||
|
||
const currentHash = hashStudyPassword(currentPassword)
|
||
const a = Buffer.from(currentHash, 'utf8')
|
||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||
return
|
||
}
|
||
|
||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||
return
|
||
}
|
||
|
||
user.passwordHash = hashStudyPassword(newPassword)
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const notes = await loadUserNotes(user.id)
|
||
const 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: 'hello@versebyversewithnate.us',
|
||
fromIdentity: 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 text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us`
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
|
||
const { error } = await resend.emails.send({
|
||
from: ADMIN_REPLY_FROM,
|
||
to: [submission.email],
|
||
subject,
|
||
replyTo: 'hello@versebyversewithnate.us',
|
||
text,
|
||
html,
|
||
})
|
||
|
||
if (error) throw error
|
||
|
||
replyHistory.unshift({
|
||
id: randomUUID(),
|
||
submissionId: submission.id,
|
||
toEmail: submission.email,
|
||
toName: submission.name,
|
||
fromEmail: 'hello@versebyversewithnate.us',
|
||
subject,
|
||
preview: message.slice(0, 500),
|
||
sentAt: new Date().toISOString(),
|
||
})
|
||
replyHistory = replyHistory.slice(0, 500)
|
||
queueReplyHistoryWrite()
|
||
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
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 submittedAt = new Date().toLocaleString('en-US', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
})
|
||
const shouldSendWelcome = shouldSendWelcomeEmail({
|
||
subscribe,
|
||
})
|
||
|
||
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)
|
||
|
||
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 welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
|
||
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 { error: welcomeError } = await resend.emails.send({
|
||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||
to: [trimmedEmail],
|
||
subject: welcomeSubject,
|
||
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’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;">“${escapeHtml(welcomeScripture)}”</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;">·</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;">·</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;">·</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’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>`,
|
||
})
|
||
|
||
if (welcomeError) throw welcomeError
|
||
}
|
||
|
||
if (shouldSendWelcome) {
|
||
res.json({ ok: true })
|
||
return
|
||
}
|
||
|
||
const { error } = await resend.emails.send({
|
||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||
to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
|
||
replyTo: trimmedEmail,
|
||
subject: `Verse by Verse contact form: ${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>`,
|
||
})
|
||
if (error) throw error
|
||
|
||
res.json({ ok: true })
|
||
} 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.' })
|
||
}
|
||
})
|
||
|
||
// 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(),
|
||
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, () => {
|
||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||
})
|
||
})
|