Files
fofos-admin/api/router.ts
T
pastilhasandClaude Opus 5 003613872d provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.

BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:

  - it must SURVIVE the checkout. deleting `website` and cloning it again is a
    normal thing to do when something is wrong with it, and it must never be the
    thing that destroys the client's photographs.
  - it must never be TRACKED. media in git is a repository nobody can clone a year
    from now, and a .gitignore is a promise somebody eventually breaks with
    `git add -f`.

both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.

NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.

`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.

no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.

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

282 lines
12 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 { 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);
}
});