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
+36
View File
@@ -43,6 +43,9 @@ interface Props {
onRestore: () => void
onExport: () => void
onBackupNow: () => void
onDownloadFullBackup: () => void
onImportFullBackup: (file: File) => void
fullBackupBusy: boolean
onPrune: () => void
onClear: () => void
onPurgeCache: () => void
@@ -66,6 +69,9 @@ export function AnalyticsPanel({
onRestore,
onExport,
onBackupNow,
onDownloadFullBackup,
onImportFullBackup,
fullBackupBusy,
onPrune,
onClear,
onPurgeCache,
@@ -74,6 +80,7 @@ export function AnalyticsPanel({
}: Props) {
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
const [fullBackupFile, setFullBackupFile] = useState<File | null>(null)
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics</p>
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
@@ -807,6 +814,35 @@ export function AnalyticsPanel({
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
</div>
)}
{/* Full Data Backup (entire /data folder — server migration) */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Full Data Backup</h2>
<p>Download every persisted file site content, uploads, study accounts, notes, progress, questions, analytics as one archive, or restore that archive on this or a new server.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onDownloadFullBackup} disabled={fullBackupBusy}>
{fullBackupBusy ? 'Working…' : 'Download Full Backup (.tar.gz)'}
</button>
</div>
<div className="admin-restore-row">
<label htmlFor="full-backup-file">Restore from File</label>
<input
id="full-backup-file"
type="file"
accept=".tar.gz,.tgz,application/gzip,application/x-gzip"
onChange={e => setFullBackupFile(e.target.files?.[0] ?? null)}
disabled={fullBackupBusy}
/>
<button
type="button"
className="btn-admin-remove"
onClick={() => { if (fullBackupFile) onImportFullBackup(fullBackupFile) }}
disabled={!fullBackupFile || fullBackupBusy}
>
{fullBackupBusy ? 'Working…' : 'Restore Full Backup'}
</button>
</div>
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
</section>
)