Add the admin page (phase 3)

services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:59:21 +00:00
parent 3f646048a2
commit 32cf13a5cb
23 changed files with 1474 additions and 6 deletions
+357
View File
@@ -0,0 +1,357 @@
/**
* The admin page's server. It renders HTML on the server and every action is
* a plain form post, so it works without client scripts. It holds the
* content service's admin token; the browser never sees it.
*
* It must only be reachable on the tailnet. On top of that, every form post
* has to come from this page itself, so another site cannot post to it.
*/
import { readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { basename } from "node:path";
import { Readable } from "node:stream";
import { noteTypeNames } from "@gk/schema";
import { ApiError, type ContentApi, type Outcome } from "./client.ts";
import { html, qs, type Html } from "./html.ts";
import { layout, type Flash } from "./layout.ts";
import * as pages from "./pages.ts";
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
class Redirect {
constructor(readonly to: string) {}
}
type Form = URLSearchParams;
type Page = { title: string; body: Html };
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
const today = () => (opts.now?.() ?? new Date()).toISOString().slice(0, 10);
/** Where to go after an action, carrying what happened. */
const after = (to: string, o: Outcome, saved: string) => {
const [path, query = ""] = to.split("?");
const p = new URLSearchParams(query);
if (o.status === "accepted" || o.status === "unchanged") p.set("msg", saved);
else if (o.status === "held") {
p.set("msg", "Saved for review. It is not live until you accept it.");
} else {
p.set("err", "Not saved.");
for (const r of o.reasons) p.append("r", r.message);
}
return new Redirect(`${path}?${p}`);
};
const pathOfOutcome = (o: Outcome) => (o.status === "rejected" ? null : o.path);
async function get(path: string, q: URLSearchParams): Promise<Page | Redirect> {
switch (path) {
case "/": {
const [status, held, problems] = await Promise.all([api.status(), api.held(), api.problems()]);
return { title: "Overview", body: pages.overview(status, held, problems) };
}
case "/syncs": {
const job = q.get("job") ?? "";
return { title: job, body: pages.syncHistory(job, await api.syncRuns(job)) };
}
case "/review": {
const status = q.get("status") ?? "pending";
return { title: "Review", body: pages.reviewList(await api.held(status), status) };
}
case "/notes":
case "/drafts": {
const drafts = path === "/drafts";
const type = q.get("type") || undefined;
const text = q.get("q") || undefined;
let notes = await api.notes({ type, q: text, limit: 1000 });
if (drafts) notes = notes.filter((n) => !n.publish);
if (!text) notes = notes.sort((a, b) => b.acceptedAt.localeCompare(a.acceptedAt)).slice(0, 300);
return {
title: drafts ? "Drafts" : "Notes",
body: pages.notesList({
title: drafts ? "Drafts" : "Notes",
lede: drafts
? "Notes that are not on the site yet. Open one to preview it and publish it."
: "Every note the site knows about, most recently changed first.",
action: path,
notes,
types: noteTypeNames,
type,
q: text,
}),
};
}
case "/note": {
const p = required(q, "path");
const [note, history] = await Promise.all([api.note(p), api.history(p)]);
return { title: note.title, body: pages.noteView(note, history) };
}
case "/note/revision": {
const p = required(q, "path");
const [note, rev] = await Promise.all([api.note(p), api.revision(p, Number(required(q, "rev")))]);
return { title: `${note.title}, version ${rev.rev}`, body: pages.revisionView(note, rev) };
}
case "/go": {
const note = await api.noteByName(required(q, "name"));
return new Redirect(pages.noteHref(note.path));
}
case "/capture": {
const kinds = (await api.fullNotes("status-kinds"))[0]?.data.kinds as { label: string }[] | undefined;
return { title: "Capture", body: pages.capture((kinds ?? []).map((k) => k.label), today()) };
}
case "/store": {
const [settings, lines] = await Promise.all([api.fullNotes("store-settings"), api.fullNotes("product-line")]);
return { title: "Store", body: pages.store(settings[0], lines.sort((a, b) => Number(a.data.order ?? 99) - Number(b.data.order ?? 99))) };
}
case "/problems":
return { title: "Problems", body: pages.problemsView(await api.problems()) };
}
const review = path.match(/^\/review\/(\d+)$/);
if (review) {
const h = await api.heldOne(Number(review[1]));
return { title: `Review ${h.path}`, body: pages.reviewOne(h) };
}
throw new ApiError(404, "There is no page here.", null);
}
async function post(path: string, f: Form): Promise<Redirect> {
switch (path) {
case "/scan": {
const r = await api.scan();
return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
}
case "/note/rollback": {
const p = required(f, "path");
const o = await api.rollback(p, Number(required(f, "rev")));
return after(pages.noteHref(p), o, "Rolled back. The site and the vault show that version again.");
}
case "/note/publish": {
const p = required(f, "path");
const publish = f.get("publish") === "1";
const o = await api.apply({ op: "update", path: p, fields: { publish } });
return after(pages.noteHref(p), o, publish ? "Published." : "Taken off the site.");
}
case "/capture/status": {
const subject = (f.get("subject") ?? "").trim().replace(/^\[\[|\]\]$/g, "");
const o = await api.apply({
op: "create",
data: {
type: "status",
title: required(f, "title"),
kind: required(f, "kind"),
date: f.get("date") || today(),
...optional(f, ["note"]),
...(subject ? { subject: `[[${subject}]]` } : {}),
...(f.get("pinned") === "1" ? { pinned: true } : {}),
publish: f.get("publish") === "1",
},
});
return after("/capture", o, "Status posted.");
}
case "/capture/library": {
const year = f.get("year");
const o = await api.apply({
op: "create",
data: {
type: "library-item",
title: required(f, "title"),
medium: required(f, "medium"),
state: required(f, "state"),
...optional(f, ["creator", "group", "verdict", "note"]),
...(year ? { year: Number(year) } : {}),
publish: f.get("publish") === "1",
},
});
const added = pathOfOutcome(o);
if (f.get("alsoStatus") === "1" && o.status === "accepted" && added) {
const s = await api.apply({
op: "create",
data: {
type: "status",
title: required(f, "title"),
kind: required(f, "kind"),
date: today(),
subject: `[[${basename(added, ".md")}]]`,
publish: f.get("publish") === "1",
},
});
return after("/capture", s, "Added to the Library and posted a status.");
}
return after("/capture", o, "Added to the Library.");
}
case "/store/category": {
const [settings] = await api.fullNotes("store-settings");
if (!settings) throw new ApiError(404, "There is no Store/_settings note yet.", null);
const name = required(f, "name");
const categories = (settings.data.categories as Record<string, unknown>[]).map((c) => {
if (c.name !== name) return c;
const next: Record<string, unknown> = { ...c, ordersOpen: f.get("ordersOpen") === "1" };
for (const k of ["reopens", "turnaround"]) {
const v = (f.get(k) ?? "").trim();
if (v) next[k] = v;
else delete next[k];
}
return next;
});
const o = await api.apply({ op: "update", path: settings.path, fields: { categories } });
return after("/store", o, `${name} saved.`);
}
case "/store/stock": {
const p = required(f, "path");
let fields: Record<string, unknown>;
if (f.has("open")) {
const note = await api.note(p);
fields = { slots: { ...(note.data.slots as object), open: Number(f.get("open")) } };
} else fields = { quantity: Number(required(f, "quantity")) };
const o = await api.apply({ op: "update", path: p, fields });
return after("/store", o, "Saved.");
}
}
const decide = path.match(/^\/review\/(\d+)\/(accept|reject)$/);
if (decide) {
const id = Number(decide[1]);
const o = decide[2] === "accept" ? await api.accept(id) : await api.reject(id, f.get("restore") === "1");
if (o.status === "rejected") return after(`/review/${id}`, o, "");
return new Redirect(`/review${qs({ msg: decide[2] === "accept" ? "Accepted. It is live." : "Rejected. The site keeps the live version." })}`);
}
throw new ApiError(404, "There is no action here.", null);
}
async function handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://admin");
const method = req.method ?? "GET";
res.setHeader("x-frame-options", "DENY");
res.setHeader("referrer-policy", "same-origin");
res.setHeader(
"content-security-policy",
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'none'; form-action 'self'; frame-ancestors 'none'",
);
if (url.pathname === "/admin.css") {
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
return res.end(css);
}
if (url.pathname === "/health") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}');
}
const attachment = url.pathname.match(/^\/attachments\/([^/]+)$/);
if (method === "GET" && attachment) {
const r = await api.attachment(decodeURIComponent(attachment[1] ?? ""));
res.writeHead(r.status, { "content-type": r.headers.get("content-type") ?? "application/octet-stream", "x-content-type-options": "nosniff" });
if (r.body) Readable.fromWeb(r.body as never).pipe(res);
else res.end();
return;
}
if (method === "POST") {
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
const form = new URLSearchParams(await readBody(req));
const r = await post(url.pathname, form);
res.writeHead(303, { location: r.to });
return res.end();
}
if (method !== "GET") return text(res, 405, "Method not allowed");
const out = await get(url.pathname, url.searchParams);
if (out instanceof Redirect) {
res.writeHead(303, { location: out.to });
return res.end();
}
const held = (await api.held().catch(() => [])).length;
page(res, 200, out, url, held);
}
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number) {
const flash: Flash = {
msg: url.searchParams.get("msg") ?? undefined,
err: url.searchParams.get("err") ?? undefined,
reasons: url.searchParams.getAll("r"),
};
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(layout({ title: p.title, path: url.pathname, held, flash, body: p.body }).toString());
}
return createServer((req, res) => {
handle(req, res).catch(async (err: unknown) => {
if (res.headersSent) return res.end();
const url = new URL(req.url ?? "/", "http://admin");
if (err instanceof ApiError) {
const reasons = (err.body as { reasons?: { message: string }[] } | null)?.reasons?.map((r) => r.message) ?? [];
const message = err.status === 404 ? "Not found." : err.message;
// A failed form post goes back where it came from, with the reason.
if (req.method === "POST") {
const back = safeBack(req.headers.referer) ?? new URL("/", "http://admin");
for (const k of ["msg", "err", "r"]) back.searchParams.delete(k);
back.searchParams.set("err", message);
for (const r of reasons) back.searchParams.append("r", r);
res.writeHead(303, { location: back.pathname + back.search });
return res.end();
}
return page(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 502, { title: "Not available", body: errorBody(message) }, url, 0);
}
console.error(err);
page(res, 500, { title: "Error", body: errorBody("Something went wrong. Is the content service running?") }, url, 0);
});
});
}
const errorBody = (message: string) => html`<h1>Not available</h1><p class="lede">${message}</p><p><a href="/">Back to the overview</a></p>`;
function required(f: URLSearchParams, k: string): string {
const v = (f.get(k) ?? "").trim();
if (!v) throw new ApiError(400, `${k} is required`, null);
return v;
}
function optional(f: URLSearchParams, keys: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const k of keys) {
const v = (f.get(k) ?? "").trim();
if (v) out[k] = v;
}
return out;
}
/** Browsers send Sec-Fetch-Site or Origin with every form post; both must say this page. */
function sameOrigin(req: IncomingMessage): boolean {
const site = req.headers["sec-fetch-site"];
if (site) return site === "same-origin" || site === "none";
const origin = req.headers.origin;
if (origin) {
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
}
}
// Neither header: not a browser (a script on the tailnet), which the network already limits.
return true;
}
/** The page a failed post came from, as a local path only. */
function safeBack(referer: string | undefined): URL | null {
if (!referer) return null;
try {
return new URL(new URL(referer).pathname + new URL(referer).search, "http://admin");
} catch {
return null;
}
}
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req) {
size += (c as Buffer).length;
if (size > 200_000) throw new ApiError(413, "The form is too large", null);
chunks.push(c as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
function text(res: ServerResponse, status: number, body: string) {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
res.end(body);
}