Add full /data backup export/import for server migration

- New admin endpoints: GET /api/admin-backup/export streams the entire
  data directory as tar.gz (after flushing queued writes); POST
  /api/admin-backup/import validates the archive, snapshots current
  data, replaces the folder, and reloads all in-memory state.
- Admin UI: "Full Data Backup" section with download and restore-from-
  file controls in the Analytics panel.
- Fix admin TOTP secret path to honor SITEFORGE_DATA_DIR (moved base
  path resolution to server/paths.js to avoid a circular import), so
  2FA for admin and study users survives backup/restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-07 12:08:23 -04:00
parent 17c9cbbc8b
commit f4fd177421
9 changed files with 371 additions and 17 deletions
+50
View File
@@ -1066,6 +1066,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [fullBackupBusy, setFullBackupBusy] = useState(false)
// TOTP management state
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
@@ -2068,6 +2069,52 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function handleDownloadFullBackup() {
setFullBackupBusy(true)
setMaintenanceMsg('Preparing full backup archive…')
try {
const r = await fetch('/api/admin-backup/export')
if (!r.ok) throw new Error('Export failed')
const blob = await r.blob()
const disposition = r.headers.get('Content-Disposition') ?? ''
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? 'siteforge-data-backup.tar.gz'
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
setMaintenanceMsg(`Full backup downloaded (${filename}). Keep it somewhere safe — it contains all site data.`)
} catch {
setMaintenanceMsg('Full backup download failed.')
} finally {
setFullBackupBusy(false)
}
}
async function handleImportFullBackup(file: File) {
if (!confirm(`Restore ALL site data from ${file.name}?\n\nThis replaces content, uploads, study accounts, notes, questions, and analytics with the archive contents. A pre-import snapshot is saved first, and study users will need to sign in again.`)) return
setFullBackupBusy(true)
setMaintenanceMsg('Restoring full backup — do not close this page…')
try {
const r = await fetch('/api/admin-backup/import', {
method: 'POST',
headers: { 'Content-Type': 'application/gzip' },
body: file,
})
const data = await r.json().catch(() => null)
if (!r.ok) throw new Error(data?.message ?? 'Restore failed')
await reloadContentFromServer()
await reloadStats()
await reloadBackups()
setMaintenanceMsg(`Full data restore complete — ${data?.restoredEntries ?? 'all'} items restored from ${file.name}.`)
} catch (err) {
setMaintenanceMsg(err instanceof Error ? err.message : 'Full data restore failed.')
} finally {
setFullBackupBusy(false)
}
}
async function handleRestoreBackup() {
if (!selectedBackup) {
setMaintenanceMsg('Select a backup first.')
@@ -5025,6 +5072,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
onRestore={handleRestoreBackup}
onExport={handleExport}
onBackupNow={handleBackupNow}
onDownloadFullBackup={handleDownloadFullBackup}
onImportFullBackup={handleImportFullBackup}
fullBackupBusy={fullBackupBusy}
onPrune={handlePrune}
onClear={handleClear}
onPurgeCache={handlePurgeCache}