Files
fofos-admin/api/router.ts
T
pastilhasandClaude Opus 5 0971764fca once both databases are there, the form becomes two rows of actions
A text box invites an edit nobody is here to make. Named and created, the two
things somebody wants are per-database actions, so the inputs and the button go
and each database becomes a label, a table count, and what can be done to it.

Push schema runs the WEBSITE's own `db:push`. Not drizzle-kit driven from here
with a config this plugin assembles — the schema, the drizzle version and the
push command all belong to that repository, and reimplementing any of them would
be a second definition of the site's database going stale on its own schedule.
This supplies the connection and runs their script.

The connection goes in the environment, which works because src/env-file.ts
consults .env files first and process.env last. So an ambient value is used only
where no file in the checkout sets that key, and this cannot quietly override a
connection somebody wrote down.

Their name guard still applies and should: env.ts refuses to connect unless the
database name matches what FOFOS_ENV expects. A plugin that worked around that
would be defeating the one check standing between development and live data.

"Schema pushed" is a COUNT of tables in public, not a comparison against the
site's expected tables — that list would be a second copy of the site's schema,
stale the first time it gains a table, and the question being asked is only
whether anything is in there yet.

Restore is present and disabled, gated on the schema being pushed. It is not
wired: which backup it reads from is not decided, and guessing at the source of
a restore is not a thing to do with a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:14:39 +01:00

421 lines
18 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 { 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 { reconcile } from './derive';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { join } 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.
*/
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);
}
});
router.get('/_config/media/sync', async (ctx) => {
const user = ctx.get('user');
return ctx.json(readSync(user.email));
});
/**
* 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);
}
});
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;
return ctx.json({
rootSet: settings.isSet,
websiteReady: website.isRepo,
mediaReady: media.complete && outstanding === 0,
});
});
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.
*/
return ctx.json({
configured: connection.configured,
lastProbeOk: connection.lastProbeOk,
rootSet: settings.isSet,
});
});
/**
* 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,
});
return passThrough(response);
} catch (error) {
return unreachable(ctx, error);
}
});