defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a tilde means "whoever is asking", and the people asking will not all be the same person: this plugin is grantable, and the site is in one place regardless of who is looking at it. stored as `~/fofos` it would give every user a different answer to a question that has one. THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on every read, so it follows the machine. writing it in at install freezes whichever home existed on install day, and a restore onto a different machine then points at a directory that is not there while looking deliberately chosen. a saved path is a decision and is stored exactly as given; only its absence is derived. the form says which of the two it is showing, because "not set" reading identically to "set to the default" is how somebody comes to believe they configured something they did not. THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes of this plugin's own. they already refuse to leave home, and they already tell a missing directory from a forbidden one, which is a distinction that cost somebody an afternoon and would have been re-guessed here. shipping a second listing and mkdir would be a second filesystem surface to audit for the sake of one form. that API is an execution capability and never grantable, so for anybody but the owner it 403s — said in a sentence rather than shown as an empty tree, because an empty listing is data and must never be how a refusal looks. it also browses the owner's home and nothing above it; a path outside home is still reachable by typing, since the picker fills the field and the FIELD is what saves. one value on screen, one Save, and picking a folder commits nothing by itself. "Use this folder" takes the one that is OPEN rather than one highlighted in the list. that makes looking inside and choosing one gesture, and it is the only way to choose a folder with nothing in it — which a freshly made one always is. a SECOND table, not a column on fofos_admin_connection, whose url and token are both notNull: a root path could not be saved there without also having an HTTP connection, and that connection is being replaced rather than extended. when it goes, its table is a clean drop instead of surgery on columns that outlived it. the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S work, and this row holds a path somebody can retype. needs `bun db:push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
4.7 KiB
TypeScript
96 lines
4.7 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'),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export type FofosAdminSettings = typeof fofosAdminSettings.$inferSelect;
|