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 { 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); }); /** * 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 connection = await readConnection(); return ctx.json({ configured: connection.configured, lastProbeOk: connection.lastProbeOk }); }); /** * 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); } });