/** * 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 }; 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 { 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 = {}; 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 { 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 { 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`${body}` : body, }; } /** Save the note editor: upload chosen images, then reorder a row or save. */ async function saveNote(f: Form, files: Upload[]): Promise { 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(); 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 { 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`${page.body}` }; } return after(posts.editHref(o.path), o, saved); } async function post(path: string, f: Form, files: Upload[] = []): Promise { 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[]).map((c) => { if (c.name !== name) return c; const next: Record = { ...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; 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`

Not available

${message}

Back to the overview

`; 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 { const out: Record = {}; 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 { 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); }