diff --git a/docs/admin.md b/docs/admin.md index 938d035..1a0e957 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -19,6 +19,8 @@ the vault so Obsidian shows them too. Its changes are recorded with the author | Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. | | Notes, Drafts | Browse and search every note, or only the unpublished ones. | | A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. | +| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. | +| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. | | Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. | | Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. | | Problems | Whole-folder problems, like links to notes that do not exist. | @@ -34,8 +36,13 @@ the vault so Obsidian shows them too. Its changes are recorded with the author same-origin` (or a matching `Origin`), so a page elsewhere cannot make the browser act on the admin page. - **Drafts cannot run code.** Previews show raw HTML as text and drop links that - are not http, https, mailto or relative. The page sends a content security - policy that allows no scripts at all; it works entirely with plain forms. + are not http, https, mailto or relative. The content security policy allows + only the page's own `/admin.js` (Copy buttons and a confirm before deleting + an image), never inline script, and every page still works with plain forms + when that script does not run. +- **Only images go into the vault.** Uploads are checked by extension and by + their first bytes, SVG is refused, and an image is deleted only when no note + uses it. ## Running it diff --git a/docs/content-service.md b/docs/content-service.md index c504b28..50cd13b 100644 --- a/docs/content-service.md +++ b/docs/content-service.md @@ -96,6 +96,9 @@ roles in `CONTENT_TOKENS`. | `POST /api/held//accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. | | `POST /api/note/rollback` | admin | `{ path, rev }` | | `POST /api/scan` | editor, admin | Compare the whole folder now. | +| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). | +| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/` under a lowercase, hyphenated name, with `-2`, `-3` added when the name is taken anywhere in `_attachments`. Answers `{ name }`. | +| `POST /api/attachments/delete` | editor, admin | `{ name }`. Refused with 409 while any note still uses the file. | | `POST /api/submissions` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. | | `POST /api/submissions//delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. | | `GET /api/submissions?handled=`, `/api/submissions/` | admin | Saved copies, unhandled first, newest first, with counts. | diff --git a/services/admin/public/admin.css b/services/admin/public/admin.css index fc25df6..879b435 100644 --- a/services/admin/public/admin.css +++ b/services/admin/public/admin.css @@ -77,3 +77,36 @@ dl.fields dt { color: var(--muted); } dl.fields dd { margin: 0; word-break: break-word; } dl.fields pre { margin: 0; white-space: pre-wrap; } input.num { width: 90px; } + +/* Posts and Images */ +.head-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; } +.button.primary { background: var(--teal); border-color: var(--teal); color: var(--midnight); font-weight: 600; } +.hint { margin: 4px 0 0; font-size: 13px; color: var(--muted); } +.post-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; } +@media (max-width: 900px) { .post-grid { grid-template-columns: 1fr; } } +.side .panel h2 { margin-top: 0; font-size: 1.05rem; } +.side input[name="links"] { margin-bottom: 8px; } +.body-text { font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.55; resize: vertical; min-height: 360px; } +.actions.stack { flex-direction: column; align-items: stretch; } +.actions.stack button { justify-content: center; } +.cover-preview { display: block; width: 100%; max-height: 180px; object-fit: cover; border-radius: 8px; margin-bottom: 6px; } +.cover-preview.wide { max-height: 320px; } +.post-preview h1 { margin-top: 4px; } +details summary { cursor: pointer; color: var(--muted); margin-top: 10px; } +.copy-row { display: flex; gap: 6px; align-items: center; } +.copy-row input { font-family: ui-monospace, monospace; font-size: 12px; padding: 4px 8px; } +.copy-row button { min-height: 30px; padding: 2px 12px; font-size: 13px; } +.thumbs { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } +.thumbs img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 6px; } +.folders { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; } +.folders a { padding: 4px 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); text-decoration: none; font-size: 14px; } +.folders a[aria-current] { border-color: var(--gold); color: var(--gold-bright); } +.images { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } +.images li { margin: 0; display: flex; flex-direction: column; gap: 10px; } +.images img, .images .file { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; background: var(--sky); } +.images .file { display: flex; align-items: center; justify-content: center; color: var(--muted); text-transform: uppercase; } +.images .meta { display: flex; flex-direction: column; gap: 6px; font-size: 14px; word-break: break-all; } +.images .used { font-size: 13px; } +form.inline { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; } +form.inline button { min-height: 30px; padding: 2px 12px; font-size: 13px; } +.preview img { max-height: 480px; object-fit: contain; } diff --git a/services/admin/public/admin.js b/services/admin/public/admin.js new file mode 100644 index 0000000..25bb6c7 --- /dev/null +++ b/services/admin/public/admin.js @@ -0,0 +1,25 @@ +// The admin page's only script, and it is optional: every page works without +// it. It adds Copy buttons for embed lines and asks before a delete. +for (const b of document.querySelectorAll("button.copy[data-copy]")) { + b.addEventListener("click", async () => { + const text = b.getAttribute("data-copy") || ""; + try { + await navigator.clipboard.writeText(text); + } catch { + const input = b.previousElementSibling; + if (input && "select" in input) { + input.select(); + document.execCommand("copy"); + } + } + const was = b.textContent; + b.textContent = "Copied"; + setTimeout(() => (b.textContent = was), 1500); + }); +} +for (const f of document.querySelectorAll("form[action='/images/delete']")) { + f.addEventListener("submit", (e) => { + const name = f.querySelector("input[name=name]")?.value ?? "this image"; + if (!confirm(`Delete ${name} from the vault? This cannot be undone.`)) e.preventDefault(); + }); +} diff --git a/services/admin/src/client.ts b/services/admin/src/client.ts index d161fe4..7eaba83 100644 --- a/services/admin/src/client.ts +++ b/services/admin/src/client.ts @@ -117,6 +117,15 @@ export interface Suggestion { cover?: string; } +/** A file in the vault's `_attachments`, with the notes that use it. */ +export interface Attachment { + name: string; + folder: string; + size: number; + modified: string; + usedBy: { path: string; title: string; type: string }[]; +} + export type FullNote = Omit; export class ContentApi { @@ -172,6 +181,20 @@ export class ContentApi { suggestions = () => this.get("/api/suggestions"); dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {}); + attachments = () => this.get("/api/attachments"); + /** Add an image to `_attachments/`; answers with the name it was saved under. */ + async upload(folder: string, name: string, bytes: Uint8Array): Promise { + const res = await fetch(new URL(`/api/attachments?${new URLSearchParams({ folder, name })}`, this.base), { + method: "POST", + headers: { authorization: `Bearer ${this.token}`, "content-type": "application/octet-stream" }, + body: bytes, + }); + const data = (await res.json().catch(() => null)) as { name?: string; error?: string } | null; + if (!res.ok || !data?.name) throw new ApiError(res.status, data?.error ?? `The content service answered ${res.status}`, data); + return data.name; + } + deleteAttachment = (name: string) => this.call<{ ok: boolean }>("POST", "/api/attachments/delete", { name }); + /** 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}` } }); diff --git a/services/admin/src/layout.ts b/services/admin/src/layout.ts index daed956..4067c7e 100644 --- a/services/admin/src/layout.ts +++ b/services/admin/src/layout.ts @@ -12,6 +12,8 @@ const nav: [string, string][] = [ ["/inbox", "Inbox"], ["/notes", "Notes"], ["/drafts", "Drafts"], + ["/posts", "Posts"], + ["/images", "Images"], ["/capture", "Capture"], ["/store", "Store"], ["/problems", "Problems"], @@ -29,6 +31,7 @@ export function layout(p: { title: string; path: string; held: number; inbox?: n +
diff --git a/services/admin/src/posts.ts b/services/admin/src/posts.ts new file mode 100644 index 0000000..5973f8a --- /dev/null +++ b/services/admin/src/posts.ts @@ -0,0 +1,260 @@ +/** + * Writing blog posts and managing images. A post is a `post` note written + * through the content service like any other change; images are files in the + * vault's `_attachments`, so Obsidian sees both. + */ +import { topics } from "@gk/schema"; +import type { Attachment, Note, Summary } from "./client.ts"; +import { html, qs, when, type Html } from "./html.ts"; +import { noteHref, preview } from "./pages.ts"; + +/** What the post form holds, from a note or from a posted form. */ +export interface PostDraft { + path?: string; + title: string; + date: string; + topic: string; + postType: string; + summary: string; + cover: string; + links: string[]; + pinned: boolean; + role: string; + employer: string; + period: string; + body: string; + publish: boolean; +} + +export const postTypes = ["article", "build log", "case study"] as const; + +/** Folders under `_attachments` the upload form offers; Posts comes first. */ +export const imageFolders = ["Posts", "Pages", "Workshop", "Library", "Tabletop", "Store"] as const; + +export const editHref = (path: string) => `/posts/edit${qs({ path })}`; +export const unlink = (v: unknown) => String(v ?? "").trim().replace(/^!?\[\[|\]\]$/g, "").split("|")[0]!.trim(); + +export function emptyPost(today: string): PostDraft { + return { title: "", date: today, topic: topics[0], postType: "article", summary: "", cover: "", links: [], pinned: false, role: "", employer: "", period: "", body: "", publish: false }; +} + +export function draftFromNote(n: Note): PostDraft { + const d = n.data; + return { + path: n.path, + title: n.title, + date: String(d.date ?? ""), + topic: String(d.topic ?? topics[0]), + postType: String(d.postType ?? "article"), + summary: String(d.summary ?? ""), + cover: unlink(d.cover), + links: Array.isArray(d.links) ? d.links.map(unlink).filter(Boolean) : [], + pinned: d.pinned === true, + role: String(d.role ?? ""), + employer: String(d.employer ?? ""), + period: String(d.period ?? ""), + body: n.body, + publish: n.publish, + }; +} + +export function draftFromForm(f: URLSearchParams): PostDraft { + const t = (k: string) => (f.get(k) ?? "").trim(); + return { + path: t("path") || undefined, + title: t("title"), + date: t("date"), + topic: t("topic"), + postType: t("postType") || "article", + summary: t("summary"), + cover: unlink(t("cover")), + links: f + .getAll("links") + .flatMap((v) => v.split("\n")) + .map(unlink) + .filter(Boolean), + pinned: f.get("pinned") === "1", + role: t("role"), + employer: t("employer"), + period: t("period"), + body: (f.get("body") ?? "").replace(/\r\n/g, "\n"), + publish: f.get("wasLive") === "1", + }; +} + +/** + * The note fields for a draft. On an update, an emptied optional field is + * sent as null so the content service drops it from the note. + */ +export function postFields(d: PostDraft, publish: boolean, update: boolean): Record { + const opt = (v: unknown, keep: boolean) => (keep ? v : update ? null : undefined); + const caseStudy = d.postType === "case study"; + const fields: Record = { + title: d.title, + date: d.date, + topic: d.topic, + postType: d.postType, + summary: d.summary, + publish, + cover: opt(`[[${d.cover}]]`, !!d.cover), + links: opt( + d.links.map((l) => `[[${l}]]`), + d.links.length > 0, + ), + pinned: opt(true, d.pinned), + role: opt(d.role, caseStudy && !!d.role), + employer: opt(d.employer, caseStudy && !!d.employer), + period: opt(d.period, caseStudy && !!d.period), + }; + for (const k of Object.keys(fields)) if (fields[k] === undefined) delete fields[k]; + return fields; +} + +const imageSrc = (name: string) => `/attachments/${encodeURIComponent(name)}`; +const kb = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1000))} KB`); +const isImage = (name: string) => /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(name); + +// ------------------------------------------------------------ posts list + +export function postsList(posts: Summary[]): Html { + const sorted = [...posts].sort((a, b) => Number(a.publish) - Number(b.publish) || b.acceptedAt.localeCompare(a.acceptedAt)); + return html` +

Every blog post, drafts first. A post saved here is a note in the vault's Posts folder, so you can also finish it in Obsidian.

+${sorted.length + ? html` + +${sorted.map( + (p) => html` + + + + +`, + )}
PostStateChanged
${p.title}${p.publish ? html`Live` : html`Draft`}${when(p.acceptedAt)}Note and history
` + : html`

No posts yet.

`}`; +} + +// ------------------------------------------------------------ editor + +export function postEditor(d: PostDraft, opts: { images: Attachment[]; notes: string[]; showPreview?: boolean }): Html { + const images = opts.images.filter((a) => isImage(a.name)); + const linkRows = [...d.links, "", "", ""].slice(0, Math.max(3, d.links.length + 1)); + return html`

${d.path ? html`Edit post` : html`Write a post`}

+

${d.path + ? html`${d.publish ? html`Live` : html`Draft`} Saved as ${d.path}.` + : html`Write in Markdown. Preview it, save it as a draft, or publish it to the Blog.`}

+
+ ${d.path ? html`` : ""} + ${d.publish ? html`` : ""} +
+
+ + + ${d.path ? html`

The title is the note's file name; rename it in Obsidian.

` : ""} +
+
+
+
+
+ + + + + + +

They are saved to the vault's Posts images and added to the end of the post; move the lines where you want them.

+
+
+
+

Cover image

+ ${d.cover ? html`` : ""} + + + + +
+
+

Pages it is about

+

Library items, crafts, makes, games: the site links the post from each page and back.

+ ${linkRows.map((l, i) => html``)} + ${opts.notes.map((n) => html` +
+
+ +
+ Case study details + + + +
+
+
+ + + +
+
+
+
+${opts.showPreview + ? html`

Preview

+
+ ${d.cover ? html`` : ""} +

${d.topic} · ${d.date}

+

${d.title || "Untitled"}

+ ${d.summary ? html`

${d.summary}

` : ""} + ${preview(d.body)} +
` + : ""} +${imageStrip(images)}`; +} + +/** The newest vault images under the editor, each with its embed line to copy. */ +function imageStrip(images: Attachment[]): Html { + if (!images.length) return html``; + return html`

Images in the vault

+

Copy a line into the post to show that image. Manage images

+
    ${images.slice(0, 24).map( + (a) => html`
  • `, + )}
`; +} + +// ------------------------------------------------------------ images + +export function imagesPage(list: Attachment[], folder: string): Html { + const folders = [...new Set(list.map((a) => a.folder))].sort((a, b) => a.localeCompare(b)); + const shown = folder ? list.filter((a) => a.folder === folder) : list; + return html`

Images

+

Everything in the vault's _attachments, newest first. Images added here land in the vault, so Obsidian shows them too.

+
+

Add images

+
+
+
+
+

PNG, JPEG, WebP, GIF or AVIF, up to 15 MB each. Names are made lowercase with hyphens, and a name already in the vault gets a number.

+
+
+ +${shown.length + ? html`
    ${shown.map( + (a) => html`
  • + ${isImage(a.name) ? html`` : html`
    ${a.name.split(".").pop()}
    `} +
    + ${a.name} + ${a.folder || "Top level"} · ${kb(a.size)} · ${when(a.modified)} + + ${a.usedBy.length + ? html`Used by ${a.usedBy.map((u, i) => html`${i ? ", " : ""}${u.title}`)}` + : html`
    Not used by any note.
    `} +
    +
  • `, + )}
` + : html`

No images here yet.

`}`; +} diff --git a/services/admin/src/server.ts b/services/admin/src/server.ts index e101aad..139ba9a 100644 --- a/services/admin/src/server.ts +++ b/services/admin/src/server.ts @@ -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 }; 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 { + 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 }) }; + } + + /** 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 "/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 { +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 > 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) { diff --git a/services/admin/test/posts.test.ts b/services/admin/test/posts.test.ts new file mode 100644 index 0000000..c50293f --- /dev/null +++ b/services/admin/test/posts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { draftFromForm, postFields } from "../src/posts.ts"; + +const form = (pairs: [string, string][]) => new URLSearchParams(pairs); + +describe("post form", () => { + const d = draftFromForm( + form([ + ["title", " Pouring galaxy dice "], + ["date", "2026-10-12"], + ["topic", "Making"], + ["postType", "article"], + ["summary", "How the set came together."], + ["cover", "[[galaxy.jpg]]"], + ["links", "Resin"], + ["links", "[[Galaxy dice]]"], + ["links", ""], + ["role", "Ignored for articles"], + ["body", "Line one\r\nLine two"], + ]), + ); + + it("reads the form into a draft", () => { + expect(d).toMatchObject({ title: "Pouring galaxy dice", cover: "galaxy.jpg", links: ["Resin", "Galaxy dice"], body: "Line one\nLine two" }); + }); + + it("writes note fields, with links and the cover as wikilinks", () => { + expect(postFields(d, true, false)).toEqual({ + title: "Pouring galaxy dice", + date: "2026-10-12", + topic: "Making", + postType: "article", + summary: "How the set came together.", + publish: true, + cover: "[[galaxy.jpg]]", + links: ["[[Resin]]", "[[Galaxy dice]]"], + }); + }); + + it("clears emptied fields on an update", () => { + const f = postFields({ ...d, cover: "", links: [] }, false, true); + expect(f).toMatchObject({ cover: null, links: null, pinned: null, role: null, publish: false }); + }); +}); diff --git a/services/content/src/http.ts b/services/content/src/http.ts index fae292b..a0179d3 100644 --- a/services/content/src/http.ts +++ b/services/content/src/http.ts @@ -15,9 +15,12 @@ import type { Change, ContentService } from "./service.ts"; import type { Author, StoredNote } from "./store.ts"; import { handleSubmissions } from "./submissions.ts"; import { handleSuggestions } from "./suggestions.ts"; +import { uploadTypes } from "./vault.ts"; import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts"; const MAX_BODY = 1_000_000; +/** The largest image the admin page can add. */ +const MAX_IMAGE = 15_000_000; class HttpError extends Error { constructor(readonly status: number, message: string) { @@ -73,6 +76,15 @@ export function createHttpServer(service: ContentService, config: Pick (n && !n.removed && (n.publish || can.readDrafts(role)) ? n : null); + /** Every attachment with the notes that link or embed it. */ + function attachmentList() { + const notes = store.list({ limit: 100_000 }); + return service.vault.attachmentFiles().map((a) => ({ + ...a, + usedBy: notes.filter((n) => n.text.includes(a.name)).map((n) => ({ path: n.path, title: n.title, type: n.type })), + })); + } + async function route(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url ?? "/", "http://localhost"); const path = url.pathname; @@ -196,6 +208,11 @@ export function createHttpServer(service: ContentService, config: Pick a.name === body.data.name); + if (!one) throw new HttpError(404, "No such attachment"); + if (one.usedBy.length) { + throw new HttpError(409, `${one.name} is still used by ${one.usedBy.map((u) => u.title).join(", ")}`); + } + service.vault.removeAttachment(one.name); + return send(res, 200, { ok: true }); + } + if (path === "/api/scan") { need(can.review(role)); return send(res, 200, service.scan()); @@ -296,6 +341,36 @@ async function readJson(req: IncomingMessage): Promise { } } +async function readBytes(req: IncomingMessage, max: number): Promise { + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > max) throw new HttpError(413, `Images can be up to ${Math.round(max / 1_000_000)} MB`); + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); +} + +/** Whether the bytes start the way the extension says, so only images land in the vault. */ +function looksLike(ext: string, b: Buffer): boolean { + const at = (i: number, s: string) => b.subarray(i, i + s.length).toString("latin1") === s; + switch (ext) { + case ".png": + return at(0, "\x89PNG"); + case ".jpg": + case ".jpeg": + return b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff; + case ".gif": + return at(0, "GIF8"); + case ".webp": + return at(0, "RIFF") && at(8, "WEBP"); + case ".avif": + return at(4, "ftyp"); + } + return false; +} + function send(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(body)); diff --git a/services/content/src/vault.ts b/services/content/src/vault.ts index fbac3e8..4a2bbf9 100644 --- a/services/content/src/vault.ts +++ b/services/content/src/vault.ts @@ -2,7 +2,7 @@ * The Site folder on disk. Only the content service reads or writes it. */ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, sep } from "node:path"; export const hashText = (text: string) => createHash("sha256").update(text).digest("hex"); @@ -22,6 +22,42 @@ export function isNotePath(rel: string): boolean { return true; } +/** Image types the admin page may add to `_attachments`. SVG stays out: it can carry script. */ +export const uploadTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".avif": "image/avif", +}; + +/** A safe attachment file name: lowercase words joined by hyphens, plus the extension. */ +export function attachmentName(original: string): string | null { + const m = /^(.*?)(\.[A-Za-z0-9]+)?$/.exec(basename(original.replace(/\\/g, "/"))); + const ext = (m?.[2] ?? "").toLowerCase(); + if (!uploadTypes[ext]) return null; + const stem = (m?.[1] ?? "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${stem || "image"}${ext === ".jpeg" ? ".jpg" : ext}`; +} + +/** A folder under `_attachments`: one plain name like Posts or Pages. */ +const folderOk = (f: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,40}$/.test(f); + +export interface AttachmentFile { + name: string; + /** The folder under `_attachments`, or "" for its top level. */ + folder: string; + size: number; + modified: string; +} + export class Vault { constructor(readonly dir: string) {} @@ -77,6 +113,56 @@ export class Vault { return out; } + /** Every file in `_attachments` with its folder, size and time. */ + attachmentFiles(): AttachmentFile[] { + const root = join(this.dir, "_attachments"); + const out: AttachmentFile[] = []; + const walk = (d: string) => { + if (!existsSync(d)) return; + for (const e of readdirSync(d, { withFileTypes: true })) { + if (e.name.startsWith(".")) continue; + const full = join(d, e.name); + if (e.isDirectory()) walk(full); + else if (e.isFile()) { + const st = statSync(full); + const folder = relative(root, d).split(sep).join("/"); + out.push({ name: e.name, folder, size: st.size, modified: st.mtime.toISOString() }); + } + } + }; + walk(root); + return out.sort((a, b) => b.modified.localeCompare(a.modified) || a.name.localeCompare(b.name)); + } + + /** + * Add an image to `_attachments/`. Names are made safe, and a name + * already used anywhere in `_attachments` gets -2, -3 and so on, because + * Obsidian links attachments by file name alone. + */ + addAttachment(folder: string, original: string, bytes: Uint8Array): string { + if (!folderOk(folder)) throw new Error("Use a plain folder name, like Posts"); + const safe = attachmentName(original); + if (!safe) throw new Error(`Only ${Object.keys(uploadTypes).join(", ")} images can be added`); + const taken = new Set(this.listAttachments().map((n) => n.toLowerCase())); + const dot = safe.lastIndexOf("."); + let name = safe; + for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${safe.slice(0, dot)}-${i}${safe.slice(dot)}`; + const full = join(this.dir, "_attachments", folder, name); + mkdirSync(dirname(full), { recursive: true }); + const tmp = join(dirname(full), `.${name}.${process.pid}.tmp`); + writeFileSync(tmp, bytes); + renameSync(tmp, full); + return name; + } + + /** Delete an attachment by file name. False when there is none. */ + removeAttachment(name: string): boolean { + const file = name.includes("/") || name.includes("\\") || name.includes("..") ? null : this.attachmentPath(name); + if (!file) return false; + unlinkSync(file); + return true; + } + /** Absolute path of an attachment by file name, for serving images. */ attachmentPath(name: string): string | null { const walk = (d: string): string | null => { diff --git a/services/content/test/http.test.ts b/services/content/test/http.test.ts index 3931aa2..8554081 100644 --- a/services/content/test/http.test.ts +++ b/services/content/test/http.test.ts @@ -77,6 +77,40 @@ describe("sync reports", () => { }); }); +describe("attachments", () => { + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)]); + const upload = (name: string, bytes: Buffer, token = T.admin) => + fetch(`${base}/api/attachments?folder=Posts&name=${encodeURIComponent(name)}`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" }, + body: new Uint8Array(bytes), + }); + + it("adds an image under a safe, unused name", async () => { + const a = (await (await upload("My Photo!.PNG", png)).json()) as { name: string }; + const b = (await (await upload("my photo.png", png)).json()) as { name: string }; + expect(a.name).toBe("my-photo.png"); + expect(b.name).toBe("my-photo-2.png"); + expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(200); + }); + + it("turns away other files and other roles", async () => { + expect((await upload("x.svg", Buffer.from(""))).status).toBe(400); + expect((await upload("x.png", Buffer.from("not a png"))).status).toBe(400); + expect((await upload("x.png", png, T.agent)).status).toBe(403); + expect((await get("/api/attachments", T.reader)).status).toBe(403); + }); + + it("lists images with the notes that use them, and deletes only unused ones", async () => { + await post_("/api/changes", T.admin, { op: "create", data: post("With a photo"), body: "![[my-photo.png]]" }); + const list = (await (await get("/api/attachments", T.admin)).json()) as { name: string; folder: string; usedBy: { title: string }[] }[]; + expect(list.find((a) => a.name === "my-photo.png")).toMatchObject({ folder: "Posts", usedBy: [{ title: "With a photo" }] }); + expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo.png" })).status).toBe(409); + expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo-2.png" })).status).toBe(200); + expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(404); + }); +}); + describe("MCP server", () => { it("lets an agent search, post a status and draft a post, but not publish it", async () => { const client = new Client({ name: "test", version: "1.0.0" });