5637 lines
203 KiB
JavaScript
5637 lines
203 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 STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
|
||
const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
|
||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
||
const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
|
||
const DIST_DIR = path.join(__dirname, 'dist')
|
||
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
||
const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images')
|
||
validateAdminPasswordSetup()
|
||
const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
|
||
? path.resolve(__dirname, process.env.TITUS_STUDY_FILE)
|
||
: path.join(__dirname, 'A_Study_of_Titus.pdf')
|
||
const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf'
|
||
const DEFAULT_REDIRECT_RULES = [
|
||
{
|
||
id: 'spotify',
|
||
path: '/spotify',
|
||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||
statusCode: 301,
|
||
},
|
||
{
|
||
id: 'apple',
|
||
path: '/apple',
|
||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||
statusCode: 301,
|
||
},
|
||
{
|
||
id: 'amazon',
|
||
path: '/amazon',
|
||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||
statusCode: 301,
|
||
},
|
||
]
|
||
|
||
function normalizeRedirectPath(value) {
|
||
if (typeof value !== 'string') return ''
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return ''
|
||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||
const normalized = withSlash.replace(/\/+/g, '/')
|
||
if (normalized === '/') return ''
|
||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
||
return normalized
|
||
}
|
||
|
||
function sanitizeUrl(value) {
|
||
if (typeof value !== 'string') return ''
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return ''
|
||
if (trimmed.startsWith('/')) return trimmed
|
||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||
return ''
|
||
}
|
||
|
||
function getSectionReleaseTime(section) {
|
||
if (!section || typeof section !== 'object') return Number.NaN
|
||
const candidateValues = [
|
||
section.releasedAt,
|
||
section.releaseDate,
|
||
section.availableAt,
|
||
section.publishAt,
|
||
]
|
||
|
||
for (const candidate of candidateValues) {
|
||
if (typeof candidate !== 'string' || !candidate.trim()) continue
|
||
const releaseTime = Date.parse(candidate)
|
||
if (Number.isFinite(releaseTime)) return releaseTime
|
||
}
|
||
|
||
return Number.NaN
|
||
}
|
||
|
||
function getSectionReleaseDate(section) {
|
||
const releaseTime = getSectionReleaseTime(section)
|
||
if (!Number.isFinite(releaseTime)) return null
|
||
return new Date(releaseTime)
|
||
}
|
||
|
||
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: {},
|
||
ipHashIndex: {}, // ipHash → visitorId — prevents same IP counting as multiple unique visitors
|
||
recentVisits: [],
|
||
geoCacheByIp: {},
|
||
}
|
||
|
||
const MAX_CONTACT_SUBMISSIONS = 5000
|
||
const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
|
||
const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
|
||
const titusDownloadTokens = new Map()
|
||
|
||
const MAX_QUESTIONS = 1000
|
||
const MAX_STUDY_USERS = 5000
|
||
const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||
const MAX_STUDY_NOTES_PER_USER = 500
|
||
const MAX_STUDY_NOTE_LENGTH = 12000
|
||
const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||
const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||
const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
||
let visitorStatsWritePromise = Promise.resolve()
|
||
let contactSubmissions = []
|
||
let contactSubmissionsWritePromise = Promise.resolve()
|
||
const contactSubmitCooldownByEmail = new Map()
|
||
const resendEmailSubmissionIndex = new Map()
|
||
let questions = []
|
||
let questionsWritePromise = Promise.resolve()
|
||
let studyUsers = []
|
||
let studyUsersWritePromise = Promise.resolve()
|
||
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
|
||
const studyNotesWriteQueues = new Map() // userId -> Promise
|
||
let studyReminders = { users: {}, updatedAt: new Date().toISOString() }
|
||
let studyRemindersWritePromise = Promise.resolve()
|
||
const studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
|
||
const studyProgressWriteQueues = new Map() // userId -> Promise
|
||
let studyCommunityPosts = []
|
||
let studyCommunityWritePromise = Promise.resolve()
|
||
let downloadCounts = {}
|
||
let downloadCountsWritePromise = Promise.resolve()
|
||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||
const studySessions = new Map()
|
||
|
||
function sanitizeReplyTemplates(value) {
|
||
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
|
||
const out = value
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '',
|
||
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
|
||
message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '',
|
||
}))
|
||
.filter(item => item.label && item.subject && item.message)
|
||
|
||
return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES]
|
||
}
|
||
|
||
function sanitizeReplyHistory(value) {
|
||
if (!Array.isArray(value)) return []
|
||
return value
|
||
.filter(item => item && typeof item === 'object')
|
||
.slice(0, 500)
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
submissionId: typeof item.submissionId === 'string' ? item.submissionId : '',
|
||
toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '',
|
||
toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '',
|
||
fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us',
|
||
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
|
||
preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '',
|
||
sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(),
|
||
}))
|
||
}
|
||
|
||
function normalizeIp(rawIp) {
|
||
if (!rawIp) return 'unknown'
|
||
|
||
let ip = String(rawIp).trim()
|
||
|
||
if (ip.includes(',')) {
|
||
ip = ip.split(',')[0].trim()
|
||
}
|
||
|
||
if (ip.startsWith('::ffff:')) {
|
||
ip = ip.slice(7)
|
||
}
|
||
|
||
if (ip === '::1') {
|
||
ip = '127.0.0.1'
|
||
}
|
||
|
||
return ip || 'unknown'
|
||
}
|
||
|
||
function isPrivateOrLocalIp(ip) {
|
||
return (
|
||
ip === '127.0.0.1'
|
||
|| ip === 'localhost'
|
||
|| ip.startsWith('10.')
|
||
|| ip.startsWith('192.168.')
|
||
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|
||
|| ip.startsWith('fc')
|
||
|| ip.startsWith('fd')
|
||
|| ip.startsWith('fe80:')
|
||
|| ip === 'unknown'
|
||
)
|
||
}
|
||
|
||
function queueVisitorStatsWrite() {
|
||
visitorStatsWritePromise = visitorStatsWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
VISITOR_STATS_FILE,
|
||
JSON.stringify({
|
||
...visitorStats,
|
||
updatedAt: new Date().toISOString(),
|
||
}, null, 2),
|
||
'utf8',
|
||
)
|
||
lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
|
||
})
|
||
.catch(err => {
|
||
console.error('[visitor-stats] failed to write visitor stats:', err)
|
||
lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
|
||
})
|
||
}
|
||
|
||
function queueContactSubmissionsWrite() {
|
||
contactSubmissionsWritePromise = contactSubmissionsWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
CONTACT_SUBMISSIONS_FILE,
|
||
JSON.stringify({
|
||
submissions: contactSubmissions,
|
||
updatedAt: new Date().toISOString(),
|
||
}, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
.catch(err => {
|
||
console.error('[contact] failed to write submissions:', err)
|
||
})
|
||
}
|
||
|
||
function queueReplyTemplatesWrite() {
|
||
replyTemplatesWritePromise = replyTemplatesWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
REPLY_TEMPLATES_FILE,
|
||
JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
.catch(err => {
|
||
console.error('[reply-templates] failed to write templates:', err)
|
||
})
|
||
}
|
||
|
||
function queueReplyHistoryWrite() {
|
||
replyHistoryWritePromise = replyHistoryWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
REPLY_HISTORY_FILE,
|
||
JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
.catch(err => {
|
||
console.error('[reply-history] failed to write history:', err)
|
||
})
|
||
}
|
||
|
||
function loadContactSubmissionsFromDisk() {
|
||
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions)
|
||
})
|
||
.catch(() => {
|
||
contactSubmissions = []
|
||
})
|
||
}
|
||
|
||
function loadReplyTemplatesFromDisk() {
|
||
return readFile(REPLY_TEMPLATES_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
replyTemplates = sanitizeReplyTemplates(parsed?.templates)
|
||
})
|
||
.catch(() => {
|
||
replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
|
||
})
|
||
}
|
||
|
||
function loadReplyHistoryFromDisk() {
|
||
return readFile(REPLY_HISTORY_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
replyHistory = sanitizeReplyHistory(parsed?.items)
|
||
})
|
||
.catch(() => {
|
||
replyHistory = []
|
||
})
|
||
}
|
||
|
||
function loadPodcastChecklistFromDisk() {
|
||
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
|
||
})
|
||
.catch(() => {
|
||
podcastChecklist = buildDefaultPodcastChecklist()
|
||
})
|
||
}
|
||
|
||
function queuePodcastChecklistWrite() {
|
||
const updatedAt = new Date().toISOString()
|
||
podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
PODCAST_CHECKLIST_FILE,
|
||
JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
|
||
return podcastChecklistWritePromise
|
||
}
|
||
|
||
function normalizeMessageType(value) {
|
||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
||
return 'general'
|
||
}
|
||
|
||
function createEmailDeliveryState(status = 'pending') {
|
||
return {
|
||
status,
|
||
lastEventAt: null,
|
||
lastEventType: null,
|
||
resendEmailId: null,
|
||
error: null,
|
||
}
|
||
}
|
||
|
||
function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return createEmailDeliveryState(fallbackStatus)
|
||
}
|
||
|
||
return {
|
||
status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus,
|
||
lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null,
|
||
lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null,
|
||
resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null,
|
||
error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null,
|
||
}
|
||
}
|
||
|
||
function normalizeContactEmailStatus(value, subscribe) {
|
||
const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||
return {
|
||
welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'),
|
||
adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'),
|
||
adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'),
|
||
}
|
||
}
|
||
|
||
function upsertContactEmailStatus(submissionId, stream, patch) {
|
||
if (!submissionId || typeof submissionId !== 'string') return
|
||
if (!stream || typeof stream !== 'string') return
|
||
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
|
||
let updated = false
|
||
|
||
contactSubmissions = contactSubmissions.map(submission => {
|
||
if (submission.id !== submissionId) return submission
|
||
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
|
||
const current = normalizeEmailDeliveryState(next[stream], 'pending')
|
||
next[stream] = {
|
||
...current,
|
||
...patch,
|
||
lastEventAt: at,
|
||
}
|
||
updated = true
|
||
return {
|
||
...submission,
|
||
emailStatus: next,
|
||
}
|
||
})
|
||
|
||
if (updated) {
|
||
queueContactSubmissionsWrite()
|
||
}
|
||
}
|
||
|
||
function extractResendMessageId(result) {
|
||
if (!result || typeof result !== 'object') return ''
|
||
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
|
||
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
|
||
return result.data.id.trim()
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
|
||
const resendMessageId = extractResendMessageId(sendResult)
|
||
if (!resendMessageId || !submissionId || !stream) return
|
||
resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
|
||
upsertContactEmailStatus(submissionId, stream, {
|
||
resendEmailId: resendMessageId,
|
||
})
|
||
}
|
||
|
||
function noteContactEmailCooldown(emailAddress) {
|
||
const normalized = String(emailAddress || '').trim().toLowerCase()
|
||
if (!normalized) return { ok: true, retryAfterMs: 0 }
|
||
|
||
const now = Date.now()
|
||
const lastAt = contactSubmitCooldownByEmail.get(normalized)
|
||
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
|
||
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
|
||
}
|
||
|
||
contactSubmitCooldownByEmail.set(normalized, now)
|
||
|
||
// Prevent unbounded growth while keeping this in-memory cache simple.
|
||
if (contactSubmitCooldownByEmail.size > 8000) {
|
||
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
|
||
for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) {
|
||
if (timestamp < cutoff) {
|
||
contactSubmitCooldownByEmail.delete(email)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: true, retryAfterMs: 0 }
|
||
}
|
||
|
||
function extractTagValue(tags, name) {
|
||
if (!Array.isArray(tags)) return ''
|
||
const target = String(name || '').trim().toLowerCase()
|
||
if (!target) return ''
|
||
for (const tag of tags) {
|
||
if (!tag || typeof tag !== 'object') continue
|
||
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
|
||
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
|
||
if (key === target && value) return value
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function mapResendEventToStatus(eventType) {
|
||
const normalized = String(eventType || '').trim().toLowerCase()
|
||
if (!normalized) return 'updated'
|
||
if (normalized.includes('delivered')) return 'delivered'
|
||
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
|
||
if (normalized.includes('bounce')) return 'bounced'
|
||
if (normalized.includes('complain')) return 'complained'
|
||
if (normalized.includes('click')) return 'clicked'
|
||
if (normalized.includes('open')) return 'opened'
|
||
if (normalized.includes('send')) return 'sent'
|
||
return 'updated'
|
||
}
|
||
|
||
function getAddressDomain(addressValue) {
|
||
const raw = String(addressValue || '').trim()
|
||
if (!raw) return ''
|
||
const candidate = raw.includes('<') && raw.includes('>')
|
||
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
|
||
: raw
|
||
const at = candidate.lastIndexOf('@')
|
||
if (at <= 0 || at === candidate.length - 1) return ''
|
||
return candidate.slice(at + 1).toLowerCase()
|
||
}
|
||
|
||
function logResendEmailAlignmentWarnings() {
|
||
const warnings = []
|
||
const fromAddress = getResendFromAddress()
|
||
const replyToAddress = getResendReplyToAddress()
|
||
const fromDomain = getAddressDomain(fromAddress)
|
||
const replyDomain = getAddressDomain(replyToAddress)
|
||
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
|
||
|
||
if (!hasApiKey) {
|
||
warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
|
||
}
|
||
if (!fromDomain) {
|
||
warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
|
||
}
|
||
if (fromDomain.endsWith('resend.dev')) {
|
||
warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
|
||
}
|
||
if (fromDomain && replyDomain && fromDomain !== replyDomain) {
|
||
warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
|
||
}
|
||
if (!process.env.RESEND_WEBHOOK_TOKEN) {
|
||
warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
|
||
}
|
||
if (hasApiKey) {
|
||
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
|
||
}
|
||
|
||
if (warnings.length > 0) {
|
||
console.warn('[email-health] Resend alignment checks:')
|
||
for (const warning of warnings) {
|
||
console.warn(`[email-health] - ${warning}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
|
||
const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
||
const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
|
||
const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
|
||
const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
|
||
|
||
function shouldSendWelcomeEmail({ subscribe }) {
|
||
return subscribe === true
|
||
}
|
||
|
||
function buildAdminReplyTemplate({ recipientName, message }) {
|
||
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
||
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
||
|
||
return `
|
||
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
|
||
<tr>
|
||
<td align="center" style="padding:28px 16px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||
<tr>
|
||
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
|
||
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
|
||
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:26px 24px 18px;">
|
||
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
|
||
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
|
||
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
|
||
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
function buildContactWelcomeEmailTemplate({
|
||
greetingName,
|
||
welcomeIntro,
|
||
welcomeCurrentSeries,
|
||
welcomeStartHereTitle,
|
||
welcomeStartHereSummary,
|
||
welcomeExpect1,
|
||
welcomeExpect2,
|
||
welcomeExpect3,
|
||
welcomeScripture,
|
||
welcomeScriptureRef,
|
||
welcomeSignoff,
|
||
welcomeHeading,
|
||
welcomeSpotifyUrl,
|
||
welcomeAppleUrl,
|
||
welcomeAmazonUrl,
|
||
welcomeWebsiteUrl,
|
||
welcomeEpisodeUrl,
|
||
welcomeImageUrl,
|
||
}) {
|
||
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
|
||
|
||
return {
|
||
text:
|
||
`Welcome to Verse by Verse with Nate!\n\n` +
|
||
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
|
||
`${welcomeIntro}\n\n` +
|
||
`${welcomeCurrentSeries}\n\n` +
|
||
`Start here: ${welcomeEpisodeUrl}\n` +
|
||
`${welcomeStartHereTitle}\n` +
|
||
`${welcomeStartHereSummary}\n` +
|
||
`Spotify: ${welcomeSpotifyUrl}\n` +
|
||
`Apple Podcasts: ${welcomeAppleUrl}\n` +
|
||
`Amazon Music: ${welcomeAmazonUrl}\n` +
|
||
`Website: ${welcomeWebsiteUrl}\n\n` +
|
||
`What to expect:\n` +
|
||
`- ${welcomeExpect1}\n` +
|
||
`- ${welcomeExpect2}\n` +
|
||
`- ${welcomeExpect3}\n\n` +
|
||
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
|
||
`${welcomeSignoff}`,
|
||
html:
|
||
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
|
||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
|
||
`<tr><td align="center" style="padding:40px 20px;">` +
|
||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
|
||
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
|
||
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
|
||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
|
||
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
|
||
`</td></tr>` +
|
||
`<tr><td style="padding:40px 40px 0;">` +
|
||
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
|
||
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
|
||
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
|
||
`</td></tr>` +
|
||
`<tr><td style="padding:0 40px 32px;">` +
|
||
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
|
||
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
|
||
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you’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>`,
|
||
}
|
||
}
|
||
|
||
function buildContactAdminNotificationTemplate({
|
||
normalizedMessageType,
|
||
trimmedName,
|
||
trimmedEmail,
|
||
submittedAt,
|
||
trimmedMessage,
|
||
}) {
|
||
return {
|
||
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
|
||
text:
|
||
`New contact form submission\n\n` +
|
||
`Message Type: ${normalizedMessageType}\n` +
|
||
`Name: ${trimmedName}\n` +
|
||
`Email: ${trimmedEmail}\n` +
|
||
`Submitted: ${submittedAt}\n\n` +
|
||
`Message:\n${trimmedMessage}`,
|
||
html:
|
||
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
|
||
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
|
||
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
|
||
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
|
||
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
|
||
`</div>` +
|
||
`<div style="padding:24px;">` +
|
||
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
|
||
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
||
`<tr>` +
|
||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
|
||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
|
||
`</tr>` +
|
||
`<tr>` +
|
||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
|
||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
||
`</tr>` +
|
||
`<tr>` +
|
||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
|
||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
|
||
`</tr>` +
|
||
`<tr>` +
|
||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
|
||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
|
||
`</tr>` +
|
||
`</table>` +
|
||
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
|
||
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
|
||
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
|
||
`</div>` +
|
||
`</div>` +
|
||
`</div>` +
|
||
`</div>`,
|
||
}
|
||
}
|
||
|
||
function getResendFromAddress() {
|
||
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||
}
|
||
|
||
function getResendReplyToAddress() {
|
||
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
|
||
}
|
||
|
||
function getResendInboxAddress() {
|
||
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
||
}
|
||
|
||
async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
|
||
let lastError = null
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
try {
|
||
const result = await resend.emails.send(payload)
|
||
if (!result?.error) return result
|
||
lastError = result.error
|
||
if (attempt < maxAttempts) {
|
||
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
|
||
}
|
||
} catch (err) {
|
||
lastError = err
|
||
if (attempt < maxAttempts) {
|
||
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
|
||
}
|
||
}
|
||
}
|
||
|
||
throw lastError ?? new Error(`[${context}] email send failed`)
|
||
}
|
||
|
||
function addContactSubmission({ name, email, message, messageType, subscribe }) {
|
||
const wantsWelcome = subscribe === true
|
||
const submission = {
|
||
id: randomUUID(),
|
||
submittedAt: new Date().toISOString(),
|
||
name,
|
||
email,
|
||
message,
|
||
messageType: normalizeMessageType(messageType),
|
||
subscribe: wantsWelcome,
|
||
archived: false,
|
||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||
}
|
||
|
||
contactSubmissions.unshift(submission)
|
||
contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||
queueContactSubmissionsWrite()
|
||
return submission
|
||
}
|
||
|
||
async function syncContactToResend(name, email) {
|
||
if (!process.env.RESEND_API_KEY) return
|
||
|
||
const { firstName, lastName } = splitName(name)
|
||
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||
|
||
try {
|
||
const { error: contactError } = await contactResend.contacts.create({
|
||
email,
|
||
firstName,
|
||
lastName,
|
||
unsubscribed: false,
|
||
...(process.env.RESEND_SEGMENT_ID
|
||
? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] }
|
||
: {}),
|
||
})
|
||
|
||
if (contactError) {
|
||
const { error: updateError } = await contactResend.contacts.update({
|
||
email,
|
||
firstName,
|
||
lastName,
|
||
unsubscribed: false,
|
||
})
|
||
|
||
if (updateError) {
|
||
console.error('[resend] contact sync error:', updateError)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[resend] contact sync exception:', err)
|
||
}
|
||
}
|
||
|
||
function createTitusDownloadToken(email) {
|
||
const token = randomUUID()
|
||
titusDownloadTokens.set(token, {
|
||
email,
|
||
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
|
||
})
|
||
return token
|
||
}
|
||
|
||
function consumeTitusDownloadToken(token) {
|
||
const entry = titusDownloadTokens.get(token)
|
||
if (!entry) return false
|
||
titusDownloadTokens.delete(token)
|
||
if (entry.expiresAt <= Date.now()) return false
|
||
return true
|
||
}
|
||
|
||
function sanitizeUserAgent(userAgent) {
|
||
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
||
return userAgent.trim().slice(0, 300) || 'unknown'
|
||
}
|
||
|
||
function detectDevice(userAgent) {
|
||
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
||
const ua = userAgent.toLowerCase()
|
||
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
|
||
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
|
||
return 'desktop'
|
||
}
|
||
|
||
function sanitizeReferrer(referrer) {
|
||
if (!referrer || typeof referrer !== 'string') return ''
|
||
try {
|
||
const parsed = new URL(referrer.trim())
|
||
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
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, overridePath = null, overrideReferrer = null) {
|
||
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 = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
|
||
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
|
||
const ip = getClientIp(req)
|
||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||
const device = detectDevice(ua)
|
||
|
||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||
const geo = await resolveGeo(ip)
|
||
|
||
// Resolve canonical visitorId by IP hash — if this IP was seen before under a
|
||
// different cookie (e.g. cleared cookies), reuse the existing record so the
|
||
// same person is never counted as a second unique visitor.
|
||
const existingIdByIp = visitorStats.ipHashIndex[ipHash]
|
||
if (existingIdByIp && existingIdByIp !== visitorId) {
|
||
// Reuse the existing record for this IP; overwrite cookie with canonical ID
|
||
visitorId = existingIdByIp
|
||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||
}
|
||
|
||
const existingVisitor = visitorStats.visitors[visitorId]
|
||
const isReturning = Boolean(existingVisitor)
|
||
|
||
if (!existingVisitor) {
|
||
visitorStats.uniqueVisitors += 1
|
||
visitorStats.ipHashIndex[ipHash] = visitorId
|
||
} else {
|
||
visitorStats.returningVisits += 1
|
||
}
|
||
|
||
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
|
||
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
|
||
|
||
// Append to page history, keeping last 100 entries per visitor
|
||
const prevHistory = existingVisitor?.pageHistory ?? []
|
||
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
|
||
|
||
visitorStats.visitors[visitorId] = {
|
||
visitorId,
|
||
ip,
|
||
ipHash,
|
||
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
||
lastSeenAt: nowIso,
|
||
visitCount: nextVisitCount,
|
||
lastPath: pathKey,
|
||
returningVisitor: isReturning,
|
||
location: geo,
|
||
userAgents,
|
||
device,
|
||
pageHistory,
|
||
}
|
||
|
||
visitorStats.totalVisits += 1
|
||
visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso
|
||
visitorStats.lastVisitAt = nowIso
|
||
visitorStats.recentVisits.unshift({
|
||
at: nowIso,
|
||
visitorId,
|
||
ip,
|
||
path: pathKey,
|
||
referrer,
|
||
device,
|
||
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)
|
||
const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}
|
||
|
||
// Rebuild ipHashIndex from saved visitors if not persisted (handles upgrades from old data)
|
||
let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {}
|
||
if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) {
|
||
for (const [vid, visitor] of Object.entries(loadedVisitors)) {
|
||
if (visitor?.ipHash && typeof visitor.ipHash === 'string') {
|
||
ipHashIndex[visitor.ipHash] = vid
|
||
}
|
||
}
|
||
}
|
||
|
||
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: loadedVisitors,
|
||
ipHashIndex,
|
||
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
|
||
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
|
||
}
|
||
})
|
||
.catch(() => {
|
||
visitorStats = { ...EMPTY_VISITOR_STATS }
|
||
})
|
||
}
|
||
|
||
function buildTopLocations(list, key) {
|
||
const counts = {}
|
||
for (const row of list) {
|
||
const val = row?.[key] || 'Unknown'
|
||
counts[val] = (counts[val] ?? 0) + 1
|
||
}
|
||
return Object.entries(counts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10)
|
||
.map(([name, hits]) => ({ name, hits }))
|
||
}
|
||
|
||
function pruneStatsByDays(daysRaw) {
|
||
const days = Number(daysRaw)
|
||
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT
|
||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
||
|
||
const keepRecent = visitorStats.recentVisits.filter(v => {
|
||
const ts = new Date(v.at).getTime()
|
||
return Number.isFinite(ts) && ts >= cutoff
|
||
})
|
||
|
||
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
|
||
const nextVisitors = {}
|
||
for (const [id, data] of Object.entries(visitorStats.visitors)) {
|
||
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
|
||
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
|
||
nextVisitors[id] = data
|
||
}
|
||
}
|
||
|
||
const nextByDay = {}
|
||
const nextByDayReal = {}
|
||
const nextByDayBot = {}
|
||
for (const [day, count] of Object.entries(hitStats.byDay)) {
|
||
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
|
||
if (Number.isFinite(ts) && ts >= cutoff) {
|
||
nextByDay[day] = count
|
||
nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0
|
||
nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0
|
||
}
|
||
}
|
||
|
||
visitorStats.recentVisits = keepRecent
|
||
visitorStats.visitors = nextVisitors
|
||
visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
|
||
visitorStats.totalVisits = keepRecent.length
|
||
visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
|
||
visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
|
||
visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
|
||
|
||
hitStats.byDay = nextByDay
|
||
hitStats.byDayReal = nextByDayReal
|
||
hitStats.byDayBot = nextByDayBot
|
||
|
||
queueHitStatsWrite()
|
||
queueVisitorStatsWrite()
|
||
|
||
return {
|
||
retentionDays,
|
||
remainingVisits: visitorStats.totalVisits,
|
||
remainingVisitors: visitorStats.uniqueVisitors,
|
||
}
|
||
}
|
||
|
||
async function createBackupSnapshot(reason = 'scheduled') {
|
||
try {
|
||
await mkdir(BACKUP_DIR, { recursive: true })
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||
const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`)
|
||
|
||
const payload = {
|
||
createdAt: new Date().toISOString(),
|
||
reason,
|
||
adminContent: null,
|
||
draftContent: null,
|
||
podcastChecklist,
|
||
publishState,
|
||
hitStats,
|
||
visitorStats,
|
||
contactSubmissions,
|
||
studyCommunityPosts,
|
||
replyTemplates,
|
||
replyHistory,
|
||
}
|
||
|
||
try {
|
||
const contentRaw = await readFile(DATA_FILE, 'utf8')
|
||
payload.adminContent = JSON.parse(contentRaw)
|
||
} catch {
|
||
payload.adminContent = null
|
||
}
|
||
|
||
try {
|
||
const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
|
||
payload.draftContent = JSON.parse(draftRaw)
|
||
} catch {
|
||
payload.draftContent = null
|
||
}
|
||
|
||
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
|
||
|
||
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
|
||
const maxFiles = BACKUP_RETENTION_DAYS
|
||
if (files.length > maxFiles) {
|
||
const toDelete = files.slice(0, files.length - maxFiles)
|
||
await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {})))
|
||
}
|
||
|
||
lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) }
|
||
} catch (err) {
|
||
lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null }
|
||
console.error('[backup] failed to create snapshot:', err)
|
||
}
|
||
}
|
||
|
||
async function listBackupFiles() {
|
||
await mkdir(BACKUP_DIR, { recursive: true })
|
||
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse()
|
||
return files
|
||
}
|
||
|
||
async function readBackupPreview(filename) {
|
||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
|
||
throw new Error('Invalid backup filename')
|
||
}
|
||
|
||
const fullPath = path.join(BACKUP_DIR, filename)
|
||
const [fileInfo, raw] = await Promise.all([
|
||
stat(fullPath),
|
||
readFile(fullPath, 'utf8'),
|
||
])
|
||
const parsed = JSON.parse(raw)
|
||
|
||
return {
|
||
filename,
|
||
sizeBytes: fileInfo.size,
|
||
createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null,
|
||
reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown',
|
||
adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null,
|
||
totalHits: Number(parsed?.hitStats?.totalHits) || 0,
|
||
totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0,
|
||
}
|
||
}
|
||
|
||
async function listBackupPreviews() {
|
||
const files = await listBackupFiles()
|
||
const previews = await Promise.all(files.map(async filename => {
|
||
try {
|
||
return await readBackupPreview(filename)
|
||
} catch {
|
||
return {
|
||
filename,
|
||
sizeBytes: 0,
|
||
createdAt: null,
|
||
reason: 'unknown',
|
||
adminUpdatedAt: null,
|
||
totalHits: 0,
|
||
totalVisits: 0,
|
||
}
|
||
}
|
||
}))
|
||
return previews
|
||
}
|
||
|
||
function sanitizeLoadedHitStats(value) {
|
||
return {
|
||
totalHits: Number(value?.totalHits) || 0,
|
||
firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null,
|
||
lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null,
|
||
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
|
||
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
|
||
}
|
||
}
|
||
|
||
function sanitizeLoadedVisitorStats(value) {
|
||
return {
|
||
totalVisits: Number(value?.totalVisits) || 0,
|
||
uniqueVisitors: Number(value?.uniqueVisitors) || 0,
|
||
returningVisits: Number(value?.returningVisits) || 0,
|
||
firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null,
|
||
lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null,
|
||
visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {},
|
||
recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
|
||
geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {},
|
||
}
|
||
}
|
||
|
||
function sanitizeLoadedContactSubmissions(value) {
|
||
if (!Array.isArray(value)) return []
|
||
return value
|
||
.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||
.filter(entry => entry && typeof entry === 'object')
|
||
.map(entry => ({
|
||
id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(),
|
||
submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(),
|
||
name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '',
|
||
email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '',
|
||
message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '',
|
||
messageType: normalizeMessageType(entry.messageType),
|
||
subscribe: entry.subscribe === true,
|
||
archived: entry.archived === true,
|
||
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
|
||
}))
|
||
}
|
||
|
||
async function restoreFromBackup(filename) {
|
||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
|
||
throw new Error('Invalid backup filename')
|
||
}
|
||
|
||
const fullPath = path.join(BACKUP_DIR, filename)
|
||
const raw = await readFile(fullPath, 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
|
||
await createBackupSnapshot('pre-restore')
|
||
|
||
if (parsed?.adminContent && typeof parsed.adminContent === 'object') {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
|
||
}
|
||
|
||
if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
|
||
}
|
||
|
||
if (parsed?.publishState && typeof parsed.publishState === 'object') {
|
||
publishState = {
|
||
draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
|
||
publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
|
||
}
|
||
}
|
||
|
||
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
|
||
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
|
||
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
||
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
||
podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
|
||
|
||
queueHitStatsWrite()
|
||
queueVisitorStatsWrite()
|
||
queueContactSubmissionsWrite()
|
||
queueReplyTemplatesWrite()
|
||
queueReplyHistoryWrite()
|
||
queuePodcastChecklistWrite()
|
||
|
||
await Promise.all([
|
||
hitStatsWritePromise,
|
||
visitorStatsWritePromise,
|
||
contactSubmissionsWritePromise,
|
||
replyTemplatesWritePromise,
|
||
replyHistoryWritePromise,
|
||
podcastChecklistWritePromise,
|
||
])
|
||
await refreshContentCaches()
|
||
await createBackupSnapshot('post-restore')
|
||
}
|
||
|
||
function normalizeHitPath(pathname) {
|
||
if (!pathname || pathname === '') return '/'
|
||
if (pathname.length > 1 && pathname.endsWith('/')) {
|
||
return pathname.slice(0, -1)
|
||
}
|
||
return pathname
|
||
}
|
||
|
||
function shouldCountHit(req) {
|
||
if (req.method !== 'GET') return false
|
||
if (req.path.startsWith('/api/')) return false
|
||
if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
|
||
if (req.path === '/favicon.ico') return false
|
||
|
||
// Ignore direct asset requests and only count document-like requests.
|
||
const hasFileExt = path.extname(req.path) !== ''
|
||
if (hasFileExt) return false
|
||
|
||
const accept = req.get('accept') ?? ''
|
||
return accept.includes('text/html') || accept === '*/*' || accept === ''
|
||
}
|
||
|
||
function queueHitStatsWrite() {
|
||
hitStatsWritePromise = hitStatsWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
HIT_STATS_FILE,
|
||
JSON.stringify({
|
||
...hitStats,
|
||
updatedAt: new Date().toISOString(),
|
||
}, null, 2),
|
||
'utf8',
|
||
)
|
||
lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
|
||
})
|
||
.catch(err => {
|
||
console.error('[stats] failed to write hit stats:', err)
|
||
lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
|
||
})
|
||
}
|
||
|
||
function recordHit(pathname, isBot = false, botReason = null) {
|
||
const nowIso = new Date().toISOString()
|
||
const dayKey = nowIso.slice(0, 10)
|
||
const safePath = normalizeHitPath(pathname)
|
||
|
||
hitStats.totalHits += 1
|
||
hitStats.lastHitAt = nowIso
|
||
hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso
|
||
|
||
if (isBot) {
|
||
hitStats.botHits += 1
|
||
hitStats.byPathBot[safePath] = (hitStats.byPathBot[safePath] ?? 0) + 1
|
||
hitStats.byDayBot[dayKey] = (hitStats.byDayBot[dayKey] ?? 0) + 1
|
||
if (botReason) {
|
||
hitStats.botReasons[botReason] = (hitStats.botReasons[botReason] ?? 0) + 1
|
||
}
|
||
} else {
|
||
hitStats.realHits += 1
|
||
hitStats.byPathReal[safePath] = (hitStats.byPathReal[safePath] ?? 0) + 1
|
||
hitStats.byDayReal[dayKey] = (hitStats.byDayReal[dayKey] ?? 0) + 1
|
||
}
|
||
|
||
// Keep legacy byPath and byDay for backward compatibility
|
||
hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1
|
||
hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1
|
||
|
||
queueHitStatsWrite()
|
||
}
|
||
|
||
function buildLastNDaysStats(days) {
|
||
const out = []
|
||
const today = new Date()
|
||
|
||
for (let i = days - 1; i >= 0; i -= 1) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const dayKey = d.toISOString().slice(0, 10)
|
||
out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 })
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
function loadHitStatsFromDisk() {
|
||
return readFile(HIT_STATS_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
hitStats = {
|
||
totalHits: Number(parsed?.totalHits) || 0,
|
||
realHits: Number(parsed?.realHits) || 0,
|
||
botHits: Number(parsed?.botHits) || 0,
|
||
firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
|
||
lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
|
||
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
||
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
|
||
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
|
||
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
||
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
|
||
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
|
||
botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {},
|
||
}
|
||
})
|
||
.catch(() => {
|
||
hitStats = { ...EMPTY_HIT_STATS }
|
||
})
|
||
}
|
||
|
||
const app = express()
|
||
app.use(express.json({ limit: '10mb' }))
|
||
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
|
||
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)
|
||
|
||
app.get('/api/admin-content', async (req, res) => {
|
||
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
||
if (source === 'draft' && !isValidAdminSession(req)) {
|
||
res.status(401).json({ message: 'Unauthorized' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||
if (source === 'published') {
|
||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
||
res.json({ ...parsed, siteContent: safeSiteContent })
|
||
return
|
||
}
|
||
|
||
res.json(parsed)
|
||
} catch {
|
||
if (source === 'draft') {
|
||
res.status(404).json({ message: 'No saved draft content file yet.' })
|
||
return
|
||
}
|
||
res.status(404).json({ message: 'No saved admin content file yet.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
|
||
res.json({
|
||
publishState,
|
||
hasDraft: Boolean(cachedDraftSiteContent),
|
||
hasPublished: Boolean(cachedSiteContent),
|
||
})
|
||
})
|
||
|
||
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
|
||
const status = await getStorageStatus()
|
||
res.json(status)
|
||
})
|
||
|
||
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||
res.json({ checklist: podcastChecklist })
|
||
})
|
||
|
||
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
||
podcastChecklist = safeChecklist
|
||
await queuePodcastChecklistWrite()
|
||
res.json({ ok: true, checklist: safeChecklist })
|
||
} catch {
|
||
res.status(500).json({ message: 'Failed to save podcast checklist.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/site-config', async (_req, res) => {
|
||
try {
|
||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||
const siteContent = parsed.siteContent ?? {}
|
||
|
||
res.json({
|
||
seo: siteContent.seo ?? DEFAULT_SEO,
|
||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
|
||
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
|
||
publishState,
|
||
updatedAt: parsed.updatedAt ?? null,
|
||
})
|
||
} catch {
|
||
res.json({
|
||
seo: DEFAULT_SEO,
|
||
legal: DEFAULT_LEGAL,
|
||
redirects: DEFAULT_REDIRECT_RULES,
|
||
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
|
||
publishState,
|
||
updatedAt: null,
|
||
})
|
||
}
|
||
})
|
||
|
||
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const { siteContent } = req.body ?? {}
|
||
|
||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
||
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
||
return
|
||
}
|
||
|
||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||
const updatedAt = new Date().toISOString()
|
||
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
DRAFT_DATA_FILE,
|
||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||
'utf8',
|
||
)
|
||
|
||
cachedDraftSiteContent = safeSiteContent
|
||
publishState.draftUpdatedAt = updatedAt
|
||
|
||
res.json({ ok: true, updatedAt })
|
||
} catch (err) {
|
||
console.error('[admin-content-draft] persist error:', err)
|
||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
|
||
}
|
||
})
|
||
|
||
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
||
try {
|
||
const source = cachedDraftSiteContent
|
||
? { siteContent: cachedDraftSiteContent, updatedAt: publishState.draftUpdatedAt ?? new Date().toISOString() }
|
||
: await loadSiteContentFile(DRAFT_DATA_FILE)
|
||
|
||
const publishedAt = new Date().toISOString()
|
||
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
DATA_FILE,
|
||
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
|
||
'utf8',
|
||
)
|
||
|
||
cachedSiteContent = source.siteContent
|
||
publishState.publishedAt = publishedAt
|
||
|
||
if (draftQuestions !== null) {
|
||
questions = draftQuestions.slice(0, MAX_QUESTIONS)
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
QUESTIONS_FILE,
|
||
JSON.stringify({ questions, updatedAt: publishedAt }, null, 2),
|
||
'utf8',
|
||
)
|
||
}
|
||
|
||
await createBackupSnapshot('post-publish')
|
||
|
||
res.json({ ok: true, publishedAt })
|
||
} catch (err) {
|
||
console.error('[admin-content-publish] persist error:', err)
|
||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
|
||
}
|
||
})
|
||
|
||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||
try {
|
||
const assets = await listUploadedAssets()
|
||
res.json({ assets })
|
||
} catch {
|
||
res.status(500).json({ message: 'Could not list uploaded assets.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||
|
||
if (!ext) {
|
||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||
return
|
||
}
|
||
|
||
const base64 = dataUrl.split(',')[1] ?? ''
|
||
const buffer = Buffer.from(base64, 'base64')
|
||
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
|
||
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
|
||
return
|
||
}
|
||
|
||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||
const finalName = `${baseName}-${Date.now()}${ext}`
|
||
|
||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||
const metadata = await readUploadsMetadata()
|
||
metadata[finalName] = []
|
||
await writeUploadsMetadata(metadata)
|
||
|
||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||
} catch {
|
||
res.status(500).json({ message: 'Upload failed.' })
|
||
}
|
||
})
|
||
|
||
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const { filename } = req.params
|
||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||
res.status(400).json({ message: 'Invalid filename.' })
|
||
return
|
||
}
|
||
|
||
const tags = Array.isArray(req.body?.tags)
|
||
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
||
: []
|
||
|
||
const filePath = path.join(UPLOADS_DIR, filename)
|
||
await stat(filePath)
|
||
|
||
const metadata = await readUploadsMetadata()
|
||
metadata[filename] = tags
|
||
await writeUploadsMetadata(metadata)
|
||
|
||
res.json({ ok: true, tags })
|
||
} catch {
|
||
res.status(404).json({ message: 'Asset not found.' })
|
||
}
|
||
})
|
||
|
||
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const { filename } = req.params
|
||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||
res.status(400).json({ message: 'Invalid filename.' })
|
||
return
|
||
}
|
||
|
||
await unlink(path.join(UPLOADS_DIR, filename))
|
||
const metadata = await readUploadsMetadata()
|
||
delete metadata[filename]
|
||
await writeUploadsMetadata(metadata)
|
||
res.json({ ok: true })
|
||
} catch {
|
||
res.status(404).json({ message: 'Asset not found.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
|
||
res.json({
|
||
buildCommit: process.env.BUILD_COMMIT ?? null,
|
||
buildNumber: process.env.BUILD_NUMBER ?? null,
|
||
deployedAt: process.env.DEPLOYED_AT ?? null,
|
||
cachePurge: lastCachePurgeStatus,
|
||
deployHook: lastDeployHookStatus,
|
||
})
|
||
})
|
||
|
||
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
|
||
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
|
||
lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||
|
||
if (!result.ok) {
|
||
res.status(400).json({ message: result.message })
|
||
return
|
||
}
|
||
|
||
res.json({ ok: true, message: result.message })
|
||
})
|
||
|
||
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
|
||
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
|
||
lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||
|
||
if (!result.ok) {
|
||
res.status(400).json({ message: result.message })
|
||
return
|
||
}
|
||
|
||
res.json({ ok: true, message: result.message })
|
||
})
|
||
|
||
function queueQuestionsWrite() {
|
||
questionsWritePromise = questionsWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
QUESTIONS_FILE,
|
||
JSON.stringify({
|
||
questions,
|
||
updatedAt: new Date().toISOString(),
|
||
}, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
.catch(err => {
|
||
console.error('[questions] failed to write questions:', err)
|
||
})
|
||
}
|
||
|
||
function queueDraftQuestionsWrite() {
|
||
if (draftQuestions === null) return
|
||
draftQuestionsWritePromise = draftQuestionsWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
DRAFT_QUESTIONS_FILE,
|
||
JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2),
|
||
'utf8',
|
||
)
|
||
publishState.draftUpdatedAt = new Date().toISOString()
|
||
})
|
||
.catch(err => {
|
||
console.error('[draft-questions] failed to write draft questions:', err)
|
||
})
|
||
}
|
||
|
||
function loadQuestionsFromDisk() {
|
||
return readFile(QUESTIONS_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) {
|
||
questions = parsed.slice(0, MAX_QUESTIONS)
|
||
} else if (Array.isArray(parsed?.questions)) {
|
||
questions = parsed.questions.slice(0, MAX_QUESTIONS)
|
||
} else {
|
||
questions = []
|
||
}
|
||
})
|
||
.catch(() => {
|
||
questions = []
|
||
})
|
||
}
|
||
|
||
function cookieFlags() {
|
||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||
}
|
||
|
||
function normalizeStudyUsername(value) {
|
||
if (typeof value !== 'string') return ''
|
||
return value.trim().toLowerCase()
|
||
}
|
||
|
||
function getStudyAvatarUrl(subject) {
|
||
let customAvatar = ''
|
||
let username = ''
|
||
|
||
if (subject && typeof subject === 'object') {
|
||
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
|
||
username = normalizeStudyUsername(subject.username)
|
||
} else if (typeof subject === 'string') {
|
||
username = normalizeStudyUsername(subject)
|
||
}
|
||
|
||
if (customAvatar) return customAvatar
|
||
if (!username) return ''
|
||
const hash = createHash('md5').update(username).digest('hex')
|
||
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
|
||
}
|
||
|
||
function findStudyUserById(userId) {
|
||
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
||
return studyUsers.find(user => user.id === userId)
|
||
}
|
||
|
||
function normalizeStudySlug(value) {
|
||
if (typeof value !== 'string') return ''
|
||
const trimmed = value.trim().toLowerCase()
|
||
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
||
}
|
||
|
||
function isValidStudyUsername(value) {
|
||
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
|
||
}
|
||
|
||
function getStudyCatalog() {
|
||
const fallback = [
|
||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||
]
|
||
const content = cachedSiteContent
|
||
if (!content || typeof content !== 'object') return fallback
|
||
|
||
if (Array.isArray(content.studies) && content.studies.length > 0) {
|
||
const out = []
|
||
const seen = new Set()
|
||
for (const study of content.studies) {
|
||
const slug = normalizeStudySlug(study?.slug)
|
||
if (!slug || seen.has(slug)) continue
|
||
seen.add(slug)
|
||
out.push({
|
||
slug,
|
||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||
status: study?.status === 'planned' ? 'planned' : 'active',
|
||
})
|
||
}
|
||
if (out.length > 0) return out
|
||
}
|
||
|
||
return fallback
|
||
}
|
||
|
||
function isEnrollableStudySlug(studySlug) {
|
||
const normalized = normalizeStudySlug(studySlug)
|
||
if (!normalized) return false
|
||
return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned')
|
||
}
|
||
|
||
function getStudyTitleBySlug(studySlug) {
|
||
const normalized = normalizeStudySlug(studySlug)
|
||
if (!normalized) return ''
|
||
const study = getStudyCatalog().find(item => item.slug === normalized)
|
||
return study?.title ?? ''
|
||
}
|
||
|
||
function isStudyUserEnrolled(user, studySlug) {
|
||
const normalized = normalizeStudySlug(studySlug)
|
||
if (!normalized || !user) return false
|
||
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
||
}
|
||
|
||
function hashStudyPassword(password) {
|
||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||
}
|
||
|
||
function hashEmailChangeToken(token) {
|
||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||
}
|
||
|
||
function getCanonicalBaseUrl() {
|
||
const configured = cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
|
||
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
|
||
return configured.trim()
|
||
}
|
||
|
||
function buildBrandedEmailHtml({
|
||
title,
|
||
eyebrow,
|
||
bodyHtml,
|
||
ctaLabel,
|
||
ctaUrl,
|
||
footerHtml,
|
||
}) {
|
||
const ctaBlock = ctaLabel && ctaUrl
|
||
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
|
||
: ''
|
||
|
||
return (
|
||
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
|
||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
|
||
`<tr><td align="center" style="padding:40px 20px;">` +
|
||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
|
||
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
|
||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
|
||
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
|
||
`</td></tr>` +
|
||
`<tr><td style="padding:40px 40px 0;">` +
|
||
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
|
||
`${ctaBlock}` +
|
||
`</td></tr>` +
|
||
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
|
||
`</table>` +
|
||
`</td></tr></table>` +
|
||
`</div>`
|
||
)
|
||
}
|
||
|
||
async function sendStudyWelcomeEmail(email, displayName) {
|
||
if (!process.env.RESEND_API_KEY) return
|
||
try {
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||
const baseUrl = getCanonicalBaseUrl()
|
||
const studiesUrl = buildAbsoluteUrl(baseUrl, '/study')
|
||
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||
const bodyHtml = (
|
||
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
|
||
`<p style="margin:0 0 16px;">Your student account is ready.</p>` +
|
||
`<p style="margin:0 0 16px;">Open studies and continue learning, or manage your account details anytime.</p>`
|
||
)
|
||
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
|
||
const { error } = await resend.emails.send({
|
||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||
to: [email],
|
||
subject: process.env.RESEND_WELCOME_SUBJECT ?? 'Welcome to Verse by Verse with Nate',
|
||
text:
|
||
`Welcome, ${namePart}.\n\n` +
|
||
`Your student account is ready.\n\n` +
|
||
`Open studies: ${studiesUrl}\n` +
|
||
`Manage account: ${accountUrl}\n\n` +
|
||
`Grace and peace,\nVerse by Verse with Nate`,
|
||
html: buildBrandedEmailHtml({
|
||
title: 'Welcome to the Study Community',
|
||
eyebrow: 'Study Account',
|
||
bodyHtml,
|
||
ctaLabel: 'Open Studies',
|
||
ctaUrl: studiesUrl,
|
||
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
|
||
}),
|
||
})
|
||
if (error) console.error('[study-signup] welcome email send error:', error)
|
||
} catch (err) {
|
||
console.error('[study-signup] welcome email exception:', err)
|
||
}
|
||
}
|
||
|
||
async function sendStudyAccountDeletedEmail(email, displayName) {
|
||
if (!process.env.RESEND_API_KEY) return
|
||
try {
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||
const baseUrl = getCanonicalBaseUrl()
|
||
const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup')
|
||
const bodyHtml = (
|
||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||
`<p style="margin:0 0 16px;">This confirms your study account and saved notes were deleted.</p>` +
|
||
`<p style="margin:0 0 16px;">If this was not you, please contact us immediately.</p>`
|
||
)
|
||
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Verse by Verse with Nate</p>`
|
||
const { error } = await resend.emails.send({
|
||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||
to: [email],
|
||
subject: 'Your study account was deleted',
|
||
text:
|
||
`Hi ${namePart},\n\n` +
|
||
`This confirms your study account and saved notes were deleted.\n\n` +
|
||
`If this was not you, please contact us immediately.\n\n` +
|
||
`Create a new account anytime: ${signupUrl}\n\n` +
|
||
`Verse by Verse with Nate`,
|
||
html: buildBrandedEmailHtml({
|
||
title: 'Study Account Deleted',
|
||
eyebrow: 'Account Update',
|
||
bodyHtml,
|
||
ctaLabel: 'Create a New Account',
|
||
ctaUrl: signupUrl,
|
||
footerHtml,
|
||
}),
|
||
})
|
||
if (error) console.error('[study-account] delete email send error:', error)
|
||
} catch (err) {
|
||
console.error('[study-account] delete email exception:', err)
|
||
}
|
||
}
|
||
|
||
function sanitizeStudyUsers(value) {
|
||
if (!Array.isArray(value)) return []
|
||
|
||
const out = []
|
||
const seen = new Set()
|
||
|
||
for (const item of value) {
|
||
const username = normalizeStudyUsername(item?.username)
|
||
const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : ''
|
||
if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue
|
||
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
||
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||
: []
|
||
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
|
||
const subscribeNewsletter = item?.subscribeNewsletter !== false
|
||
const studyRemindersEnabled = item?.studyRemindersEnabled === true
|
||
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,
|
||
studyRemindersEnabled,
|
||
enrolledStudySlugs,
|
||
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
|
||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||
pendingEmailChange,
|
||
})
|
||
}
|
||
|
||
return out.slice(0, MAX_STUDY_USERS)
|
||
}
|
||
|
||
function sanitizeUserNotes(value) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||
const out = {}
|
||
let count = 0
|
||
for (const [sectionId, note] of Object.entries(value)) {
|
||
if (count >= MAX_STUDY_NOTES_PER_USER) break
|
||
if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue
|
||
if (typeof note !== 'string') continue
|
||
const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||
if (!trimmed) continue
|
||
out[sectionId] = trimmed
|
||
count += 1
|
||
}
|
||
return out
|
||
}
|
||
|
||
function sanitizeStudyCommunityReply(reply) {
|
||
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null
|
||
const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : ''
|
||
if (!message) return null
|
||
return {
|
||
id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(),
|
||
authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '',
|
||
authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '',
|
||
message,
|
||
createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
function sanitizeStudyCommunityPosts(value) {
|
||
if (!Array.isArray(value)) return []
|
||
|
||
return value
|
||
.filter(item => item && typeof item === 'object' && !Array.isArray(item))
|
||
.map(item => {
|
||
const studySlug = normalizeStudySlug(item.studySlug)
|
||
const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : ''
|
||
const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : ''
|
||
const replies = Array.isArray(item.replies)
|
||
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
|
||
: []
|
||
|
||
if (!studySlug || !message) return null
|
||
|
||
return {
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
studySlug,
|
||
sectionId,
|
||
authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '',
|
||
authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '',
|
||
message,
|
||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||
replies,
|
||
}
|
||
})
|
||
.filter(Boolean)
|
||
}
|
||
|
||
async function loadStudyCommunityFromDisk() {
|
||
return readFile(STUDY_COMMUNITY_FILE, 'utf8')
|
||
.then(raw => {
|
||
const parsed = JSON.parse(raw)
|
||
studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed)
|
||
})
|
||
.catch(() => {
|
||
studyCommunityPosts = []
|
||
})
|
||
}
|
||
|
||
function queueStudyCommunityWrite() {
|
||
studyCommunityWritePromise = studyCommunityWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(
|
||
STUDY_COMMUNITY_FILE,
|
||
JSON.stringify({ posts: studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2),
|
||
'utf8',
|
||
)
|
||
})
|
||
.catch(err => {
|
||
console.error('[study-community] failed to write discussion posts:', err)
|
||
})
|
||
}
|
||
|
||
function getUserNotesFilePath(userId) {
|
||
// userId is a UUID — safe as a filename
|
||
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
|
||
}
|
||
|
||
async function loadUserNotes(userId) {
|
||
if (studyNotesCache.has(userId)) return studyNotesCache.get(userId)
|
||
try {
|
||
const raw = await readFile(getUserNotesFilePath(userId), 'utf8')
|
||
const notes = sanitizeUserNotes(JSON.parse(raw))
|
||
studyNotesCache.set(userId, notes)
|
||
return notes
|
||
} catch {
|
||
const notes = {}
|
||
studyNotesCache.set(userId, notes)
|
||
return notes
|
||
}
|
||
}
|
||
|
||
function queueUserNotesWrite(userId) {
|
||
const prev = studyNotesWriteQueues.get(userId) ?? Promise.resolve()
|
||
const next = prev
|
||
.then(async () => {
|
||
const notes = studyNotesCache.get(userId) ?? {}
|
||
await mkdir(STUDY_NOTES_DIR, { recursive: true })
|
||
await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8')
|
||
})
|
||
.catch(err => {
|
||
console.error(`[study-notes] failed to write notes for user ${userId}:`, err)
|
||
})
|
||
studyNotesWriteQueues.set(userId, next)
|
||
}
|
||
|
||
function getUserProgressFilePath(userId) {
|
||
return path.join(STUDY_PROGRESS_DIR, `${userId}.json`)
|
||
}
|
||
|
||
function sanitizeStudyProgress(value) {
|
||
const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() }
|
||
if (!value || typeof value !== 'object') return defaultResult
|
||
|
||
const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
|
||
if (value.byStudy && typeof value.byStudy === 'object') {
|
||
for (const [studySlug, studyData] of Object.entries(value.byStudy)) {
|
||
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
|
||
const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
|
||
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
|
||
: []
|
||
const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers)
|
||
? Object.fromEntries(
|
||
Object.entries(studyData.quizAnswers)
|
||
.filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim())
|
||
.map(([sectionId, answers]) => [
|
||
sectionId.trim(),
|
||
Array.isArray(answers)
|
||
? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim())
|
||
: [],
|
||
])
|
||
)
|
||
: {}
|
||
progress.byStudy[studySlug.trim().toLowerCase()] = {
|
||
completedSectionIds: Array.from(new Set(completedSectionIds)),
|
||
quizAnswers,
|
||
}
|
||
}
|
||
}
|
||
|
||
return progress
|
||
}
|
||
|
||
async function loadUserProgress(userId) {
|
||
if (studyProgressCache.has(userId)) return studyProgressCache.get(userId)
|
||
try {
|
||
const raw = await readFile(getUserProgressFilePath(userId), 'utf8')
|
||
const progress = sanitizeStudyProgress(JSON.parse(raw))
|
||
studyProgressCache.set(userId, progress)
|
||
return progress
|
||
} catch {
|
||
const progress = { byStudy: {}, updatedAt: new Date().toISOString() }
|
||
studyProgressCache.set(userId, progress)
|
||
return progress
|
||
}
|
||
}
|
||
|
||
function queueUserProgressWrite(userId) {
|
||
const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve()
|
||
const next = prev
|
||
.then(async () => {
|
||
const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() }
|
||
await mkdir(STUDY_PROGRESS_DIR, { recursive: true })
|
||
await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8')
|
||
})
|
||
.catch(err => {
|
||
console.error(`[study-progress] failed to write progress for user ${userId}:`, err)
|
||
})
|
||
studyProgressWriteQueues.set(userId, next)
|
||
}
|
||
|
||
function sanitizeStudyReminders(value) {
|
||
const defaultResult = { users: {}, updatedAt: new Date().toISOString() }
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult
|
||
|
||
const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
|
||
if (value.users && typeof value.users === 'object') {
|
||
for (const [userId, userData] of Object.entries(value.users)) {
|
||
if (typeof userId !== 'string' || !userId.trim()) continue
|
||
const studies = typeof userData === 'object' && userData && !Array.isArray(userData)
|
||
? userData
|
||
: {}
|
||
const normalizedStudies = {}
|
||
for (const [studySlug, sectionIds] of Object.entries(studies)) {
|
||
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
|
||
const ids = Array.isArray(sectionIds)
|
||
? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
|
||
: []
|
||
if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids))
|
||
}
|
||
reminders.users[userId.trim()] = normalizedStudies
|
||
}
|
||
}
|
||
|
||
return reminders
|
||
}
|
||
|
||
async function loadStudyRemindersFromDisk() {
|
||
try {
|
||
const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8')
|
||
studyReminders = sanitizeStudyReminders(JSON.parse(raw))
|
||
} catch {
|
||
studyReminders = { users: {}, updatedAt: new Date().toISOString() }
|
||
}
|
||
}
|
||
|
||
function queueStudyRemindersWrite() {
|
||
studyRemindersWritePromise = studyRemindersWritePromise
|
||
.then(async () => {
|
||
await mkdir(DATA_DIR, { recursive: true })
|
||
await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8')
|
||
})
|
||
.catch(err => {
|
||
console.error('[study-reminders] failed to write reminders:', err)
|
||
})
|
||
}
|
||
|
||
async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
|
||
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 bodyHtml = (
|
||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||
`<p style="margin:0 0 16px;">A new study lesson is available in ${escapeHtml(studyTitle)}.</p>` +
|
||
`<p style="margin:0 0 16px;">${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.</p>`
|
||
)
|
||
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
|
||
const { error } = await resend.emails.send({
|
||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||
to: [email],
|
||
subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`,
|
||
text:
|
||
`Hi ${namePart},\n\n` +
|
||
`A new lesson is available in ${studyTitle}.\n` +
|
||
`${sectionTitle} (${sectionReference}) is now unlocked.\n\n` +
|
||
`Open it here: ${sectionUrl}\n\n` +
|
||
`Grace and peace,\nVerse by Verse with Nate`,
|
||
html: buildBrandedEmailHtml({
|
||
title: 'New Lesson Available',
|
||
eyebrow: 'Study Reminder',
|
||
bodyHtml,
|
||
ctaLabel: 'Open the Lesson',
|
||
ctaUrl: sectionUrl,
|
||
footerHtml,
|
||
}),
|
||
})
|
||
if (error) console.error('[study-reminder] send error:', error)
|
||
} catch (err) {
|
||
console.error('[study-reminder] send exception:', err)
|
||
}
|
||
}
|
||
|
||
async function scheduleStudyReminders() {
|
||
if (!cachedSiteContent) return
|
||
const now = new Date()
|
||
const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id)
|
||
if (userIds.length === 0) return
|
||
|
||
for (const user of studyUsers) {
|
||
if (user.studyRemindersEnabled !== true) continue
|
||
const email = user.username
|
||
const displayName = user.displayName || email
|
||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||
if (enrolledStudySlugs.length === 0) continue
|
||
|
||
const userSent = studyReminders.users[user.id] ?? {}
|
||
for (const studySlug of enrolledStudySlugs) {
|
||
const study = Array.isArray(cachedSiteContent.studies)
|
||
? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||
: undefined
|
||
if (!study) continue
|
||
|
||
for (const section of study.sections ?? []) {
|
||
const sectionId = section.id
|
||
const releaseDate = getSectionReleaseDate(section)
|
||
if (!releaseDate) continue
|
||
if (releaseDate > now) continue
|
||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||
if (sentForStudy.includes(sectionId)) continue
|
||
|
||
const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60)
|
||
if (hoursSinceRelease > 24) continue
|
||
|
||
const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`)
|
||
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
|
||
userSent[studySlug] = [...sentForStudy, sectionId]
|
||
studyReminders.users[user.id] = userSent
|
||
}
|
||
}
|
||
}
|
||
studyReminders.updatedAt = new Date().toISOString()
|
||
queueStudyRemindersWrite()
|
||
}
|
||
|
||
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,
|
||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||
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,
|
||
studyRemindersEnabled: false,
|
||
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,
|
||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||
avatarUrl: getStudyAvatarUrl(user.username),
|
||
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,
|
||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||
avatarUrl: getStudyAvatarUrl(user.username),
|
||
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
||
})
|
||
})
|
||
|
||
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
res.json({
|
||
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||
availableStudies: getStudyCatalog()
|
||
.filter(study => study.status !== 'planned')
|
||
.map(study => ({ slug: study.slug, title: study.title })),
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||
res.status(404).json({ message: 'Study not found.' })
|
||
return
|
||
}
|
||
|
||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||
if (!enrolled.includes(studySlug)) {
|
||
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
}
|
||
|
||
res.json({
|
||
ok: true,
|
||
studySlug,
|
||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||
})
|
||
})
|
||
|
||
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||
res.status(404).json({ message: 'Study not found.' })
|
||
return
|
||
}
|
||
|
||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||
if (enrolled.includes(studySlug)) {
|
||
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
}
|
||
|
||
res.json({
|
||
ok: true,
|
||
studySlug,
|
||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-auth/logout', (req, res) => {
|
||
const cookies = parseCookies(req.headers.cookie)
|
||
const token = cookies[STUDY_SESSION_COOKIE]
|
||
if (token) {
|
||
studySessions.delete(token)
|
||
}
|
||
clearStudySessionCookie(res)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const notes = await loadUserNotes(user.id)
|
||
res.json({ notes })
|
||
})
|
||
|
||
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!sectionId) {
|
||
res.status(400).json({ message: 'Invalid section id.' })
|
||
return
|
||
}
|
||
const user = req.studyUser
|
||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||
return
|
||
}
|
||
const notes = await loadUserNotes(user.id)
|
||
res.json({ note: notes[sectionId] ?? '' })
|
||
})
|
||
|
||
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!sectionId) {
|
||
res.status(400).json({ message: 'Invalid section id.' })
|
||
return
|
||
}
|
||
const user = req.studyUser
|
||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||
return
|
||
}
|
||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||
const notes = await loadUserNotes(user.id)
|
||
|
||
if (!note) {
|
||
delete notes[sectionId]
|
||
} else {
|
||
const existingCount = Object.keys(notes).length
|
||
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
||
return
|
||
}
|
||
notes[sectionId] = note
|
||
}
|
||
|
||
studyNotesCache.set(user.id, notes)
|
||
queueUserNotesWrite(user.id)
|
||
res.json({ ok: true, note })
|
||
})
|
||
|
||
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
if (!studySlug) {
|
||
res.status(400).json({ message: 'Study slug is required.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
|
||
return
|
||
}
|
||
|
||
const progress = await loadUserProgress(user.id)
|
||
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||
res.json({ studySlug, completedSectionIds })
|
||
})
|
||
|
||
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!studySlug || !sectionId) {
|
||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||
return
|
||
}
|
||
|
||
const progress = await loadUserProgress(user.id)
|
||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||
if (!studyProgress.completedSectionIds.includes(sectionId)) {
|
||
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
|
||
}
|
||
progress.byStudy[studySlug] = studyProgress
|
||
progress.updatedAt = new Date().toISOString()
|
||
studyProgressCache.set(user.id, progress)
|
||
queueUserProgressWrite(user.id)
|
||
|
||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||
})
|
||
|
||
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!studySlug || !sectionId) {
|
||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
|
||
return
|
||
}
|
||
|
||
const progress = await loadUserProgress(user.id)
|
||
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
|
||
res.json({ studySlug, sectionId, answers: quizAnswers })
|
||
})
|
||
|
||
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!studySlug || !sectionId) {
|
||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
|
||
return
|
||
}
|
||
|
||
const rawAnswers = req.body?.answers
|
||
const answers = Array.isArray(rawAnswers)
|
||
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
|
||
: []
|
||
|
||
const progress = await loadUserProgress(user.id)
|
||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
|
||
studyProgress.quizAnswers[sectionId] = answers
|
||
progress.byStudy[studySlug] = studyProgress
|
||
progress.updatedAt = new Date().toISOString()
|
||
studyProgressCache.set(user.id, progress)
|
||
queueUserProgressWrite(user.id)
|
||
|
||
res.json({ ok: true, studySlug, sectionId, answers })
|
||
})
|
||
|
||
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||
if (!studySlug || !sectionId) {
|
||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||
return
|
||
}
|
||
|
||
const progress = await loadUserProgress(user.id)
|
||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||
studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId)
|
||
progress.byStudy[studySlug] = studyProgress
|
||
progress.updatedAt = new Date().toISOString()
|
||
studyProgressCache.set(user.id, progress)
|
||
queueUserProgressWrite(user.id)
|
||
|
||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||
})
|
||
|
||
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
|
||
|
||
if (!studySlug) {
|
||
res.status(400).json({ message: 'Study slug is required.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
||
return
|
||
}
|
||
|
||
const posts = studyCommunityPosts
|
||
.filter(post => post.studySlug === studySlug)
|
||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||
.slice(0, 50)
|
||
.map(post => {
|
||
const author = findStudyUserById(post.authorUserId)
|
||
const enrichedPost = {
|
||
...post,
|
||
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
|
||
replies: Array.isArray(post.replies)
|
||
? post.replies.map(reply => {
|
||
const replyAuthor = findStudyUserById(reply.authorUserId)
|
||
return {
|
||
...reply,
|
||
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
|
||
}
|
||
})
|
||
: [],
|
||
}
|
||
return enrichedPost
|
||
})
|
||
|
||
res.json({
|
||
studySlug,
|
||
posts,
|
||
})
|
||
})
|
||
|
||
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const studySlug = normalizeStudySlug(req.body?.studySlug)
|
||
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
|
||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||
|
||
if (!studySlug || !message) {
|
||
res.status(400).json({ message: 'Study slug and message are required.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
||
return
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
|
||
const post = {
|
||
id: randomUUID(),
|
||
studySlug,
|
||
sectionId,
|
||
authorUserId: user.id,
|
||
authorName,
|
||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||
message,
|
||
createdAt: now,
|
||
replies: [],
|
||
}
|
||
|
||
studyCommunityPosts.unshift(post)
|
||
studyCommunityPosts = sanitizeStudyCommunityPosts(studyCommunityPosts).slice(0, 500)
|
||
queueStudyCommunityWrite()
|
||
|
||
res.json({ ok: true, post })
|
||
})
|
||
|
||
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||
|
||
if (!postId || !message) {
|
||
res.status(400).json({ message: 'Post id and message are required.' })
|
||
return
|
||
}
|
||
|
||
const post = studyCommunityPosts.find(item => item.id === postId)
|
||
if (!post) {
|
||
res.status(404).json({ message: 'Post not found.' })
|
||
return
|
||
}
|
||
|
||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
|
||
return
|
||
}
|
||
|
||
const reply = {
|
||
id: randomUUID(),
|
||
authorUserId: user.id,
|
||
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
|
||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||
message,
|
||
createdAt: new Date().toISOString(),
|
||
}
|
||
|
||
post.replies = Array.isArray(post.replies) ? post.replies : []
|
||
post.replies.push(reply)
|
||
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
|
||
queueStudyCommunityWrite()
|
||
|
||
res.json({ ok: true, reply })
|
||
})
|
||
|
||
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const notes = await loadUserNotes(user.id)
|
||
const progress = await loadUserProgress(user.id)
|
||
|
||
// Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content
|
||
const sectionMeta = {}
|
||
const content = cachedSiteContent
|
||
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
|
||
? content.studies
|
||
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
|
||
|
||
for (const study of studies) {
|
||
for (const section of (study.sections ?? [])) {
|
||
sectionMeta[`${study.slug}--${section.id}`] = {
|
||
studyTitle: study.title,
|
||
studyDescription: typeof study.description === 'string' ? study.description : '',
|
||
title: section.title,
|
||
reference: section.reference,
|
||
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
|
||
}
|
||
}
|
||
}
|
||
|
||
const studyEntries = {}
|
||
|
||
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'
|
||
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||
studyEntries[studySlug][sectionId].noteText = noteText.trim()
|
||
}
|
||
|
||
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
||
const quizAnswersBySection = studyProgress.quizAnswers || {}
|
||
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
|
||
if (!Array.isArray(answers) || answers.length === 0) continue
|
||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
|
||
}
|
||
}
|
||
|
||
const studySlugs = Array.from(new Set([
|
||
...Object.keys(studyEntries),
|
||
...Object.values(studies).map(study => study.slug),
|
||
]))
|
||
|
||
const docChildren = [
|
||
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
|
||
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
|
||
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
|
||
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
|
||
]
|
||
|
||
for (const studySlug of studySlugs) {
|
||
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||
const studyTitle = study?.title || studySlug
|
||
const studyDescription = typeof study?.description === 'string' ? study.description : ''
|
||
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
|
||
|
||
if (sectionIds.length === 0) continue
|
||
|
||
docChildren.push(
|
||
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
|
||
)
|
||
if (studyDescription) {
|
||
docChildren.push(
|
||
new Paragraph({ text: studyDescription, spacing: { after: 240 } }),
|
||
)
|
||
}
|
||
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
|
||
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
|
||
docChildren.push(
|
||
new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }),
|
||
)
|
||
|
||
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
|
||
for (const sectionId of orderedSectionIds) {
|
||
const entry = studyEntries[studySlug][sectionId]
|
||
if (!entry) continue
|
||
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
|
||
docChildren.push(
|
||
new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
|
||
)
|
||
if (meta.reference) {
|
||
docChildren.push(
|
||
new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }),
|
||
)
|
||
}
|
||
if (entry.noteText) {
|
||
docChildren.push(
|
||
new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }),
|
||
)
|
||
for (const line of entry.noteText.split('\n')) {
|
||
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
|
||
}
|
||
}
|
||
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
|
||
docChildren.push(
|
||
new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
|
||
)
|
||
meta.studyQuestions.forEach((question, index) => {
|
||
docChildren.push(
|
||
new Paragraph({
|
||
children: [
|
||
new TextRun({ text: `${index + 1}. `, bold: true }),
|
||
new TextRun({ text: question }),
|
||
],
|
||
spacing: { after: 80 },
|
||
}),
|
||
)
|
||
})
|
||
}
|
||
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
|
||
docChildren.push(
|
||
new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
|
||
)
|
||
entry.quizAnswers.forEach((answer, index) => {
|
||
docChildren.push(
|
||
new Paragraph({
|
||
children: [
|
||
new TextRun({ text: `Answer ${index + 1}: `, bold: true }),
|
||
new TextRun({ text: answer }),
|
||
],
|
||
spacing: { after: 80 },
|
||
}),
|
||
)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if (docChildren.length <= 4) {
|
||
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
|
||
}
|
||
|
||
const doc = new Document({
|
||
creator: 'Verse by Verse with Nate',
|
||
title: 'My Study Export',
|
||
sections: [{ children: docChildren }],
|
||
})
|
||
|
||
const buffer = await Packer.toBuffer(doc)
|
||
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||
res.send(buffer)
|
||
})
|
||
|
||
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||
|
||
const currentHash = hashStudyPassword(currentPassword)
|
||
const a = Buffer.from(currentHash, 'utf8')
|
||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||
return
|
||
}
|
||
|
||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||
return
|
||
}
|
||
|
||
user.passwordHash = hashStudyPassword(newPassword)
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
|
||
const user = req.studyUser
|
||
const notes = await loadUserNotes(user.id)
|
||
const progress = await loadUserProgress(user.id)
|
||
const noteEntries = Object.entries(notes)
|
||
|
||
const studies = getStudyCatalog().map(study => {
|
||
const totalLessons = Array.isArray(cachedSiteContent?.studies)
|
||
? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
|
||
: 0
|
||
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
|
||
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
|
||
return {
|
||
slug: study.slug,
|
||
title: study.title,
|
||
status: study.status,
|
||
enrolled: isStudyUserEnrolled(user, study.slug),
|
||
totalLessons,
|
||
completedLessons,
|
||
noteCount,
|
||
}
|
||
})
|
||
|
||
res.json({
|
||
profile: {
|
||
username: user.username,
|
||
displayName: user.displayName ?? '',
|
||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||
avatarUrl: getStudyAvatarUrl(user),
|
||
},
|
||
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) : ''
|
||
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
|
||
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
|
||
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
|
||
return
|
||
}
|
||
|
||
user.displayName = displayName
|
||
user.avatarUrl = avatarUrl
|
||
user.updatedAt = new Date().toISOString()
|
||
queueStudyUsersWrite()
|
||
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
|
||
})
|
||
|
||
app.post('/api/study-account/avatar-upload', requireStudyAuth, 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, or GIF data URL.' })
|
||
return
|
||
}
|
||
|
||
const base64 = dataUrl.split(',')[1] ?? ''
|
||
const buffer = Buffer.from(base64, 'base64')
|
||
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
|
||
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
|
||
return
|
||
}
|
||
|
||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||
const finalName = `${baseName || 'avatar'}-${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, url: `/uploads/${finalName}` })
|
||
} catch (err) {
|
||
console.error('[study-account-avatar-upload] upload error:', err)
|
||
res.status(500).json({ message: 'Avatar upload failed.' })
|
||
}
|
||
})
|
||
|
||
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
|
||
const user = req.studyUser
|
||
const subscribeNewsletter = req.body?.subscribeNewsletter === true
|
||
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
|
||
user.subscribeNewsletter = subscribeNewsletter
|
||
user.studyRemindersEnabled = studyRemindersEnabled
|
||
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, studyRemindersEnabled: user.studyRemindersEnabled === true })
|
||
})
|
||
|
||
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 })
|
||
})
|
||
|
||
// Client-side SPA pageview tracking (fires on every React Router navigation)
|
||
app.post('/api/analytics/pageview', async (req, res) => {
|
||
if (!hasVisitorConsent(req)) {
|
||
res.json({ ok: false, reason: 'no-consent' })
|
||
return
|
||
}
|
||
const ua = req.get('user-agent') ?? ''
|
||
const { isBot } = detectBot(ua)
|
||
if (isBot) {
|
||
res.json({ ok: false, reason: 'bot' })
|
||
return
|
||
}
|
||
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
||
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
||
recordHit(rawPath, false)
|
||
await recordVisitor(req, res, rawPath, rawReferrer)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
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).map(row => {
|
||
const fullVisitor = visitorStats.visitors[row.visitorId]
|
||
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||
})
|
||
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'),
|
||
deviceBreakdown: (() => {
|
||
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||
for (const row of recentVisitorRows) {
|
||
const d = row.device ?? 'unknown'
|
||
counts[d] = (counts[d] ?? 0) + 1
|
||
}
|
||
return counts
|
||
})(),
|
||
topReferrers: (() => {
|
||
const counts = {}
|
||
for (const row of recentVisitorRows) {
|
||
if (!row.referrer) continue
|
||
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
||
}
|
||
return Object.entries(counts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10)
|
||
.map(([referrer, count]) => ({ referrer, count }))
|
||
})(),
|
||
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: hitStats.byDayReal?.[item.day] ?? 0 })),
|
||
recentVisits: recentVisitorRows,
|
||
},
|
||
writeStatus: {
|
||
hitStats: lastHitStatsWrite,
|
||
visitorStats: lastVisitorStatsWrite,
|
||
backups: lastBackupStatus,
|
||
cachePurge: lastCachePurgeStatus,
|
||
deployHook: lastDeployHookStatus,
|
||
},
|
||
contactTotals: {
|
||
totalSubmissions: contactSubmissions.length,
|
||
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
|
||
},
|
||
studyEnrollment: {
|
||
totalUsers: studyUsers.length,
|
||
enrolledUsers,
|
||
totalEnrollments,
|
||
enrollmentsByStudy,
|
||
users,
|
||
},
|
||
})
|
||
})
|
||
|
||
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||
res.json({ submissions: contactSubmissions.slice(0, 300) })
|
||
})
|
||
|
||
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||
const { id } = req.params
|
||
if (typeof id !== 'string' || !id.trim()) {
|
||
res.status(400).json({ message: 'Invalid submission id.' })
|
||
return
|
||
}
|
||
|
||
const archived = req.body?.archived === true
|
||
let found = false
|
||
contactSubmissions = contactSubmissions.map(item => {
|
||
if (item.id !== id) return item
|
||
found = true
|
||
return { ...item, archived }
|
||
})
|
||
|
||
if (!found) {
|
||
res.status(404).json({ message: 'Submission not found.' })
|
||
return
|
||
}
|
||
|
||
queueContactSubmissionsWrite()
|
||
res.json({ ok: true, archived })
|
||
})
|
||
|
||
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
|
||
res.json({
|
||
fromEmail: getResendReplyToAddress(),
|
||
fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
|
||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||
canSendReplies: Boolean(process.env.RESEND_API_KEY),
|
||
note: process.env.RESEND_API_KEY
|
||
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
|
||
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
|
||
})
|
||
})
|
||
|
||
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
|
||
res.json({ templates: replyTemplates })
|
||
})
|
||
|
||
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
|
||
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
|
||
replyTemplates = nextTemplates
|
||
queueReplyTemplatesWrite()
|
||
res.json({ ok: true, templates: replyTemplates })
|
||
})
|
||
|
||
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
|
||
res.json({ items: replyHistory.slice(0, 100) })
|
||
})
|
||
|
||
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||
const { id } = req.params
|
||
if (typeof id !== 'string' || !id.trim()) {
|
||
res.status(400).json({ message: 'Invalid submission id.' })
|
||
return
|
||
}
|
||
|
||
const startLength = contactSubmissions.length
|
||
contactSubmissions = contactSubmissions.filter(item => item.id !== id)
|
||
if (contactSubmissions.length === startLength) {
|
||
res.status(404).json({ message: 'Submission not found.' })
|
||
return
|
||
}
|
||
|
||
queueContactSubmissionsWrite()
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
if (!process.env.RESEND_API_KEY) {
|
||
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' })
|
||
return
|
||
}
|
||
|
||
const { id } = req.params
|
||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||
|
||
if (!id || typeof id !== 'string') {
|
||
res.status(400).json({ message: 'Invalid submission id.' })
|
||
return
|
||
}
|
||
|
||
if (!subject || subject.length > 180) {
|
||
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' })
|
||
return
|
||
}
|
||
|
||
if (!message || message.length > 6000) {
|
||
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' })
|
||
return
|
||
}
|
||
|
||
const submission = contactSubmissions.find(entry => entry.id === id)
|
||
if (!submission) {
|
||
res.status(404).json({ message: 'Submission not found.' })
|
||
return
|
||
}
|
||
|
||
if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) {
|
||
res.status(400).json({ message: 'Submission does not have a valid email address.' })
|
||
return
|
||
}
|
||
|
||
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
|
||
const html = buildAdminReplyTemplate({ recipientName, message })
|
||
const replyToAddress = getResendReplyToAddress()
|
||
const fromAddress = getResendFromAddress()
|
||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
|
||
const sendResult = await sendResendEmailWithRetry({
|
||
resend,
|
||
context: 'admin-contact-reply',
|
||
payload: {
|
||
from: fromAddress || ADMIN_REPLY_FROM,
|
||
to: [submission.email],
|
||
subject,
|
||
replyTo: replyToAddress,
|
||
tags: [
|
||
{ name: 'flow', value: 'admin-reply' },
|
||
{ name: 'message_type', value: submission.messageType ?? 'general' },
|
||
{ name: 'submission_id', value: submission.id },
|
||
],
|
||
headers: {
|
||
'X-Contact-Submission-Id': submission.id,
|
||
},
|
||
text,
|
||
html,
|
||
},
|
||
})
|
||
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
|
||
upsertContactEmailStatus(submission.id, 'adminReply', {
|
||
status: 'sent',
|
||
lastEventType: 'email.sent',
|
||
error: null,
|
||
})
|
||
|
||
replyHistory.unshift({
|
||
id: randomUUID(),
|
||
submissionId: submission.id,
|
||
toEmail: submission.email,
|
||
toName: submission.name,
|
||
fromEmail: replyToAddress,
|
||
subject,
|
||
preview: message.slice(0, 500),
|
||
sentAt: new Date().toISOString(),
|
||
})
|
||
replyHistory = replyHistory.slice(0, 500)
|
||
queueReplyHistoryWrite()
|
||
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
|
||
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
|
||
status: 'failed',
|
||
lastEventType: 'email.failed',
|
||
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
|
||
})
|
||
}
|
||
console.error('[admin-reply] send error:', err)
|
||
res.status(500).json({ message: 'Failed to send reply email.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||
res.json({ counts: downloadCounts })
|
||
})
|
||
|
||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||
const seen = new Set()
|
||
const subscribers = contactSubmissions
|
||
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
||
.map(entry => ({
|
||
name: entry.name,
|
||
email: entry.email,
|
||
subscribedAt: entry.submittedAt,
|
||
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||
}))
|
||
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
|
||
res.json({ subscribers, total: subscribers.length })
|
||
})
|
||
|
||
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
|
||
const seen = new Set()
|
||
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
|
||
contactSubmissions
|
||
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||
.forEach(entry => {
|
||
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
|
||
rows.push([entry.name, entry.email, entry.submittedAt, source])
|
||
})
|
||
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
|
||
res.setHeader('Content-Type', 'text/csv')
|
||
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
||
res.send(csv)
|
||
})
|
||
|
||
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
|
||
let adminContent = null
|
||
let draftContent = null
|
||
try {
|
||
const raw = await readFile(DATA_FILE, 'utf8')
|
||
adminContent = JSON.parse(raw)
|
||
} catch {
|
||
adminContent = null
|
||
}
|
||
|
||
try {
|
||
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
|
||
draftContent = JSON.parse(rawDraft)
|
||
} catch {
|
||
draftContent = null
|
||
}
|
||
|
||
res.json({
|
||
exportedAt: new Date().toISOString(),
|
||
adminContent,
|
||
draftContent,
|
||
publishState,
|
||
hitStats,
|
||
visitorStats,
|
||
contactSubmissions,
|
||
replyTemplates,
|
||
replyHistory,
|
||
})
|
||
})
|
||
|
||
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
|
||
hitStats = { ...EMPTY_HIT_STATS }
|
||
visitorStats = { ...EMPTY_VISITOR_STATS }
|
||
queueHitStatsWrite()
|
||
queueVisitorStatsWrite()
|
||
createBackupSnapshot('post-clear').catch(() => {})
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
|
||
const result = pruneStatsByDays(req.body?.days)
|
||
createBackupSnapshot('post-prune').catch(() => {})
|
||
res.json({ ok: true, ...result })
|
||
})
|
||
|
||
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
|
||
await createBackupSnapshot('manual')
|
||
res.json({ ok: true, backup: lastBackupStatus })
|
||
})
|
||
|
||
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
|
||
try {
|
||
const backups = await listBackupPreviews()
|
||
res.json({ backups })
|
||
} catch {
|
||
res.status(500).json({ message: 'Could not list backups.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const { filename } = req.body ?? {}
|
||
const preview = await readBackupPreview(filename)
|
||
res.json({ preview })
|
||
} catch (err) {
|
||
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
|
||
try {
|
||
const { filename } = req.body ?? {}
|
||
await restoreFromBackup(filename)
|
||
const backups = await listBackupPreviews()
|
||
res.json({ ok: true, restored: filename, backups })
|
||
} catch (err) {
|
||
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
|
||
}
|
||
})
|
||
|
||
app.use((req, res, next) => {
|
||
if (shouldCountHit(req)) {
|
||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||
const botDetection = detectBot(ua)
|
||
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||
if (hasVisitorConsent(req) && !botDetection.isBot) {
|
||
recordVisitor(req, res).catch(err => {
|
||
console.error('[visitor-stats] failed to record visitor:', err)
|
||
})
|
||
}
|
||
}
|
||
next()
|
||
})
|
||
|
||
// Rate-limit contact submissions: max 5 per IP per 10 minutes
|
||
const contactHits = new Map()
|
||
const downloadHits = new Map()
|
||
function contactRateLimit(req, res, next) {
|
||
const ip = req.ip ?? 'unknown'
|
||
const now = Date.now()
|
||
const windowMs = 10 * 60 * 1000
|
||
const entry = contactHits.get(ip) ?? { count: 0, start: now }
|
||
if (now - entry.start > windowMs) {
|
||
entry.count = 0
|
||
entry.start = now
|
||
}
|
||
entry.count += 1
|
||
contactHits.set(ip, entry)
|
||
if (entry.count > 5) {
|
||
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
|
||
return
|
||
}
|
||
next()
|
||
}
|
||
|
||
function studyDownloadRateLimit(req, res, next) {
|
||
const ip = req.ip ?? 'unknown'
|
||
const now = Date.now()
|
||
const windowMs = 10 * 60 * 1000
|
||
const entry = downloadHits.get(ip) ?? { count: 0, start: now }
|
||
if (now - entry.start > windowMs) {
|
||
entry.count = 0
|
||
entry.start = now
|
||
}
|
||
entry.count += 1
|
||
downloadHits.set(ip, entry)
|
||
if (entry.count > 10) {
|
||
res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
|
||
return
|
||
}
|
||
next()
|
||
}
|
||
|
||
app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
|
||
try {
|
||
const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||
|
||
if (_honey) {
|
||
res.json({ ok: true })
|
||
return
|
||
}
|
||
|
||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||
res.status(400).json({ message: 'First name is required.' })
|
||
return
|
||
}
|
||
|
||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||
res.status(400).json({ message: 'Last name is required.' })
|
||
return
|
||
}
|
||
|
||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||
res.status(400).json({ message: 'A valid email address is required.' })
|
||
return
|
||
}
|
||
|
||
const published = await loadSiteContentFile(DATA_FILE)
|
||
const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
|
||
|
||
if (!configuredDownloadUrl) {
|
||
try {
|
||
await stat(TITUS_STUDY_FILE)
|
||
} catch {
|
||
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' })
|
||
return
|
||
}
|
||
}
|
||
|
||
const trimmedFirstName = firstName.trim()
|
||
const trimmedLastName = lastName.trim()
|
||
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
|
||
const trimmedEmail = email.trim()
|
||
const wantsSubscribe = subscribe !== false
|
||
|
||
addContactSubmission({
|
||
name: trimmedName,
|
||
email: trimmedEmail,
|
||
message: 'Requested Titus study download.',
|
||
messageType: 'general',
|
||
subscribe: wantsSubscribe,
|
||
})
|
||
|
||
if (wantsSubscribe) {
|
||
await syncContactToResend(trimmedName, trimmedEmail)
|
||
}
|
||
|
||
incrementDownloadCount('titus-study')
|
||
|
||
if (configuredDownloadUrl) {
|
||
res.json({ ok: true, downloadUrl: configuredDownloadUrl })
|
||
return
|
||
}
|
||
|
||
const token = createTitusDownloadToken(trimmedEmail)
|
||
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
|
||
} catch (err) {
|
||
console.error('[study-download] request error:', err)
|
||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
|
||
try {
|
||
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||
|
||
if (_honey) {
|
||
res.json({ ok: true })
|
||
return
|
||
}
|
||
|
||
if (!resourceId || typeof resourceId !== 'string') {
|
||
res.status(400).json({ message: 'Resource ID is required.' })
|
||
return
|
||
}
|
||
|
||
const published = await loadSiteContentFile(DATA_FILE)
|
||
const siteContent = published?.siteContent
|
||
|
||
function resolveResourceFromId(id) {
|
||
if (!siteContent || typeof siteContent !== 'object') return null
|
||
|
||
const customResources = Array.isArray(siteContent.customLinks)
|
||
? siteContent.customLinks.filter(link => link?.placement === 'resources')
|
||
: []
|
||
|
||
if (id.startsWith('custom:')) {
|
||
const customId = id.slice('custom:'.length)
|
||
const match = customResources.find(link => link.id === customId)
|
||
return match ? { label: match.label, url: match.url } : null
|
||
}
|
||
|
||
if (id.startsWith('archived:')) {
|
||
const [, seriesId, ...linkIdParts] = id.split(':')
|
||
const linkId = linkIdParts.join(':')
|
||
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
|
||
const series = archivedSeries.find(item => item.id === seriesId)
|
||
const link = Array.isArray(series?.resourceLinks)
|
||
? series.resourceLinks.find(item => item.id === linkId)
|
||
: null
|
||
return link ? { label: link.label || series?.title, url: link.url } : null
|
||
}
|
||
|
||
const customMatch = customResources.find(link => link.id === id)
|
||
if (customMatch) return { label: customMatch.label, url: customMatch.url }
|
||
|
||
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
|
||
for (const series of archivedSeries) {
|
||
if (!Array.isArray(series?.resourceLinks)) continue
|
||
const link = series.resourceLinks.find(item => item.id === id)
|
||
if (link) return { label: link.label || series?.title, url: link.url }
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const resource = resolveResourceFromId(resourceId)
|
||
|
||
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
|
||
res.status(400).json({ message: 'Resource not found.' })
|
||
return
|
||
}
|
||
|
||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||
res.status(400).json({ message: 'First name is required.' })
|
||
return
|
||
}
|
||
|
||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||
res.status(400).json({ message: 'Last name is required.' })
|
||
return
|
||
}
|
||
|
||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||
res.status(400).json({ message: 'A valid email address is required.' })
|
||
return
|
||
}
|
||
|
||
const trimmedFirstName = firstName.trim()
|
||
const trimmedLastName = lastName.trim()
|
||
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
|
||
const trimmedEmail = email.trim()
|
||
const wantsSubscribe = subscribe !== false
|
||
|
||
addContactSubmission({
|
||
name: trimmedName,
|
||
email: trimmedEmail,
|
||
message: `Requested resource download: ${resource.label ?? resource.url}`,
|
||
messageType: 'general',
|
||
subscribe: wantsSubscribe,
|
||
})
|
||
|
||
if (wantsSubscribe) {
|
||
await syncContactToResend(trimmedName, trimmedEmail)
|
||
}
|
||
|
||
incrementDownloadCount(`resource:${resourceId}`)
|
||
res.json({ ok: true, downloadUrl: resource.url.trim() })
|
||
} catch (err) {
|
||
console.error('[resource-download] request error:', err)
|
||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/study-downloads/titus/file', async (req, res) => {
|
||
const token = typeof req.query?.token === 'string' ? req.query.token : ''
|
||
if (!token || !consumeTitusDownloadToken(token)) {
|
||
res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
await stat(TITUS_STUDY_FILE)
|
||
res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
|
||
} catch {
|
||
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
||
try {
|
||
const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}
|
||
|
||
// Honeypot — silently discard if filled by a bot
|
||
if (_honey) {
|
||
res.json({ ok: true })
|
||
return
|
||
}
|
||
|
||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||
res.status(400).json({ message: 'First name is required.' })
|
||
return
|
||
}
|
||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||
res.status(400).json({ message: 'Last name is required.' })
|
||
return
|
||
}
|
||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||
res.status(400).json({ message: 'A valid email address is required.' })
|
||
return
|
||
}
|
||
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
|
||
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' })
|
||
return
|
||
}
|
||
|
||
if (!process.env.RESEND_API_KEY) {
|
||
console.error('[contact] RESEND_API_KEY env var not set')
|
||
res.status(503).json({ message: 'The contact form is not yet configured on the server.' })
|
||
return
|
||
}
|
||
|
||
const trimmedName = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||
const trimmedEmail = email.trim()
|
||
const trimmedMessage = message.trim()
|
||
const normalizedMessageType = normalizeMessageType(messageType)
|
||
const cooldown = noteContactEmailCooldown(trimmedEmail)
|
||
if (!cooldown.ok) {
|
||
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
|
||
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` })
|
||
return
|
||
}
|
||
const submittedAt = new Date().toLocaleString('en-US', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
})
|
||
const shouldSendWelcome = shouldSendWelcomeEmail({
|
||
subscribe,
|
||
})
|
||
|
||
const submission = addContactSubmission({
|
||
name: trimmedName,
|
||
email: trimmedEmail,
|
||
message: trimmedMessage,
|
||
messageType: normalizedMessageType,
|
||
subscribe,
|
||
})
|
||
|
||
// If this is a question, also add to questions array for public Q&A section
|
||
if (normalizedMessageType === 'question') {
|
||
const question = {
|
||
id: randomUUID(),
|
||
submittedAt: new Date().toISOString(),
|
||
firstName: splitName(trimmedName).firstName,
|
||
email: trimmedEmail,
|
||
question: trimmedMessage,
|
||
answer: '',
|
||
answeredAt: null,
|
||
isApproved: false,
|
||
approvedAt: null,
|
||
}
|
||
questions.unshift(question)
|
||
questions = questions.slice(0, MAX_QUESTIONS)
|
||
if (draftQuestions !== null) {
|
||
draftQuestions.unshift(question)
|
||
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
|
||
queueDraftQuestionsWrite()
|
||
}
|
||
queueQuestionsWrite()
|
||
}
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
const adminInbox = getResendInboxAddress()
|
||
const replyToAddress = getResendReplyToAddress()
|
||
const fromAddress = getResendFromAddress()
|
||
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
|
||
const adminTemplate = buildContactAdminNotificationTemplate({
|
||
normalizedMessageType,
|
||
trimmedName,
|
||
trimmedEmail,
|
||
submittedAt,
|
||
trimmedMessage,
|
||
})
|
||
let welcomeSent = false
|
||
|
||
if (subscribe === true) {
|
||
await syncContactToResend(trimmedName, trimmedEmail)
|
||
}
|
||
|
||
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
|
||
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
|
||
let publishedSiteContent = cachedSiteContent
|
||
if (!publishedSiteContent) {
|
||
try {
|
||
const published = await loadSiteContentFile(DATA_FILE)
|
||
publishedSiteContent = published?.siteContent ?? null
|
||
} catch {
|
||
publishedSiteContent = null
|
||
}
|
||
}
|
||
|
||
const emailConfig = publishedSiteContent ?? {}
|
||
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
|
||
? emailConfig.seo.canonicalUrl.trim()
|
||
: DEFAULT_SEO.canonicalUrl
|
||
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
||
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
||
const welcomeHeading = greetingName
|
||
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
|
||
: `${escapeHtml(welcomeGreetingPrefix)}.`
|
||
const welcomeIntro = emailConfig.welcomeEmailIntro?.trim()
|
||
|| 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.'
|
||
const welcomeCurrentSeries = emailConfig.welcomeEmailCurrentSeries?.trim()
|
||
|| 'Right now we\'re working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it\'s rooted in sound doctrine.'
|
||
const welcomeStartHereTitle = emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus'
|
||
const welcomeStartHereSummary = emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.'
|
||
const welcomeExpect1 = emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.'
|
||
const welcomeExpect2 = emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.'
|
||
const welcomeExpect3 = emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.'
|
||
const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.'
|
||
const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
|
||
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
|
||
const welcomeSpotifyUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
|
||
)
|
||
const welcomeAppleUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple',
|
||
)
|
||
const welcomeAmazonUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon',
|
||
)
|
||
const welcomeWebsiteUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/',
|
||
)
|
||
const welcomeEpisodeUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here',
|
||
)
|
||
const welcomeImageUrl = buildAbsoluteUrl(
|
||
welcomeBaseUrl,
|
||
process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
|
||
)
|
||
|
||
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
||
greetingName,
|
||
welcomeIntro,
|
||
welcomeCurrentSeries,
|
||
welcomeStartHereTitle,
|
||
welcomeStartHereSummary,
|
||
welcomeExpect1,
|
||
welcomeExpect2,
|
||
welcomeExpect3,
|
||
welcomeScripture,
|
||
welcomeScriptureRef,
|
||
welcomeSignoff,
|
||
welcomeHeading,
|
||
welcomeSpotifyUrl,
|
||
welcomeAppleUrl,
|
||
welcomeAmazonUrl,
|
||
welcomeWebsiteUrl,
|
||
welcomeEpisodeUrl,
|
||
welcomeImageUrl,
|
||
})
|
||
|
||
try {
|
||
const welcomeSendResult = await sendResendEmailWithRetry({
|
||
resend,
|
||
context: 'contact-welcome',
|
||
payload: {
|
||
from: fromAddress,
|
||
to: [trimmedEmail],
|
||
replyTo: replyToAddress,
|
||
subject: welcomeSubject,
|
||
tags: [
|
||
{ name: 'flow', value: 'contact-welcome' },
|
||
{ name: 'message_type', value: safeMessageTypeTag },
|
||
{ name: 'submission_id', value: submission.id },
|
||
],
|
||
headers: {
|
||
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
|
||
'X-Contact-Submission-Id': submission.id,
|
||
},
|
||
text: welcomeTemplate.text,
|
||
html: welcomeTemplate.html,
|
||
},
|
||
})
|
||
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
|
||
upsertContactEmailStatus(submission.id, 'welcome', {
|
||
status: 'sent',
|
||
lastEventType: 'email.sent',
|
||
error: null,
|
||
})
|
||
welcomeSent = true
|
||
} catch (welcomeErr) {
|
||
upsertContactEmailStatus(submission.id, 'welcome', {
|
||
status: 'failed',
|
||
lastEventType: 'email.failed',
|
||
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
|
||
})
|
||
throw welcomeErr
|
||
}
|
||
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
|
||
upsertContactEmailStatus(submission.id, 'welcome', {
|
||
status: 'automation-enabled',
|
||
lastEventType: 'email.automation.enabled',
|
||
error: null,
|
||
})
|
||
}
|
||
|
||
try {
|
||
const adminSendResult = await sendResendEmailWithRetry({
|
||
resend,
|
||
context: 'contact-admin-notification',
|
||
payload: {
|
||
from: fromAddress,
|
||
to: [adminInbox],
|
||
replyTo: trimmedEmail,
|
||
subject: adminTemplate.subject,
|
||
tags: [
|
||
{ name: 'flow', value: 'contact-admin' },
|
||
{ name: 'message_type', value: safeMessageTypeTag },
|
||
{ name: 'submission_id', value: submission.id },
|
||
],
|
||
headers: {
|
||
'X-Contact-Submission-Id': submission.id,
|
||
},
|
||
text: adminTemplate.text,
|
||
html: adminTemplate.html,
|
||
},
|
||
})
|
||
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
|
||
upsertContactEmailStatus(submission.id, 'adminNotification', {
|
||
status: 'sent',
|
||
lastEventType: 'email.sent',
|
||
error: null,
|
||
})
|
||
} catch (adminSendErr) {
|
||
upsertContactEmailStatus(submission.id, 'adminNotification', {
|
||
status: 'failed',
|
||
lastEventType: 'email.failed',
|
||
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
|
||
})
|
||
throw adminSendErr
|
||
}
|
||
|
||
res.json({
|
||
ok: true,
|
||
welcomeSent,
|
||
welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME,
|
||
})
|
||
} catch (err) {
|
||
console.error('[contact] send error:', err)
|
||
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
|
||
}
|
||
})
|
||
|
||
app.post('/api/resend/webhook', (req, res) => {
|
||
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
|
||
if (!expectedToken) {
|
||
res.status(503).json({ message: 'Webhook token is not configured.' })
|
||
return
|
||
}
|
||
|
||
const providedToken = (req.get('x-webhook-token') || '').trim()
|
||
|| (req.get('x-resend-webhook-token') || '').trim()
|
||
|| String(req.query?.token || '').trim()
|
||
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
|
||
|
||
if (!providedToken || providedToken !== expectedToken) {
|
||
res.status(401).json({ message: 'Unauthorized webhook.' })
|
||
return
|
||
}
|
||
|
||
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
||
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
|
||
const data = body.data && typeof body.data === 'object' ? body.data : {}
|
||
const tags = Array.isArray(data.tags) ? data.tags : []
|
||
|
||
const resendMessageId = (
|
||
typeof data.email_id === 'string' && data.email_id.trim()
|
||
? data.email_id.trim()
|
||
: (typeof data.emailId === 'string' && data.emailId.trim()
|
||
? data.emailId.trim()
|
||
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
|
||
)
|
||
|
||
const indexed = resendMessageId ? resendEmailSubmissionIndex.get(resendMessageId) : null
|
||
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
|
||
const submissionId = indexed?.submissionId || taggedSubmissionId
|
||
|
||
const flow = extractTagValue(tags, 'flow')
|
||
const stream = indexed?.stream
|
||
|| (flow === 'contact-welcome' ? 'welcome' : '')
|
||
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|
||
|| (flow === 'admin-reply' ? 'adminReply' : '')
|
||
|
||
if (!submissionId || !stream) {
|
||
res.json({ ok: true, ignored: true })
|
||
return
|
||
}
|
||
|
||
upsertContactEmailStatus(submissionId, stream, {
|
||
status: mapResendEventToStatus(eventType),
|
||
lastEventType: eventType || 'webhook.event',
|
||
resendEmailId: resendMessageId || null,
|
||
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
|
||
})
|
||
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
|
||
const fromAddress = getResendFromAddress()
|
||
const replyToAddress = getResendReplyToAddress()
|
||
const fromDomain = getAddressDomain(fromAddress)
|
||
const replyDomain = getAddressDomain(replyToAddress)
|
||
const warnings = []
|
||
|
||
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
|
||
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
|
||
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
|
||
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
|
||
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
|
||
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
|
||
|
||
const recent = contactSubmissions.slice(0, 300)
|
||
const failed = recent.filter(item => {
|
||
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
|
||
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|
||
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|
||
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
|
||
}).length
|
||
|
||
res.json({
|
||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
|
||
fromAddress,
|
||
replyToAddress,
|
||
fromDomain,
|
||
replyDomain,
|
||
warnings,
|
||
recentSubmissionFailures: failed,
|
||
trackedSubmissions: recent.length,
|
||
})
|
||
})
|
||
|
||
// Get all questions (for admin)
|
||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||
res.json({ questions: draftQuestions ?? questions })
|
||
})
|
||
|
||
// Get only approved public questions (for homepage)
|
||
app.get('/api/questions', (_req, res) => {
|
||
const sourceQuestions = draftQuestions ?? questions
|
||
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||
res.json({ questions: publicQuestions })
|
||
})
|
||
|
||
// Create a manual question (admin)
|
||
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
|
||
const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
|
||
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
|
||
const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
|
||
const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
|
||
const approveNow = req.body?.approve === true
|
||
|
||
if (!firstName || firstName.length > 100) {
|
||
res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' })
|
||
return
|
||
}
|
||
|
||
if (!questionText || questionText.length < 5 || questionText.length > 3000) {
|
||
res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' })
|
||
return
|
||
}
|
||
|
||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||
res.status(400).json({ message: 'If provided, email must be a valid email address.' })
|
||
return
|
||
}
|
||
|
||
if (answerText.length > 5000) {
|
||
res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' })
|
||
return
|
||
}
|
||
|
||
ensureDraftQuestions()
|
||
const now = new Date().toISOString()
|
||
const created = {
|
||
id: randomUUID(),
|
||
submittedAt: now,
|
||
firstName,
|
||
email,
|
||
question: questionText,
|
||
answer: answerText,
|
||
answeredAt: answerText ? now : null,
|
||
isApproved: approveNow,
|
||
approvedAt: approveNow ? now : null,
|
||
}
|
||
|
||
draftQuestions.unshift(created)
|
||
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
|
||
queueDraftQuestionsWrite()
|
||
|
||
res.status(201).json({ ok: true, question: created })
|
||
})
|
||
|
||
// Answer a question (admin)
|
||
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
|
||
const { id } = req.params
|
||
const { answer } = req.body ?? {}
|
||
|
||
if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
|
||
res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' })
|
||
return
|
||
}
|
||
|
||
ensureDraftQuestions()
|
||
const question = draftQuestions.find(q => q.id === id)
|
||
if (!question) {
|
||
res.status(404).json({ message: 'Question not found.' })
|
||
return
|
||
}
|
||
|
||
question.answer = answer.trim()
|
||
question.answeredAt = new Date().toISOString()
|
||
queueDraftQuestionsWrite()
|
||
|
||
res.json({ ok: true, question })
|
||
})
|
||
|
||
// Approve/unapprove a question (admin)
|
||
app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
|
||
const { id } = req.params
|
||
const { approved } = req.body ?? {}
|
||
|
||
ensureDraftQuestions()
|
||
const question = draftQuestions.find(q => q.id === id)
|
||
if (!question) {
|
||
res.status(404).json({ message: 'Question not found.' })
|
||
return
|
||
}
|
||
|
||
question.isApproved = approved === true
|
||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||
queueDraftQuestionsWrite()
|
||
|
||
res.json({ ok: true, question })
|
||
})
|
||
|
||
// Delete a question (admin)
|
||
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||
const { id } = req.params
|
||
ensureDraftQuestions()
|
||
const index = draftQuestions.findIndex(q => q.id === id)
|
||
|
||
if (index === -1) {
|
||
res.status(404).json({ message: 'Question not found.' })
|
||
return
|
||
}
|
||
|
||
draftQuestions.splice(index, 1)
|
||
queueDraftQuestionsWrite()
|
||
|
||
res.json({ ok: true })
|
||
})
|
||
// ── Episodes (RSS feed proxy) ──────────────────────────────────────────────
|
||
const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss'
|
||
let episodesCache = null
|
||
let episodesCacheAt = 0
|
||
const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes
|
||
|
||
function extractCdata(raw) {
|
||
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
|
||
return cdata ? cdata[1].trim() : raw.trim()
|
||
}
|
||
|
||
function parseRssItems(xml, limit = Infinity) {
|
||
const items = []
|
||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||
let match
|
||
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
|
||
const block = match[1]
|
||
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
|
||
const title = extractCdata(titleRaw)
|
||
if (!title) continue
|
||
|
||
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
|
||
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
|
||
const guid = extractCdata(guidRaw)
|
||
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
|
||
const link = guid.startsWith('http') ? guid : enclosureUrl
|
||
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
|
||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||
items.push({
|
||
title,
|
||
pubDate,
|
||
link,
|
||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||
duration,
|
||
episode,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
async function fetchAllEpisodes() {
|
||
const now = Date.now()
|
||
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
|
||
return episodesCache
|
||
}
|
||
const controller = new AbortController()
|
||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
|
||
clearTimeout(timeout)
|
||
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
|
||
const xml = await response.text()
|
||
const episodes = parseRssItems(xml)
|
||
episodesCache = episodes
|
||
episodesCacheAt = now
|
||
return episodes
|
||
}
|
||
|
||
function toSpotifyEpisodeEmbedUrl(urlValue) {
|
||
if (!urlValue) return ''
|
||
|
||
try {
|
||
const parsed = new URL(urlValue)
|
||
if (parsed.protocol !== 'https:') return ''
|
||
|
||
const host = parsed.hostname.toLowerCase()
|
||
const parts = parsed.pathname.split('/').filter(Boolean)
|
||
|
||
if (host === 'open.spotify.com') {
|
||
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
|
||
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
|
||
}
|
||
if (parts[0] === 'episode' && parts[1]) {
|
||
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
|
||
}
|
||
}
|
||
} catch {
|
||
return ''
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
|
||
const input = String(html || '')
|
||
if (!input) return ''
|
||
|
||
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
|
||
|
||
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
|
||
let match
|
||
let firstEpisodeId = ''
|
||
while ((match = blockRegex.exec(input)) !== null) {
|
||
const episodeSlug = match[1]
|
||
const spotifyUrl = decodeEscapedJsonUrl(match[2])
|
||
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
|
||
|
||
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
|
||
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) {
|
||
return episodeId
|
||
}
|
||
}
|
||
|
||
if (firstEpisodeId) return firstEpisodeId
|
||
|
||
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
|
||
return urlMatch ? (urlMatch[2] || '') : ''
|
||
}
|
||
|
||
function isAllowedSpotifyResolverHost(hostname) {
|
||
const host = String(hostname || '').toLowerCase()
|
||
return host === 'open.spotify.com'
|
||
|| host === 'creators.spotify.com'
|
||
|| host === 'anchor.fm'
|
||
|| host === 'podcasters.spotify.com'
|
||
}
|
||
|
||
function decodeEscapedJsonUrl(value) {
|
||
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
|
||
}
|
||
|
||
app.get('/api/spotify/embed-url', async (req, res) => {
|
||
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
|
||
const safeInput = sanitizeUrl(incoming)
|
||
|
||
if (!safeInput || safeInput.startsWith('/')) {
|
||
res.status(400).json({ message: 'A valid episode URL is required.' })
|
||
return
|
||
}
|
||
|
||
let parsed
|
||
try {
|
||
parsed = new URL(safeInput)
|
||
} catch {
|
||
res.status(400).json({ message: 'Malformed URL.' })
|
||
return
|
||
}
|
||
|
||
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
|
||
res.status(400).json({ message: 'Unsupported episode URL host.' })
|
||
return
|
||
}
|
||
|
||
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
|
||
if (directEmbed) {
|
||
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||
const response = await fetch(safeInput, {
|
||
signal: controller.signal,
|
||
headers: {
|
||
'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)',
|
||
Accept: 'text/html',
|
||
},
|
||
})
|
||
clearTimeout(timeout)
|
||
|
||
if (!response.ok) {
|
||
res.status(404).json({ message: 'Could not fetch episode page.' })
|
||
return
|
||
}
|
||
|
||
const html = await response.text()
|
||
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
|
||
|
||
if (!spotifyEpisodeId) {
|
||
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' })
|
||
return
|
||
}
|
||
|
||
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
|
||
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
|
||
} catch (err) {
|
||
console.error('[spotify/embed-url] resolve error:', err.message)
|
||
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
|
||
}
|
||
})
|
||
|
||
app.get('/api/episodes', async (_req, res) => {
|
||
try {
|
||
const episodes = await fetchAllEpisodes()
|
||
res.json({ episodes: episodes.slice(0, 6) })
|
||
} catch (err) {
|
||
console.error('[episodes] RSS fetch error:', err.message)
|
||
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/episodes/all', async (_req, res) => {
|
||
try {
|
||
const episodes = await fetchAllEpisodes()
|
||
res.json({ episodes })
|
||
} catch (err) {
|
||
console.error('[episodes/all] RSS fetch error:', err.message)
|
||
res.json({ episodes: episodesCache ?? [] })
|
||
}
|
||
})
|
||
|
||
app.get('/robots.txt', async (_req, res) => {
|
||
let content = cachedSiteContent
|
||
if (!content) {
|
||
try {
|
||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||
content = parsed.siteContent
|
||
} catch {
|
||
content = {}
|
||
}
|
||
}
|
||
|
||
const seo = content?.seo ?? DEFAULT_SEO
|
||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||
|
||
res.type('text/plain').send(
|
||
[
|
||
'User-agent: *',
|
||
'Allow: /',
|
||
`Sitemap: ${root}/sitemap.xml`,
|
||
].join('\n'),
|
||
)
|
||
})
|
||
|
||
app.get('/sitemap.xml', async (_req, res) => {
|
||
let content = cachedSiteContent
|
||
if (!content) {
|
||
try {
|
||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||
content = parsed.siteContent
|
||
} catch {
|
||
content = {}
|
||
}
|
||
}
|
||
|
||
const seo = content?.seo ?? DEFAULT_SEO
|
||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
|
||
? seo.sitemapPaths
|
||
: DEFAULT_SEO.sitemapPaths
|
||
|
||
const urls = paths
|
||
.map(item => normalizeSitemapPath(item))
|
||
.filter(Boolean)
|
||
.map(item => `${root}${item}`)
|
||
|
||
const xml = [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
|
||
'</urlset>',
|
||
].join('\n')
|
||
|
||
res.type('application/xml').send(xml)
|
||
})
|
||
|
||
app.use((req, res, next) => {
|
||
const rules = sanitizeRedirectRules(cachedSiteContent?.redirects)
|
||
const match = rules.find(rule => rule.path === req.path)
|
||
if (!match) {
|
||
next()
|
||
return
|
||
}
|
||
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
|
||
})
|
||
|
||
app.use('/images', express.static(DIST_IMAGES_DIR))
|
||
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
|
||
app.use('/uploads', express.static(UPLOADS_DIR))
|
||
|
||
// Per-question social share stub — serves question-specific OG tags so
|
||
// platforms (X, Facebook) render a rich preview card. After the crawl
|
||
// delay the page immediately redirects the human visitor to the real SPA URL.
|
||
app.get('/questions/share/:id', (req, res) => {
|
||
const id = req.params.id
|
||
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
||
res.redirect(302, '/questions')
|
||
return
|
||
}
|
||
const sourceQuestions = draftQuestions ?? questions
|
||
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
||
if (!question) {
|
||
res.redirect(302, '/questions')
|
||
return
|
||
}
|
||
|
||
const BASE = 'https://versebyversewithnate.us'
|
||
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
|
||
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
|
||
const ogTitle = escapeHtml(question.question.length > 100
|
||
? `${question.question.slice(0, 97)}\u2026`
|
||
: question.question)
|
||
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
|
||
const ogDescription = escapeHtml(answerSnippet.length > 200
|
||
? `${answerSnippet.slice(0, 197)}\u2026`
|
||
: answerSnippet)
|
||
const ogImage = `${BASE}/images/banner.png`
|
||
|
||
res.type('html').send(`<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>${ogTitle} — Verse by Verse with Nate</title>
|
||
<meta name="description" content="${ogDescription}"/>
|
||
<meta property="og:type" content="article"/>
|
||
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
|
||
<meta property="og:title" content="${ogTitle}"/>
|
||
<meta property="og:description" content="${ogDescription}"/>
|
||
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||
<meta property="og:image:alt" content="${ogTitle}"/>
|
||
<meta name="twitter:card" content="summary_large_image"/>
|
||
<meta name="twitter:title" content="${ogTitle}"/>
|
||
<meta name="twitter:description" content="${ogDescription}"/>
|
||
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
|
||
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
|
||
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
|
||
</head>
|
||
<body></body>
|
||
</html>`)
|
||
})
|
||
|
||
app.use(express.static(DIST_DIR))
|
||
|
||
app.use(async (_req, res) => {
|
||
try {
|
||
const html = await readFile(INDEX_FILE, 'utf8')
|
||
res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent))
|
||
} catch {
|
||
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
||
}
|
||
})
|
||
|
||
const PORT = Number(process.env.PORT ?? 4173)
|
||
Promise.all([
|
||
loadHitStatsFromDisk(),
|
||
loadVisitorStatsFromDisk(),
|
||
loadContactSubmissionsFromDisk(),
|
||
loadReplyTemplatesFromDisk(),
|
||
loadReplyHistoryFromDisk(),
|
||
loadQuestionsFromDisk(),
|
||
loadDraftQuestionsFromDisk(),
|
||
loadStudyUsersFromDisk(),
|
||
loadStudyCommunityFromDisk(),
|
||
loadStudyRemindersFromDisk(),
|
||
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)
|
||
|
||
// Send study reminder emails for newly released lessons every hour
|
||
setInterval(() => {
|
||
scheduleStudyReminders().catch(err => {
|
||
console.error('[study-reminders] failed to schedule reminders:', err)
|
||
})
|
||
}, 60 * 60 * 1000)
|
||
void scheduleStudyReminders()
|
||
|
||
app.listen(PORT, () => {
|
||
logResendEmailAlignmentWarnings()
|
||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||
})
|
||
})
|