Beta: admin assets, resource download forms, and resource page redesign

This commit is contained in:
nmemmert
2026-04-30 15:47:10 -04:00
parent 10bd6848ef
commit 7f56060d6b
14 changed files with 2000 additions and 1029 deletions
+250 -280
View File
@@ -4,23 +4,31 @@ 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function splitName(fullName) {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
return {
firstName: parts[0] ?? '',
lastName: parts.slice(1).join(' '),
}
}
import {
sanitizeSiteContent,
escapeHtml,
escapeXml,
buildAbsoluteUrl,
injectSeoIntoHtml,
normalizeAssetBaseName,
inferImageExtensionFromDataUrl,
getClientIp,
hasVisitorConsent,
setConsentCookie,
splitName,
parseCookies,
} from './server/helpers.js'
import {
isAdminPasswordConfigured,
isValidAdminSession,
requireAdminAuth,
setAdminSessionCookie,
clearAdminSessionCookie,
createAdminSession,
deleteAdminSession,
validateAdminPasswordSetup,
isAdminPasswordValid,
} from './server/auth.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -31,13 +39,16 @@ const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images')
validateAdminPasswordSetup()
const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
? path.resolve(__dirname, process.env.TITUS_STUDY_FILE)
: path.join(__dirname, 'A_Study_of_Titus.pdf')
@@ -62,6 +73,50 @@ const DEFAULT_REDIRECT_RULES = [
statusCode: 301,
},
]
function normalizeRedirectPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const normalized = withSlash.replace(/\/+/g, '/')
if (normalized === '/') return ''
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
return normalized
}
function sanitizeUrl(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) return trimmed
if (/^https?:\/\//i.test(trimmed)) return trimmed
return ''
}
function sanitizeRedirectRules(value) {
const source = Array.isArray(value) ? value : []
const seen = new Set()
const out = []
for (const item of source) {
const pathValue = normalizeRedirectPath(item?.path)
const target = sanitizeUrl(item?.target)
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
if (!pathValue || !target) continue
if (seen.has(pathValue)) continue
seen.add(pathValue)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
path: pathValue,
target,
statusCode,
})
}
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}
const DEFAULT_SEO = {
title: 'Verse by Verse with Nate',
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
@@ -95,130 +150,8 @@ const DEFAULT_PUBLISH_STATE = {
let cachedSiteContent = null
let cachedDraftSiteContent = null
let publishState = { ...DEFAULT_PUBLISH_STATE }
function sanitizeUrl(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) return trimmed
if (/^https?:\/\//i.test(trimmed)) return trimmed
return ''
}
function normalizeRedirectPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const normalized = withSlash.replace(/\/+/g, '/')
if (normalized === '/') return ''
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
return normalized
}
function normalizeSitemapPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return normalizeRedirectPath(trimmed)
}
function sanitizeRedirectRules(value) {
const source = Array.isArray(value) ? value : []
const seen = new Set()
const out = []
for (const item of source) {
const pathValue = normalizeRedirectPath(item?.path)
const target = sanitizeUrl(item?.target)
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
if (!pathValue || !target) continue
if (seen.has(pathValue)) continue
seen.add(pathValue)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
path: pathValue,
target,
statusCode,
})
}
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}
function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const discussionQuestions = Array.isArray(item.discussionQuestions)
? item.discussionQuestions
.filter(question => typeof question === 'string')
.map(question => question.trim())
.filter(Boolean)
.slice(0, 30)
: []
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
url: sanitizeUrl(item.url),
embedUrl: sanitizeUrl(item.embedUrl),
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
discussionQuestions,
}
})
.filter(item => item.title || item.summary || item.url || item.embedUrl || item.showNotes || item.discussionQuestions.length > 0)
}
function sanitizeSiteContent(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
return {
...siteContent,
redirects: sanitizeRedirectRules(siteContent.redirects),
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
seo: {
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
sitemapPaths: Array.isArray(seo.sitemapPaths)
? seo.sitemapPaths
.map(pathItem => normalizeSitemapPath(pathItem))
.filter(Boolean)
: [...DEFAULT_SEO.sitemapPaths],
},
legal: {
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.privacyBody],
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.termsBody],
},
}
}
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
let draftQuestions = null
let draftQuestionsWritePromise = Promise.resolve()
async function loadSiteContentFile(filePath) {
const raw = await readFile(filePath, 'utf8')
@@ -252,61 +185,50 @@ async function refreshContentCaches() {
}
}
function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
const value = maybeRelativePath.trim()
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('/')) return `${root}${value}`
return `${root}/${value}`
async function loadDraftQuestionsFromDisk() {
return readFile(DRAFT_QUESTIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
draftQuestions = parsed.slice(0, MAX_QUESTIONS)
} else if (Array.isArray(parsed?.questions)) {
draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS)
} else {
draftQuestions = null
}
if (typeof parsed?.updatedAt === 'string') {
publishState.draftUpdatedAt = parsed.updatedAt
}
})
.catch(() => {
draftQuestions = null
})
}
function injectSeoIntoHtml(html, siteContent) {
const seo = siteContent?.seo ?? DEFAULT_SEO
const title = seo.title || DEFAULT_SEO.title
const description = seo.description || DEFAULT_SEO.description
const ogTitle = seo.ogTitle || title
const ogDescription = seo.ogDescription || description
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
return html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
.replace(/<meta name="description" content="[^"]*"\s*\/>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
.replace(/<meta name="robots" content="[^"]*"\s*\/>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
.replace(/<meta property="og:title" content="[^"]*"\s*\/>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
.replace(/<meta property="og:image" content="[^"]*"\s*\/>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:url" content="[^"]*"\s*\/>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
.replace(/<link rel="canonical" href="[^"]*"\s*\/>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
function ensureDraftQuestions() {
if (draftQuestions !== null) return
draftQuestions = questions.slice(0, MAX_QUESTIONS)
}
function normalizeAssetBaseName(name) {
if (typeof name !== 'string') return `upload-${Date.now()}`
const cleaned = name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || `upload-${Date.now()}`
async function readUploadsMetadata() {
try {
const raw = await readFile(UPLOADS_META_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
function inferImageExtensionFromDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
return null
async function writeUploadsMetadata(metadata) {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
}
async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
@@ -315,6 +237,7 @@ async function listUploadedAssets() {
url: `/uploads/${filename}`,
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
}
}))
@@ -362,13 +285,11 @@ 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,
@@ -397,47 +318,6 @@ let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
let lastCachePurgeStatus = { ok: true, at: null, error: null }
let lastDeployHookStatus = { ok: true, at: null, error: null }
const 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'
@@ -459,45 +339,6 @@ function normalizeIp(rawIp) {
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'
@@ -1214,6 +1055,17 @@ app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
cachedSiteContent = source.siteContent
publishState.publishedAt = publishedAt
if (draftQuestions !== null) {
questions = draftQuestions.slice(0, MAX_QUESTIONS)
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
QUESTIONS_FILE,
JSON.stringify({ questions, updatedAt: publishedAt }, null, 2),
'utf8',
)
}
await createBackupSnapshot('post-publish')
res.json({ ok: true, publishedAt })
@@ -1254,6 +1106,9 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
} catch {
@@ -1261,6 +1116,31 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
}
})
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
const tags = Array.isArray(req.body?.tags)
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
: []
const filePath = path.join(UPLOADS_DIR, filename)
await stat(filePath)
const metadata = await readUploadsMetadata()
metadata[filename] = tags
await writeUploadsMetadata(metadata)
res.json({ ok: true, tags })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
@@ -1270,6 +1150,9 @@ app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) =>
}
await unlink(path.join(UPLOADS_DIR, filename))
const metadata = await readUploadsMetadata()
delete metadata[filename]
await writeUploadsMetadata(metadata)
res.json({ ok: true })
} catch {
res.status(404).json({ message: 'Asset not found.' })
@@ -1415,6 +1298,23 @@ function queueQuestionsWrite() {
})
}
function queueDraftQuestionsWrite() {
if (draftQuestions === null) return
draftQuestionsWritePromise = draftQuestionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DRAFT_QUESTIONS_FILE,
JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
publishState.draftUpdatedAt = new Date().toISOString()
})
.catch(err => {
console.error('[draft-questions] failed to write draft questions:', err)
})
}
function loadQuestionsFromDisk() {
return readFile(QUESTIONS_FILE, 'utf8')
.then(raw => {
@@ -1446,13 +1346,12 @@ app.post('/api/admin-auth/login', (req, res) => {
return
}
if (sha256(password) !== sha256(ADMIN_PASSWORD)) {
if (!isAdminPasswordValid(password)) {
res.status(401).json({ message: 'Invalid password.' })
return
}
const sessionToken = randomUUID()
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
@@ -1460,9 +1359,7 @@ app.post('/api/admin-auth/login', (req, res) => {
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)
}
deleteAdminSession(sessionToken)
clearAdminSessionCookie(res)
res.json({ ok: true })
})
@@ -1728,6 +1625,70 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
}
})
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
try {
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) {
res.json({ ok: true })
return
}
if (!resourceId || typeof resourceId !== 'string') {
res.status(400).json({ message: 'Resource ID is required.' })
return
}
const published = await loadSiteContentFile(DATA_FILE)
const resource = Array.isArray(published?.siteContent?.customLinks)
? published.siteContent.customLinks.find(link => link.id === resourceId && link.placement === 'resources')
: undefined
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
res.status(400).json({ message: 'Resource not found.' })
return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' })
return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' })
return
}
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' })
return
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: `Requested resource download: ${resource.label ?? resource.url}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
res.json({ ok: true, downloadUrl: resource.url.trim() })
} catch (err) {
console.error('[resource-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/study-downloads/titus/file', async (req, res) => {
const token = typeof req.query?.token === 'string' ? req.query.token : ''
if (!token || !consumeTitusDownloadToken(token)) {
@@ -1808,6 +1769,11 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
}
questions.unshift(question)
questions = questions.slice(0, MAX_QUESTIONS)
if (draftQuestions !== null) {
draftQuestions.unshift(question)
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
}
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
@@ -1874,7 +1840,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
// Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions })
res.json({ questions: draftQuestions ?? questions })
})
// Get only approved public questions (for homepage)
@@ -1893,7 +1859,8 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
return
}
const question = questions.find(q => q.id === id)
ensureDraftQuestions()
const question = draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
@@ -1901,7 +1868,7 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
question.answer = answer.trim()
question.answeredAt = new Date().toISOString()
queueQuestionsWrite()
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
@@ -1911,7 +1878,8 @@ 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)
ensureDraftQuestions()
const question = draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
@@ -1919,7 +1887,7 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
question.isApproved = approved === true
question.approvedAt = approved === true ? new Date().toISOString() : null
queueQuestionsWrite()
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
@@ -1927,15 +1895,16 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
// 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)
ensureDraftQuestions()
const index = draftQuestions.findIndex(q => q.id === id)
if (index === -1) {
res.status(404).json({ message: 'Question not found.' })
return
}
questions.splice(index, 1)
queueQuestionsWrite()
draftQuestions.splice(index, 1)
queueDraftQuestionsWrite()
res.json({ ok: true })
})
@@ -2091,6 +2060,7 @@ Promise.all([
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadChatbotFromDisk(),
refreshContentCaches(),
])