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 { readBackups, runBackup, saveBackups, schedule } from './backups'; 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); } }); /** * 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 { cron, dir } = (ctx.get('body') ?? {}) as { cron?: string; dir?: string }; try { return ctx.json(await saveBackups(cron ?? '', dir ?? '')); } 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); } }); 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); } }); /* 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(() => {});