refractor server.js
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { sanitizeSiteContent } from '../helpers.js'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import {
|
||||
DATA_DIR,
|
||||
DATA_FILE,
|
||||
DRAFT_DATA_FILE,
|
||||
QUESTIONS_FILE,
|
||||
DEFAULT_SEO,
|
||||
DEFAULT_LEGAL,
|
||||
DEFAULT_REDIRECT_RULES,
|
||||
DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
MAX_QUESTIONS,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
loadSiteContentFile,
|
||||
getStorageStatus,
|
||||
refreshContentCaches,
|
||||
queueHitStatsWrite,
|
||||
queueVisitorStatsWrite,
|
||||
queueContactSubmissionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queuePodcastChecklistWrite,
|
||||
createBackupSnapshot,
|
||||
listBackupPreviews,
|
||||
readBackupPreview,
|
||||
restoreFromBackup,
|
||||
sanitizePodcastChecklist,
|
||||
} from '../data.js'
|
||||
import {
|
||||
pruneStatsByDays,
|
||||
filterSiteContentByReleaseDate,
|
||||
} from '../study-helpers.js'
|
||||
|
||||
function invokeWebhook(url, action) {
|
||||
if (!url) {
|
||||
return Promise.resolve({ ok: false, message: `${action} webhook URL is not configured.` })
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin' }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) return { ok: false, message: `${action} webhook failed with ${response.status}.` }
|
||||
return { ok: true, message: `${action} webhook triggered.` }
|
||||
})
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
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 parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||
if (source === 'published') {
|
||||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
||||
res.json({ ...parsed, siteContent: safeSiteContent })
|
||||
return
|
||||
}
|
||||
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: state.publishState,
|
||||
hasDraft: Boolean(state.cachedDraftSiteContent),
|
||||
hasPublished: Boolean(state.cachedSiteContent),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
|
||||
const status = await getStorageStatus()
|
||||
res.json(status)
|
||||
})
|
||||
|
||||
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||||
res.json({ checklist: state.podcastChecklist })
|
||||
})
|
||||
|
||||
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
||||
state.podcastChecklist = safeChecklist
|
||||
await queuePodcastChecklistWrite()
|
||||
res.json({ ok: true, checklist: safeChecklist })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to save podcast checklist.' })
|
||||
}
|
||||
})
|
||||
|
||||
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: state.publishState,
|
||||
updatedAt: parsed.updatedAt ?? null,
|
||||
})
|
||||
} catch {
|
||||
res.json({
|
||||
seo: DEFAULT_SEO,
|
||||
legal: DEFAULT_LEGAL,
|
||||
redirects: DEFAULT_REDIRECT_RULES,
|
||||
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
publishState: state.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',
|
||||
)
|
||||
|
||||
state.cachedDraftSiteContent = safeSiteContent
|
||||
state.publishState.draftUpdatedAt = updatedAt
|
||||
|
||||
res.json({ ok: true, updatedAt })
|
||||
} catch (err) {
|
||||
console.error('[admin-content-draft] persist error:', err)
|
||||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||||
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const source = state.cachedDraftSiteContent
|
||||
? { siteContent: state.cachedDraftSiteContent, updatedAt: state.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',
|
||||
)
|
||||
|
||||
state.cachedSiteContent = source.siteContent
|
||||
state.publishState.publishedAt = publishedAt
|
||||
|
||||
if (state.draftQuestions !== null) {
|
||||
state.questions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
QUESTIONS_FILE,
|
||||
JSON.stringify({ questions: state.questions, updatedAt: publishedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
await createBackupSnapshot('post-publish')
|
||||
res.json({ ok: true, publishedAt })
|
||||
} catch (err) {
|
||||
console.error('[admin-content-publish] persist error:', err)
|
||||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||||
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DATA_FILE,
|
||||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
state.cachedSiteContent = safeSiteContent
|
||||
state.publishState.publishedAt = updatedAt
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to persist admin content.' })
|
||||
}
|
||||
})
|
||||
|
||||
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: state.lastCachePurgeStatus,
|
||||
deployHook: state.lastDeployHookStatus,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
|
||||
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
|
||||
state.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')
|
||||
state.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 })
|
||||
})
|
||||
|
||||
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)
|
||||
} catch { 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: state.publishState,
|
||||
hitStats: state.hitStats,
|
||||
visitorStats: state.visitorStats,
|
||||
contactSubmissions: state.contactSubmissions,
|
||||
replyTemplates: state.replyTemplates,
|
||||
replyHistory: state.replyHistory,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
|
||||
state.hitStats = { ...EMPTY_HIT_STATS }
|
||||
state.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)
|
||||
queueHitStatsWrite()
|
||||
queueVisitorStatsWrite()
|
||||
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: state.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.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user