From 89c9dd6f637b49dfa522dde6a2226fe9a4ca20ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 13:11:57 +0100 Subject: [PATCH] restore: pick a dump, pick a database, say yes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that already has the schema — it fails on every object. The only honest restore empties the target first, and that is not a detail to bury behind a button. So choosing a destination and confirming are two separate clicks, and the second names what it is about to destroy. `confirm` is required by the function, not merely by the panel, so no code path can reach a restore by accident. The file is matched against what listDumps actually found in the backup directory rather than taken as given. 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. Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the DATABASE — that would need a connection to another one, fail while anything is attached, and lose whatever was granted on it. Replacing the schema does the same job to the part that matters. psql rather than pg_restore, because these are plain-SQL dumps, which is what pg_dump writes without -F c. Co-Authored-By: Claude Opus 5 --- api/backups.ts | 64 ++++++++++++++++++++++++ api/router.ts | 26 +++++++++- web/BackupsRow.tsx | 110 ++++++++++++++++++++++++++++++++++++++++-- web/DatabaseBlock.tsx | 2 +- 4 files changed, 195 insertions(+), 7 deletions(-) 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. */}