diff --git a/api/backups.ts b/api/backups.ts index 629a4cb..75dbbd3 100644 --- a/api/backups.ts +++ b/api/backups.ts @@ -356,6 +356,70 @@ async function prune(keep: number, dir: string, host: string | null, path: strin return lines; } +/** + * Load a dump into one of the two databases. + * + * ── This DESTROYS what is there, and says so ── + * + * A pg_dump carries `CREATE TABLE`, so loading one into a database that already has the schema fails + * on every object rather than replacing it. The only honest restore is therefore to empty the target + * first — `DROP SCHEMA public CASCADE` — and that is not a detail to bury. The caller must pass + * `confirm: true`, which exists purely so that no code path can reach this by accident. + * + * ── The file is chosen from the listing, not named freely ── + * + * `file` is matched against what `listDumps` actually found in the backup directory. So there is no + * path to traverse out of and no way to point `psql` at a file this module did not write: an unknown + * name is refused before anything is opened, rather than sanitised and hoped about. + * + * ── psql, not pg_restore ── + * + * These are plain-SQL dumps, which is what `pg_dump` writes without `-F c`. pg_restore does not read + * them. + */ +export async function restoreBackup(input: { + file: string; + target: 'production' | 'development'; + confirm: boolean; +}): Promise<{ ok: boolean; output: string }> { + if (!input.confirm) throw new Error('A restore replaces everything in the target database.'); + + const row = await readRow(); + const dir = row?.backupDir ?? defaultBackupDir(); + + const available = await listDumps(dir); + const chosen = available.find((entry) => entry.file === input.file); + if (!chosen) throw new Error(`"${input.file}" is not one of the dumps in ${dir}.`); + + const status = await readDatabases(); + const name = input.target === 'production' ? status.production.name : status.development.name; + if (!name) throw new Error(`No ${input.target} database is named.`); + if (!status.available.includes(name)) throw new Error(`"${name}" does not exist yet.`); + + const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; + if (!raw) throw new Error('The platform has no Postgres connection configured.'); + const url = new URL(raw); + url.pathname = `/${name}`; + + const lines = [`$ restore ${chosen.file} -> ${name}`]; + + /* + Emptied through `psql -c`, not by dropping and recreating the DATABASE. + + Dropping the database would need a connection to another one, would fail while anything is attached, + and would lose whatever was granted on it. Replacing the schema does the same job to the thing that + actually matters — every table, view and sequence in `public`. + */ + const cleared = await run(['psql', url.toString(), '-v', 'ON_ERROR_STOP=1', '-c', 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;']); + if (!cleared.ok) return { ok: false, output: `${lines.join('\n')}\nCould not empty ${name}: ${cleared.output}` }; + lines.push(`emptied ${name}`); + + const loaded = await run(['psql', url.toString(), '-v', 'ON_ERROR_STOP=1', '-f', join(dir, chosen.file)]); + lines.push(loaded.output || (loaded.ok ? 'loaded' : 'failed')); + + return { ok: loaded.ok, output: lines.join('\n') }; +} + let job: CronJob | null = null; let catchup: ReturnType | null = null; diff --git a/api/router.ts b/api/router.ts index d92c3d7..8fd4df7 100644 --- a/api/router.ts +++ b/api/router.ts @@ -9,7 +9,7 @@ import { readSync, startSync } from './sync'; import { readRuns } from './ledger'; import { reconcile } from './derive'; import { createDatabases, pushSchema, readDatabases } from './databases'; -import { readBackups, runBackup, saveBackups, schedule, testDestination } from './backups'; +import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups'; import { join } from 'node:path'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; @@ -368,6 +368,30 @@ router.post('/_config/backups/test', async (ctx) => { } }); +/** + * Load a dump back into one of the databases. + * + * `confirm` is required by the function behind this, not merely by the panel. A restore empties the + * target first — a pg_dump carries CREATE TABLE and cannot be layered onto a database that already has + * the schema — and that is not something any code path should be able to reach by accident. + */ +router.post('/_config/backups/restore', async (ctx) => { + const { file, target, confirm } = (ctx.get('body') ?? {}) as { + file?: string; + target?: string; + confirm?: boolean; + }; + if (target !== 'production' && target !== 'development') { + return ctx.json({ error: 'Target must be production or development.' }, 400); + } + try { + const result = await restoreBackup({ file: file ?? '', target, confirm: confirm === true }); + return ctx.json({ ...result, status: await readDatabases() }, result.ok ? 200 : 400); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.post('/_config/backups/run', async (ctx) => { try { const result = await runBackup(); diff --git a/web/BackupsRow.tsx b/web/BackupsRow.tsx index 1db5095..c5ed504 100644 --- a/web/BackupsRow.tsx +++ b/web/BackupsRow.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Check, ChevronDown, DatabaseBackup, Loader2, PlayCircle, PlugZap } from 'lucide-react'; +import { Check, ChevronDown, DatabaseBackup, History, Loader2, PlayCircle, PlugZap } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -57,7 +57,7 @@ const size = (bytes: number) => const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null); -export const BackupsRow = () => { +export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: string }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; @@ -70,7 +70,12 @@ export const BackupsRow = () => { const [sshPath, setSshPath] = useState(''); const [keep, setKeep] = useState('7'); const [picking, setPicking] = useState(false); - const [busy, setBusy] = useState<'save' | 'run' | 'test' | null>(null); + const [busy, setBusy] = useState<'save' | 'run' | 'test' | 'restore' | null>(null); + /* Which dump is being restored, and into what — null until somebody opens the list. */ + const [restoring, setRestoring] = useState<{ file: string; target: 'production' | 'development' } | null>( + null, + ); + const [browsing, setBrowsing] = useState(false); const [error, setError] = useState(null); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); @@ -137,6 +142,28 @@ export const BackupsRow = () => { } }; + const restore = async (file: string, target: 'production' | 'development') => { + setBusy('restore'); + setError(null); + setFailed(false); + setOutput(null); + try { + const result = await clientRef.current.post<{ ok: boolean; output: string }>( + '/fofos-admin/_config/backups/restore', + { file, target, confirm: true }, + ); + setOutput(result.output); + setFailed(!result.ok); + } catch (failure) { + setOutput(readableError(failure)); + setFailed(true); + } finally { + setBusy(null); + setRestoring(null); + void load(); + } + }; + const run = async () => { setBusy('run'); setError(null); @@ -186,6 +213,21 @@ export const BackupsRow = () => {
+ {settings.recent.length > 0 && ( + + )} {configured && ( + + + ) : ( + + + + + )} + + ))} + + )} + + {/* A quiet summary when the list is closed, so the count above is not the only evidence. */} + {!browsing && settings.recent.length > 0 && !open && (
    - {settings.recent.slice(0, 5).map((backup) => ( + {settings.recent.slice(0, 3).map((backup) => (
  • {backup.file} {size(backup.bytes)} diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx index 7e535f9..d40b1db 100644 --- a/web/DatabaseBlock.tsx +++ b/web/DatabaseBlock.tsx @@ -241,7 +241,7 @@ export const DatabaseBlock = () => { {/* Below the two databases, because it is about both of them rather than either. */} - + {/* The common case is both. Doing them one at a time is still there for when it is not. */}