Beta: admin assets, resource download forms, and resource page redesign
This commit is contained in:
@@ -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, '<')
|
||||
.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(' '),
|
||||
}
|
||||
}
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
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(),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user