Fix admin publish data loss for unreleased lessons and harden data persistence
This commit is contained in:
@@ -7,6 +7,7 @@ services:
|
|||||||
- "4173:4173"
|
- "4173:4173"
|
||||||
environment:
|
environment:
|
||||||
- PORT=4173
|
- PORT=4173
|
||||||
|
- SITEFORGE_DATA_DIR=/app/data
|
||||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||||
- RESEND_API_KEY=`set your Resend API key here`
|
- RESEND_API_KEY=`set your Resend API key here`
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
DATA_DIR=/app/data
|
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
|
||||||
SEED_DIR=/app/data-seed
|
SEED_DIR=/app/data-seed
|
||||||
|
|
||||||
# Ensure the data directory exists (in case the volume was not mounted)
|
# 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.json" "$SEED_DIR/admin-content.json"
|
||||||
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.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
|
exec node server.js
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ import {
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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 DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
|
||||||
const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.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 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() {
|
async function refreshContentCaches() {
|
||||||
try {
|
try {
|
||||||
const published = await loadSiteContentFile(DATA_FILE)
|
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) => {
|
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ checklist: podcastChecklist })
|
res.json({ checklist: podcastChecklist })
|
||||||
})
|
})
|
||||||
@@ -1583,8 +1637,10 @@ app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
|
|||||||
publishState.draftUpdatedAt = updatedAt
|
publishState.draftUpdatedAt = updatedAt
|
||||||
|
|
||||||
res.json({ ok: true, updatedAt })
|
res.json({ ok: true, updatedAt })
|
||||||
} catch {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Failed to persist admin draft content.' })
|
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')
|
await createBackupSnapshot('post-publish')
|
||||||
|
|
||||||
res.json({ ok: true, publishedAt })
|
res.json({ ok: true, publishedAt })
|
||||||
} catch {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Failed to publish draft content.' })
|
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}` })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -691,11 +691,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reloadContentFromServer() {
|
async function reloadContentFromServer() {
|
||||||
const r = await fetch('/api/admin-content')
|
const draftRes = await fetch('/api/admin-content?source=draft')
|
||||||
if (!r.ok) return
|
if (draftRes.ok) {
|
||||||
const data = await r.json() as { siteContent?: Partial<SiteContent> }
|
const draftData = await draftRes.json() as { siteContent?: Partial<SiteContent> }
|
||||||
if (data?.siteContent && typeof data.siteContent === 'object') {
|
if (draftData?.siteContent && typeof draftData.siteContent === 'object') {
|
||||||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
|
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<SiteContent> }
|
||||||
|
if (publishedData?.siteContent && typeof publishedData.siteContent === 'object') {
|
||||||
|
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...publishedData.siteContent })
|
||||||
setForm(next)
|
setForm(next)
|
||||||
onSave(next)
|
onSave(next)
|
||||||
}
|
}
|
||||||
@@ -771,7 +782,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const published = await publishRes.json() as { publishedAt?: string }
|
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')
|
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
||||||
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
||||||
if (latest?.siteContent) {
|
if (latest?.siteContent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user