1523 lines
49 KiB
JavaScript
1523 lines
49 KiB
JavaScript
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, '"')
|
|
.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 PODCAST_ART_DIST_FILE = path.join(DIST_DIR, 'images', 'podcast-art.jpeg')
|
|
const PODCAST_ART_PUBLIC_FILE = path.join(__dirname, 'public', 'images', 'podcast-art.jpeg')
|
|
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 <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 })
|
|
})
|
|
|
|
// 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 = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
|
|
return cdata ? cdata[1].trim() : raw.trim()
|
|
}
|
|
|
|
function parseRssItems(xml, limit = 6) {
|
|
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
|
|
}
|
|
|
|
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.get('/spotify', (_req, res) => {
|
|
res.redirect(301, 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl')
|
|
})
|
|
|
|
app.get('/apple', (_req, res) => {
|
|
res.redirect(301, 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate')
|
|
})
|
|
|
|
app.get('/amazon', (_req, res) => {
|
|
res.redirect(301, 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate')
|
|
})
|
|
|
|
app.get('/images/podcast-art.jpeg', (_req, res) => {
|
|
res.sendFile(PODCAST_ART_DIST_FILE, distErr => {
|
|
if (!distErr) return
|
|
|
|
res.sendFile(PODCAST_ART_PUBLIC_FILE, publicErr => {
|
|
if (publicErr) {
|
|
res.redirect(301, 'https://necloud.us/images/podcast-art.jpeg')
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
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}`)
|
|
})
|
|
})
|