mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The content service's HTTP API, as the admin page uses it. The admin page
|
||||
* holds the admin token on the server side; the browser never sees it.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(readonly status: number, message: string, readonly body: unknown) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
path: string;
|
||||
type: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
publish: boolean;
|
||||
acceptedAt: string;
|
||||
}
|
||||
|
||||
export interface Note extends Summary {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
rev: number;
|
||||
author: string;
|
||||
text: string;
|
||||
backlinks: Summary[];
|
||||
pending: Held | null;
|
||||
}
|
||||
|
||||
export interface Reason {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Held {
|
||||
id: number;
|
||||
path: string;
|
||||
author: string;
|
||||
source: "vault" | "api";
|
||||
reasons: Reason[];
|
||||
at: string;
|
||||
status: string;
|
||||
decidedAt: string | null;
|
||||
text?: string;
|
||||
current?: string | null;
|
||||
}
|
||||
|
||||
export interface Revision {
|
||||
rev: number;
|
||||
author: string;
|
||||
at: string;
|
||||
action: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
job: string;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
counts: Record<string, number>;
|
||||
lastOkAt?: string | null;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
startedAt: string;
|
||||
lastScan: { at: string; changed: number; held: number } | null;
|
||||
lastError: { at: string; message: string } | null;
|
||||
counts: { notes: number; published: number; held: number };
|
||||
syncs: SyncJob[];
|
||||
}
|
||||
|
||||
export interface Problem {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Outcome =
|
||||
| { status: "accepted"; path: string }
|
||||
| { status: "held"; path: string; held: Held }
|
||||
| { status: "rejected"; path: string | null; reasons: Reason[] }
|
||||
| { status: "unchanged"; path: string };
|
||||
|
||||
export type Change =
|
||||
| { op: "create"; data: Record<string, unknown>; body?: string }
|
||||
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
|
||||
|
||||
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
||||
|
||||
export class ContentApi {
|
||||
constructor(private readonly base: string, private readonly token: string) {}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown, okStatuses = [200]): Promise<T> {
|
||||
const res = await fetch(new URL(path, this.base), {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${this.token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as unknown;
|
||||
if (!okStatuses.includes(res.status)) {
|
||||
throw new ApiError(res.status, (data as { error?: string } | null)?.error ?? `The content service answered ${res.status}`, data);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
private get = <T>(path: string, params: Record<string, string | number | undefined> = {}) => {
|
||||
const u = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") u.set(k, String(v));
|
||||
const s = u.toString();
|
||||
return this.call<T>("GET", s ? `${path}?${s}` : path);
|
||||
};
|
||||
|
||||
status = () => this.get<Status>("/api/status");
|
||||
notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
|
||||
this.get<Summary[]>("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
|
||||
fullNotes = (type: string) => this.get<FullNote[]>("/api/notes", { type, full: "true", limit: 1000 });
|
||||
note = (path: string) => this.get<Note>("/api/note", { path });
|
||||
noteByName = (name: string) => this.get<Note>("/api/note", { name });
|
||||
history = (path: string) => this.get<Revision[]>("/api/note/history", { path });
|
||||
revision = (path: string, rev: number) => this.get<Revision>("/api/note/revision", { path, rev });
|
||||
held = (status = "pending") => this.get<Held[]>("/api/held", { status });
|
||||
heldOne = (id: number) => this.get<Held>(`/api/held/${id}`);
|
||||
problems = () => this.get<Problem[]>("/api/problems");
|
||||
syncRuns = (job: string) => this.get<SyncJob[]>("/api/syncs", { job });
|
||||
|
||||
apply = (change: Change) => this.call<Outcome>("POST", "/api/changes", change, [200, 422]);
|
||||
accept = (id: number) => this.call<Outcome>("POST", `/api/held/${id}/accept`, {}, [200, 422]);
|
||||
reject = (id: number, restore: boolean) => this.call<Outcome>("POST", `/api/held/${id}/reject`, { restore }, [200, 422]);
|
||||
rollback = (path: string, rev: number) => this.call<Outcome>("POST", "/api/note/rollback", { path, rev }, [200, 422]);
|
||||
scan = () => this.call<{ changed: number; held: number }>("POST", "/api/scan", {});
|
||||
|
||||
/** Fetch an attachment to pass through to the browser. */
|
||||
attachment = (name: string) =>
|
||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* A line diff for the review queue and history: what a change adds and
|
||||
* removes, with unchanged lines folded down to a little context.
|
||||
*/
|
||||
export type DiffLine = { kind: "same" | "add" | "remove"; text: string } | { kind: "skip"; count: number };
|
||||
|
||||
/** Notes are short, so a plain longest-common-subsequence table is enough. */
|
||||
const MAX_CELLS = 4_000_000;
|
||||
|
||||
export function diffLines(before: string, after: string, context = 3): DiffLine[] {
|
||||
const a = before === "" ? [] : before.replace(/\r\n/g, "\n").split("\n");
|
||||
const b = after === "" ? [] : after.replace(/\r\n/g, "\n").split("\n");
|
||||
let full: DiffLine[];
|
||||
if (a.length * b.length > MAX_CELLS) {
|
||||
// Too large to compare line by line: show it as replaced.
|
||||
full = [...a.map((text) => ({ kind: "remove" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
||||
} else {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
full = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
full.push({ kind: "same", text: a[i]! });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) full.push({ kind: "remove", text: a[i++]! });
|
||||
else full.push({ kind: "add", text: b[j++]! });
|
||||
}
|
||||
while (i < n) full.push({ kind: "remove", text: a[i++]! });
|
||||
while (j < m) full.push({ kind: "add", text: b[j++]! });
|
||||
}
|
||||
return fold(full, context);
|
||||
}
|
||||
|
||||
function fold(lines: DiffLine[], context: number): DiffLine[] {
|
||||
const changed = lines.map((l) => l.kind !== "same");
|
||||
const keep = lines.map((_, i) => {
|
||||
for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++) if (changed[k]) return true;
|
||||
return false;
|
||||
});
|
||||
const out: DiffLine[] = [];
|
||||
let skipped = 0;
|
||||
lines.forEach((l, i) => {
|
||||
if (keep[i]) {
|
||||
if (skipped) out.push({ kind: "skip", count: skipped });
|
||||
skipped = 0;
|
||||
out.push(l);
|
||||
} else skipped++;
|
||||
});
|
||||
if (skipped) out.push({ kind: "skip", count: skipped });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* A tagged template that escapes every value unless it is already HTML. The
|
||||
* admin page shows text agents wrote, so nothing reaches the page unescaped.
|
||||
*/
|
||||
export class Html {
|
||||
constructor(readonly value: string) {}
|
||||
toString() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export const raw = (s: string) => new Html(s);
|
||||
|
||||
export function escape(s: unknown): string {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const part = (v: unknown): string => {
|
||||
if (v instanceof Html) return v.value;
|
||||
if (Array.isArray(v)) return v.map(part).join("");
|
||||
if (v === null || v === undefined || v === false) return "";
|
||||
return escape(v);
|
||||
};
|
||||
|
||||
export function html(strings: TemplateStringsArray, ...values: unknown[]): Html {
|
||||
let out = strings[0] ?? "";
|
||||
values.forEach((v, i) => {
|
||||
out += part(v) + (strings[i + 1] ?? "");
|
||||
});
|
||||
return new Html(out);
|
||||
}
|
||||
|
||||
/** A query string from the set values only. */
|
||||
export function qs(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const u = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v));
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export const when = (d: string | null | undefined) => (d ? d.replace("T", " ").slice(0, 16) + " UTC" : "never");
|
||||
@@ -0,0 +1,49 @@
|
||||
import { html, type Html } from "./html.ts";
|
||||
|
||||
export interface Flash {
|
||||
msg?: string;
|
||||
err?: string;
|
||||
reasons?: string[];
|
||||
}
|
||||
|
||||
const nav: [string, string][] = [
|
||||
["/", "Overview"],
|
||||
["/review", "Review"],
|
||||
["/notes", "Notes"],
|
||||
["/drafts", "Drafts"],
|
||||
["/capture", "Capture"],
|
||||
["/store", "Store"],
|
||||
["/problems", "Problems"],
|
||||
];
|
||||
|
||||
export function layout(p: { title: string; path: string; held: number; flash?: Flash; body: Html }): Html {
|
||||
const current = (href: string) => (href === "/" ? p.path === "/" : p.path.startsWith(href));
|
||||
return html`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>${p.title} · GregKilmer.com admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="/">GregKilmer.com admin</a>
|
||||
<nav>${nav.map(
|
||||
([href, label]) =>
|
||||
html`<a href="${href}" ${current(href) ? html`aria-current="page"` : ""}>${label}${href === "/review" && p.held ? html`<span class="count">${p.held}</span>` : ""}</a>`,
|
||||
)}</nav>
|
||||
</header>
|
||||
<main>
|
||||
${p.flash?.msg ? html`<div class="flash" role="status">${p.flash.msg}</div>` : ""}
|
||||
${p.flash?.err
|
||||
? html`<div class="flash err" role="alert">${p.flash.err}${p.flash.reasons?.length ? html`<ul>${p.flash.reasons.map((r) => html`<li>${r}</li>`)}</ul>` : ""}</div>`
|
||||
: ""}
|
||||
${p.body}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Starts the admin page. It talks to the content service with the admin
|
||||
* token and must be published on the tailnet only.
|
||||
*/
|
||||
import { ContentApi } from "./client.ts";
|
||||
import { createAdminServer } from "./server.ts";
|
||||
|
||||
const contentUrl = process.env.CONTENT_URL ?? "http://localhost:4310";
|
||||
const token = process.env.ADMIN_TOKEN;
|
||||
if (!token) throw new Error("ADMIN_TOKEN is required: the content service's admin token");
|
||||
const port = Number(process.env.PORT ?? 4320);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
const server = createAdminServer(new ContentApi(contentUrl, token));
|
||||
server.listen(port, host, () => console.log(`Admin page on ${host}:${port}, using ${contentUrl}`));
|
||||
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
process.on("SIGTERM", stop);
|
||||
process.on("SIGINT", stop);
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Draft previews. Raw HTML in a note is shown as text and only http, https,
|
||||
* mailto and relative links survive, so a draft an agent wrote cannot run
|
||||
* anything on the admin page. Wikilinks become links to the note.
|
||||
*/
|
||||
import { Marked, type Tokens } from "marked";
|
||||
import { escape } from "./html.ts";
|
||||
|
||||
const safeUrl = (href: string) => (/^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(href.trim()) || !/^[a-z][a-z0-9+.-]*:/i.test(href.trim()) ? href : "#");
|
||||
|
||||
export function renderMarkdown(body: string, opts: { noteHref: (name: string) => string; imageSrc: (file: string) => string }): string {
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html: ({ text }: Tokens.HTML | Tokens.Tag) => escape(text),
|
||||
link({ href, title, tokens }: Tokens.Link) {
|
||||
const t = title ? ` title="${escape(title)}"` : "";
|
||||
return `<a href="${escape(safeUrl(href))}"${t} rel="noreferrer">${this.parser.parseInline(tokens)}</a>`;
|
||||
},
|
||||
image: ({ href, text }: Tokens.Image) => `<img src="${escape(safeUrl(href))}" alt="${escape(text)}" loading="lazy">`,
|
||||
},
|
||||
});
|
||||
// ![[file.png]] embeds and [[Note|label]] links, before Markdown sees them.
|
||||
const withLinks = body
|
||||
.replace(/!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g, (_, f: string) => `)})`)
|
||||
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => `[${label ?? n}](${opts.noteHref(n.trim())})`);
|
||||
return marked.parse(withLinks, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Page bodies. Each takes data already fetched from the content service and
|
||||
* returns HTML; nothing here talks to the network.
|
||||
*/
|
||||
import { libraryStates, media } from "@gk/schema";
|
||||
import type { FullNote, Held, Note, Problem, Revision, Status, Summary, SyncJob } from "./client.ts";
|
||||
import { diffLines } from "./diff.ts";
|
||||
import { html, qs, raw, when, type Html } from "./html.ts";
|
||||
import { renderMarkdown } from "./markdown.ts";
|
||||
|
||||
export const noteHref = (path: string) => `/note${qs({ path })}`;
|
||||
|
||||
const authorLabel: Record<string, string> = { greg: "You", agent: "An agent", admin: "Admin page" };
|
||||
const who = (a: string) => authorLabel[a] ?? a;
|
||||
|
||||
const publishPill = (publish: boolean) => (publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`);
|
||||
|
||||
const reasonList = (reasons: Held["reasons"]) =>
|
||||
reasons.length
|
||||
? reasons.map((r) => html`<p class="reason"><code>${r.code}</code> ${r.message}</p>`)
|
||||
: html`<p class="reason muted">Waiting for review: no rule flagged it.</p>`;
|
||||
|
||||
export function diffView(before: string, after: string): Html {
|
||||
const lines = diffLines(before, after);
|
||||
if (!lines.some((l) => l.kind === "add" || l.kind === "remove")) return html`<p class="muted">No differences.</p>`;
|
||||
return html`<div class="diff">${lines.map((l) =>
|
||||
l.kind === "skip" ? html`<div class="skip">${l.count} unchanged lines</div>` : html`<div class="${l.kind}">${l.text}</div>`,
|
||||
)}</div>`;
|
||||
}
|
||||
|
||||
export function preview(body: string): Html {
|
||||
if (!body.trim()) return html`<p class="muted">This note has no body text.</p>`;
|
||||
return html`<div class="preview">${raw(
|
||||
renderMarkdown(body, {
|
||||
noteHref: (name) => `/go${qs({ name })}`,
|
||||
imageSrc: (file) => `/attachments/${encodeURIComponent(file)}`,
|
||||
}),
|
||||
)}</div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ overview
|
||||
|
||||
export function overview(s: Status, held: Held[], problems: Problem[]): Html {
|
||||
const syncs = s.syncs;
|
||||
return html`
|
||||
<h1>Overview</h1>
|
||||
<p class="lede">What the site is serving, what is waiting for you, and whether the jobs that feed it are running.</p>
|
||||
<div class="grid">
|
||||
<div class="panel"><div class="stat">${s.counts.published}</div>live notes of ${s.counts.notes}</div>
|
||||
<div class="panel"><div class="stat">${held.length}</div><a href="/review">waiting for review</a></div>
|
||||
<div class="panel"><div class="stat">${problems.length}</div><a href="/problems">problems across the folder</a></div>
|
||||
</div>
|
||||
<h2>Content service</h2>
|
||||
<div class="panel">
|
||||
<dl class="fields">
|
||||
<dt>Running since</dt><dd>${when(s.startedAt)}</dd>
|
||||
<dt>Last full scan</dt><dd>${s.lastScan ? html`${when(s.lastScan.at)}: ${s.lastScan.changed} changed, ${s.lastScan.held} held` : "not yet"}</dd>
|
||||
<dt>Last error</dt><dd>${s.lastError ? html`<span class="pill bad">${when(s.lastError.at)}</span> ${s.lastError.message}` : "none"}</dd>
|
||||
</dl>
|
||||
<form method="post" action="/scan" class="actions"><button>Scan the Site folder now</button></form>
|
||||
</div>
|
||||
<h2>Sync jobs</h2>
|
||||
${syncs.length
|
||||
? html`<div class="table-wrap"><table>
|
||||
<thead><tr><th>Job</th><th>Last run</th><th>Result</th><th>Last success</th><th>Counts</th></tr></thead>
|
||||
<tbody>${syncs.map(syncRow)}</tbody></table></div>`
|
||||
: html`<p class="muted">No sync job has reported yet. PersonalReader, BoardGameGeek and the store syncs report here once they run.</p>`}`;
|
||||
}
|
||||
|
||||
const syncRow = (j: SyncJob) => html`<tr>
|
||||
<td><a href="/syncs${qs({ job: j.job })}">${j.job}</a></td>
|
||||
<td>${when(j.at)}</td>
|
||||
<td>${j.ok ? html`<span class="pill live">OK</span>` : html`<span class="pill bad">Failed</span>`} ${j.message}</td>
|
||||
<td>${when(j.lastOkAt)}</td>
|
||||
<td class="mono">${Object.entries(j.counts).map(([k, v]) => `${k} ${v}`).join(", ")}</td>
|
||||
</tr>`;
|
||||
|
||||
export function syncHistory(job: string, runs: SyncJob[]): Html {
|
||||
return html`<h1>${job}</h1><p class="lede"><a href="/">Back to the overview</a></p>
|
||||
<div class="table-wrap"><table><thead><tr><th>When</th><th>Result</th><th>Counts</th></tr></thead><tbody>
|
||||
${runs.map((r) => html`<tr><td>${when(r.at)}</td><td>${r.ok ? html`<span class="pill live">OK</span>` : html`<span class="pill bad">Failed</span>`} ${r.message}</td><td class="mono">${Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(", ")}</td></tr>`)}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ review
|
||||
|
||||
const statuses = ["pending", "accepted", "rejected", "superseded"];
|
||||
|
||||
export function reviewList(held: Held[], status: string): Html {
|
||||
return html`<h1>Review</h1>
|
||||
<p class="lede">Changes the rules held. The site keeps showing the last accepted version until you decide.</p>
|
||||
<p class="actions">${statuses.map((s) => (s === status ? html`<strong>${label(s)}</strong>` : html`<a href="/review${qs({ status: s === "pending" ? undefined : s })}">${label(s)}</a>`))}</p>
|
||||
${held.length
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>Note</th><th>From</th><th>Why</th><th>When</th></tr></thead><tbody>
|
||||
${held.map((h) => html`<tr>
|
||||
<td><a href="/review/${h.id}">${h.path}</a></td>
|
||||
<td>${who(h.author)}${h.source === "vault" ? " in Obsidian" : ""}</td>
|
||||
<td>${h.reasons.map((r) => r.code).join(", ") || "review"}</td>
|
||||
<td>${when(h.at)}</td></tr>`)}
|
||||
</tbody></table></div>`
|
||||
: html`<p class="muted">${status === "pending" ? "Nothing is waiting." : "None."}</p>`}`;
|
||||
}
|
||||
|
||||
const label = (s: string) => s[0]!.toUpperCase() + s.slice(1);
|
||||
|
||||
export function reviewOne(h: Held): Html {
|
||||
const pending = h.status === "pending";
|
||||
return html`<h1>${h.path}</h1>
|
||||
<p class="lede">${who(h.author)}${h.source === "vault" ? " in Obsidian" : " through the API"}, ${when(h.at)}. ${pending ? "" : html`<span class="pill">${label(h.status)} ${when(h.decidedAt)}</span>`}</p>
|
||||
<div class="panel">${reasonList(h.reasons)}</div>
|
||||
${pending
|
||||
? html`<div class="actions">
|
||||
<form method="post" action="/review/${h.id}/accept" class="inline"><button class="primary">Accept and go live</button></form>
|
||||
<form method="post" action="/review/${h.id}/reject" class="inline"><button>Reject</button></form>
|
||||
${h.source === "vault" && h.current
|
||||
? html`<form method="post" action="/review/${h.id}/reject" class="inline"><input type="hidden" name="restore" value="1"><button class="danger">Reject and put the live version back in Obsidian</button></form>`
|
||||
: ""}
|
||||
</div>`
|
||||
: ""}
|
||||
<h2>${h.current === null ? "New note" : "Changes against the live version"}</h2>
|
||||
${diffView(h.current ?? "", h.text ?? "")}
|
||||
<p><a href="${noteHref(h.path)}">Open the live note</a></p>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ notes
|
||||
|
||||
export function notesList(p: { title: string; lede: string; action: string; notes: Summary[]; types: string[]; type?: string; q?: string }): Html {
|
||||
return html`<h1>${p.title}</h1>
|
||||
<p class="lede">${p.lede}</p>
|
||||
<form class="filters" method="get" action="${p.action}">
|
||||
<div><label for="q">Search</label><input id="q" name="q" value="${p.q ?? ""}" placeholder="Title or text"></div>
|
||||
<div><label for="type">Type</label><select id="type" name="type"><option value="">All types</option>${p.types.map(
|
||||
(t) => html`<option ${t === p.type ? html`selected` : ""}>${t}</option>`,
|
||||
)}</select></div>
|
||||
<button>Show</button>
|
||||
</form>
|
||||
${p.notes.length
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th></tr></thead><tbody>
|
||||
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td></tr>`)}
|
||||
</tbody></table></div>`
|
||||
: html`<p class="muted">No notes match.</p>`}`;
|
||||
}
|
||||
|
||||
const fieldValue = (v: unknown): Html =>
|
||||
typeof v === "object" && v !== null ? html`<pre>${JSON.stringify(v, null, 2)}</pre>` : html`${String(v)}`;
|
||||
|
||||
export function noteView(n: Note, history: Revision[]): Html {
|
||||
const shown = Object.entries(n.data).filter(([k]) => !["type", "title"].includes(k));
|
||||
return html`<h1>${n.title}</h1>
|
||||
<p class="lede">${publishPill(n.publish)} ${n.type} · <span class="mono">${n.path}</span> · last changed by ${who(n.author).toLowerCase()} ${when(n.acceptedAt)}</p>
|
||||
${n.pending ? html`<div class="flash">A change to this note is waiting: <a href="/review/${n.pending.id}">review it</a>.</div>` : ""}
|
||||
<div class="actions">
|
||||
<form method="post" action="/note/publish" class="inline">
|
||||
<input type="hidden" name="path" value="${n.path}">
|
||||
<input type="hidden" name="publish" value="${n.publish ? "0" : "1"}">
|
||||
<button class="${n.publish ? "" : "primary"}">${n.publish ? "Unpublish" : "Publish"}</button>
|
||||
</form>
|
||||
</div>
|
||||
<h2>Preview</h2>
|
||||
${preview(n.body)}
|
||||
<h2>Fields</h2>
|
||||
<div class="panel"><dl class="fields">${shown.map(([k, v]) => html`<dt>${k}</dt><dd>${fieldValue(v)}</dd>`)}</dl></div>
|
||||
<h2>Linked from</h2>
|
||||
${n.backlinks.length ? html`<ul>${n.backlinks.map((b) => html`<li><a href="${noteHref(b.path)}">${b.title}</a> <span class="muted">${b.type}</span></li>`)}</ul>` : html`<p class="muted">Nothing links here yet.</p>`}
|
||||
<h2>History</h2>
|
||||
${historyTable(n, history)}`;
|
||||
}
|
||||
|
||||
function historyTable(n: Note, history: Revision[]): Html {
|
||||
return html`<div class="table-wrap"><table><thead><tr><th>Version</th><th>Who</th><th>What</th><th>When</th><th></th></tr></thead><tbody>
|
||||
${history.map((r) => html`<tr>
|
||||
<td>${r.rev}${r.rev === n.rev ? html` <span class="pill live">current</span>` : ""}</td>
|
||||
<td>${who(r.author)}</td><td>${r.action}</td><td>${when(r.at)}</td>
|
||||
<td>${r.rev === n.rev || r.action === "remove" ? "" : html`<a href="/note/revision${qs({ path: n.path, rev: r.rev })}">Compare and roll back</a>`}</td>
|
||||
</tr>`)}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
export function revisionView(n: Note, r: Revision): Html {
|
||||
return html`<h1>${n.title}, version ${r.rev}</h1>
|
||||
<p class="lede">${who(r.author)}, ${when(r.at)}. Showing what rolling back would change in the live version ${n.rev}.</p>
|
||||
<div class="actions">
|
||||
<form method="post" action="/note/rollback" class="inline">
|
||||
<input type="hidden" name="path" value="${n.path}"><input type="hidden" name="rev" value="${r.rev}">
|
||||
<button class="primary">Roll back to version ${r.rev}</button>
|
||||
</form>
|
||||
<a href="${noteHref(n.path)}">Back to the note</a>
|
||||
</div>
|
||||
${diffView(n.text, r.text ?? "")}`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ capture
|
||||
|
||||
export function capture(kinds: string[], today: string): Html {
|
||||
return html`<h1>Capture</h1>
|
||||
<p class="lede">Add a line to the Now log or something to the Library. Both are written into the vault, so they show up in Obsidian too.</p>
|
||||
<div class="grid">
|
||||
<form class="panel" method="post" action="/capture/status">
|
||||
<h2>Status</h2>
|
||||
<div class="row">
|
||||
<div><label for="s-kind">Kind</label><select id="s-kind" name="kind" required>${kinds.map((k) => html`<option>${k}</option>`)}</select></div>
|
||||
<div><label for="s-date">Date</label><input id="s-date" name="date" type="date" value="${today}" required></div>
|
||||
</div>
|
||||
<label for="s-title">What</label><input id="s-title" name="title" required placeholder="Mistborn">
|
||||
<label for="s-subject">About note (optional)</label><input id="s-subject" name="subject" placeholder="The note's name, like Mistborn">
|
||||
<label for="s-note">Note (optional)</label><input id="s-note" name="note" placeholder="Second read, still great">
|
||||
<label class="check"><input type="checkbox" name="pinned" value="1"> Pin it</label>
|
||||
<label class="check"><input type="checkbox" name="publish" value="1" checked> Show it on the site</label>
|
||||
<div class="actions"><button class="primary">Post status</button></div>
|
||||
</form>
|
||||
<form class="panel" method="post" action="/capture/library">
|
||||
<h2>Library item</h2>
|
||||
<label for="l-title">Title</label><input id="l-title" name="title" required>
|
||||
<div class="row">
|
||||
<div><label for="l-medium">Medium</label><select id="l-medium" name="medium">${media.map((m) => html`<option>${m}</option>`)}</select></div>
|
||||
<div><label for="l-state">State</label><select id="l-state" name="state">${libraryStates.map((s) => html`<option>${s}</option>`)}</select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label for="l-creator">Author or creator</label><input id="l-creator" name="creator"></div>
|
||||
<div><label for="l-year">Year</label><input id="l-year" name="year" inputmode="numeric" pattern="[0-9]{4}"></div>
|
||||
</div>
|
||||
<label for="l-group">Group (optional)</label><input id="l-group" name="group" placeholder="Cosmere">
|
||||
<label for="l-verdict">Verdict (optional)</label><input id="l-verdict" name="verdict">
|
||||
<label for="l-note">Note (optional)</label><input id="l-note" name="note">
|
||||
<label class="check"><input type="checkbox" name="publish" value="1" checked> Show it on the site</label>
|
||||
<label class="check"><input type="checkbox" name="alsoStatus" value="1"> Also post a status</label>
|
||||
<label for="l-kind">Status kind</label><select id="l-kind" name="kind">${kinds.map((k) => html`<option>${k}</option>`)}</select>
|
||||
<div class="actions"><button class="primary">Add to the Library</button></div>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ store
|
||||
|
||||
interface Category {
|
||||
name: string;
|
||||
ordersOpen: boolean;
|
||||
reopens?: string;
|
||||
turnaround?: string;
|
||||
}
|
||||
|
||||
export function store(settings: FullNote | undefined, lines: FullNote[]): Html {
|
||||
const categories = (settings?.data.categories as Category[] | undefined) ?? [];
|
||||
return html`<h1>Store</h1>
|
||||
<p class="lede">Open and close orders by category, and set how many order slots each made to order line has open.</p>
|
||||
<h2>Categories</h2>
|
||||
${settings
|
||||
? html`<div class="grid">${categories.map(
|
||||
(c) => html`<form class="panel" method="post" action="/store/category">
|
||||
<h3>${c.name} ${c.ordersOpen ? html`<span class="pill live">Open</span>` : html`<span class="pill draft">Closed</span>`}</h3>
|
||||
<input type="hidden" name="name" value="${c.name}">
|
||||
<label class="check"><input type="checkbox" name="ordersOpen" value="1" ${c.ordersOpen ? html`checked` : ""}> Taking orders</label>
|
||||
<label for="reopens-${c.name}">Reopens (when closed)</label><input id="reopens-${c.name}" type="date" name="reopens" value="${c.reopens ?? ""}">
|
||||
<label for="turn-${c.name}">Turnaround</label><input id="turn-${c.name}" name="turnaround" value="${c.turnaround ?? ""}">
|
||||
<div class="actions"><button class="primary">Save ${c.name}</button></div>
|
||||
</form>`,
|
||||
)}</div>`
|
||||
: html`<p class="muted">There is no Store/_settings note yet.</p>`}
|
||||
<h2>Product lines</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>Line</th><th>Category</th><th>Sold</th><th>Open slots or stock</th></tr></thead><tbody>
|
||||
${lines.map((l) => {
|
||||
const slots = l.data.slots as { open: number; total: number } | undefined;
|
||||
const qty = l.data.quantity as number | undefined;
|
||||
return html`<tr>
|
||||
<td><a href="${noteHref(l.path)}">${l.title}</a> ${publishPill(l.publish)}</td>
|
||||
<td>${String(l.data.category)}</td>
|
||||
<td>${String(l.data.mode)}, ${String(l.data.channel)}</td>
|
||||
<td>${slots
|
||||
? html`<form method="post" action="/store/stock" class="actions"><input type="hidden" name="path" value="${l.path}">
|
||||
<input aria-label="Open slots for ${l.title}" name="open" type="number" min="0" max="${slots.total}" value="${slots.open}" class="num"> <span class="muted">of ${slots.total}</span> <button>Save</button></form>`
|
||||
: qty !== undefined
|
||||
? html`<form method="post" action="/store/stock" class="actions"><input type="hidden" name="path" value="${l.path}">
|
||||
<input aria-label="Stock for ${l.title}" name="quantity" type="number" min="0" value="${qty}" class="num"> <span class="muted">in stock</span> <button>Save</button></form>`
|
||||
: html`<span class="muted">Not counted here</span>`}</td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ problems
|
||||
|
||||
export function problemsView(problems: Problem[]): Html {
|
||||
return html`<h1>Problems</h1>
|
||||
<p class="lede">Things that are wrong across the folder as a whole, like links to notes that do not exist. They do not stop a note going live.</p>
|
||||
${problems.length
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>Note</th><th>Problem</th></tr></thead><tbody>
|
||||
${problems.map((p) => html`<tr><td><a href="${noteHref(p.path)}">${p.path}</a></td><td>${p.message}</td></tr>`)}</tbody></table></div>`
|
||||
: html`<p class="muted">No problems.</p>`}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user