Files
com.gregkilmer.www/services/admin/src/server.ts
T
Claude 02437358cf Add a Before launch placeholder check to admin
Lists pages still showing built-in wording, notes holding placeholder
text, unpublished posts and live notes missing the field their page is
built around, each linked to its editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 16:52:56 +00:00

563 lines
26 KiB
TypeScript

/**
* 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";
import * as inbox from "./inbox.ts";
import * as posts from "./posts.ts";
import * as editor from "./editor.ts";
import { sitePagesView } from "./sitepages.ts";
import { launchCheck, launchPage } from "./launch.ts";
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
const js = readFileSync(new URL("../public/admin.js", import.meta.url), "utf8");
/** The largest form with files: a few photos at a time. */
const MAX_UPLOAD = 60_000_000;
class Redirect {
constructor(readonly to: string) {}
}
type Form = URLSearchParams;
type Upload = { field: string; name: string; bytes: Uint8Array<ArrayBuffer> };
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, suggestions] = await Promise.all([
api.status(),
api.held(),
api.problems(),
api.suggestions().catch(() => []),
]);
return { title: "Overview", body: html`${pages.overview(status, held, problems)}${suggestionsPanel(suggestions)}` };
}
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()) };
case "/posts":
return { title: "Posts", body: posts.postsList(await api.notes({ type: "post", limit: 1000 })) };
case "/posts/new":
return editorPage(posts.emptyPost(today()));
case "/posts/edit": {
const note = await api.note(required(q, "path"));
if (note.type !== "post") return new Redirect(pages.noteHref(note.path));
return editorPage(posts.draftFromNote(note));
}
case "/site": {
const all = await Promise.all(["page", "site-settings", "store-settings", "status-kinds", "skills", "education"].map((t) => api.fullNotes(t)));
const singles = new Map(all.slice(1).flatMap((l) => (l[0] ? [[l[0].type, l[0]] as const] : [])));
return { title: "Site pages", body: sitePagesView(all[0]!, singles) };
}
case "/launch":
return { title: "Before launch", body: launchPage(launchCheck(await api.allNotes())) };
case "/edit": {
const note = await api.note(required(q, "path"));
if (note.type === "post") return new Redirect(posts.editHref(note.path));
return noteEditorPage(editor.draftFromNote(note));
}
case "/edit/new": {
const type = required(q, "type");
if (type === "post") return new Redirect("/posts/new");
if (!noteTypeNames.includes(type as never)) throw new ApiError(404, `There is no note type ${type}.`, null);
const data: Record<string, unknown> = {};
if (type === "page" && q.get("page")) data.page = q.get("page");
return noteEditorPage({ type, title: q.get("title") ?? "", publish: false, data, body: "" });
}
case "/images": {
const folder = q.get("folder") ?? "";
return { title: "Images", body: posts.imagesPage(await api.attachments(), folder === "-" ? "" : folder) };
}
}
if (path === "/inbox") {
const show = q.get("show") ?? "open";
const { counts, submissions } = await api.submissions(show === "all" ? undefined : show === "handled");
return { title: "Inbox", body: inbox.inboxList(submissions, counts, show) };
}
const inboxOne = path.match(/^\/inbox\/(\d+)$/);
if (inboxOne) {
const s = await api.submission(Number(inboxOne[1]));
return { title: `${s.kind === "order" ? "Order" : "Inquiry"} from ${s.name}`, body: inbox.inboxOne(s) };
}
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 editorPage(d: posts.PostDraft, showPreview = false): Promise<Page> {
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
const names = [...new Set(notes.filter((n) => n.type !== "post" && n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
return { title: d.title || "Write a post", body: posts.postEditor(d, { images, notes: names, showPreview }) };
}
async function noteEditorPage(d: editor.EditorDraft, reasons: string[] = []): Promise<Page> {
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
const names = [...new Set(notes.filter((n) => n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
const body = editor.noteEditor(d, { images, notes: names });
return {
title: d.title || "New note",
body: reasons.length ? html`<div class="flash err" role="alert">Not saved.<ul>${reasons.map((r) => html`<li>${r}</li>`)}</ul></div>${body}` : body,
};
}
/** Save the note editor: upload chosen images, then reorder a row or save. */
async function saveNote(f: Form, files: Upload[]): Promise<Page | Redirect> {
const type = required(f, "type");
if (!noteTypeNames.includes(type as never)) throw new ApiError(400, `There is no note type ${type}.`, null);
const path = f.get("path") || undefined;
const uploaded = new Map<string, string[]>();
for (const u of files.filter((x) => x.field.startsWith("upload."))) {
const key = u.field.slice("upload.".length);
uploaded.set(key, [...(uploaded.get(key) ?? []), await api.upload(editor.uploadFolder(type), u.name, u.bytes)]);
}
let values = editor.formData(type, f, uploaded);
const title = (f.get("title") ?? "").trim();
const body = (f.get("body") ?? "").replace(/\r\n/g, "\n");
const draft = (): editor.EditorDraft => ({ type, path, title, publish: values.publish === true, data: values, body });
const move = f.get("move");
if (move) {
values = editor.moveRow(values, move);
return noteEditorPage(draft());
}
if (!title) return noteEditorPage(draft(), ["A note needs a title."]);
const fields = editor.changeFields(values, !!path);
const text = editor.hasBody(type) ? body : undefined;
const o = path
? await api.apply({ op: "update", path, fields, body: text })
: await api.apply({ op: "create", data: { type, title, ...fields }, body: text });
if (o.status === "rejected") return noteEditorPage(draft(), o.reasons.map((r) => r.message));
return after(editor.editHref(o.path), o, "Saved. The site shows it within a minute.");
}
/** Save the post form: upload its images, then preview, save a draft or publish. */
async function savePost(f: Form, files: Upload[]): Promise<Page | Redirect> {
const d = posts.draftFromForm(f);
const cover = files.find((u) => u.field === "coverFile");
if (cover) d.cover = await api.upload("Posts", cover.name, cover.bytes);
for (const u of files.filter((x) => x.field === "bodyImages")) {
const name = await api.upload("Posts", u.name, u.bytes);
d.body = `${d.body.trimEnd()}\n\n![[${name}]]\n`;
}
const action = f.get("action") ?? "draft";
if (action === "preview") return editorPage(d, true);
if (!d.title) throw new ApiError(400, "A post needs a title", null);
const publish = action === "publish";
const o = d.path
? await api.apply({ op: "update", path: d.path, fields: posts.postFields(d, publish, true), body: d.body })
: await api.apply({ op: "create", data: { type: "post", ...posts.postFields(d, publish, false) }, body: d.body });
const saved = publish ? (d.publish ? "Saved. The live post is updated." : "Published. It is on the Blog now.") : d.publish ? "Taken off the site and saved as a draft." : "Draft saved.";
if (o.status === "rejected") {
// Keep what was typed: show the form again with the reasons.
const page = await editorPage(d);
return { ...page, body: html`<div class="flash err" role="alert">Not saved.<ul>${o.reasons.map((r) => html`<li>${r.message}</li>`)}</ul></div>${page.body}` };
}
return after(posts.editHref(o.path), o, saved);
}
async function post(path: string, f: Form, files: Upload[] = []): Promise<Redirect | Page> {
switch (path) {
case "/posts/save":
return savePost(f, files);
case "/edit/save":
return saveNote(f, files);
case "/images/upload": {
const folder = f.get("folder") || "Posts";
const added: string[] = [];
for (const u of files.filter((x) => x.field === "files")) added.push(await api.upload(folder, u.name, u.bytes));
if (!added.length) throw new ApiError(400, "Choose at least one image", null);
return new Redirect(`/images${qs({ folder, msg: `Added ${added.join(", ")}.` })}`);
}
case "/images/delete": {
const name = required(f, "name");
await api.deleteAttachment(name);
return new Redirect(`/images${qs({ folder: f.get("folder") || undefined, msg: `Deleted ${name}.` })}`);
}
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 "/suggestions/add": {
const key = required(f, "key");
const s = (await api.suggestions()).find((x) => x.key === key);
if (!s) throw new ApiError(409, "That suggestion is gone: it was added or dismissed already.", null);
const o = await api.apply(suggestionChange(s));
return after("/", o, `Added ${s.title} to the Library as Now.`);
}
case "/suggestions/dismiss": {
await api.dismissSuggestion(required(f, "key"));
return new Redirect(`/${qs({ msg: "Dismissed. It will not be suggested again." })}`);
}
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 handled = path.match(/^\/inbox\/(\d+)\/handled$/);
if (handled) {
const id = Number(handled[1]);
const s = await api.setHandled(id, f.get("handled") === "1");
return new Redirect(`${s.handled ? "/inbox" : inbox.inboxHref(id)}${qs({ msg: s.handled ? `Marked ${s.name}'s ${s.kind} as handled.` : "Marked as not handled." })}`);
}
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 'self'; 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 === "/admin.js") {
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "max-age=300" });
return res.end(js);
}
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;
}
const inboxFile = url.pathname.match(/^\/inbox\/(\d+)\/attachment$/);
if (method === "GET" && inboxFile) {
// Always a download, never shown inline: a visitor chose this file.
const r = await api.submissionFile(Number(inboxFile[1]));
if (!r.ok || !r.body) return text(res, r.status === 404 ? 404 : 502, "No such attachment.");
res.writeHead(200, {
"content-type": r.headers.get("content-type") ?? "application/octet-stream",
"content-disposition": r.headers.get("content-disposition") ?? "attachment",
"x-content-type-options": "nosniff",
"cache-control": "no-store",
});
Readable.fromWeb(r.body as never).pipe(res);
return;
}
if (method === "POST") {
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
const { form, files } = await readForm(req);
const r = await post(url.pathname, form, files);
if (r instanceof Redirect) {
res.writeHead(303, { location: r.to });
return res.end();
}
const held = await api.held().then((h) => h.length).catch(() => 0);
return page(res, 200, r, url, held);
}
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, unhandled] = await Promise.all([
api.held().then((h) => h.length).catch(() => 0),
api.submissions(false).then((s) => s.counts.unhandled).catch(() => 0),
]);
page(res, 200, out, url, held, unhandled);
}
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number, unhandled = 0) {
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, inbox: unhandled, 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, max: number): Promise<Buffer> {
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req) {
size += (c as Buffer).length;
if (size > max) throw new ApiError(413, "The form is too large", null);
chunks.push(c as Buffer);
}
return Buffer.concat(chunks);
}
/** A posted form: plain fields, and any chosen files when it was sent as multipart. */
async function readForm(req: IncomingMessage): Promise<{ form: URLSearchParams; files: Upload[] }> {
const type = req.headers["content-type"] ?? "";
if (!type.startsWith("multipart/form-data")) {
return { form: new URLSearchParams((await readBody(req, 200_000)).toString("utf8")), files: [] };
}
const data = await new Request("http://admin/", { method: "POST", headers: { "content-type": type }, body: new Uint8Array(await readBody(req, MAX_UPLOAD)) }).formData();
const form = new URLSearchParams();
const files: Upload[] = [];
for (const [field, v] of data.entries()) {
if (typeof v === "string") form.append(field, v);
// An empty file input still posts a nameless, empty part.
else if (v.size > 0 && v.name) files.push({ field, name: v.name, bytes: new Uint8Array(await v.arrayBuffer()) });
}
return { form, files };
}
function text(res: ServerResponse, status: number, body: string) {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
res.end(body);
}