Files
Siteforge/server.js
T
2026-05-12 09:12:55 -04:00

3077 lines
110 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 } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Resend } from 'resend'
import qrcode from 'qrcode'
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 DATA_DIR = path.join(__dirname, 'data')
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 REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.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 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_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()
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 refreshContentCaches() {
try {
const published = await loadSiteContentFile(DATA_FILE)
cachedSiteContent = published.siteContent
if (typeof published?.updatedAt === 'string') {
publishState.publishedAt = published.updatedAt
}
} catch {
cachedSiteContent = null
}
try {
const draft = await loadSiteContentFile(DRAFT_DATA_FILE)
cachedDraftSiteContent = draft.siteContent
if (typeof draft?.updatedAt === 'string') {
publishState.draftUpdatedAt = draft.updatedAt
}
} catch {
cachedDraftSiteContent = null
}
}
async function loadDraftQuestionsFromDisk() {
return readFile(DRAFT_QUESTIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
draftQuestions = parsed.slice(0, MAX_QUESTIONS)
} else if (Array.isArray(parsed?.questions)) {
draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS)
} else {
draftQuestions = null
}
if (typeof parsed?.updatedAt === 'string') {
publishState.draftUpdatedAt = parsed.updatedAt
}
})
.catch(() => {
draftQuestions = null
})
}
function ensureDraftQuestions() {
if (draftQuestions !== null) return
draftQuestions = questions.slice(0, MAX_QUESTIONS)
}
async function readUploadsMetadata() {
try {
const raw = await readFile(UPLOADS_META_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
function loadDownloadCountsFromDisk() {
return readFile(DOWNLOAD_COUNTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
})
.catch(() => {
downloadCounts = {}
})
}
function queueDownloadCountsWrite() {
downloadCountsWritePromise = downloadCountsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(downloadCounts, null, 2), 'utf8')
})
.catch(err => {
console.error('[download-counts] failed to write:', err)
})
}
function incrementDownloadCount(resourceKey) {
downloadCounts[resourceKey] = (downloadCounts[resourceKey] ?? 0) + 1
queueDownloadCountsWrite()
}
async function writeUploadsMetadata(metadata) {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
}
async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
return {
filename,
url: `/uploads/${filename}`,
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
}
}))
return withStats
}
async function invokeWebhook(url, action) {
if (!url) {
return { ok: false, message: `${action} webhook URL is not configured.` }
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action,
at: new Date().toISOString(),
source: 'siteforge-admin',
}),
})
if (!response.ok) {
return { ok: false, message: `${action} webhook failed with ${response.status}.` }
}
return { ok: true, message: `${action} webhook triggered.` }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }
}
}
const EMPTY_HIT_STATS = {
totalHits: 0,
realHits: 0,
botHits: 0,
firstHitAt: null,
lastHitAt: null,
byPath: {},
byPathReal: {},
byPathBot: {},
byDay: {},
byDayReal: {},
byDayBot: {},
botReasons: {},
}
let hitStats = { ...EMPTY_HIT_STATS }
let hitStatsWritePromise = Promise.resolve()
const VISITOR_COOKIE = 'vbn_vid'
const CONSENT_COOKIE = 'vbn_analytics_consent'
const MAX_RECENT_VISITS = 1000
const VISITOR_RETENTION_DAYS_DEFAULT = 180
const BACKUP_RETENTION_DAYS = 30
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const EMPTY_VISITOR_STATS = {
totalVisits: 0,
uniqueVisitors: 0,
returningVisits: 0,
firstVisitAt: null,
lastVisitAt: null,
visitors: {},
recentVisits: [],
geoCacheByIp: {},
}
const MAX_CONTACT_SUBMISSIONS = 5000
const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
const titusDownloadTokens = new Map()
const MAX_QUESTIONS = 1000
let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve()
let questions = []
let questionsWritePromise = 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 }
function sanitizeReplyTemplates(value) {
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
const out = value
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '',
}))
.filter(item => item.label && item.subject && item.message)
return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES]
}
function sanitizeReplyHistory(value) {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.slice(0, 500)
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
submissionId: typeof item.submissionId === 'string' ? item.submissionId : '',
toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '',
toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '',
fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '',
sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(),
}))
}
function normalizeIp(rawIp) {
if (!rawIp) return 'unknown'
let ip = String(rawIp).trim()
if (ip.includes(',')) {
ip = ip.split(',')[0].trim()
}
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7)
}
if (ip === '::1') {
ip = '127.0.0.1'
}
return ip || 'unknown'
}
function isPrivateOrLocalIp(ip) {
return (
ip === '127.0.0.1'
|| ip === 'localhost'
|| ip.startsWith('10.')
|| ip.startsWith('192.168.')
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|| ip.startsWith('fc')
|| ip.startsWith('fd')
|| ip.startsWith('fe80:')
|| ip === 'unknown'
)
}
function queueVisitorStatsWrite() {
visitorStatsWritePromise = visitorStatsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
VISITOR_STATS_FILE,
JSON.stringify({
...visitorStats,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
})
.catch(err => {
console.error('[visitor-stats] failed to write visitor stats:', err)
lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
})
}
function queueContactSubmissionsWrite() {
contactSubmissionsWritePromise = contactSubmissionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CONTACT_SUBMISSIONS_FILE,
JSON.stringify({
submissions: contactSubmissions,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[contact] failed to write submissions:', err)
})
}
function queueReplyTemplatesWrite() {
replyTemplatesWritePromise = replyTemplatesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_TEMPLATES_FILE,
JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-templates] failed to write templates:', err)
})
}
function queueReplyHistoryWrite() {
replyHistoryWritePromise = replyHistoryWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_HISTORY_FILE,
JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-history] failed to write history:', err)
})
}
function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
contactSubmissions = Array.isArray(parsed?.submissions)
? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
: []
})
.catch(() => {
contactSubmissions = []
})
}
function loadReplyTemplatesFromDisk() {
return readFile(REPLY_TEMPLATES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
replyTemplates = sanitizeReplyTemplates(parsed?.templates)
})
.catch(() => {
replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
})
}
function loadReplyHistoryFromDisk() {
return readFile(REPLY_HISTORY_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
replyHistory = sanitizeReplyHistory(parsed?.items)
})
.catch(() => {
replyHistory = []
})
}
function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general'
}
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true
}
function buildAdminReplyTemplate({ recipientName, message }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
function addContactSubmission({ name, email, message, messageType, subscribe }) {
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name,
email,
message,
messageType: normalizeMessageType(messageType),
subscribe: subscribe === true,
archived: false,
}
contactSubmissions.unshift(submission)
contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
return submission
}
async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try {
const { error: contactError } = await contactResend.contacts.create({
email,
firstName,
lastName,
unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID
? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] }
: {}),
})
if (contactError) {
const { error: updateError } = await contactResend.contacts.update({
email,
firstName,
lastName,
unsubscribed: false,
})
if (updateError) {
console.error('[resend] contact sync error:', updateError)
}
}
} catch (err) {
console.error('[resend] contact sync exception:', err)
}
}
function createTitusDownloadToken(email) {
const token = randomUUID()
titusDownloadTokens.set(token, {
email,
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
})
return token
}
function consumeTitusDownloadToken(token) {
const entry = titusDownloadTokens.get(token)
if (!entry) return false
titusDownloadTokens.delete(token)
if (entry.expiresAt <= Date.now()) return false
return true
}
function sanitizeUserAgent(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
return userAgent.trim().slice(0, 300) || 'unknown'
}
function detectBot(userAgent, pathInfo = {}) {
if (!userAgent || typeof userAgent !== 'string') {
return { isBot: true, reason: 'missing-user-agent' }
}
const ua = userAgent.toLowerCase()
// Search engine crawlers
if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
return { isBot: true, reason: 'search-crawler' }
}
// Social media crawlers
if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
return { isBot: true, reason: 'social-crawler' }
}
// Headless browsers and automation
if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
return { isBot: true, reason: 'headless-browser' }
}
// Monitoring and uptime checkers
if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
return { isBot: true, reason: 'monitoring-tool' }
}
// Security scanners and tools
if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
return { isBot: true, reason: 'security-scanner' }
}
// HTTP clients and frameworks
if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
return { isBot: true, reason: 'http-client' }
}
// Common crawler keywords
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
// But allow some common real user agents that might contain these words
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
return { isBot: true, reason: 'bot-keyword' }
}
}
return { isBot: false, reason: null }
}
async function resolveGeo(ip) {
if (!ip || isPrivateOrLocalIp(ip)) {
return {
country: 'Local/Unknown',
state: 'Local/Unknown',
county: 'Local/Unknown',
city: 'Local/Unknown',
}
}
const cached = visitorStats.geoCacheByIp[ip]
if (cached) {
return cached
}
const providers = [
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
{ signal: controller.signal },
)
if (!response.ok) return null
const data = await response.json()
if (data?.status !== 'success') return null
return {
country: data?.country || 'Unknown',
state: data?.regionName || 'Unknown',
county: data?.district || 'Unknown',
city: data?.city || 'Unknown',
}
} finally {
clearTimeout(timeout)
}
},
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
if (!response.ok) return null
const data = await response.json()
if (!data?.success) return null
return {
country: data?.country || 'Unknown',
state: data?.region || 'Unknown',
county: data?.region || 'Unknown',
city: data?.city || 'Unknown',
}
} finally {
clearTimeout(timeout)
}
},
]
for (const provider of providers) {
try {
const geo = await provider()
if (geo) {
visitorStats.geoCacheByIp[ip] = geo
queueVisitorStatsWrite()
return geo
}
} catch {
// Try next provider.
}
}
const fallback = {
country: 'Unknown',
state: 'Unknown',
county: 'Unknown',
city: 'Unknown',
}
visitorStats.geoCacheByIp[ip] = fallback
queueVisitorStatsWrite()
return fallback
}
async function recordVisitor(req, res) {
const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE]
if (!visitorId) {
visitorId = randomUUID()
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
const nowIso = new Date().toISOString()
const pathKey = normalizeHitPath(req.path)
const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent'))
const existingVisitor = visitorStats.visitors[visitorId]
const isReturning = Boolean(existingVisitor)
const geo = await resolveGeo(ip)
if (!existingVisitor) {
visitorStats.uniqueVisitors += 1
} else {
visitorStats.returningVisits += 1
}
const ipHash = createHash('sha256').update(ip).digest('hex')
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
visitorStats.visitors[visitorId] = {
visitorId,
ip,
ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso,
visitCount: nextVisitCount,
lastPath: pathKey,
returningVisitor: isReturning,
location: geo,
userAgents,
}
visitorStats.totalVisits += 1
visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso
visitorStats.lastVisitAt = nowIso
visitorStats.recentVisits.unshift({
at: nowIso,
visitorId,
ip,
path: pathKey,
country: geo.country,
state: geo.state,
county: geo.county,
city: geo.city,
returningVisitor: isReturning,
visitCount: nextVisitCount,
})
visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite()
}
function loadVisitorStatsFromDisk() {
return readFile(VISITOR_STATS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
visitorStats = {
totalVisits: Number(parsed?.totalVisits) || 0,
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
returningVisits: Number(parsed?.returningVisits) || 0,
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {},
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
}
})
.catch(() => {
visitorStats = { ...EMPTY_VISITOR_STATS }
})
}
function buildTopLocations(list, key) {
const counts = {}
for (const row of list) {
const val = row?.[key] || 'Unknown'
counts[val] = (counts[val] ?? 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, hits]) => ({ name, hits }))
}
function pruneStatsByDays(daysRaw) {
const days = Number(daysRaw)
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
const keepRecent = visitorStats.recentVisits.filter(v => {
const ts = new Date(v.at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
const nextVisitors = {}
for (const [id, data] of Object.entries(visitorStats.visitors)) {
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
nextVisitors[id] = data
}
}
const nextByDay = {}
const nextByDayReal = {}
const nextByDayBot = {}
for (const [day, count] of Object.entries(hitStats.byDay)) {
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
if (Number.isFinite(ts) && ts >= cutoff) {
nextByDay[day] = count
nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0
nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0
}
}
visitorStats.recentVisits = keepRecent
visitorStats.visitors = nextVisitors
visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
visitorStats.totalVisits = keepRecent.length
visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
hitStats.byDay = nextByDay
hitStats.byDayReal = nextByDayReal
hitStats.byDayBot = nextByDayBot
queueHitStatsWrite()
queueVisitorStatsWrite()
return {
retentionDays,
remainingVisits: visitorStats.totalVisits,
remainingVisitors: visitorStats.uniqueVisitors,
}
}
async function createBackupSnapshot(reason = 'scheduled') {
try {
await mkdir(BACKUP_DIR, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`)
const payload = {
createdAt: new Date().toISOString(),
reason,
adminContent: null,
draftContent: null,
publishState,
hitStats,
visitorStats,
contactSubmissions,
replyTemplates,
replyHistory,
}
try {
const contentRaw = await readFile(DATA_FILE, 'utf8')
payload.adminContent = JSON.parse(contentRaw)
} catch {
payload.adminContent = null
}
try {
const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
payload.draftContent = JSON.parse(draftRaw)
} catch {
payload.draftContent = null
}
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
const maxFiles = BACKUP_RETENTION_DAYS
if (files.length > maxFiles) {
const toDelete = files.slice(0, files.length - maxFiles)
await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {})))
}
lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) }
} catch (err) {
lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null }
console.error('[backup] failed to create snapshot:', err)
}
}
async function listBackupFiles() {
await mkdir(BACKUP_DIR, { recursive: true })
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse()
return files
}
async function readBackupPreview(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const [fileInfo, raw] = await Promise.all([
stat(fullPath),
readFile(fullPath, 'utf8'),
])
const parsed = JSON.parse(raw)
return {
filename,
sizeBytes: fileInfo.size,
createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null,
reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown',
adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null,
totalHits: Number(parsed?.hitStats?.totalHits) || 0,
totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0,
}
}
async function listBackupPreviews() {
const files = await listBackupFiles()
const previews = await Promise.all(files.map(async filename => {
try {
return await readBackupPreview(filename)
} catch {
return {
filename,
sizeBytes: 0,
createdAt: null,
reason: 'unknown',
adminUpdatedAt: null,
totalHits: 0,
totalVisits: 0,
}
}
}))
return previews
}
function sanitizeLoadedHitStats(value) {
return {
totalHits: Number(value?.totalHits) || 0,
firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null,
lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null,
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
}
}
function sanitizeLoadedVisitorStats(value) {
return {
totalVisits: Number(value?.totalVisits) || 0,
uniqueVisitors: Number(value?.uniqueVisitors) || 0,
returningVisits: Number(value?.returningVisits) || 0,
firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null,
lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null,
visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {},
recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {},
}
}
function sanitizeLoadedContactSubmissions(value) {
if (!Array.isArray(value)) return []
return value
.slice(0, MAX_CONTACT_SUBMISSIONS)
.filter(entry => entry && typeof entry === 'object')
.map(entry => ({
id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(),
submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(),
name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '',
email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '',
message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '',
messageType: normalizeMessageType(entry.messageType),
subscribe: entry.subscribe === true,
archived: entry.archived === true,
}))
}
async function restoreFromBackup(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const raw = await readFile(fullPath, 'utf8')
const parsed = JSON.parse(raw)
await createBackupSnapshot('pre-restore')
if (parsed?.adminContent && typeof parsed.adminContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
}
if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
}
if (parsed?.publishState && typeof parsed.publishState === 'object') {
publishState = {
draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
}
}
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
queueHitStatsWrite()
queueVisitorStatsWrite()
queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise])
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)
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/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 {
res.status(500).json({ message: 'Failed to persist admin draft content.' })
}
})
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 {
res.status(500).json({ message: 'Failed to publish draft content.' })
}
})
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 = []
})
}
// 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,
})
app.get('/api/admin-auth/status', async (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
configured: isAdminPasswordConfigured(),
totpEnabled: await isTotpEnabled(),
})
})
// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token.
// If TOTP is not yet configured, logs straight in (backwards compatible).
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!isAdminPasswordConfigured()) {
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
return
}
if (!isAdminPasswordValid(password)) {
res.status(401).json({ message: 'Invalid password.' })
return
}
const totpOn = await isTotpEnabled()
if (totpOn) {
const pendingToken = createPendingSession()
res.json({ totpRequired: true, pendingToken })
return
}
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
// Step 2a: verify TOTP code (or recovery code) after password was accepted
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
const { pendingToken, code } = req.body ?? {}
if (!consumePendingSession(pendingToken)) {
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
return
}
const state = await loadTotpState()
if (!state?.secret || !state?.verified) {
res.status(400).json({ message: 'TOTP is not configured.' })
return
}
const codeStr = typeof code === 'string' ? code.trim() : ''
// Try TOTP first
if (verifyTotpCode(state.secret, codeStr)) {
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
return
}
// Try recovery code
if (consumeRecoveryCode(state, codeStr)) {
await saveTotpState(state)
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length })
return
}
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})
// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password)
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
const secret = generateTotpSecret()
const uri = getTotpUri(secret)
const qrDataUrl = await qrcode.toDataURL(uri)
// Store unverified secret temporarily
const existing = await loadTotpState()
await saveTotpState({ ...existing, secret, verified: false })
res.json({ qrDataUrl, secret })
})
// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
const { code } = req.body ?? {}
const state = await loadTotpState()
if (!state?.secret) {
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
return
}
if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) {
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({
secret: state.secret,
verified: true,
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
enabledAt: new Date().toISOString(),
})
res.json({ ok: true, recoveryCodes })
})
// Disable TOTP (requires active admin session)
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
res.json({ ok: true })
})
// Regenerate recovery codes (requires active admin session)
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
const state = await loadTotpState()
if (!state?.secret || !state?.verified) {
res.status(400).json({ message: 'TOTP is not enabled.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
deleteAdminSession(sessionToken)
clearAdminSessionCookie(res)
res.json({ ok: true })
})
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
cachedSiteContent = safeSiteContent
publishState.publishedAt = updatedAt
res.json({ ok: true })
} catch {
res.status(500).json({ message: 'Failed to persist admin content.' })
}
})
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
res.json({ ok: true, consent })
})
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
const topPaths = Object.entries(hitStats.byPath)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const topPathsReal = Object.entries(hitStats.byPathReal)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const topPathsBot = Object.entries(hitStats.byPathBot)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const last7Days = buildLastNDaysStats(7)
const last7DaysReal = last7Days.map(item => ({
day: item.day,
hits: hitStats.byDayReal?.[item.day] ?? 0
}))
const last7DaysBot = last7Days.map(item => ({
day: item.day,
hits: hitStats.byDayBot?.[item.day] ?? 0
}))
const last30Days = buildLastNDaysStats(30)
const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayReal?.[item.day] ?? 0), 0)
const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayBot?.[item.day] ?? 0), 0)
const botReasons = Object.entries(hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([reason, count]) => ({ reason, count }))
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
res.json({
totalHits: hitStats.totalHits,
realHits: hitStats.realHits ?? 0,
botHits: hitStats.botHits ?? 0,
firstHitAt: hitStats.firstHitAt,
lastHitAt: hitStats.lastHitAt,
topPaths,
topPathsReal,
topPathsBot,
last7Days,
last7DaysReal,
last7DaysBot,
last30DaysTotal,
last30DaysRealTotal,
last30DaysBotTotal,
botReasons,
visitors: {
totalVisits: visitorStats.totalVisits,
uniqueVisitors: visitorStats.uniqueVisitors,
returningVisits: visitorStats.returningVisits,
firstVisitAt: visitorStats.firstVisitAt,
lastVisitAt: visitorStats.lastVisitAt,
topCountries: buildTopLocations(recentVisitorRows, 'country'),
topStates: buildTopLocations(recentVisitorRows, 'state'),
topCounties: buildTopLocations(recentVisitorRows, 'county'),
topCities: buildTopLocations(recentVisitorRows, 'city'),
recentVisits: recentVisitorRows,
},
writeStatus: {
hitStats: lastHitStatsWrite,
visitorStats: lastVisitorStatsWrite,
backups: lastBackupStatus,
cachePurge: lastCachePurgeStatus,
deployHook: lastDeployHookStatus,
},
contactTotals: {
totalSubmissions: contactSubmissions.length,
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
},
})
})
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: contactSubmissions.slice(0, 300) })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
const archived = req.body?.archived === true
let found = false
contactSubmissions = contactSubmissions.map(item => {
if (item.id !== id) return item
found = true
return { ...item, archived }
})
if (!found) {
res.status(404).json({ message: 'Submission not found.' })
return
}
queueContactSubmissionsWrite()
res.json({ ok: true, archived })
})
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({
fromEmail: 'hello@versebyversewithnate.us',
fromIdentity: ADMIN_REPLY_FROM,
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
canSendReplies: Boolean(process.env.RESEND_API_KEY),
note: process.env.RESEND_API_KEY
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
})
})
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
res.json({ templates: replyTemplates })
})
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
replyTemplates = nextTemplates
queueReplyTemplatesWrite()
res.json({ ok: true, templates: replyTemplates })
})
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
res.json({ items: replyHistory.slice(0, 100) })
})
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
const startLength = contactSubmissions.length
contactSubmissions = contactSubmissions.filter(item => item.id !== id)
if (contactSubmissions.length === startLength) {
res.status(404).json({ message: 'Submission not found.' })
return
}
queueContactSubmissionsWrite()
res.json({ ok: true })
})
app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
try {
if (!process.env.RESEND_API_KEY) {
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' })
return
}
const { id } = req.params
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
if (!id || typeof id !== 'string') {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
if (!subject || subject.length > 180) {
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' })
return
}
if (!message || message.length > 6000) {
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' })
return
}
const submission = contactSubmissions.find(entry => entry.id === id)
if (!submission) {
res.status(404).json({ message: 'Submission not found.' })
return
}
if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) {
res.status(400).json({ message: 'Submission does not have a valid email address.' })
return
}
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
const html = buildAdminReplyTemplate({ recipientName, message })
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us`
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: ADMIN_REPLY_FROM,
to: [submission.email],
subject,
replyTo: 'hello@versebyversewithnate.us',
text,
html,
})
if (error) throw error
replyHistory.unshift({
id: randomUUID(),
submissionId: submission.id,
toEmail: submission.email,
toName: submission.name,
fromEmail: 'hello@versebyversewithnate.us',
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
})
replyHistory = replyHistory.slice(0, 500)
queueReplyHistoryWrite()
res.json({ ok: true })
} catch (err) {
console.error('[admin-reply] send error:', err)
res.status(500).json({ message: 'Failed to send reply email.' })
}
})
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
res.json({ counts: downloadCounts })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.map(entry => ({
name: entry.name,
email: entry.email,
subscribedAt: entry.submittedAt,
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
}))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length })
})
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
const seen = new Set()
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.forEach(entry => {
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
rows.push([entry.name, entry.email, entry.submittedAt, source])
})
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null
let draftContent = null
try {
const raw = await readFile(DATA_FILE, 'utf8')
adminContent = JSON.parse(raw)
} catch {
adminContent = null
}
try {
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
draftContent = JSON.parse(rawDraft)
} catch {
draftContent = null
}
res.json({
exportedAt: new Date().toISOString(),
adminContent,
draftContent,
publishState,
hitStats,
visitorStats,
contactSubmissions,
replyTemplates,
replyHistory,
})
})
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
hitStats = { ...EMPTY_HIT_STATS }
visitorStats = { ...EMPTY_VISITOR_STATS }
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-clear').catch(() => {})
res.json({ ok: true })
})
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
const result = pruneStatsByDays(req.body?.days)
createBackupSnapshot('post-prune').catch(() => {})
res.json({ ok: true, ...result })
})
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
await createBackupSnapshot('manual')
res.json({ ok: true, backup: lastBackupStatus })
})
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
try {
const backups = await listBackupPreviews()
res.json({ backups })
} catch {
res.status(500).json({ message: 'Could not list backups.' })
}
})
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
const preview = await readBackupPreview(filename)
res.json({ preview })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
}
})
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
await restoreFromBackup(filename)
const backups = await listBackupPreviews()
res.json({ ok: true, restored: filename, backups })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
}
})
app.use((req, res, next) => {
if (shouldCountHit(req)) {
const ua = sanitizeUserAgent(req.get('user-agent'))
const botDetection = detectBot(ua)
recordHit(req.path, botDetection.isBot, botDetection.reason)
if (hasVisitorConsent(req) && !botDetection.isBot) {
recordVisitor(req, res).catch(err => {
console.error('[visitor-stats] failed to record visitor:', err)
})
}
}
next()
})
// Rate-limit contact submissions: max 5 per IP per 10 minutes
const contactHits = new Map()
const downloadHits = new Map()
function contactRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = contactHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) {
entry.count = 0
entry.start = now
}
entry.count += 1
contactHits.set(ip, entry)
if (entry.count > 5) {
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
return
}
next()
}
function studyDownloadRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = downloadHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) {
entry.count = 0
entry.start = now
}
entry.count += 1
downloadHits.set(ip, entry)
if (entry.count > 10) {
res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
return
}
next()
}
app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) {
res.json({ ok: true })
return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' })
return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' })
return
}
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' })
return
}
const published = await loadSiteContentFile(DATA_FILE)
const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
if (!configuredDownloadUrl) {
try {
await stat(TITUS_STUDY_FILE)
} catch {
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' })
return
}
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: 'Requested Titus study download.',
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount('titus-study')
if (configuredDownloadUrl) {
res.json({ ok: true, downloadUrl: configuredDownloadUrl })
return
}
const token = createTitusDownloadToken(trimmedEmail)
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
} catch (err) {
console.error('[study-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
try {
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) {
res.json({ ok: true })
return
}
if (!resourceId || typeof resourceId !== 'string') {
res.status(400).json({ message: 'Resource ID is required.' })
return
}
const published = await loadSiteContentFile(DATA_FILE)
const siteContent = published?.siteContent
function resolveResourceFromId(id) {
if (!siteContent || typeof siteContent !== 'object') return null
const customResources = Array.isArray(siteContent.customLinks)
? siteContent.customLinks.filter(link => link?.placement === 'resources')
: []
if (id.startsWith('custom:')) {
const customId = id.slice('custom:'.length)
const match = customResources.find(link => link.id === customId)
return match ? { label: match.label, url: match.url } : null
}
if (id.startsWith('archived:')) {
const [, seriesId, ...linkIdParts] = id.split(':')
const linkId = linkIdParts.join(':')
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
const series = archivedSeries.find(item => item.id === seriesId)
const link = Array.isArray(series?.resourceLinks)
? series.resourceLinks.find(item => item.id === linkId)
: null
return link ? { label: link.label || series?.title, url: link.url } : null
}
const customMatch = customResources.find(link => link.id === id)
if (customMatch) return { label: customMatch.label, url: customMatch.url }
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
for (const series of archivedSeries) {
if (!Array.isArray(series?.resourceLinks)) continue
const link = series.resourceLinks.find(item => item.id === id)
if (link) return { label: link.label || series?.title, url: link.url }
}
return null
}
const resource = resolveResourceFromId(resourceId)
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
res.status(400).json({ message: 'Resource not found.' })
return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' })
return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' })
return
}
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' })
return
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: `Requested resource download: ${resource.label ?? resource.url}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount(`resource:${resourceId}`)
res.json({ ok: true, downloadUrl: resource.url.trim() })
} catch (err) {
console.error('[resource-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/study-downloads/titus/file', async (req, res) => {
const token = typeof req.query?.token === 'string' ? req.query.token : ''
if (!token || !consumeTitusDownloadToken(token)) {
res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' })
return
}
try {
await stat(TITUS_STUDY_FILE)
res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
} catch {
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
}
})
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}
// Honeypot — silently discard if filled by a bot
if (_honey) {
res.json({ ok: true })
return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' })
return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' })
return
}
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' })
return
}
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' })
return
}
if (!process.env.RESEND_API_KEY) {
console.error('[contact] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'The contact form is not yet configured on the server.' })
return
}
const trimmedName = `${firstName.trim()} ${lastName.trim()}`.trim()
const trimmedEmail = email.trim()
const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType)
const submittedAt = new Date().toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})
const shouldSendWelcome = shouldSendWelcomeEmail({
subscribe,
})
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
messageType: normalizedMessageType,
subscribe,
})
// If this is a question, also add to questions array for public Q&A section
if (normalizedMessageType === 'question') {
const question = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
firstName: splitName(trimmedName).firstName,
email: trimmedEmail,
question: trimmedMessage,
answer: '',
answeredAt: null,
isApproved: false,
approvedAt: null,
}
questions.unshift(question)
questions = questions.slice(0, MAX_QUESTIONS)
if (draftQuestions !== null) {
draftQuestions.unshift(question)
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
}
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
if (subscribe === true) {
await syncContactToResend(trimmedName, trimmedEmail)
}
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
let publishedSiteContent = cachedSiteContent
if (!publishedSiteContent) {
try {
const published = await loadSiteContentFile(DATA_FILE)
publishedSiteContent = published?.siteContent ?? null
} catch {
publishedSiteContent = null
}
}
const emailConfig = publishedSiteContent ?? {}
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
? emailConfig.seo.canonicalUrl.trim()
: DEFAULT_SEO.canonicalUrl
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
const welcomeHeading = greetingName
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
: `${escapeHtml(welcomeGreetingPrefix)}.`
const welcomeIntro = emailConfig.welcomeEmailIntro?.trim()
|| 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.'
const welcomeCurrentSeries = emailConfig.welcomeEmailCurrentSeries?.trim()
|| 'Right now we\'re working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it\'s rooted in sound doctrine.'
const welcomeStartHereTitle = emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus'
const welcomeStartHereSummary = emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.'
const welcomeExpect1 = emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.'
const welcomeExpect2 = emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.'
const welcomeExpect3 = emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.'
const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.'
const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
const welcomeSpotifyUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
)
const welcomeAppleUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple',
)
const welcomeAmazonUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon',
)
const welcomeWebsiteUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/',
)
const welcomeEpisodeUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here',
)
const welcomeImageUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
)
const { error: welcomeError } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [trimmedEmail],
subject: welcomeSubject,
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`Spotify: ${welcomeSpotifyUrl}\n` +
`Apple Podcasts: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html:
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
`</td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
})
if (welcomeError) throw welcomeError
}
if (shouldSendWelcome) {
res.json({ ok: true })
return
}
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
replyTo: trimmedEmail,
subject: `Verse by Verse contact form: ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
})
if (error) throw error
res.json({ ok: true })
} catch (err) {
console.error('[contact] send error:', err)
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
}
})
// Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions: draftQuestions ?? questions })
})
// Get only approved public questions (for homepage)
app.get('/api/questions', (_req, res) => {
const sourceQuestions = draftQuestions ?? questions
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
res.json({ questions: publicQuestions })
})
// Create a manual question (admin)
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
const approveNow = req.body?.approve === true
if (!firstName || firstName.length > 100) {
res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' })
return
}
if (!questionText || questionText.length < 5 || questionText.length > 3000) {
res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' })
return
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ message: 'If provided, email must be a valid email address.' })
return
}
if (answerText.length > 5000) {
res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' })
return
}
ensureDraftQuestions()
const now = new Date().toISOString()
const created = {
id: randomUUID(),
submittedAt: now,
firstName,
email,
question: questionText,
answer: answerText,
answeredAt: answerText ? now : null,
isApproved: approveNow,
approvedAt: approveNow ? now : null,
}
draftQuestions.unshift(created)
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
res.status(201).json({ ok: true, question: created })
})
// Answer a question (admin)
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
const { id } = req.params
const { answer } = req.body ?? {}
if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' })
return
}
ensureDraftQuestions()
const question = draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
}
question.answer = answer.trim()
question.answeredAt = new Date().toISOString()
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
// Approve/unapprove a question (admin)
app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
const { id } = req.params
const { approved } = req.body ?? {}
ensureDraftQuestions()
const question = draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
}
question.isApproved = approved === true
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
// Delete a question (admin)
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const index = draftQuestions.findIndex(q => q.id === id)
if (index === -1) {
res.status(404).json({ message: 'Question not found.' })
return
}
draftQuestions.splice(index, 1)
queueDraftQuestionsWrite()
res.json({ ok: true })
})
// ── Episodes (RSS feed proxy) ──────────────────────────────────────────────
const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss'
let episodesCache = null
let episodesCacheAt = 0
const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes
function extractCdata(raw) {
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
const title = extractCdata(titleRaw)
if (!title) continue
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
const guid = extractCdata(guidRaw)
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
const link = guid.startsWith('http') ? guid : enclosureUrl
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
items.push({
title,
pubDate,
link,
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
duration,
episode,
})
}
return items
}
async function fetchAllEpisodes() {
const now = Date.now()
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
return episodesCache
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
const episodes = parseRssItems(xml)
episodesCache = episodes
episodesCacheAt = now
return episodes
}
function toSpotifyEpisodeEmbedUrl(urlValue) {
if (!urlValue) return ''
try {
const parsed = new URL(urlValue)
if (parsed.protocol !== 'https:') return ''
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch {
return ''
}
return ''
}
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
const input = String(html || '')
if (!input) return ''
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
let match
let firstEpisodeId = ''
while ((match = blockRegex.exec(input)) !== null) {
const episodeSlug = match[1]
const spotifyUrl = decodeEscapedJsonUrl(match[2])
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) {
return episodeId
}
}
if (firstEpisodeId) return firstEpisodeId
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
return urlMatch ? (urlMatch[2] || '') : ''
}
function isAllowedSpotifyResolverHost(hostname) {
const host = String(hostname || '').toLowerCase()
return host === 'open.spotify.com'
|| host === 'creators.spotify.com'
|| host === 'anchor.fm'
|| host === 'podcasters.spotify.com'
}
function decodeEscapedJsonUrl(value) {
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
}
app.get('/api/spotify/embed-url', async (req, res) => {
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
const safeInput = sanitizeUrl(incoming)
if (!safeInput || safeInput.startsWith('/')) {
res.status(400).json({ message: 'A valid episode URL is required.' })
return
}
let parsed
try {
parsed = new URL(safeInput)
} catch {
res.status(400).json({ message: 'Malformed URL.' })
return
}
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
res.status(400).json({ message: 'Unsupported episode URL host.' })
return
}
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
if (directEmbed) {
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' })
return
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(safeInput, {
signal: controller.signal,
headers: {
'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)',
Accept: 'text/html',
},
})
clearTimeout(timeout)
if (!response.ok) {
res.status(404).json({ message: 'Could not fetch episode page.' })
return
}
const html = await response.text()
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
if (!spotifyEpisodeId) {
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' })
return
}
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
} catch (err) {
console.error('[spotify/embed-url] resolve error:', err.message)
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
}
})
app.get('/api/episodes', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes: episodes.slice(0, 6) })
} catch (err) {
console.error('[episodes] RSS fetch error:', err.message)
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
}
})
app.get('/api/episodes/all', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes })
} catch (err) {
console.error('[episodes/all] RSS fetch error:', err.message)
res.json({ episodes: episodesCache ?? [] })
}
})
app.get('/robots.txt', async (_req, res) => {
let content = cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch {
content = {}
}
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send(
[
'User-agent: *',
'Allow: /',
`Sitemap: ${root}/sitemap.xml`,
].join('\n'),
)
})
app.get('/sitemap.xml', async (_req, res) => {
let content = cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch {
content = {}
}
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
? seo.sitemapPaths
: DEFAULT_SEO.sitemapPaths
const urls = paths
.map(item => normalizeSitemapPath(item))
.filter(Boolean)
.map(item => `${root}${item}`)
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
'</urlset>',
].join('\n')
res.type('application/xml').send(xml)
})
app.use((req, res, next) => {
const rules = sanitizeRedirectRules(cachedSiteContent?.redirects)
const match = rules.find(rule => rule.path === req.path)
if (!match) {
next()
return
}
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
})
app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR))
// Per-question social share stub — serves question-specific OG tags so
// platforms (X, Facebook) render a rich preview card. After the crawl
// delay the page immediately redirects the human visitor to the real SPA URL.
app.get('/questions/share/:id', (req, res) => {
const id = req.params.id
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions')
return
}
const sourceQuestions = draftQuestions ?? questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions')
return
}
const BASE = 'https://versebyversewithnate.us'
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
const ogTitle = escapeHtml(question.question.length > 100
? `${question.question.slice(0, 97)}\u2026`
: question.question)
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
const ogDescription = escapeHtml(answerSnippet.length > 200
? `${answerSnippet.slice(0, 197)}\u2026`
: answerSnippet)
const ogImage = `${BASE}/images/banner.png`
res.type('html').send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>${ogTitle} — Verse by Verse with Nate</title>
<meta name="description" content="${ogDescription}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
<meta property="og:title" content="${ogTitle}"/>
<meta property="og:description" content="${ogDescription}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${ogTitle}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${ogTitle}"/>
<meta name="twitter:description" content="${ogDescription}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
</head>
<body></body>
</html>`)
})
app.use(express.static(DIST_DIR))
app.use(async (_req, res) => {
try {
const html = await readFile(INDEX_FILE, 'utf8')
res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent))
} catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.')
}
})
const PORT = Number(process.env.PORT ?? 4173)
Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadDownloadCountsFromDisk(),
refreshContentCaches(),
])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})
.finally(() => {
createBackupSnapshot('startup').catch(() => {})
setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
app.listen(PORT, () => {
console.log(`Portfolio app listening on http://localhost:${PORT}`)
})
})