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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user