diff --git a/api/publish.ts b/api/publish.ts new file mode 100644 index 0000000..d0216fb --- /dev/null +++ b/api/publish.ts @@ -0,0 +1,194 @@ +import { mkdir, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { db } from 'officerdb/db'; +import { fofosAdminSettings } from '../db/schema'; +import { readDatabases } from './databases'; +import { backupAfterPublish, defaultBackupDir } from './backups'; +import { buildSite } from './static-site'; + +/** + * Publish: copy the site's content from development to production, then build. + * + * ── Why this exists ── + * + * The admin edits development and the build renders production, so nothing written in the panel could + * reach the live site. Build was only half of publishing. Without this the first symptom is a + * successful build of yesterday's content, which reads as a broken build and sends somebody looking at + * the renderer. + * + * Copy and build are ONE action rather than two buttons in an order nobody is told. A copy without a + * build changes nothing anybody can see, and a build without a copy is the bug above. + * + * ── What is copied, and what is deliberately not ── + * + * Every table the site owns EXCEPT two: + * + * `users` — accounts and password hashes. Production's logins are not development's, + * and overwriting them would lock somebody out of the live site in order to + * publish a photograph. + * `contact_submissions` — the legacy inbox. Messages live in SQLite now and this table is only what + * predates that. Development's copy is empty, so copying it over production + * would destroy real messages to publish nothing — the most expensive thing + * this file could possibly do. + * + * The list is explicit rather than "every table except". A table added later should be considered + * rather than swept along, and the failure mode of the other choice — production-only data silently + * overwritten by an empty development table — is discovered far too late to fix. + */ + +/** The site's content. Parents before children, so the restore satisfies its own foreign keys. */ +const CONTENT_TABLES = [ + 'home_content', + 'home_gallery_media', + 'about_content', + 'galleries', + 'gallery_items', + 'collections', + 'collection_items', + 'collection_item_media', + 'gallery_pieces', + 'gallery_piece_media', + 'drops', + 'drop_media', + 'collabs', + 'collab_media', + 'service_nodes', + 'partners', + 'social_links', + 'contact_details', +] as const; + +/** Long enough for a slow dump of a few megabytes, short enough that a wedged psql is not forever. */ +const TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Connection details as ENVIRONMENT, not as a URL argument. + * + * `libpq` reads these, so the password never appears in `ps` or in anything that logs a command line. + * The database name is the one part passed as an argument, because it is the part that is not secret + * and the part that changes between the two calls. + */ +const connection = (): Record => { + 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); + return { + PGHOST: url.hostname, + PGPORT: url.port || '5432', + PGUSER: decodeURIComponent(url.username), + PGPASSWORD: decodeURIComponent(url.password), + }; +}; + +const run = async (argv: string[], env: Record) => { + const proc = Bun.spawn(argv, { + stdout: 'pipe', + stderr: 'pipe', + stdin: 'ignore', + env: { ...process.env, ...env }, + }); + const timer = setTimeout(() => proc.kill(), TIMEOUT_MS); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: code === 0, output: `${stdout}${stderr}`.trim() }; + } finally { + clearTimeout(timer); + } +}; + +export type PublishResult = { ok: boolean; output: string }; + +export async function publish(secrets: Parameters[0]): Promise { + const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; + const status = await readDatabases(); + const production = status.production.name; + const development = status.development.name; + + if (!production || !status.production.exists) throw new Error('The production database does not exist.'); + if (!development || !status.development.exists) throw new Error('The development database does not exist.'); + + const env = connection(); + const dir = row?.backupDir ?? defaultBackupDir(); + await mkdir(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-'); + const lines: string[] = []; + + /* + ── 1. A way back, before anything is destroyed ── + + This truncates and replaces. A dump taken immediately before is the difference between a mistake + that costs a minute and one that costs everything the client has written, and the whole database is + a few megabytes. If it cannot be taken, nothing else happens. + */ + const safety = join(dir, `${production}-before-publish-${stamp}.sql`); + const dumped = await run(['pg_dump', '-f', safety, production], env); + if (!dumped.ok) { + return { ok: false, output: `Refusing to publish: production could not be backed up first.\n${dumped.output}` }; + } + lines.push(`backed up production → ${safety}`); + + /* + ── 2. Read development BEFORE clearing production ── + + Ordered this way on purpose. Dumping second would mean a failure to read development leaves + production already empty — the site down, for a reason that had nothing to do with production. Now + the worst a bad dump can do is stop before touching anything. + */ + const staged = join(dir, `.publish-${stamp}.sql`); + const dumpArgs = CONTENT_TABLES.flatMap((table) => ['--table', table]); + const copy = await run(['pg_dump', '--data-only', ...dumpArgs, '-f', staged, development], env); + if (!copy.ok) { + await rm(staged, { force: true }); + return { ok: false, output: `${lines.join('\n')}\nCould not read development: ${copy.output}` }; + } + const size = (await stat(staged).catch(() => null))?.size ?? 0; + lines.push(`read ${CONTENT_TABLES.length} tables from ${development} (${size} bytes)`); + + try { + /* + ── 3. Clear, in one statement ── + + All of them together, because foreign keys between them make any single truncate fail on its own. + `RESTART IDENTITY` so the sequence values the dump then sets are not merely ahead of the rows it + inserts — a copy whose sequences still start at 1 collides on the client's next save, hours later, + somewhere else entirely. + */ + const cleared = await run( + ['psql', '-v', 'ON_ERROR_STOP=1', '-c', `TRUNCATE ${CONTENT_TABLES.join(', ')} RESTART IDENTITY CASCADE;`, production], + env, + ); + if (!cleared.ok) return { ok: false, output: `${lines.join('\n')}\nCould not clear production: ${cleared.output}` }; + + // ── 4. Load ── + const loaded = await run(['psql', '-v', 'ON_ERROR_STOP=1', '-f', staged, production], env); + if (!loaded.ok) { + return { + ok: false, + output: `${lines.join('\n')}\nThe copy failed and production content is now EMPTY.\nRestore it with:\n psql ${production} -f ${safety}\n\n${loaded.output}`, + }; + } + lines.push('copied development content into production'); + } finally { + await rm(staged, { force: true }); + } + + // ── 5. Render it ── + const built = await buildSite(secrets); + lines.push(built.output); + if (!built.ok) return { ok: false, output: lines.join('\n') }; + + /* + The configured backup, if "on publish" is what it is set to. + + `backupAfterPublish` decides that for itself and swallows its own failures, so this calls it + unconditionally: a backup that could not be taken must not turn into a publish that appears to have + failed, when the thing the person asked for actually worked. + */ + await backupAfterPublish(); + + return { ok: true, output: lines.join('\n') }; +} diff --git a/api/router.ts b/api/router.ts index 380a265..db89b87 100644 --- a/api/router.ts +++ b/api/router.ts @@ -14,6 +14,7 @@ import { watchMailbox } from './messages'; import { watchOriginals } from './originals-watch'; import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers'; import { buildSite, readStatic, startStatic, stopStatic } from './static-site'; +import { publish } from './publish'; import { join, resolve, sep } from 'node:path'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; @@ -514,6 +515,22 @@ router.post('/_config/static/build', async (ctx) => { } }); +/* + Publish: promote development's content to production, then build. + + A route of its own rather than a flag on build, and BEFORE `/:action` below — that handler would + otherwise catch `publish` and reject it as "must be start or stop", which is the kind of 400 nobody + can read backwards to a route-ordering mistake. +*/ +router.post('/_config/static/publish', async (ctx) => { + try { + const result = await publish(await siteSecrets()); + return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.post('/_config/static/:action', async (ctx) => { const action = ctx.req.param('action'); if (action !== 'start' && action !== 'stop') { diff --git a/web/StaticBlock.tsx b/web/StaticBlock.tsx index fa82317..579166a 100644 --- a/web/StaticBlock.tsx +++ b/web/StaticBlock.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react'; +import { CircleDot, Hammer, Loader2, Play, Square, Upload } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { readableError } from './errors'; @@ -19,6 +19,17 @@ import { Console } from './Console'; * * It renders every route through the real app and copies every referenced file. Said in the button, * because a spinner sitting for three minutes with no explanation is indistinguishable from a hang. + * + * ── Publish and Build are different things, and Publish is the one to press ── + * + * The admin edits DEVELOPMENT. The build renders PRODUCTION. So Build alone renders whatever was last + * promoted, which after a morning of edits is yesterday's site — a successful build of the wrong + * content, which reads as a broken renderer and is the reason this needed a second button rather than + * a note somewhere. + * + * Publish copies the content across and then builds. Build stays because rebuilding production without + * promoting anything is a real thing to want — a template change, a failed build to retry — but it is + * the secondary action here and looks like one. */ type Static = { @@ -38,7 +49,7 @@ export const StaticBlock = () => { clientRef.current = client; const [status, setStatus] = useState(null); - const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null); + const [busy, setBusy] = useState<'publish' | 'build' | 'start' | 'stop' | null>(null); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); @@ -54,12 +65,18 @@ export const StaticBlock = () => { void load(); }, [load]); - const act = async (what: 'build' | 'start' | 'stop') => { + const act = async (what: 'publish' | 'build' | 'start' | 'stop') => { setBusy(what); setFailed(false); - setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null); + setOutput( + what === 'publish' + ? 'Copying content to production, then rendering every page. This takes a few minutes.' + : what === 'build' + ? 'Rendering every page. This takes a few minutes.' + : null, + ); try { - const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`; + const path = `/fofos-admin/_config/static/${what}`; const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {}); setStatus(result.status); setOutput(result.output); @@ -100,6 +117,16 @@ export const StaticBlock = () => {
+