import express from 'express' 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' function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function splitName(fullName) { const parts = fullName.trim().split(/\s+/).filter(Boolean) return { firstName: parts[0] ?? '', lastName: parts.slice(1).join(' '), } } 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 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 CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') 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 EMPTY_HIT_STATS = { totalHits: 0, firstHitAt: null, lastHitAt: null, byPath: {}, byDay: {}, } let hitStats = { ...EMPTY_HIT_STATS } let hitStatsWritePromise = Promise.resolve() const VISITOR_COOKIE = 'vbn_vid' const CONSENT_COOKIE = 'vbn_analytics_consent' const ADMIN_SESSION_COOKIE = 'vbn_admin_session' 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 ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password' 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 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 } const adminSessions = new Map() function sha256(value) { return createHash('sha256').update(value).digest('hex') } function isAdminPasswordConfigured() { return ADMIN_PASSWORD !== 'change-me-admin-password' } function isValidAdminSession(req) { const cookies = parseCookies(req.headers.cookie) const sessionToken = cookies[ADMIN_SESSION_COOKIE] if (!sessionToken) return false const expiresAt = adminSessions.get(sessionToken) if (!expiresAt) return false if (expiresAt <= Date.now()) { adminSessions.delete(sessionToken) return false } adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) return true } function setAdminSessionCookie(res, token) { res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax`) } function clearAdminSessionCookie(res) { res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`) } function requireAdminAuth(req, res, next) { if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Unauthorized' }) return } next() } 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 getClientIp(req) { const forwarded = req.headers['x-forwarded-for'] if (forwarded) { return normalizeIp(forwarded) } return normalizeIp(req.ip) } function parseCookies(cookieHeader) { if (!cookieHeader) return {} return cookieHeader .split(';') .map(v => v.trim()) .filter(Boolean) .reduce((acc, part) => { const idx = part.indexOf('=') if (idx === -1) return acc const key = part.slice(0, idx).trim() const value = part.slice(idx + 1).trim() try { acc[key] = decodeURIComponent(value) } catch { acc[key] = value } return acc }, {}) } function hasVisitorConsent(req) { const cookies = parseCookies(req.headers.cookie) return cookies[CONSENT_COOKIE] === 'yes' } function setConsentCookie(res, consent) { const value = consent ? 'yes' : 'no' res.append('Set-Cookie', `${CONSENT_COOKIE}=${value}; Max-Age=31536000; Path=/; SameSite=Lax`) } 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 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 normalizeMessageType(value) { if (value === 'question' || value === 'testimony' || value === 'topic') return value return 'general' } function addContactSubmission({ name, email, message, messageType, subscribe }) { const submission = { id: randomUUID(), submittedAt: new Date().toISOString(), name, email, message, messageType: normalizeMessageType(messageType), subscribe: subscribe === true, } 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' } 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 = {} 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 } } 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 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, hitStats, visitorStats, contactSubmissions, } try { const contentRaw = await readFile(DATA_FILE, 'utf8') payload.adminContent = JSON.parse(contentRaw) } catch { payload.adminContent = 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) } 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') } hitStats = sanitizeLoadedHitStats(parsed?.hitStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) queueHitStatsWrite() queueVisitorStatsWrite() queueContactSubmissionsWrite() await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise]) 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) { 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 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, 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 : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, } }) .catch(() => { hitStats = { ...EMPTY_HIT_STATS } }) } const app = express() app.use(express.json({ limit: '10mb' })) app.set('trust proxy', true) app.get('/api/admin-content', async (_req, res) => { try { const raw = await readFile(DATA_FILE, 'utf8') const parsed = JSON.parse(raw) res.json(parsed) } catch { res.status(404).json({ message: 'No saved admin content file yet.' }) } }) // ── Chatbot knowledge base ────────────────────────────────────────────────── const MAX_CHATBOT_ENTRIES = 500 let chatbotEntries = [] let chatbotWritePromise = Promise.resolve() let chatbotFileMtimeMs = 0 function queueChatbotWrite() { chatbotWritePromise = chatbotWritePromise .then(async () => { await mkdir(DATA_DIR, { recursive: true }) await writeFile( CHATBOT_FILE, JSON.stringify(chatbotEntries, null, 2), 'utf8', ) }) .catch(err => { console.error('[chatbot] failed to write chatbot content:', err) }) } async function loadChatbotFromDisk() { try { const [fileStats, raw] = await Promise.all([ stat(CHATBOT_FILE), readFile(CHATBOT_FILE, 'utf8'), ]) const parsed = JSON.parse(raw) chatbotEntries = Array.isArray(parsed) ? parsed.slice(0, MAX_CHATBOT_ENTRIES) : [] chatbotFileMtimeMs = fileStats.mtimeMs } catch { chatbotEntries = [] chatbotFileMtimeMs = 0 } } async function refreshChatbotFromDiskIfChanged() { try { const fileStats = await stat(CHATBOT_FILE) if (fileStats.mtimeMs <= chatbotFileMtimeMs) return await loadChatbotFromDisk() } catch { if (chatbotFileMtimeMs === 0) return chatbotEntries = [] chatbotFileMtimeMs = 0 } } // Public: return all chatbot entries for client-side matching app.get('/api/chatbot-content', async (req, res) => { await refreshChatbotFromDiskIfChanged() res.json(chatbotEntries) }) // Admin: get all entries app.get('/api/admin/chatbot-content', async (req, res) => { if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return } await refreshChatbotFromDiskIfChanged() res.json(chatbotEntries) }) // Admin: save full list (replace all) app.post('/api/admin/chatbot-content', (req, res) => { if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return } const body = req.body if (!Array.isArray(body)) { res.status(400).json({ message: 'Expected array.' }); return } const sanitized = body .filter(e => e && typeof e.title === 'string' && typeof e.content === 'string') .slice(0, MAX_CHATBOT_ENTRIES) .map(e => ({ id: typeof e.id === 'string' && e.id ? e.id : randomUUID(), type: ['qa', 'topic', 'episode'].includes(e.type) ? e.type : 'qa', title: String(e.title).trim().slice(0, 500), content: String(e.content).trim().slice(0, 4000), sourceLabel: typeof e.sourceLabel === 'string' ? e.sourceLabel.trim().slice(0, 160) : '', priority: e.priority === true, keywords: Array.isArray(e.keywords) ? e.keywords.filter(k => typeof k === 'string').map(k => k.trim().toLowerCase()).slice(0, 20) : [], createdAt: typeof e.createdAt === 'string' ? e.createdAt : new Date().toISOString(), updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : new Date().toISOString(), })) chatbotEntries = sanitized queueChatbotWrite() res.json({ ok: true, count: chatbotEntries.length }) }) 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 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 = [] }) } app.get('/api/admin-auth/status', (req, res) => { res.json({ authenticated: isValidAdminSession(req), configured: isAdminPasswordConfigured(), }) }) app.post('/api/admin-auth/login', (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 (sha256(password) !== sha256(ADMIN_PASSWORD)) { res.status(401).json({ message: 'Invalid password.' }) return } const sessionToken = randomUUID() adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) setAdminSessionCookie(res, sessionToken) res.json({ ok: true }) }) app.post('/api/admin-auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const sessionToken = cookies[ADMIN_SESSION_COOKIE] if (sessionToken) { adminSessions.delete(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 } await mkdir(DATA_DIR, { recursive: true }) await writeFile( DATA_FILE, JSON.stringify({ siteContent, updatedAt: new Date().toISOString() }, null, 2), 'utf8', ) 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 recentVisitorRows = visitorStats.recentVisits.slice(0, 100) res.json({ totalHits: hitStats.totalHits, firstHitAt: hitStats.firstHitAt, lastHitAt: hitStats.lastHitAt, topPaths, last7Days: buildLastNDaysStats(7), last30DaysTotal: buildLastNDaysStats(30).reduce((sum, item) => sum + item.hits, 0), 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, }, contactTotals: { totalSubmissions: contactSubmissions.length, totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length, }, }) }) app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { let adminContent = null try { const raw = await readFile(DATA_FILE, 'utf8') adminContent = JSON.parse(raw) } catch { adminContent = null } res.json({ exportedAt: new Date().toISOString(), adminContent, hitStats, visitorStats, contactSubmissions, }) }) 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)) { recordHit(req.path) if (hasVisitorConsent(req)) { 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 } try { await stat(TITUS_STUDY_FILE) } catch { res.status(503).json({ message: 'The Titus study file 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) } 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.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', }) 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) queueQuestionsWrite() } const resend = new Resend(process.env.RESEND_API_KEY) if (subscribe === true) { await syncContactToResend(trimmedName, trimmedEmail) } const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', 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: `
` + `
` + `
` + `
Verse by Verse with Nate
` + `

New Contact Form Submission

` + `
` + `
` + `

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

` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + `
` + `
Message
` + `
${escapeHtml(trimmedMessage)}
` + `
` + `
` + `
` + `
`, }) 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 }) }) // Get only approved public questions (for homepage) app.get('/api/questions', (_req, res) => { const publicQuestions = questions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) res.json({ questions: publicQuestions }) }) // 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 } const question = questions.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() queueQuestionsWrite() 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 ?? {} const question = questions.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 queueQuestionsWrite() res.json({ ok: true, question }) }) // Delete a question (admin) app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { const { id } = req.params const index = questions.findIndex(q => q.id === id) if (index === -1) { res.status(404).json({ message: 'Question not found.' }) return } questions.splice(index, 1) queueQuestionsWrite() res.json({ ok: true }) }) // ── Episodes (RSS feed proxy) ────────────────────────────────────────────── const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss' let episodesCache = null let episodesCacheAt = 0 const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes function extractCdata(raw) { const cdata = /^$/.exec(raw.trim()) return cdata ? cdata[1].trim() : raw.trim() } function parseRssItems(xml, limit = 6) { const items = [] const itemRegex = /([\s\S]*?)<\/item>/g let match while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { const block = match[1] const titleRaw = /([\s\S]*?)<\/title>/.exec(block)?.[1] ?? '' const title = extractCdata(titleRaw) if (!title) continue const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim() const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? '' const guid = extractCdata(guidRaw) const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? '' const link = guid.startsWith('http') ? guid : enclosureUrl const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? '' const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() items.push({ title, pubDate, link, description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), duration, episode, }) } return items } app.get('/api/episodes', async (_req, res) => { const now = Date.now() if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) { return res.json({ episodes: episodesCache }) } try { 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, 6) episodesCache = episodes episodesCacheAt = now res.json({ episodes }) } catch (err) { console.error('[episodes] RSS fetch error:', err.message) res.json({ episodes: episodesCache ?? [] }) } }) app.use(express.static(DIST_DIR)) app.use(async (_req, res) => { try { const html = await readFile(INDEX_FILE, 'utf8') res.type('html').send(html) } 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(), loadQuestionsFromDisk(), loadChatbotFromDisk()]) .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}`) }) })