From bd047b029c443bab89a7a5509aa9f7a3dccbf59b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 21 May 2026 12:39:28 -0400 Subject: [PATCH] Fix admin publish data loss for unreleased lessons and harden data persistence --- docker-compose.yml | 1 + entrypoint.sh | 3 +- server.js | 68 ++++++++++++++++++++++++++++++++++++++++++---- src/AdminPage.tsx | 23 ++++++++++++---- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 00ca674..ec48419 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "4173:4173" environment: - PORT=4173 + - SITEFORGE_DATA_DIR=/app/data - ADMIN_PASSWORD=`generate a random password and set it here` - RESEND_API_KEY=`set your Resend API key here` volumes: diff --git a/entrypoint.sh b/entrypoint.sh index dfd41fa..7f7e964 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -DATA_DIR=/app/data +DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data} SEED_DIR=/app/data-seed # Ensure the data directory exists (in case the volume was not mounted) @@ -210,4 +210,5 @@ NODE merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" +export SITEFORGE_DATA_DIR="$DATA_DIR" exec node server.js diff --git a/server.js b/server.js index 2bbc87b..ac4ad12 100644 --- a/server.js +++ b/server.js @@ -46,7 +46,11 @@ import { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const DATA_DIR = path.join(__dirname, 'data') +const DEFAULT_DATA_DIR = path.join(__dirname, 'data') +const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : '' +const DATA_DIR = configuredDataDir + ? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(__dirname, configuredDataDir)) + : DEFAULT_DATA_DIR 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') @@ -386,6 +390,51 @@ async function loadSiteContentFile(filePath) { } } +async function checkDataDirWritable() { + try { + await mkdir(DATA_DIR, { recursive: true }) + const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`) + await writeFile(marker, 'ok', 'utf8') + await unlink(marker) + return { ok: true, error: null } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' } + } +} + +async function getStorageStatus() { + const writable = await checkDataDirWritable() + const files = {} + for (const [key, filePath] of Object.entries({ + adminContent: DATA_FILE, + adminContentDraft: DRAFT_DATA_FILE, + studyUsers: STUDY_USERS_FILE, + })) { + try { + const fileStat = await stat(filePath) + files[key] = { + path: filePath, + exists: true, + sizeBytes: fileStat.size, + mtime: fileStat.mtime.toISOString(), + } + } catch { + files[key] = { + path: filePath, + exists: false, + sizeBytes: 0, + mtime: null, + } + } + } + + return { + dataDir: DATA_DIR, + writable, + files, + } +} + async function refreshContentCaches() { try { const published = await loadSiteContentFile(DATA_FILE) @@ -1520,6 +1569,11 @@ app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => { }) }) +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: podcastChecklist }) }) @@ -1583,8 +1637,10 @@ app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => { publishState.draftUpdatedAt = updatedAt res.json({ ok: true, updatedAt }) - } catch { - res.status(500).json({ message: 'Failed to persist admin draft content.' }) + } 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}` }) } }) @@ -1619,8 +1675,10 @@ app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => { await createBackupSnapshot('post-publish') res.json({ ok: true, publishedAt }) - } catch { - res.status(500).json({ message: 'Failed to publish draft content.' }) + } 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}` }) } }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 37bf577..cd3c4d9 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -691,11 +691,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } async function reloadContentFromServer() { - const r = await fetch('/api/admin-content') - if (!r.ok) return - const data = await r.json() as { siteContent?: Partial } - if (data?.siteContent && typeof data.siteContent === 'object') { - const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent }) + const draftRes = await fetch('/api/admin-content?source=draft') + if (draftRes.ok) { + const draftData = await draftRes.json() as { siteContent?: Partial } + if (draftData?.siteContent && typeof draftData.siteContent === 'object') { + const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...draftData.siteContent }) + setForm(next) + onSave(next) + return + } + } + + const publishedRes = await fetch('/api/admin-content') + if (!publishedRes.ok) return + const publishedData = await publishedRes.json() as { siteContent?: Partial } + if (publishedData?.siteContent && typeof publishedData.siteContent === 'object') { + const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...publishedData.siteContent }) setForm(next) onSave(next) } @@ -771,7 +782,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } const published = await publishRes.json() as { publishedAt?: string } - const latestRes = await fetch('/api/admin-content') + const latestRes = await fetch('/api/admin-content?source=draft') if (!latestRes.ok) throw new Error('Failed to refresh published content') const latest = await latestRes.json() as { siteContent?: Partial } if (latest?.siteContent) {