Three things. The local directory defaults to <officerdev root>/dockers/postgres/dumps, derived from OFFICER_ROOT rather than written down — the same reasoning as rootPath: a path fixed at install freezes the layout of the machine it was installed on, and a restore elsewhere then points somewhere that is not there while looking deliberately chosen. The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the machine it came from is a second copy on one disk, lost to the same failure that would make anybody want it. So local is staging and the copy off the box is the backup, and a schedule with nowhere to send it does not count as configured. Testable, in two steps reported separately, because they fail for different reasons and the fixes differ: the connection is keys and networking, the directory is permissions on the far side. It tests what is in the FORM rather than what is stored — testing a saved destination while somebody looks at a different one is worse than not testing. Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs, so a key that has lost access must FAIL rather than block forever on a prompt nothing will answer. It is also why no credential is stored: there is none to store and none to leak. The remote path is the one value here a shell interprets — `ssh host mkdir -p <path>` runs through it — so shell characters are refused rather than escaped. Refusing is checkable by reading it; escaping is checkable by being right about every shell. And rotation keeps the newest N per database at both ends. Per database, because two sharing one budget means a busy one evicts the other's history. Sorted by NAME rather than mtime: the timestamp is in the filename and is sortable, while mtime stops meaning "when this was taken" the moment a file is copied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
7.8 KiB
TypeScript
162 lines
7.8 KiB
TypeScript
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
|
|
|
/**
|
|
* The connection to the fofos server. One row, and the ONLY thing this plugin stores.
|
|
*
|
|
* ── Why there is nothing else here ──
|
|
*
|
|
* Every temptation to add a table to this file should be resisted, and it is worth writing down why
|
|
* rather than relying on taste. fofos owns its own Postgres and its own media store; this plugin
|
|
* reaches them over HTTP. If the client's collections and photographs lived here instead, they would
|
|
* live under Officer's plugin installer — which drops a plugin's tables when the "delete its data"
|
|
* checkbox is ticked.
|
|
*
|
|
* So the strongest thing that can be said about this plugin is a consequence of this file being one
|
|
* table long: **no operation on it can destroy the client's work.** Deleting it removes a URL and a
|
|
* token, and the site is untouched.
|
|
*
|
|
* A cache would be the thin end of it. If a panel ever needs one, it belongs in memory or in the
|
|
* browser, not in a table that outlives the request.
|
|
*
|
|
* ── Why not core `service_connections` ──
|
|
*
|
|
* It fits the shape — `url`, an encrypted `secret` — but its `ServiceName` is a closed TypeScript
|
|
* union, so using it means editing the platform to add a name, and a plugin should install into a
|
|
* platform it did not have to change. The same reasoning `plugins/proxy-manager/db/schema.ts` gives,
|
|
* and worth revisiting on the day that union opens.
|
|
*/
|
|
export const fofosAdminConnection = pgTable('fofos_admin_connection', {
|
|
id: serial('id').primaryKey(),
|
|
|
|
/** Where the fofos server is — the origin its API is served from. Normalised without a trailing slash. */
|
|
url: text('url').notNull(),
|
|
|
|
/**
|
|
* fofos's `FOFOS_API_TOKEN`, encrypted with `encryptSecret('fofos-admin', …)` — its own purpose
|
|
* key, so a leak of that one opens this column and nothing else.
|
|
*
|
|
* **Never returned across the API boundary**, at any level, to anybody, including the owner. A
|
|
* settings form asking "is one set" gets a boolean.
|
|
*
|
|
* There is exactly one of these rather than one per user, and that is not laziness: fofos has a
|
|
* single token and no notion of accounts. WHO is editing is a question Officer answers with its own
|
|
* accounts and permissions, in front of this.
|
|
*/
|
|
token: text('token').notNull(),
|
|
|
|
/** From the last successful probe — which sections that server says it serves. Diagnostic only. */
|
|
sections: text('sections').array(),
|
|
|
|
lastProbeAt: timestamp('last_probe_at', { withTimezone: true }),
|
|
/**
|
|
* `integer` rather than `boolean` for the same reason proxy-manager uses one: three states, not
|
|
* two. Null means never probed, which reads differently from probed-and-failed.
|
|
*/
|
|
lastProbeOk: integer('last_probe_ok'),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect;
|
|
|
|
/**
|
|
* Administration's own settings. One row, and nothing in it belongs to the client.
|
|
*
|
|
* ── Why a second table and not a column on the one above ──
|
|
*
|
|
* `fofos_admin_connection.url` and `.token` are both `notNull`, so a root path could not be saved
|
|
* without also having an HTTP connection — and that connection is being replaced, not extended. When
|
|
* it goes, its table is a clean `drop` rather than surgery on columns that outlived it.
|
|
*
|
|
* The rule at the top of this file still holds and this does not bend it: what is forbidden here is
|
|
* the CLIENT'S WORK — collections, photographs, copy. This row holds a path and, later, how to read
|
|
* fofos's own database. Deleting it loses a setting somebody can retype, never a thing they made.
|
|
*/
|
|
export const fofosAdminSettings = pgTable('fofos_admin_settings', {
|
|
id: serial('id').primaryKey(),
|
|
|
|
/**
|
|
* Where the fofos site lives on this machine. ABSOLUTE, always.
|
|
*
|
|
* Never `~/fofos`. A tilde means "whoever is asking", and the people asking will not all be the same
|
|
* person — this plugin is grantable, and a member reading this setting must see the one real
|
|
* directory the site is in rather than a path that resolves to their own home and is not there.
|
|
*
|
|
* Null means never set, and the default is computed at read time rather than written here. See
|
|
* `api/settings.ts` for why that is not the same as writing it once at install.
|
|
*/
|
|
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'),
|
|
|
|
/**
|
|
* Backups: when, and where they land.
|
|
*
|
|
* A cron expression, because a backup wants to happen at an HOUR — "every day at three" — and an
|
|
* interval measured from whenever somebody last pressed save drifts across the clock until it is
|
|
* running at two in the afternoon. The panel offers the handful of schedules anybody wants, and the
|
|
* expression is validated on the way in so a malformed one cannot be stored and then fail at a time
|
|
* nobody is watching.
|
|
*
|
|
* Null in either means backups are not set up. Both are needed, so one without the other is not a
|
|
* half-configured state to interpret — it is off.
|
|
*/
|
|
backupCron: text('backup_cron'),
|
|
|
|
/**
|
|
* Where dumps are written on THIS machine, before they are sent anywhere.
|
|
*
|
|
* Null means the default, computed at read time from `OFFICER_ROOT` — the same reasoning as
|
|
* `rootPath` above: a path written in at install freezes the layout of the machine it was installed
|
|
* on, and a restore elsewhere then points at a directory that is not there while looking chosen.
|
|
*/
|
|
backupDir: text('backup_dir'),
|
|
|
|
/**
|
|
* Where they are COPIED to. `user@host`, and an absolute path on it.
|
|
*
|
|
* A backup that only exists on the machine it came from is not a backup — it is a second copy on the
|
|
* same disk, lost to the same failure. So the local directory is staging and this is the destination.
|
|
*
|
|
* Authenticated by the owner's existing SSH key, never a password: there is nobody at a terminal when
|
|
* this runs, and a credential stored here would be one this plugin had to hold and could leak.
|
|
*/
|
|
backupSshHost: text('backup_ssh_host'),
|
|
backupSshPath: text('backup_ssh_path'),
|
|
|
|
/**
|
|
* How many dumps to keep, per database, in each place.
|
|
*
|
|
* Null means keep everything, which is a real choice and a bad default — a nightly dump of a small
|
|
* site fills a disk slowly enough that nobody notices until something else fails.
|
|
*/
|
|
backupKeep: integer('backup_keep'),
|
|
|
|
/**
|
|
* When the last one finished, which is what makes the schedule survive a restart.
|
|
*
|
|
* The timer does not measure elapsed time from when the process started; it asks whether a backup is
|
|
* DUE, against this. A daily backup on a machine that restarts every few hours would otherwise never
|
|
* fire once — the interval would be reset before it ever elapsed, and nothing would look wrong.
|
|
*/
|
|
lastBackupAt: timestamp('last_backup_at', { withTimezone: true }),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export type FofosAdminSettings = typeof fofosAdminSettings.$inferSelect;
|