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 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 CHATBOT_LLM_ENABLED = process.env.CHATBOT_LLM_ENABLED === 'true' const CHATBOT_LLM_BASE_URL = process.env.CHATBOT_LLM_BASE_URL ?? 'http://127.0.0.1:11434' const CHATBOT_LLM_MODEL = process.env.CHATBOT_LLM_MODEL ?? 'qwen2.5:3b-instruct' const CHATBOT_LLM_TIMEOUT_MS = Number(process.env.CHATBOT_LLM_TIMEOUT_MS) > 0 ? Number(process.env.CHATBOT_LLM_TIMEOUT_MS) : 25000 const CHATBOT_LLM_NUM_CTX = Number(process.env.CHATBOT_LLM_NUM_CTX) > 0 ? Number(process.env.CHATBOT_LLM_NUM_CTX) : 2048 const EMPTY_VISITOR_STATS = { totalVisits: 0, uniqueVisitors: 0, returningVisits: 0, firstVisitAt: null, lastVisitAt: null, visitors: {}, recentVisits: [], geoCacheByIp: {}, } const MAX_CONTACT_SUBMISSIONS = 5000 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() const llmResponseCache = new Map() const MAX_CACHE_SIZE = 500 const CACHE_TTL_MS = 60 * 60 * 1000 function getCacheKey(question, draftAnswer) { return sha256(`${question}||${draftAnswer}`).slice(0, 16) } function getCachedResponse(cacheKey) { const cached = llmResponseCache.get(cacheKey) if (cached && Date.now() - cached.at < CACHE_TTL_MS) { return cached.text } if (cached) { llmResponseCache.delete(cacheKey) } return null } function setCachedResponse(cacheKey, text) { if (llmResponseCache.size >= MAX_CACHE_SIZE) { const firstKey = llmResponseCache.keys().next().value if (firstKey) llmResponseCache.delete(firstKey) } llmResponseCache.set(cacheKey, { text, at: Date.now() }) } function sha256(value) 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 } 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 } } function normalizeChatbotLlmContext(chunks) { if (!Array.isArray(chunks)) return [] return chunks .filter(chunk => chunk && typeof chunk.content === 'string') .slice(0, 4) .map((chunk, index) => { const title = typeof chunk.title === 'string' ? chunk.title.trim().slice(0, 160) : `Context ${index + 1}` const sourceLabel = typeof chunk.sourceLabel === 'string' && chunk.sourceLabel.trim() ? chunk.sourceLabel.trim().slice(0, 200) : title const content = chunk.content.trim().slice(0, 1400) return { title, sourceLabel, content, } }) .filter(chunk => chunk.content.length > 0) } function buildGroundedRewritePrompt({ question, draftAnswer, sources, contextChunks }) { const sourceList = Array.isArray(sources) && sources.length > 0 ? sources.slice(0, 6).map(source => `- ${String(source).slice(0, 220)}`).join('\n') : '- No explicit source labels provided' const contextBlock = contextChunks.length > 0 ? contextChunks .map((chunk, index) => ( `Context ${index + 1}: ${chunk.sourceLabel}\nTitle: ${chunk.title}\nExcerpt: ${chunk.content}` )) .join('\n\n') : 'No context excerpts were provided.' return [ 'You are a Bible study assistant helping rewrite responses to be clearer and more pastoral.', '', 'HARD RULES:', '1) Use ONLY facts from DRAFT ANSWER and CONTEXT EXCERPTS. Never add new information.', '2) If the draft is uncertain or incomplete, preserve that. Do not fill gaps or speculate.', '3) Do not invent verses, names, historical details, or theological claims.', '4) Maintain a warm, encouraging pastoral tone—like Nate teaching directly.', '5) Keep sentences clear and direct. Avoid jargon unless biblical.', '6) Return plain text only. Use paragraph breaks but no markdown formatting.', '', 'GOALS:', '- Help the reader understand Scripture better', '- Stay faithful to Nate\'s teaching and tone', '- Be encouraging but honest about limitations', '', `QUESTION:\n${question}`, '', `DRAFT ANSWER:\n${draftAnswer}`, '', `SOURCES:\n${sourceList}`, '', `CONTEXT EXCERPTS:\n${contextBlock}`, ].join('\n') } async function rewriteWithChatbotLlm({ question, draftAnswer, sources, contextChunks }) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), CHATBOT_LLM_TIMEOUT_MS) const cacheKey = getCacheKey(question, draftAnswer) const cached = getCachedResponse(cacheKey) if (cached) { return cached } try { const prompt = buildGroundedRewritePrompt({ question, draftAnswer, sources, contextChunks, }) const response = await fetch(`${CHATBOT_LLM_BASE_URL.replace(/\/$/, '')}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ model: CHATBOT_LLM_MODEL, stream: false, options: { temperature: 0.2, num_ctx: CHATBOT_LLM_NUM_CTX, }, messages: [ { role: 'system', content: 'Rewrite grounded answers faithfully. Never add information not present in the provided draft/context.', }, { role: 'user', content: prompt, }, ], }), }) if (!response.ok) { const text = await response.text() throw new Error(`LLM request failed (${response.status}): ${text.slice(0, 220)}`) } const payload = await response.json() const rewritten = typeof payload?.message?.content === 'string' ? payload.message.content.trim() : '' if (!rewritten) { throw new Error('LLM returned an empty response') } const result = rewritten.slice(0, 3500) setCachedResponse(cacheKey, result) return result } finally { clearTimeout(timeout) } } // Public: return all chatbot entries for client-side matching app.get('/api/chatbot-content', async (req, res) => { await refreshChatbotFromDiskIfChanged() res.json(chatbotEntries) }) // Optional: grounded rewrite endpoint for local Ollama usage. app.post('/api/chatbot-grounded-rewrite', async (req, res) => { if (!CHATBOT_LLM_ENABLED) { res.status(503).json({ ok: false, message: 'Chatbot LLM is disabled. Set CHATBOT_LLM_ENABLED=true to enable.', }) return } const question = typeof req.body?.question === 'string' ? req.body.question.trim() : '' const draftAnswer = typeof req.body?.draftAnswer === 'string' ? req.body.draftAnswer.trim() : '' const sources = Array.isArray(req.body?.sources) ? req.body.sources.filter(source => typeof source === 'string').slice(0, 8) : [] const contextChunks = normalizeChatbotLlmContext(req.body?.contextChunks) if (!question) { res.status(400).json({ ok: false, message: 'Missing required field: question' }) return } if (!draftAnswer) { res.status(400).json({ ok: false, message: 'Missing required field: draftAnswer' }) return } try { const rewrittenAnswer = await rewriteWithChatbotLlm({ question, draftAnswer, sources, contextChunks, }) res.json({ ok: true, text: rewrittenAnswer, model: CHATBOT_LLM_MODEL, grounded: true, }) } catch (err) { console.error('[chatbot-llm] rewrite failed:', err) res.status(502).json({ ok: false, message: 'LLM rewrite failed. Returning deterministic draft is recommended.', error: String(err), }) } }) // 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() 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() } app.post('/api/contact', contactRateLimit, async (req, res) => { try { const { name, email, message, messageType, subscribe, _honey } = req.body ?? {} // Honeypot — silently discard if filled by a bot if (_honey) { res.json({ ok: true }) return } if (!name || typeof name !== 'string' || name.trim().length < 1 || name.trim().length > 200) { res.status(400).json({ message: '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 = name.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) { const { firstName, lastName } = splitName(trimmedName) const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) try { const { error: contactError } = await contactResend.contacts.create({ email: trimmedEmail, 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: trimmedEmail, firstName, lastName, unsubscribed: false, }) if (updateError) { console.error('[contact] contact sync error:', updateError) } } } catch (contactSyncErr) { console.error('[contact] contact sync exception:', contactSyncErr) } } 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 }) }) 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}`) }) })