Admin: write blog posts and manage images

A Posts page lists every post with Write a post: title, topic, kind,
date, summary, a cover picked from the vault or uploaded, the pages it is
about, the Markdown body with a rendered preview, and Save draft or
Publish. An Images page uploads several images into an _attachments
folder, shows which notes use each one, copies its embed line, and
deletes unused ones. The content service gains the attachment list,
upload and delete endpoints behind the editor and admin tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-12 03:40:59 +00:00
parent 151bed230a
commit a9fb5b3e85
12 changed files with 694 additions and 12 deletions
+98 -9
View File
@@ -16,15 +16,21 @@ 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 { 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 } = {}) {
@@ -112,6 +118,19 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
}
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 "/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";
@@ -131,8 +150,53 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
throw new ApiError(404, "There is no page here.", null);
}
async function post(path: string, f: Form): Promise<Redirect> {
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 }) };
}
/** 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 "/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.` })}`);
@@ -258,13 +322,17 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
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'",
"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}');
@@ -296,10 +364,14 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
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();
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");
@@ -392,15 +464,32 @@ function safeBack(referer: string | undefined): URL | null {
}
}
async function readBody(req: IncomingMessage): Promise<string> {
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 > 200_000) throw new ApiError(413, "The form is too large", null);
if (size > max) throw new ApiError(413, "The form is too large", null);
chunks.push(c as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
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) {