Two things the panel could not answer. A run's state lives in a Map, so a restart erased it — and with it any record that a file FAILED. That is the gap the manifests structurally cannot cover: a derived folder records the size, mtime and variants it was built from, so provenance is already precise, but a file that did not build has no folder and leaves no trace. Once the watcher is running, a failure at three in the morning would never be discovered. So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than a table because db/schema.ts is emphatic that the connection is the only thing this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside derived/ rather than inside it, so wiping derived does not erase the history of what happened before, which is the comparison it exists for. Written in a finally, so a run that died still records that it did. And the line above the button now says what is TRUE rather than what a run once did: reconcile() walks originals, reads each manifest, and reports current, missing and changed. It uses upToDate — the same predicate the sync uses to skip a file — because two definitions of "current" is how a panel comes to say everything is in step while the next run rebuilds half of it. Missing and changed are separated because only the second means somebody edited something. The ledger is not polled. It reads the whole tree, so it loads on arrival and again when a run stops — the only two moments its answer can change. Sync also takes an optional folder now, so a subtree can be re-synced on its own rather than the whole library. The scope is recorded on each run, because "3 built" means different things for one folder and for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
15 KiB
TypeScript
346 lines
15 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 { 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.
|
|
*/
|
|
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);
|
|
}
|
|
});
|