Files
fofos-admin/api/router.ts
T
pastilhasandClaude Opus 5 b165e10f53 every proxied request carries who is asking
Officer's session is the only place the acting user is known, and fofos writes it
into its own change history. An email in X-Actor, because that database has no
users to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:36:34 +01:00

803 lines
34 KiB
TypeScript

import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { sidecarPort } from '@@/sidecar-registry';
import { readSettings, saveRootPath } from './settings';
import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website';
import { readMediaStatus, createMediaDir } from './media';
import { readSync, startSync } from './sync';
import { readRuns } from './ledger';
import { listOriginals, originalsDir, reconcile, safeScope } from './derive';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
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';
/**
* Mounted at `/api/fofos-admin`.
*
* ── Mostly a pass-through, and that is the design ──
*
* fofos's `/api/admin` is already the right shape for a panel: every route answers `{ data: … }`, and
* a mutation returns the whole section it belongs to. Re-wrapping that here would add a second place
* for the contract to be described and a second place for it to drift.
*
* So this holds the credential and forwards. What it does NOT do is decide what fofos's answers mean:
* a 404 from fofos reaches the panel as a 404, because fofos's error envelope names the field and
* nothing here could improve on that.
*
* The exception is `/_config`, which is this plugin's own and never leaves the machine.
*
* ── Permissions ──
*
* The platform's gate decides who gets here, and `/_config` is `ownerOnly` in the manifest. Nothing in
* this file checks again: two places deciding the same thing is how they come to disagree.
*/
export const router = createRouter();
/**
* The upload path forwards a body it must not have parsed. This is load-bearing.
*
* `bodyParser` runs across the whole protected router and calls `parseBody` on `multipart/form-data`.
* Hono then caches the parsed FormData, so asking for the bytes afterwards RE-SERIALISES them under a
* NEW multipart boundary — while the original `content-type` header, naming the old one, is what gets
* forwarded. Header and body disagree and fofos rejects every upload with "Unexpected end of form".
*
* Registering the prefix tells `bodyParser` to leave these requests alone. Narrow on purpose — one
* route, not the whole plugin — because everything else here is JSON that is genuinely fine to parse,
* and claiming more than is needed turns an opt-out into a habit.
*/
registerProxiedPrefix('/api/fofos-admin/media');
/** fofos's own answer, passed through with its status and content type intact. */
const passThrough = (response: FofosResponse) =>
new Response(response.body, {
status: response.status,
headers: { 'content-type': response.contentType ?? 'application/json' },
});
/**
* A shaped failure for the two things that can go wrong before fofos answers at all.
*
* `unconfigured` is the one panels branch on: it is a setup step rather than an error, and rendering
* it as a broken list would tell somebody their site was down when they have simply not connected it
* yet.
*/
const noConnection = (ctx: Context<{ Variables: HonoVariables }>) =>
ctx.json({ error: 'No fofos connection is configured.', unconfigured: true }, 409);
const unreachable = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => {
if (error instanceof FofosError) {
return ctx.json({ error: error.message, unreachable: error.status === null }, 502);
}
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500);
};
// ── Everything the panels read and write ─────────────────────────────────────────────────────────
/**
* Uploads. Registered above so its body arrives untouched.
*
* Buffered rather than streamed: Bun's fetch can take a stream, but the platform is a hop in the
* middle and a half-forwarded upload that fails mid-flight is harder to reason about than one that is
* whole before it leaves. `arrayBuffer()` here is the ORIGINAL bytes with the ORIGINAL boundary,
* which is exactly why the original `content-type` can be forwarded with them.
*/
router.post('/media', async (ctx) => {
const credentials = await readCredentials();
if (!credentials) return noConnection(ctx);
try {
const body = await ctx.req.arrayBuffer();
const response = await fofosRequest(credentials, {
method: 'POST',
path: '/media',
body: new Blob([body]) as unknown as FormData,
contentType: ctx.req.header('content-type') ?? null,
});
return passThrough(response);
} catch (error) {
return unreachable(ctx, error);
}
});
// ── Settings — owner only, by the manifest ───────────────────────────────────────────────────────
router.get('/_config', async (ctx) => ctx.json(await readConnection()));
router.put('/_config', async (ctx) => {
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
if (!payload?.url?.trim()) return ctx.json({ error: 'A fofos URL is required.' }, 400);
try {
const saved = await saveConnection({ url: payload.url, token: payload.token?.trim() || undefined });
// Probed immediately, so the form can say whether it works rather than only that it saved. A
// failure here is NOT a failed save — the connection is stored either way, which is what lets
// somebody fix a token without re-entering the URL.
const credentials = await readCredentials();
if (credentials) {
const result = await probe(credentials);
await recordProbe(result.sections ?? null, result.authenticated);
return ctx.json({ ...(await readConnection()), probe: result });
}
return ctx.json(saved);
} catch (error) {
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
});
router.delete('/_config', async (ctx) => {
await deleteConnection();
return ctx.json(await readConnection());
});
/**
* Check a URL and token WITHOUT storing them.
*
* So a wrong token never replaces a working one — the same reason proxy-manager's test route exists.
* When the token is omitted it tests the STORED one, which is how "is my connection still good?" is
* answered without anybody having to retype a secret.
*/
router.post('/_config/test', async (ctx) => {
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
const stored = await readCredentials();
const url = payload?.url?.trim() || stored?.url;
const token = payload?.token?.trim() || stored?.token;
if (!url || !token) {
return ctx.json({ reachable: false, authenticated: false, error: 'A URL and a token are needed.' }, 400);
}
const result = await probe({ url, token });
// Only when testing what is actually stored. Recording a probe of some other credential would put a
// verdict about a different server against this row.
if (!payload?.url && !payload?.token && stored) {
await recordProbe(result.sections ?? null, result.authenticated);
}
return ctx.json(result);
});
/**
* Where the fofos site is on this machine.
*
* Under `/_config`, so the manifest's `ownerOnly: ['/_config']` covers it with no second rule to keep
* in step. That is the right level: this path is what the plugin will read and write the client's
* site through, so choosing it is an owner's decision even though editing the site is not.
*
* The caller's email only ever reaches `getOwnerHomeDir`, which ignores it and answers with the
* OWNER's home. That is deliberate rather than sloppy — see `api/settings.ts`.
*/
router.get('/_config/root', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await readSettings(user.email));
});
router.put('/_config/root', async (ctx) => {
const user = ctx.get('user');
const { path } = (ctx.get('body') ?? {}) as { path?: string };
try {
return ctx.json(await saveRootPath(path ?? '', user.email));
} catch (error) {
// The only failures here are the caller's own words being unusable, which is a 400 and not a fault.
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* The website checkout under the root location — is it there, and put it there.
*
* Under `/_config` so the manifest's `ownerOnly` covers it, and it has to be: these write to the
* filesystem and reach the network with the owner's SSH key. Reading the state is owner-only for the
* same reason rather than a separate rule — it names a path on the machine.
*
* Clone and update return git's own output on failure. It is more use than anything this could write
* in its place: "Permission denied (publickey)" says exactly what is wrong, and a friendlier sentence
* would be a guess at which of a dozen causes it was.
*/
router.get('/_config/website', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await readWebsiteStatus(user.email));
});
router.post('/_config/website/clone', async (ctx) => {
const user = ctx.get('user');
const result = await cloneWebsite(user.email);
// The status is re-read either way, so a partial failure shows what actually ended up on disk rather
// than what was asked for.
return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400);
});
router.post('/_config/website/update', async (ctx) => {
const user = ctx.get('user');
const result = await updateWebsite(user.email);
return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400);
});
/**
* The media directory beside the checkout. Same `/_config` prefix, so the same owner-only rule.
*/
router.get('/_config/media', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await readMediaStatus(user.email));
});
router.post('/_config/media/create', async (ctx) => {
const user = ctx.get('user');
const result = await createMediaDir(user.email);
// The status is re-read either way, so what the panel shows is what is on disk rather than what was
// asked for.
return ctx.json({ ...result, status: await readMediaStatus(user.email) }, result.ok ? 200 : 400);
});
/**
* Building `derived/` from `originals/`.
*
* Two routes and no more: start one, and ask how it is going. The work is asynchronous because a
* folder of photographs takes minutes, and the panel polls the second route rather than holding a
* request open — a connection that has to survive an eight-minute encode is a connection that will
* not.
*
* `POST` answers 200 whether it started a run or found one already going, and says which in `started`.
* "Already running" is not a failure to report to somebody who pressed Sync; the honest answer is the
* run that is in progress, which is what comes back.
*/
/**
* The media files under a folder in `originals/`, relative to it.
*
* For "Create Gallery" on a directory: what somebody right-clicked is the folder, and what a gallery
* holds is the files. Expanded here rather than in the browser because this side already knows which
* extensions are media and how to walk the tree — `listOriginals` is the same function the sync uses,
* so a gallery cannot contain a file the pipeline would not convert.
*
* Recursive, deliberately: a folder of folders is still "these pictures", and refusing to descend would
* make the action silently miss most of what somebody pointed at.
*/
/**
* Serve a file out of `derived/`.
*
* ── Why this exists when the site serves them too ──
*
* The site has its own `/derived/*`, and the admin cannot use it: the panel talks to this plugin, and
* the catch-all below forwards everything to the site's `/api/admin`, which is a different route. More
* importantly, an admin thumbnail should not require the development server to be running — looking at
* a gallery is not editing the site.
*
* So this reads the disk directly. The plugin knows the root; the file is right there.
*
* ── Not under `/_config` ──
*
* That prefix is owner-only, and a thumbnail is not a credential. A member who can open the section can
* see the pictures in it, which is the whole point of the section.
*
* Registered BEFORE the catch-all, or the catch-all would forward it to fofos.
*/
router.get('/derived/*', async (ctx) => {
const user = ctx.get('user');
const settings = await readSettings(user.email);
const url = new URL(ctx.req.url);
const after = url.pathname.slice(url.pathname.indexOf('/derived/') + '/derived/'.length);
const relative = decodeURIComponent(after);
const base = join(settings.rootPath, 'media', 'derived');
const full = resolve(join(base, relative));
// `resolve` first, then compare, so `..` cannot climb out — and against the directory plus a separator
// as well as itself, since a bare startsWith accepts `derived-old` for a root of `derived`.
if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404);
const file = Bun.file(full);
if (!(await file.exists())) return ctx.json({ error: 'Not found' }, 404);
return new Response(file, {
// A rebuild replaces the contents of a rendition rather than its name, so unlike a content-addressed
// store these can go stale. A few minutes is enough for a page of thumbnails and short enough that a
// reconverted image appears while somebody is still looking at it.
headers: { 'Cache-Control': 'private, max-age=300' },
});
});
/**
* An ORIGINAL, rendered at a width, for the admin to draw at size.
*
* ── Why not the derived ladder ──
*
* A framing slot is most of the panel wide — around 1900 CSS pixels — and can be zoomed into on top of
* that. Asking `derived/` for a rung means choosing one in advance and being wrong: 640 stretched three
* times over reads as a poor photograph rather than as the wrong file, and the widest rung available is
* whatever the ladder happens to hold.
*
* The original has every pixel there is. It is also, often, something a browser cannot decode — a HEIC
* off a phone — which is exactly what av-club is for.
*
* ── Every encoder is av-club ──
*
* `/v1/render` takes a path and a width and answers with something a browser can draw, whatever went
* in. This route resolves the path inside `originals/` and forwards; it decodes nothing itself, which
* is the standing rule and the reason there is no image library in this plugin.
*/
router.get('/original/*', async (ctx) => {
const user = ctx.get('user');
const settings = await readSettings(user.email);
if (!settings.rootPath) return ctx.json({ error: 'No root location is set.' }, 400);
const url = new URL(ctx.req.url);
const after = url.pathname.slice(url.pathname.indexOf('/original/') + '/original/'.length);
const relative = decodeURIComponent(after);
const base = join(settings.rootPath, 'media', 'originals');
const full = resolve(join(base, relative));
// `resolve` first, then compare against the directory plus a separator as well as itself — a bare
// startsWith accepts `originals-old` for a root of `originals`.
if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404);
if (!(await Bun.file(full).exists())) return ctx.json({ error: 'Not found' }, 404);
// Clamped at both ends: below 256 nothing is worth rendering, and above 4096 a panel is asking for
// more pixels than any screen it runs on. The same bounds the platform's own /render uses.
const asked = Number(url.searchParams.get('width'));
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 1600;
const port = sidecarPort('av-club');
if (!port) return ctx.json({ error: 'av-club is not running.' }, 503);
const rendered = await fetch(
`http://127.0.0.1:${port}/v1/render?path=${encodeURIComponent(full)}&width=${width}`,
{ headers: { 'x-officer-user': user.email } },
);
if (!rendered.ok) return ctx.json({ error: 'That file could not be rendered.' }, 422);
return new Response(await rendered.arrayBuffer(), {
headers: {
'Content-Type': rendered.headers.get('content-type') ?? 'image/webp',
// Not cached on disk. A full-size render is large, looked at once, and keyed on a width the panel
// chose — caching it would fill a disk to make the second look at one photograph faster.
'Cache-Control': 'private, max-age=300',
},
});
});
router.get('/_config/media/files', async (ctx) => {
const user = ctx.get('user');
const scope = ctx.req.query('path') ?? '';
try {
const root = await originalsDir(user.email);
/*
Returned as-is. `listOriginals` already includes the prefix in every path it yields — it builds
`prefix/name` as it descends — so prefixing again produced
`Home_Page_Gallery/Home_Page_Gallery/…`, a real-looking path to nothing.
*/
const files = await listOriginals(root, safeScope(scope));
return ctx.json({ files });
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.post('/_config/media/sync', async (ctx) => {
const user = ctx.get('user');
// A folder inside `originals/`, or nothing for all of it. Named `path` because that is what the file
// browser calls it, and this is the value it will hand over when a folder is synced from there.
const { path } = (ctx.get('body') ?? {}) as { path?: string };
try {
return ctx.json(startSync(user.email, { scope: path }));
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/*
The sync's state, and the part of its transcript the caller does not have yet.
`?since=<n>` is what keeps a modal open on a long run cheap: the transcript is thousands of lines by
the end and polls once a second, so sending all of it every tick would be the same megabyte over and
over. The caller keeps what it has been given and asks for what came after.
Absent `since` means "everything", which is what a modal opening mid-run wants — a watcher that
arrived late should see what already happened rather than start from the next line.
*/
router.get('/_config/media/sync', async (ctx) => {
const user = ctx.get('user');
const state = readSync(user.email);
const raw = ctx.req.query('since');
const since = raw === undefined ? null : Number(raw);
if (since === null || !Number.isFinite(since)) return ctx.json(state);
return ctx.json({ ...state, log: state.log.filter((line) => line.n > since) });
});
/**
* What has been done, and what is left.
*
* Separate from the live job state on purpose: that one is polled every second while a run is in
* flight, and this one walks the whole tree reading manifests. Answering both from one route would
* make every tick of a progress bar re-read the library.
*/
/**
* The three bits Root location needs BEFORE it paints: is it set up?
*
* ── Why this is not assembled from the children ──
*
* Each part of that block fetches its own status, which is right — but it means the block cannot know
* whether it is finished until its children have mounted and answered, so it must render open and then
* fold itself away. That flicker is on every page load, and it is not a rendering problem to be tuned
* away: the information genuinely was not there yet.
*
* One request that answers it first removes the cause. The children still own their own detail and
* still report changes upward while the block is open — this only seeds the opening state so the first
* paint is already correct.
*
* 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`.
*
* ONE write route, not two. There was a `PUT` that only stored the names, on the reasoning that naming
* and creating are separate decisions — but the panel has one button, so nothing called it, and a
* second way to write the same two columns is a second thing that can drift from the first.
*/
router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases()));
router.post('/_config/databases/:target/push-schema', async (ctx) => {
const user = ctx.get('user');
const target = ctx.req.param('target');
if (target !== 'production' && target !== 'development') {
return ctx.json({ error: 'Target must be production or development.' }, 400);
}
try {
const settings = await readSettings(user.email);
const result = await pushSchema(target, join(settings.rootPath, 'website'));
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/databases/create', async (ctx) => {
// The names come with the request, so what gets created is what is on screen rather than what was
// stored at some earlier point. They are saved first, by the same validation as the PUT above.
const { production, development } = (ctx.get('body') ?? {}) as { production?: string; development?: string };
try {
return ctx.json(await createDatabases({ production, development }));
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* Backups — the schedule, the location, and one on demand.
*
* `POST /run` exists so somebody can prove the location and the credentials work without waiting until
* three in the morning to find out they do not.
*/
router.get('/_config/backups', async (ctx) => ctx.json(await readBackups()));
router.put('/_config/backups', async (ctx) => {
const body = (ctx.get('body') ?? {}) as {
cron?: string;
dir?: string;
sshHost?: string;
sshPath?: string;
keep?: number | null;
};
try {
return ctx.json(
await saveBackups({
cron: body.cron ?? '',
dir: body.dir ?? '',
sshHost: body.sshHost ?? '',
sshPath: body.sshPath ?? '',
keep: body.keep ?? null,
}),
);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* Prove the destination before anything is trusted to it.
*
* Takes the values from the FORM rather than from storage, so it tests what is on screen — testing a
* saved destination while somebody looks at a different one is worse than not testing at all.
*/
router.post('/_config/backups/test', async (ctx) => {
const { sshHost, sshPath } = (ctx.get('body') ?? {}) as { sshHost?: string; sshPath?: string };
try {
const result = await testDestination(sshHost ?? '', sshPath ?? '');
return ctx.json(result, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* 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();
return ctx.json({ ...result, settings: await readBackups() }, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* The site's two servers, under pm2.
*
* The target is a route PARAM and is checked against the two names this plugin owns before anything is
* run. Everything downstream is built from that pair and from nothing a caller supplies, so no request
* can reach another process on this machine — which matters, because Officer's own processes share the
* pm2 daemon these commands talk to.
*/
router.get('/_config/servers', async (ctx) => ctx.json(await readServers()));
router.put('/_config/servers', async (ctx) => {
const { development, production } = (ctx.get('body') ?? {}) as { development?: number; production?: number };
try {
return ctx.json(await savePorts(development, production));
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* Production — a static build, and the nginx that serves it.
*
* Separate routes from the servers above because it is a different kind of thing: there is no process,
* no port and nothing to restart. A build either succeeded or did not, and a container is either
* serving the directory or is not.
*/
router.get('/_config/static', async (ctx) => ctx.json(await readStatic()));
router.post('/_config/static/build', async (ctx) => {
try {
const result = await buildSite(await siteSecrets());
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/*
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') {
return ctx.json({ error: 'Action must be start or stop.' }, 400);
}
try {
const result = action === 'start' ? await startStatic() : await stopStatic();
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/servers/:which/:action', async (ctx) => {
const which = ctx.req.param('which');
const action = ctx.req.param('action');
if (which !== 'development' && which !== 'production') {
return ctx.json({ error: 'Server must be development or production.' }, 400);
}
if (action !== 'start' && action !== 'stop') {
return ctx.json({ error: 'Action must be start or stop.' }, 400);
}
try {
const result = action === 'start' ? await startServer(which) : await stopServer(which);
return ctx.json({ ...result, servers: await readServers() }, result.ok ? 200 : 400);
} 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);
const [website, media] = await Promise.all([
readWebsiteStatus(user.email),
readMediaStatus(user.email),
]);
// Only walked when there is somewhere to walk. Reconciling against a media folder that does not
// exist is a guaranteed empty answer bought at the price of a tree walk.
const outstanding = media.complete
? await reconcile(user.email).then((state) => state.missing.length + state.stale.length)
: null;
/*
The Database block folds away on the same terms as Root location, so its answer comes from here too
— for the same reason: a block that cannot know it is finished until its children report must paint
open and then shut, on every load.
"Finished" is both databases existing WITH a schema in them, and backups configured. Not backups
having run: a site that has just been set up has taken none, and telling somebody their setup is
incomplete because nothing has happened yet is telling them to fix something that is not broken.
*/
const databases = await readDatabases();
const backups = await readBackups();
return ctx.json({
rootSet: settings.isSet,
websiteReady: website.isRepo,
mediaReady: media.complete && outstanding === 0,
databasesReady:
Boolean(databases.production.exists && databases.development.exists) &&
(databases.production.tables ?? 0) > 0 &&
(databases.development.tables ?? 0) > 0 &&
Boolean(backups.cron && backups.sshHost && backups.sshPath),
});
});
router.get('/_config/media/ledger', async (ctx) => {
const user = ctx.get('user');
const scope = ctx.req.query('path') ?? '';
try {
const [runs, state] = await Promise.all([readRuns(user.email), reconcile(user.email, scope)]);
return ctx.json({ runs, reconciliation: state });
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
*
* Not owner-only, and not under `/_config` for that reason: a member needs to know whether to render a
* section or a "not connected yet" message, and that answer contains no credential.
*/
router.get('/status', async (ctx) => {
const user = ctx.get('user');
const connection = await readConnection();
const settings = await readSettings(user.email);
/*
`rootSet` is a BOOLEAN and this route is deliberately not owner-only.
The nav has to know whether Media can be opened, and every account that can see the section needs
that answer. The path itself stays behind `/_config`, which is owner-only — this says only whether
one has been chosen, which reveals nothing about the machine.
*/
/*
`ready` is whether the site sections can be OPENED, and it is now answerable.
It was hardcoded false for as long as there was nothing behind them: the sections proxy to the
site's admin API, and no address for one was ever stored. Both halves exist now — the development
server runs on a port this plugin chose, holding a token this plugin generated — so the question
became a real one and this is the answer.
It asks whether the server is ANSWERING, not whether pm2 says online. `bun --hot` survives a crash
at startup: the process stays up, pm2 reports online, and nothing is listening. Opening nine
sections onto that would make one failed boot look like nine broken screens.
*/
const servers = await readServers();
const development = servers.find((server) => server.name === 'development');
return ctx.json({
configured: connection.configured,
lastProbeOk: connection.lastProbeOk,
rootSet: settings.isSet,
ready: development?.reachable === true,
});
});
/**
* Everything else, forwarded verbatim.
*
* A catch-all rather than a route per section, because the sections are fofos's to define — see its
* `docs/SURFACES.md`. Enumerating them here would mean this plugin needs a release every time fofos
* adds one, which is a coupling with nothing to show for it.
*
* ── LAST in the file, and that is load-bearing ──
*
* Hono runs every matching handler in REGISTRATION order, so `/*` declared above `/_config` would
* swallow it — the settings routes would forward themselves to fofos, which has no such endpoint, and
* the settings form would 404 against a plugin that was working perfectly. Everything this plugin
* serves itself is registered before this point.
*/
router.all('/*', async (ctx) => {
const credentials = await readCredentials();
if (!credentials) return noConnection(ctx);
const url = new URL(ctx.req.url);
// Everything after the mount, so `/gallery/3/media` reaches fofos as `/api/admin/gallery/3/media`.
const path = url.pathname.slice(url.pathname.indexOf('/fofos-admin') + '/fofos-admin'.length) || '';
const method = ctx.req.method;
// Read as TEXT and forwarded as text, so what fofos parses is what the panel sent. Safe after
// bodyParser because Hono caches the body — and correct because these are all JSON.
const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text();
try {
const response = await fofosRequest(credentials, {
method,
path,
search: url.search,
body,
contentType: body ? 'application/json' : null,
// Officer's session is the only place the acting user is known. fofos writes it into its own
// history — see its record.ts, and the note on `actor` in fofos-client.
actor: ctx.get('user')?.email ?? null,
});
return passThrough(response);
} catch (error) {
return unreachable(ctx, error);
}
});
/*
Start the backup timer when this router is imported, which is once, at boot.
Not awaited: the server must not wait on a database read to finish mounting a router, and a schedule
that starts a moment late costs nothing. Failures are swallowed for the same reason the timer swallows
them — a bad expression in one column must not stop the plugin from loading, because the panel is
where somebody would go to fix it.
*/
void schedule().catch(() => {});
/*
Watch the contact inbox and copy it whenever it changes.
Separate from the backup schedule on purpose: messages arrive between publishes, so they are the one
thing a publish-triggered backup would never contain. Not awaited, and failures swallowed, for the
same reason as above.
*/
void watchMailbox().catch(() => {});
/*
Watch `originals/` and convert what arrives.
A filesystem watcher rather than the platform's event bus, because the bus reports ACTIONS Officer
took — an upload, an extraction — and moving files into a folder is not one of them. Nine files moved
produced no event at all, and the conversion waited for somebody to press a button they had no reason
to know about.
*/
void watchOriginals().catch(() => {});