diff --git a/api/databases.ts b/api/databases.ts new file mode 100644 index 0000000..f11cbd5 --- /dev/null +++ b/api/databases.ts @@ -0,0 +1,166 @@ +import postgres from 'postgres'; +import { db } from 'officerdb/db'; +import { eq } from 'drizzle-orm'; +import { fofosAdminSettings } from '../db/schema'; + +/** + * The two Postgres databases the fofos site reads — production and development. + * + * ── Names, on the instance Officer is already using ── + * + * The site runs on the same box and the same Postgres as Officer, so what has to be stored is a NAME. + * Storing a URL would be a second copy of a host, a port and a credential that already exist in the + * platform's configuration, free to drift from it and free to point at another machine entirely. + * + * ── This module creates databases, which is worth saying out loud ── + * + * `CREATE DATABASE` cannot be parameterised — the name goes into the statement as text. So the name is + * validated to an identifier this module is willing to write, and anything else is refused before a + * statement is built. That check is the whole security boundary here, which is why it is strict rather + * than accommodating: lowercase, starts with a letter or underscore, and nothing else in it. + * + * Postgres folds an unquoted identifier to lowercase, so accepting `Fofos` would create `fofos` and + * then report a name nobody would find. Refusing it is kinder than silently changing it. + * + * ── It never drops, and never overwrites ── + * + * Create makes what is missing and leaves what is there. There is no route here that destroys a + * database, and there should not be: the difference between `fofos_website` and `fofos_old` is one + * keystroke, and one of those holds the client's site. + */ + +/** Postgres's own rule, minus the parts nobody should be using in a name typed into a form. */ +const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/; + +export type DatabaseName = { + name: string | null; + /** Null when no name is set — "does it exist" has no answer for a database nobody has named. */ + exists: boolean | null; +}; + +export type DatabasesStatus = { + production: DatabaseName; + development: DatabaseName; + /** Every database on the instance, so the form can say what is already there. */ + available: string[]; +}; + +/** + * The connection used to ask about and create databases. + * + * Against `postgres`, the maintenance database, because `CREATE DATABASE` cannot run from inside the + * database it is creating and cannot run in a transaction. A fresh client per call, closed after: + * these are rare administrative actions, and a pool held open for them would outlive every one. + */ +const maintenance = () => { + 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 = '/postgres'; + return postgres(url.toString(), { max: 1 }); +}; + +/** + * Names this module refuses, whatever anybody types. + * + * `postgres` and the templates are the instance's own machinery. Officer's database is the important + * one: pointing the site at it would have fofos reading and eventually writing Officer's tables, and + * the failure would not look like a configuration mistake — it would look like the platform corrupting + * itself. + */ +const reserved = (): Set => { + const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; + const own = raw ? new URL(raw).pathname.replace(/^\//, '') : ''; + return new Set(['postgres', 'template0', 'template1', ...(own ? [own] : [])]); +}; + +/** Throws with a sentence a person can act on, rather than returning false. */ +export function checkName(name: string, field: string): string { + const trimmed = name.trim(); + if (!trimmed) throw new Error(`The ${field} database needs a name.`); + if (!IDENTIFIER.test(trimmed)) { + throw new Error( + `"${trimmed}" is not a database name this will create. Lowercase letters, digits and underscores, starting with a letter or underscore.`, + ); + } + if (reserved().has(trimmed)) throw new Error(`"${trimmed}" belongs to Officer or to Postgres itself.`); + return trimmed; +} + +const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; + +export async function readDatabases(): Promise { + const row = await readRow(); + const production = row?.dbProduction ?? null; + const development = row?.dbDevelopment ?? null; + + const sql = maintenance(); + try { + const rows = await sql<{ datname: string }[]>` + select datname from pg_database where not datistemplate order by datname + `; + const available = rows.map((entry) => entry.datname); + return { + production: { name: production, exists: production ? available.includes(production) : null }, + development: { name: development, exists: development ? available.includes(development) : null }, + available, + }; + } finally { + await sql.end({ timeout: 5 }); + } +} + +/** + * Store the two names. Saving does NOT create anything — see the note in the panel. + * + * Validated here as well as at creation, so a name that could never be created cannot be stored and + * sit there looking configured. + */ +export async function saveDatabases(production: string, development: string): Promise { + const prod = checkName(production, 'production'); + const dev = checkName(development, 'development'); + if (prod === dev) throw new Error('Production and development must be different databases.'); + + const row = await readRow(); + if (row) { + await db + .update(fofosAdminSettings) + .set({ dbProduction: prod, dbDevelopment: dev, updatedAt: new Date() }) + .where(eq(fofosAdminSettings.id, row.id)); + } else { + await db.insert(fofosAdminSettings).values({ dbProduction: prod, dbDevelopment: dev }); + } + + return readDatabases(); +} + +/** + * Create whichever of the two is missing. + * + * Idempotent by checking first rather than by catching "already exists": the second would also swallow + * a name that failed for a different reason, and reporting "created" about a database that is not + * there is the one outcome worth engineering against. + */ +export async function createDatabases(): Promise<{ created: string[]; status: DatabasesStatus }> { + const status = await readDatabases(); + const wanted = [status.production.name, status.development.name].filter((name): name is string => Boolean(name)); + if (!wanted.length) throw new Error('Name the databases before creating them.'); + + const missing = wanted.filter((name) => !status.available.includes(name)); + if (!missing.length) return { created: [], status }; + + const sql = maintenance(); + try { + for (const name of missing) { + // Re-checked immediately before the statement is built. The name came from our own row, but the + // gap between storing and creating is exactly where a check that happened "earlier" stops being + // a check — and this is the one line in the plugin that interpolates into SQL. + const safe = checkName(name, 'database'); + await sql.unsafe(`CREATE DATABASE "${safe}"`); + } + } finally { + await sql.end({ timeout: 5 }); + } + + return { created: missing, status: await readDatabases() }; +} diff --git a/api/router.ts b/api/router.ts index 45fc545..74722ce 100644 --- a/api/router.ts +++ b/api/router.ts @@ -8,6 +8,7 @@ import { readMediaStatus, createMediaDir } from './media'; import { readSync, startSync } from './sync'; import { readRuns } from './ledger'; import { reconcile } from './derive'; +import { createDatabases, readDatabases, saveDatabases } from './databases'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; /** @@ -283,6 +284,32 @@ router.get('/_config/media/sync', async (ctx) => { * * It walks the media tree, which is the expensive part, and is therefore deliberately NOT polled. */ +/** + * The two databases the site reads. Owner-only, like everything else under `/_config`. + * + * Saving and creating are separate routes because they are separate decisions: a name can be stored + * for a database that already exists and needs no creating, and pressing Create must never be the only + * way to record what you typed. + */ +router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases())); + +router.put('/_config/databases', async (ctx) => { + const { production, development } = (ctx.get('body') ?? {}) as { production?: string; development?: string }; + try { + return ctx.json(await saveDatabases(production ?? '', development ?? '')); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + +router.post('/_config/databases/create', async (ctx) => { + try { + return ctx.json(await createDatabases()); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.get('/_config/summary', async (ctx) => { const user = ctx.get('user'); const settings = await readSettings(user.email); diff --git a/db/schema.ts b/db/schema.ts index fdccd4f..68f3b7c 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -88,6 +88,20 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', { */ rootPath: text('root_path'), + /** + * The two Postgres databases the site uses, by NAME. + * + * Names and not URLs, deliberately. They live on the same instance Officer is already connected to — + * that is the whole reason this is a name — so a URL here would be a second copy of a host, a port + * and a credential that already exist in the platform's own configuration, able to drift from it and + * able to point somewhere nobody intended. + * + * Null means not chosen yet. There is no default: a database name is not something to guess at, and + * a wrong guess here is a site reading somebody else's tables. + */ + dbProduction: text('db_production'), + dbDevelopment: text('db_development'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx index af3d5b0..c353da9 100644 --- a/web/DatabaseBlock.tsx +++ b/web/DatabaseBlock.tsx @@ -1,15 +1,185 @@ -import { Database } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { readableError } from './errors'; import { Block } from './Block'; +import { useFlash } from './useFlash'; /** - * Where the fofos site's own data lives. + * Where the fofos site's own data lives — two databases, named here. * - * Deliberately empty. The block exists so the shape is settled and so the screen says what is coming - * rather than looking like a section nobody finished — but nothing is decided about what goes in it, - * and inventing fields to fill the space would be deciding it in the wrong place. + * ── Naming and creating are separate ── + * + * Save stores what you typed; Create makes whichever of the two is not there yet. They are different + * decisions and were worth two buttons: a database that already exists still needs its name recorded, + * and making Create the only way to save would mean nobody could point the site at something that was + * already set up. + * + * ── It reports what is THERE, per name ── + * + * A tick or a dashed circle beside each field, from `pg_database` rather than from what was saved. The + * useful question about a database name is not whether somebody typed it — it is whether the thing it + * names exists, and those two came apart the moment saving stopped creating. + * + * ── Create only offers itself when there is something to create ── + * + * Both present and the button goes. A button that does nothing is a thing to wonder about later. */ -export const DatabaseBlock = () => ( - -

Nothing to set yet.

-
-); + +type Named = { name: string | null; exists: boolean | null }; +type Status = { production: Named; development: Named; available: string[] }; + +export const DatabaseBlock = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [status, setStatus] = useState(null); + const [production, setProduction] = useState(''); + const [development, setDevelopment] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [flashing, flash] = useFlash(); + + const load = useCallback(async () => { + try { + const current = await clientRef.current.get('/fofos-admin/_config/databases'); + setStatus(current); + // Only seed the fields from the server when they are untouched, so a reload landing mid-typing + // cannot replace what somebody is in the middle of writing. + setProduction((typed) => typed || (current.production.name ?? '')); + setDevelopment((typed) => typed || (current.development.name ?? '')); + } catch { + setStatus(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const save = async () => { + setBusy(true); + setError(null); + try { + setStatus( + await clientRef.current.put('/fofos-admin/_config/databases', { production, development }), + ); + flash(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + const create = async () => { + setBusy(true); + setError(null); + try { + const result = await clientRef.current.post<{ created: string[]; status: Status }>( + '/fofos-admin/_config/databases/create', + {}, + ); + setStatus(result.status); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + const field = ( + label: string, + hint: string, + value: string, + onChange: (next: string) => void, + state: Named | undefined, + ) => { + // Only about what is SAVED. A tick next to a name somebody is still typing would be about a + // different database from the one on screen. + const settled = state?.name === value.trim() ? state : undefined; + const Icon = settled?.exists ? CircleCheck : CircleDashed; + + return ( +
+
+ + {hint} +
+
+ onChange(event.target.value)} + placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'} + className="w-full font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> + + {settled ? ( + <> + + {settled.exists ? 'exists' : 'not yet'} + + ) : null} + +
+
+ ); + }; + + const dirty = + production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? ''); + + // From the SAVED names, not the typed ones — Create acts on what is stored. + const missing = [status?.production, status?.development].filter((entry) => entry?.name && entry.exists === false); + + return ( + + + Saved + + ) : undefined + } + > + {!status ? null : ( + <> + {field('Production', 'what the live site reads', production, setProduction, status.production)} + {field('Development', 'what you work against', development, setDevelopment, status.development)} + + {error &&

{error}

} + +
+ + + {/* Only when a saved name has nothing behind it. Nothing to create, no button. */} + {missing.length > 0 && ( + + )} + + {/* Save first. Create acts on what is stored, and a button that quietly used a different + value from the one in the field would be the worst kind of surprise here. */} + {missing.length > 0 && dirty && ( + Save the names first. + )} +
+ + )} +
+ ); +};