Reorganize admin workflow and persist episode discussion questions
This commit is contained in:
@@ -26,12 +26,14 @@ 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 DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.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 UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
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')
|
||||
@@ -40,6 +42,312 @@ 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 DEFAULT_REDIRECT_RULES = [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
]
|
||||
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.',
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
}
|
||||
const DEFAULT_LEGAL = {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'Content on this site is for informational and ministry purposes.',
|
||||
'External links are provided for convenience and are subject to third-party policies.',
|
||||
'By using this site, you agree to lawful use and respectful communication.',
|
||||
],
|
||||
}
|
||||
const DEFAULT_PODCAST_FEATURED_LINKS = []
|
||||
const DEFAULT_PUBLISH_STATE = {
|
||||
draftUpdatedAt: null,
|
||||
publishedAt: null,
|
||||
}
|
||||
|
||||
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, ''')
|
||||
}
|
||||
|
||||
async function loadSiteContentFile(filePath) {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const safeSiteContent = sanitizeSiteContent(parsed?.siteContent)
|
||||
return {
|
||||
...parsed,
|
||||
siteContent: safeSiteContent,
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshContentCaches() {
|
||||
try {
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
cachedSiteContent = published.siteContent
|
||||
if (typeof published?.updatedAt === 'string') {
|
||||
publishState.publishedAt = published.updatedAt
|
||||
}
|
||||
} catch {
|
||||
cachedSiteContent = null
|
||||
}
|
||||
|
||||
try {
|
||||
const draft = await loadSiteContentFile(DRAFT_DATA_FILE)
|
||||
cachedDraftSiteContent = draft.siteContent
|
||||
if (typeof draft?.updatedAt === 'string') {
|
||||
publishState.draftUpdatedAt = draft.updatedAt
|
||||
}
|
||||
} catch {
|
||||
cachedDraftSiteContent = null
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
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 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()}`
|
||||
}
|
||||
|
||||
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 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 withStats = await Promise.all(imageFiles.map(async filename => {
|
||||
const info = await stat(path.join(UPLOADS_DIR, filename))
|
||||
return {
|
||||
filename,
|
||||
url: `/uploads/${filename}`,
|
||||
sizeBytes: info.size,
|
||||
updatedAt: info.mtime.toISOString(),
|
||||
}
|
||||
}))
|
||||
|
||||
return withStats
|
||||
}
|
||||
|
||||
async function invokeWebhook(url, action) {
|
||||
if (!url) {
|
||||
return { ok: false, message: `${action} webhook URL is not configured.` }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
at: new Date().toISOString(),
|
||||
source: 'siteforge-admin',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, message: `${action} webhook failed with ${response.status}.` }
|
||||
}
|
||||
|
||||
return { ok: true, message: `${action} webhook triggered.` }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_HIT_STATS = {
|
||||
totalHits: 0,
|
||||
@@ -87,6 +395,8 @@ 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 }
|
||||
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||||
const adminSessions = new Map()
|
||||
|
||||
function sha256(value) {
|
||||
@@ -561,6 +871,8 @@ async function createBackupSnapshot(reason = 'scheduled') {
|
||||
createdAt: new Date().toISOString(),
|
||||
reason,
|
||||
adminContent: null,
|
||||
draftContent: null,
|
||||
publishState,
|
||||
hitStats,
|
||||
visitorStats,
|
||||
contactSubmissions,
|
||||
@@ -573,6 +885,13 @@ async function createBackupSnapshot(reason = 'scheduled') {
|
||||
payload.adminContent = null
|
||||
}
|
||||
|
||||
try {
|
||||
const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
|
||||
payload.draftContent = JSON.parse(draftRaw)
|
||||
} catch {
|
||||
payload.draftContent = null
|
||||
}
|
||||
|
||||
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
|
||||
|
||||
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
|
||||
@@ -682,6 +1001,18 @@ async function restoreFromBackup(filename) {
|
||||
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
if (parsed?.publishState && typeof parsed.publishState === 'object') {
|
||||
publishState = {
|
||||
draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
|
||||
publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
|
||||
}
|
||||
}
|
||||
|
||||
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
|
||||
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
|
||||
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||||
@@ -691,6 +1022,7 @@ async function restoreFromBackup(filename) {
|
||||
queueContactSubmissionsWrite()
|
||||
|
||||
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise])
|
||||
await refreshContentCaches()
|
||||
await createBackupSnapshot('post-restore')
|
||||
}
|
||||
|
||||
@@ -785,16 +1117,199 @@ const app = express()
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.set('trust proxy', true)
|
||||
|
||||
app.get('/api/admin-content', async (_req, res) => {
|
||||
app.get('/api/admin-content', async (req, res) => {
|
||||
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
||||
if (source === 'draft' && !isValidAdminSession(req)) {
|
||||
res.status(401).json({ message: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(DATA_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||
res.json(parsed)
|
||||
} catch {
|
||||
if (source === 'draft') {
|
||||
res.status(404).json({ message: 'No saved draft content file yet.' })
|
||||
return
|
||||
}
|
||||
res.status(404).json({ message: 'No saved admin content file yet.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
publishState,
|
||||
hasDraft: Boolean(cachedDraftSiteContent),
|
||||
hasPublished: Boolean(cachedSiteContent),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/site-config', async (_req, res) => {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
const siteContent = parsed.siteContent ?? {}
|
||||
|
||||
res.json({
|
||||
seo: siteContent.seo ?? DEFAULT_SEO,
|
||||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||||
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
|
||||
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
publishState,
|
||||
updatedAt: parsed.updatedAt ?? null,
|
||||
})
|
||||
} catch {
|
||||
res.json({
|
||||
seo: DEFAULT_SEO,
|
||||
legal: DEFAULT_LEGAL,
|
||||
redirects: DEFAULT_REDIRECT_RULES,
|
||||
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
publishState,
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin-content-draft', 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
|
||||
}
|
||||
|
||||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DRAFT_DATA_FILE,
|
||||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
cachedDraftSiteContent = safeSiteContent
|
||||
publishState.draftUpdatedAt = updatedAt
|
||||
|
||||
res.json({ ok: true, updatedAt })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to persist admin draft content.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const source = cachedDraftSiteContent
|
||||
? { siteContent: cachedDraftSiteContent, updatedAt: publishState.draftUpdatedAt ?? new Date().toISOString() }
|
||||
: await loadSiteContentFile(DRAFT_DATA_FILE)
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DATA_FILE,
|
||||
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
cachedSiteContent = source.siteContent
|
||||
publishState.publishedAt = publishedAt
|
||||
await createBackupSnapshot('post-publish')
|
||||
|
||||
res.json({ ok: true, publishedAt })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to publish draft content.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const assets = await listUploadedAssets()
|
||||
res.json({ assets })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Could not list uploaded assets.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
|
||||
return
|
||||
}
|
||||
|
||||
const base64 = dataUrl.split(',')[1] ?? ''
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
|
||||
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
|
||||
return
|
||||
}
|
||||
|
||||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||||
const finalName = `${baseName}-${Date.now()}${ext}`
|
||||
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
|
||||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Upload failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/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
|
||||
}
|
||||
|
||||
await unlink(path.join(UPLOADS_DIR, filename))
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
buildCommit: process.env.BUILD_COMMIT ?? null,
|
||||
buildNumber: process.env.BUILD_NUMBER ?? null,
|
||||
deployedAt: process.env.DEPLOYED_AT ?? null,
|
||||
cachePurge: lastCachePurgeStatus,
|
||||
deployHook: lastDeployHookStatus,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
|
||||
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
|
||||
lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||||
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: result.message })
|
||||
})
|
||||
|
||||
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
|
||||
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
|
||||
lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||||
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: result.message })
|
||||
})
|
||||
|
||||
// ── Chatbot knowledge base ──────────────────────────────────────────────────
|
||||
const MAX_CHATBOT_ENTRIES = 500
|
||||
let chatbotEntries = []
|
||||
@@ -961,13 +1476,19 @@ app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DATA_FILE,
|
||||
JSON.stringify({ siteContent, updatedAt: new Date().toISOString() }, null, 2),
|
||||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
cachedSiteContent = safeSiteContent
|
||||
publishState.publishedAt = updatedAt
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to persist admin content.' })
|
||||
@@ -1011,6 +1532,8 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
hitStats: lastHitStatsWrite,
|
||||
visitorStats: lastVisitorStatsWrite,
|
||||
backups: lastBackupStatus,
|
||||
cachePurge: lastCachePurgeStatus,
|
||||
deployHook: lastDeployHookStatus,
|
||||
},
|
||||
contactTotals: {
|
||||
totalSubmissions: contactSubmissions.length,
|
||||
@@ -1021,6 +1544,7 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
|
||||
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
|
||||
let adminContent = null
|
||||
let draftContent = null
|
||||
try {
|
||||
const raw = await readFile(DATA_FILE, 'utf8')
|
||||
adminContent = JSON.parse(raw)
|
||||
@@ -1028,9 +1552,18 @@ app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
|
||||
adminContent = null
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
|
||||
draftContent = JSON.parse(rawDraft)
|
||||
} catch {
|
||||
draftContent = null
|
||||
}
|
||||
|
||||
res.json({
|
||||
exportedAt: new Date().toISOString(),
|
||||
adminContent,
|
||||
draftContent,
|
||||
publishState,
|
||||
hitStats,
|
||||
visitorStats,
|
||||
contactSubmissions,
|
||||
@@ -1470,34 +2003,97 @@ app.get('/api/episodes', async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/spotify', (_req, res) => {
|
||||
res.redirect(301, 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl')
|
||||
app.get('/robots.txt', async (_req, res) => {
|
||||
let content = cachedSiteContent
|
||||
if (!content) {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
content = parsed.siteContent
|
||||
} catch {
|
||||
content = {}
|
||||
}
|
||||
}
|
||||
|
||||
const seo = content?.seo ?? DEFAULT_SEO
|
||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
|
||||
res.type('text/plain').send(
|
||||
[
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
`Sitemap: ${root}/sitemap.xml`,
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/apple', (_req, res) => {
|
||||
res.redirect(301, 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate')
|
||||
app.get('/sitemap.xml', async (_req, res) => {
|
||||
let content = cachedSiteContent
|
||||
if (!content) {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
content = parsed.siteContent
|
||||
} catch {
|
||||
content = {}
|
||||
}
|
||||
}
|
||||
|
||||
const seo = content?.seo ?? DEFAULT_SEO
|
||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
|
||||
? seo.sitemapPaths
|
||||
: DEFAULT_SEO.sitemapPaths
|
||||
|
||||
const urls = paths
|
||||
.map(item => normalizeSitemapPath(item))
|
||||
.filter(Boolean)
|
||||
.map(item => `${root}${item}`)
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
|
||||
'</urlset>',
|
||||
].join('\n')
|
||||
|
||||
res.type('application/xml').send(xml)
|
||||
})
|
||||
|
||||
app.get('/amazon', (_req, res) => {
|
||||
res.redirect(301, 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate')
|
||||
app.use((req, res, next) => {
|
||||
const rules = sanitizeRedirectRules(cachedSiteContent?.redirects)
|
||||
const match = rules.find(rule => rule.path === req.path)
|
||||
if (!match) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
|
||||
})
|
||||
|
||||
app.use('/images', express.static(DIST_IMAGES_DIR))
|
||||
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
|
||||
app.use('/uploads', express.static(UPLOADS_DIR))
|
||||
|
||||
app.use(express.static(DIST_DIR))
|
||||
|
||||
app.use(async (_req, res) => {
|
||||
try {
|
||||
const html = await readFile(INDEX_FILE, 'utf8')
|
||||
res.type('html').send(html)
|
||||
res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent))
|
||||
} 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()])
|
||||
Promise.all([
|
||||
loadHitStatsFromDisk(),
|
||||
loadVisitorStatsFromDisk(),
|
||||
loadContactSubmissionsFromDisk(),
|
||||
loadQuestionsFromDisk(),
|
||||
loadChatbotFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
.catch(err => {
|
||||
console.error('[stats] failed to load persisted stats:', err)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user