mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
02437358cf
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
563 lines
26 KiB
TypeScript
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);
|
|
}
|