Officer's session is the only place the acting user is known, and fofos writes it into its own change history. An email in X-Actor, because that database has no users to point at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
803 lines
34 KiB
TypeScript
803 lines
34 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 { sidecarPort } from '@@/sidecar-registry';
|
|
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 { listOriginals, originalsDir, reconcile, safeScope } from './derive';
|
|
import { createDatabases, pushSchema, readDatabases } from './databases';
|
|
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
|
|
import { watchMailbox } from './messages';
|
|
import { watchOriginals } from './originals-watch';
|
|
import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers';
|
|
import { buildSite, readStatic, startStatic, stopStatic } from './static-site';
|
|
import { publish } from './publish';
|
|
import { join, resolve, sep } 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.
|
|
*/
|
|
/**
|
|
* The media files under a folder in `originals/`, relative to it.
|
|
*
|
|
* For "Create Gallery" on a directory: what somebody right-clicked is the folder, and what a gallery
|
|
* holds is the files. Expanded here rather than in the browser because this side already knows which
|
|
* extensions are media and how to walk the tree — `listOriginals` is the same function the sync uses,
|
|
* so a gallery cannot contain a file the pipeline would not convert.
|
|
*
|
|
* Recursive, deliberately: a folder of folders is still "these pictures", and refusing to descend would
|
|
* make the action silently miss most of what somebody pointed at.
|
|
*/
|
|
/**
|
|
* Serve a file out of `derived/`.
|
|
*
|
|
* ── Why this exists when the site serves them too ──
|
|
*
|
|
* The site has its own `/derived/*`, and the admin cannot use it: the panel talks to this plugin, and
|
|
* the catch-all below forwards everything to the site's `/api/admin`, which is a different route. More
|
|
* importantly, an admin thumbnail should not require the development server to be running — looking at
|
|
* a gallery is not editing the site.
|
|
*
|
|
* So this reads the disk directly. The plugin knows the root; the file is right there.
|
|
*
|
|
* ── Not under `/_config` ──
|
|
*
|
|
* That prefix is owner-only, and a thumbnail is not a credential. A member who can open the section can
|
|
* see the pictures in it, which is the whole point of the section.
|
|
*
|
|
* Registered BEFORE the catch-all, or the catch-all would forward it to fofos.
|
|
*/
|
|
router.get('/derived/*', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const settings = await readSettings(user.email);
|
|
|
|
const url = new URL(ctx.req.url);
|
|
const after = url.pathname.slice(url.pathname.indexOf('/derived/') + '/derived/'.length);
|
|
const relative = decodeURIComponent(after);
|
|
|
|
const base = join(settings.rootPath, 'media', 'derived');
|
|
const full = resolve(join(base, relative));
|
|
// `resolve` first, then compare, so `..` cannot climb out — and against the directory plus a separator
|
|
// as well as itself, since a bare startsWith accepts `derived-old` for a root of `derived`.
|
|
if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404);
|
|
|
|
const file = Bun.file(full);
|
|
if (!(await file.exists())) return ctx.json({ error: 'Not found' }, 404);
|
|
|
|
return new Response(file, {
|
|
// A rebuild replaces the contents of a rendition rather than its name, so unlike a content-addressed
|
|
// store these can go stale. A few minutes is enough for a page of thumbnails and short enough that a
|
|
// reconverted image appears while somebody is still looking at it.
|
|
headers: { 'Cache-Control': 'private, max-age=300' },
|
|
});
|
|
});
|
|
|
|
/**
|
|
* An ORIGINAL, rendered at a width, for the admin to draw at size.
|
|
*
|
|
* ── Why not the derived ladder ──
|
|
*
|
|
* A framing slot is most of the panel wide — around 1900 CSS pixels — and can be zoomed into on top of
|
|
* that. Asking `derived/` for a rung means choosing one in advance and being wrong: 640 stretched three
|
|
* times over reads as a poor photograph rather than as the wrong file, and the widest rung available is
|
|
* whatever the ladder happens to hold.
|
|
*
|
|
* The original has every pixel there is. It is also, often, something a browser cannot decode — a HEIC
|
|
* off a phone — which is exactly what av-club is for.
|
|
*
|
|
* ── Every encoder is av-club ──
|
|
*
|
|
* `/v1/render` takes a path and a width and answers with something a browser can draw, whatever went
|
|
* in. This route resolves the path inside `originals/` and forwards; it decodes nothing itself, which
|
|
* is the standing rule and the reason there is no image library in this plugin.
|
|
*/
|
|
router.get('/original/*', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const settings = await readSettings(user.email);
|
|
if (!settings.rootPath) return ctx.json({ error: 'No root location is set.' }, 400);
|
|
|
|
const url = new URL(ctx.req.url);
|
|
const after = url.pathname.slice(url.pathname.indexOf('/original/') + '/original/'.length);
|
|
const relative = decodeURIComponent(after);
|
|
|
|
const base = join(settings.rootPath, 'media', 'originals');
|
|
const full = resolve(join(base, relative));
|
|
// `resolve` first, then compare against the directory plus a separator as well as itself — a bare
|
|
// startsWith accepts `originals-old` for a root of `originals`.
|
|
if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404);
|
|
if (!(await Bun.file(full).exists())) return ctx.json({ error: 'Not found' }, 404);
|
|
|
|
// Clamped at both ends: below 256 nothing is worth rendering, and above 4096 a panel is asking for
|
|
// more pixels than any screen it runs on. The same bounds the platform's own /render uses.
|
|
const asked = Number(url.searchParams.get('width'));
|
|
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 1600;
|
|
|
|
const port = sidecarPort('av-club');
|
|
if (!port) return ctx.json({ error: 'av-club is not running.' }, 503);
|
|
|
|
const rendered = await fetch(
|
|
`http://127.0.0.1:${port}/v1/render?path=${encodeURIComponent(full)}&width=${width}`,
|
|
{ headers: { 'x-officer-user': user.email } },
|
|
);
|
|
if (!rendered.ok) return ctx.json({ error: 'That file could not be rendered.' }, 422);
|
|
|
|
return new Response(await rendered.arrayBuffer(), {
|
|
headers: {
|
|
'Content-Type': rendered.headers.get('content-type') ?? 'image/webp',
|
|
// Not cached on disk. A full-size render is large, looked at once, and keyed on a width the panel
|
|
// chose — caching it would fill a disk to make the second look at one photograph faster.
|
|
'Cache-Control': 'private, max-age=300',
|
|
},
|
|
});
|
|
});
|
|
|
|
router.get('/_config/media/files', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const scope = ctx.req.query('path') ?? '';
|
|
try {
|
|
const root = await originalsDir(user.email);
|
|
/*
|
|
Returned as-is. `listOriginals` already includes the prefix in every path it yields — it builds
|
|
`prefix/name` as it descends — so prefixing again produced
|
|
`Home_Page_Gallery/Home_Page_Gallery/…`, a real-looking path to nothing.
|
|
*/
|
|
const files = await listOriginals(root, safeScope(scope));
|
|
return ctx.json({ files });
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
/*
|
|
The sync's state, and the part of its transcript the caller does not have yet.
|
|
|
|
`?since=<n>` is what keeps a modal open on a long run cheap: the transcript is thousands of lines by
|
|
the end and polls once a second, so sending all of it every tick would be the same megabyte over and
|
|
over. The caller keeps what it has been given and asks for what came after.
|
|
|
|
Absent `since` means "everything", which is what a modal opening mid-run wants — a watcher that
|
|
arrived late should see what already happened rather than start from the next line.
|
|
*/
|
|
router.get('/_config/media/sync', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const state = readSync(user.email);
|
|
|
|
const raw = ctx.req.query('since');
|
|
const since = raw === undefined ? null : Number(raw);
|
|
if (since === null || !Number.isFinite(since)) return ctx.json(state);
|
|
|
|
return ctx.json({ ...state, log: state.log.filter((line) => line.n > since) });
|
|
});
|
|
|
|
/**
|
|
* 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 body = (ctx.get('body') ?? {}) as {
|
|
cron?: string;
|
|
dir?: string;
|
|
sshHost?: string;
|
|
sshPath?: string;
|
|
keep?: number | null;
|
|
};
|
|
try {
|
|
return ctx.json(
|
|
await saveBackups({
|
|
cron: body.cron ?? '',
|
|
dir: body.dir ?? '',
|
|
sshHost: body.sshHost ?? '',
|
|
sshPath: body.sshPath ?? '',
|
|
keep: body.keep ?? null,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Prove the destination before anything is trusted to it.
|
|
*
|
|
* Takes the values from the FORM rather than from storage, so it tests what is on screen — testing a
|
|
* saved destination while somebody looks at a different one is worse than not testing at all.
|
|
*/
|
|
router.post('/_config/backups/test', async (ctx) => {
|
|
const { sshHost, sshPath } = (ctx.get('body') ?? {}) as { sshHost?: string; sshPath?: string };
|
|
try {
|
|
const result = await testDestination(sshHost ?? '', sshPath ?? '');
|
|
return ctx.json(result, result.ok ? 200 : 400);
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Load a dump back into one of the databases.
|
|
*
|
|
* `confirm` is required by the function behind this, not merely by the panel. A restore empties the
|
|
* target first — a pg_dump carries CREATE TABLE and cannot be layered onto a database that already has
|
|
* the schema — and that is not something any code path should be able to reach by accident.
|
|
*/
|
|
router.post('/_config/backups/restore', async (ctx) => {
|
|
const { file, target, confirm } = (ctx.get('body') ?? {}) as {
|
|
file?: string;
|
|
target?: string;
|
|
confirm?: boolean;
|
|
};
|
|
if (target !== 'production' && target !== 'development') {
|
|
return ctx.json({ error: 'Target must be production or development.' }, 400);
|
|
}
|
|
try {
|
|
const result = await restoreBackup({ file: file ?? '', target, confirm: confirm === true });
|
|
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/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);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The site's two servers, under pm2.
|
|
*
|
|
* The target is a route PARAM and is checked against the two names this plugin owns before anything is
|
|
* run. Everything downstream is built from that pair and from nothing a caller supplies, so no request
|
|
* can reach another process on this machine — which matters, because Officer's own processes share the
|
|
* pm2 daemon these commands talk to.
|
|
*/
|
|
router.get('/_config/servers', async (ctx) => ctx.json(await readServers()));
|
|
|
|
router.put('/_config/servers', async (ctx) => {
|
|
const { development, production } = (ctx.get('body') ?? {}) as { development?: number; production?: number };
|
|
try {
|
|
return ctx.json(await savePorts(development, production));
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Production — a static build, and the nginx that serves it.
|
|
*
|
|
* Separate routes from the servers above because it is a different kind of thing: there is no process,
|
|
* no port and nothing to restart. A build either succeeded or did not, and a container is either
|
|
* serving the directory or is not.
|
|
*/
|
|
router.get('/_config/static', async (ctx) => ctx.json(await readStatic()));
|
|
|
|
router.post('/_config/static/build', async (ctx) => {
|
|
try {
|
|
const result = await buildSite(await siteSecrets());
|
|
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
/*
|
|
Publish: promote development's content to production, then build.
|
|
|
|
A route of its own rather than a flag on build, and BEFORE `/:action` below — that handler would
|
|
otherwise catch `publish` and reject it as "must be start or stop", which is the kind of 400 nobody
|
|
can read backwards to a route-ordering mistake.
|
|
*/
|
|
router.post('/_config/static/publish', async (ctx) => {
|
|
try {
|
|
const result = await publish(await siteSecrets());
|
|
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
router.post('/_config/static/:action', async (ctx) => {
|
|
const action = ctx.req.param('action');
|
|
if (action !== 'start' && action !== 'stop') {
|
|
return ctx.json({ error: 'Action must be start or stop.' }, 400);
|
|
}
|
|
try {
|
|
const result = action === 'start' ? await startStatic() : await stopStatic();
|
|
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
|
|
} catch (error) {
|
|
return ctx.json({ error: (error as Error).message }, 400);
|
|
}
|
|
});
|
|
|
|
router.post('/_config/servers/:which/:action', async (ctx) => {
|
|
const which = ctx.req.param('which');
|
|
const action = ctx.req.param('action');
|
|
if (which !== 'development' && which !== 'production') {
|
|
return ctx.json({ error: 'Server must be development or production.' }, 400);
|
|
}
|
|
if (action !== 'start' && action !== 'stop') {
|
|
return ctx.json({ error: 'Action must be start or stop.' }, 400);
|
|
}
|
|
try {
|
|
const result = action === 'start' ? await startServer(which) : await stopServer(which);
|
|
return ctx.json({ ...result, servers: await readServers() }, 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;
|
|
|
|
/*
|
|
The Database block folds away on the same terms as Root location, so its answer comes from here too
|
|
— for the same reason: a block that cannot know it is finished until its children report must paint
|
|
open and then shut, on every load.
|
|
|
|
"Finished" is both databases existing WITH a schema in them, and backups configured. Not backups
|
|
having run: a site that has just been set up has taken none, and telling somebody their setup is
|
|
incomplete because nothing has happened yet is telling them to fix something that is not broken.
|
|
*/
|
|
const databases = await readDatabases();
|
|
const backups = await readBackups();
|
|
|
|
return ctx.json({
|
|
rootSet: settings.isSet,
|
|
websiteReady: website.isRepo,
|
|
mediaReady: media.complete && outstanding === 0,
|
|
databasesReady:
|
|
Boolean(databases.production.exists && databases.development.exists) &&
|
|
(databases.production.tables ?? 0) > 0 &&
|
|
(databases.development.tables ?? 0) > 0 &&
|
|
Boolean(backups.cron && backups.sshHost && backups.sshPath),
|
|
});
|
|
});
|
|
|
|
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.
|
|
*/
|
|
/*
|
|
`ready` is whether the site sections can be OPENED, and it is now answerable.
|
|
|
|
It was hardcoded false for as long as there was nothing behind them: the sections proxy to the
|
|
site's admin API, and no address for one was ever stored. Both halves exist now — the development
|
|
server runs on a port this plugin chose, holding a token this plugin generated — so the question
|
|
became a real one and this is the answer.
|
|
|
|
It asks whether the server is ANSWERING, not whether pm2 says online. `bun --hot` survives a crash
|
|
at startup: the process stays up, pm2 reports online, and nothing is listening. Opening nine
|
|
sections onto that would make one failed boot look like nine broken screens.
|
|
*/
|
|
const servers = await readServers();
|
|
const development = servers.find((server) => server.name === 'development');
|
|
|
|
return ctx.json({
|
|
configured: connection.configured,
|
|
lastProbeOk: connection.lastProbeOk,
|
|
rootSet: settings.isSet,
|
|
ready: development?.reachable === true,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
// Officer's session is the only place the acting user is known. fofos writes it into its own
|
|
// history — see its record.ts, and the note on `actor` in fofos-client.
|
|
actor: ctx.get('user')?.email ?? 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(() => {});
|
|
|
|
/*
|
|
Watch the contact inbox and copy it whenever it changes.
|
|
|
|
Separate from the backup schedule on purpose: messages arrive between publishes, so they are the one
|
|
thing a publish-triggered backup would never contain. Not awaited, and failures swallowed, for the
|
|
same reason as above.
|
|
*/
|
|
void watchMailbox().catch(() => {});
|
|
|
|
/*
|
|
Watch `originals/` and convert what arrives.
|
|
|
|
A filesystem watcher rather than the platform's event bus, because the bus reports ACTIONS Officer
|
|
took — an upload, an extraction — and moving files into a folder is not one of them. Nine files moved
|
|
produced no event at all, and the conversion waited for somebody to press a button they had no reason
|
|
to know about.
|
|
*/
|
|
void watchOriginals().catch(() => {});
|