mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
+9
-2
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
|
||||||
browser act on the admin page.
|
browser act on the admin page.
|
||||||
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
|
- **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
|
are not http, https, mailto or relative. The content security policy allows
|
||||||
policy that allows no scripts at all; it works entirely with plain forms.
|
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
|
## Running it
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ roles in `CONTENT_TOKENS`.
|
|||||||
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
|
| `POST /api/held/<id>/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/note/rollback` | admin | `{ path, rev }` |
|
||||||
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
|
| `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/<folder>` 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` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. |
|
||||||
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
|
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
|
||||||
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
|
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
|
||||||
|
|||||||
@@ -77,3 +77,36 @@ dl.fields dt { color: var(--muted); }
|
|||||||
dl.fields dd { margin: 0; word-break: break-word; }
|
dl.fields dd { margin: 0; word-break: break-word; }
|
||||||
dl.fields pre { margin: 0; white-space: pre-wrap; }
|
dl.fields pre { margin: 0; white-space: pre-wrap; }
|
||||||
input.num { width: 90px; }
|
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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -117,6 +117,15 @@ export interface Suggestion {
|
|||||||
cover?: string;
|
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<Note, "text" | "backlinks" | "pending">;
|
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
||||||
|
|
||||||
export class ContentApi {
|
export class ContentApi {
|
||||||
@@ -172,6 +181,20 @@ export class ContentApi {
|
|||||||
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
||||||
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
||||||
|
|
||||||
|
attachments = () => this.get<Attachment[]>("/api/attachments");
|
||||||
|
/** Add an image to `_attachments/<folder>`; answers with the name it was saved under. */
|
||||||
|
async upload(folder: string, name: string, bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
||||||
|
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. */
|
/** Fetch an attachment to pass through to the browser. */
|
||||||
attachment = (name: string) =>
|
attachment = (name: string) =>
|
||||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const nav: [string, string][] = [
|
|||||||
["/inbox", "Inbox"],
|
["/inbox", "Inbox"],
|
||||||
["/notes", "Notes"],
|
["/notes", "Notes"],
|
||||||
["/drafts", "Drafts"],
|
["/drafts", "Drafts"],
|
||||||
|
["/posts", "Posts"],
|
||||||
|
["/images", "Images"],
|
||||||
["/capture", "Capture"],
|
["/capture", "Capture"],
|
||||||
["/store", "Store"],
|
["/store", "Store"],
|
||||||
["/problems", "Problems"],
|
["/problems", "Problems"],
|
||||||
@@ -29,6 +31,7 @@ export function layout(p: { title: string; path: string; held: number; inbox?: n
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="/admin.css">
|
<link rel="stylesheet" href="/admin.css">
|
||||||
|
<script src="/admin.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top">
|
<header class="top">
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
const opt = (v: unknown, keep: boolean) => (keep ? v : update ? null : undefined);
|
||||||
|
const caseStudy = d.postType === "case study";
|
||||||
|
const fields: Record<string, unknown> = {
|
||||||
|
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`<div class="head-row"><h1>Posts</h1><a class="button primary" href="/posts/new">Write a post</a></div>
|
||||||
|
<p class="lede">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.</p>
|
||||||
|
${sorted.length
|
||||||
|
? html`<table class="list">
|
||||||
|
<thead><tr><th>Post</th><th>State</th><th>Changed</th><th></th></tr></thead>
|
||||||
|
<tbody>${sorted.map(
|
||||||
|
(p) => html`<tr>
|
||||||
|
<td><a href="${editHref(p.path)}">${p.title}</a></td>
|
||||||
|
<td>${p.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`}</td>
|
||||||
|
<td class="muted">${when(p.acceptedAt)}</td>
|
||||||
|
<td><a href="${noteHref(p.path)}">Note and history</a></td>
|
||||||
|
</tr>`,
|
||||||
|
)}</tbody></table>`
|
||||||
|
: html`<p class="muted">No posts yet.</p>`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 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`<h1>${d.path ? html`Edit post` : html`Write a post`}</h1>
|
||||||
|
<p class="lede">${d.path
|
||||||
|
? html`${d.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`} Saved as <code>${d.path}</code>.`
|
||||||
|
: html`Write in Markdown. Preview it, save it as a draft, or publish it to the Blog.`}</p>
|
||||||
|
<form method="post" action="/posts/save" enctype="multipart/form-data" class="post-form">
|
||||||
|
${d.path ? html`<input type="hidden" name="path" value="${d.path}">` : ""}
|
||||||
|
${d.publish ? html`<input type="hidden" name="wasLive" value="1">` : ""}
|
||||||
|
<div class="post-grid">
|
||||||
|
<div class="panel">
|
||||||
|
<label for="p-title">Title</label>
|
||||||
|
<input id="p-title" name="title" value="${d.title}" required ${d.path ? html`readonly aria-describedby="p-title-note"` : ""}>
|
||||||
|
${d.path ? html`<p class="hint" id="p-title-note">The title is the note's file name; rename it in Obsidian.</p>` : ""}
|
||||||
|
<div class="row">
|
||||||
|
<div><label for="p-topic">Topic</label><select id="p-topic" name="topic">${topics.map((t) => html`<option ${t === d.topic ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||||
|
<div><label for="p-type">Kind of post</label><select id="p-type" name="postType">${postTypes.map((t) => html`<option ${t === d.postType ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||||
|
<div><label for="p-date">Publish date</label><input id="p-date" name="date" type="date" value="${d.date}" required></div>
|
||||||
|
</div>
|
||||||
|
<label for="p-summary">Summary</label>
|
||||||
|
<input id="p-summary" name="summary" value="${d.summary}" required placeholder="One or two sentences for the Blog list and link previews">
|
||||||
|
<label for="p-body">Post</label>
|
||||||
|
<textarea id="p-body" name="body" rows="22" class="body-text" placeholder="Write in Markdown. Embed a vault image with ![[file.jpg]] and link a page with [[Note name]].">${d.body}</textarea>
|
||||||
|
<label for="p-add">Add images to the post</label>
|
||||||
|
<input id="p-add" name="bodyImages" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple>
|
||||||
|
<p class="hint">They are saved to the vault's Posts images and added to the end of the post; move the lines where you want them.</p>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Cover image</h2>
|
||||||
|
${d.cover ? html`<img class="cover-preview" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||||
|
<label for="p-cover">Pick from the vault</label>
|
||||||
|
<select id="p-cover" name="cover">
|
||||||
|
<option value="">No cover</option>
|
||||||
|
${images.map((a) => html`<option value="${a.name}" ${a.name === d.cover ? html`selected` : ""}>${a.folder ? `${a.folder} / ` : ""}${a.name}</option>`)}
|
||||||
|
</select>
|
||||||
|
<label for="p-cover-file">Or upload one</label>
|
||||||
|
<input id="p-cover-file" name="coverFile" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif">
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Pages it is about</h2>
|
||||||
|
<p class="hint">Library items, crafts, makes, games: the site links the post from each page and back.</p>
|
||||||
|
${linkRows.map((l, i) => html`<input name="links" list="note-names" value="${l}" aria-label="Page ${i + 1}" placeholder="A note's name">`)}
|
||||||
|
<datalist id="note-names">${opts.notes.map((n) => html`<option value="${n}">`)}</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<label class="check"><input type="checkbox" name="pinned" value="1" ${d.pinned ? html`checked` : ""}> Feature it at the top of the Blog</label>
|
||||||
|
<details ${d.postType === "case study" ? html`open` : ""}>
|
||||||
|
<summary>Case study details</summary>
|
||||||
|
<label for="p-role">Role</label><input id="p-role" name="role" value="${d.role}">
|
||||||
|
<label for="p-employer">Employer</label><input id="p-employer" name="employer" value="${d.employer}">
|
||||||
|
<label for="p-period">Period</label><input id="p-period" name="period" value="${d.period}" placeholder="2021 to now">
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="panel actions stack">
|
||||||
|
<button name="action" value="preview">Preview</button>
|
||||||
|
<button name="action" value="draft">${d.publish ? "Save and take off the site" : "Save draft"}</button>
|
||||||
|
<button name="action" value="publish" class="primary">${d.publish ? "Save" : "Publish"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
${opts.showPreview
|
||||||
|
? html`<h2 id="preview">Preview</h2>
|
||||||
|
<div class="panel post-preview">
|
||||||
|
${d.cover ? html`<img class="cover-preview wide" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||||
|
<p class="muted">${d.topic} · ${d.date}</p>
|
||||||
|
<h1>${d.title || "Untitled"}</h1>
|
||||||
|
${d.summary ? html`<p class="lede">${d.summary}</p>` : ""}
|
||||||
|
${preview(d.body)}
|
||||||
|
</div>`
|
||||||
|
: ""}
|
||||||
|
${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`<h2>Images in the vault</h2>
|
||||||
|
<p class="hint">Copy a line into the post to show that image. <a href="/images">Manage images</a></p>
|
||||||
|
<ul class="thumbs">${images.slice(0, 24).map(
|
||||||
|
(a) => html`<li><img src="${imageSrc(a.name)}" alt="" loading="lazy"><span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span></li>`,
|
||||||
|
)}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 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`<h1>Images</h1>
|
||||||
|
<p class="lede">Everything in the vault's <code>_attachments</code>, newest first. Images added here land in the vault, so Obsidian shows them too.</p>
|
||||||
|
<form class="panel" method="post" action="/images/upload" enctype="multipart/form-data">
|
||||||
|
<h2>Add images</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div><label for="i-files">Images</label><input id="i-files" name="files" type="file" multiple required accept="image/png,image/jpeg,image/webp,image/gif,image/avif"></div>
|
||||||
|
<div><label for="i-folder">Folder</label><select id="i-folder" name="folder">${imageFolders.map((f) => html`<option ${f === (folder || "Posts") ? html`selected` : ""}>${f}</option>`)}</select></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">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.</p>
|
||||||
|
<div class="actions"><button class="primary">Upload</button></div>
|
||||||
|
</form>
|
||||||
|
<nav class="folders" aria-label="Folder">
|
||||||
|
<a href="/images" ${!folder ? html`aria-current="page"` : ""}>All (${list.length})</a>
|
||||||
|
${folders.map((f) => html`<a href="/images${qs({ folder: f || "-" })}" ${f === folder ? html`aria-current="page"` : ""}>${f || "Top level"} (${list.filter((a) => a.folder === f).length})</a>`)}
|
||||||
|
</nav>
|
||||||
|
${shown.length
|
||||||
|
? html`<ul class="images">${shown.map(
|
||||||
|
(a) => html`<li class="panel">
|
||||||
|
${isImage(a.name) ? html`<a href="${imageSrc(a.name)}"><img src="${imageSrc(a.name)}" alt="" loading="lazy"></a>` : html`<div class="file">${a.name.split(".").pop()}</div>`}
|
||||||
|
<div class="meta">
|
||||||
|
<strong>${a.name}</strong>
|
||||||
|
<span class="muted">${a.folder || "Top level"} · ${kb(a.size)} · ${when(a.modified)}</span>
|
||||||
|
<span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span>
|
||||||
|
${a.usedBy.length
|
||||||
|
? html`<span class="used">Used by ${a.usedBy.map((u, i) => html`${i ? ", " : ""}<a href="${noteHref(u.path)}">${u.title}</a>`)}</span>`
|
||||||
|
: html`<form method="post" action="/images/delete" class="inline"><input type="hidden" name="name" value="${a.name}"><input type="hidden" name="folder" value="${folder}"><span class="muted">Not used by any note.</span> <button class="danger">Delete</button></form>`}
|
||||||
|
</div>
|
||||||
|
</li>`,
|
||||||
|
)}</ul>`
|
||||||
|
: html`<p class="muted">No images here yet.</p>`}`;
|
||||||
|
}
|
||||||
@@ -16,15 +16,21 @@ import { html, qs, type Html } from "./html.ts";
|
|||||||
import { layout, type Flash } from "./layout.ts";
|
import { layout, type Flash } from "./layout.ts";
|
||||||
import * as pages from "./pages.ts";
|
import * as pages from "./pages.ts";
|
||||||
import * as inbox from "./inbox.ts";
|
import * as inbox from "./inbox.ts";
|
||||||
|
import * as posts from "./posts.ts";
|
||||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||||
|
|
||||||
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
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 {
|
class Redirect {
|
||||||
constructor(readonly to: string) {}
|
constructor(readonly to: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Form = URLSearchParams;
|
type Form = URLSearchParams;
|
||||||
|
type Upload = { field: string; name: string; bytes: Uint8Array<ArrayBuffer> };
|
||||||
type Page = { title: string; body: Html };
|
type Page = { title: string; body: Html };
|
||||||
|
|
||||||
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
||||||
@@ -112,6 +118,19 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
}
|
}
|
||||||
case "/problems":
|
case "/problems":
|
||||||
return { title: "Problems", body: pages.problemsView(await api.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") {
|
if (path === "/inbox") {
|
||||||
const show = q.get("show") ?? "open";
|
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);
|
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) {
|
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": {
|
case "/scan": {
|
||||||
const r = await api.scan();
|
const r = await api.scan();
|
||||||
return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
|
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("referrer-policy", "same-origin");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"content-security-policy",
|
"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") {
|
if (url.pathname === "/admin.css") {
|
||||||
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
||||||
return res.end(css);
|
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") {
|
if (url.pathname === "/health") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end('{"ok":true}');
|
return res.end('{"ok":true}');
|
||||||
@@ -296,10 +364,14 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
|
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||||
const form = new URLSearchParams(await readBody(req));
|
const { form, files } = await readForm(req);
|
||||||
const r = await post(url.pathname, form);
|
const r = await post(url.pathname, form, files);
|
||||||
res.writeHead(303, { location: r.to });
|
if (r instanceof Redirect) {
|
||||||
return res.end();
|
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");
|
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[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
for await (const c of req) {
|
for await (const c of req) {
|
||||||
size += (c as Buffer).length;
|
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);
|
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) {
|
function text(res: ServerResponse, status: number, body: string) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,9 +15,12 @@ import type { Change, ContentService } from "./service.ts";
|
|||||||
import type { Author, StoredNote } from "./store.ts";
|
import type { Author, StoredNote } from "./store.ts";
|
||||||
import { handleSubmissions } from "./submissions.ts";
|
import { handleSubmissions } from "./submissions.ts";
|
||||||
import { handleSuggestions } from "./suggestions.ts";
|
import { handleSuggestions } from "./suggestions.ts";
|
||||||
|
import { uploadTypes } from "./vault.ts";
|
||||||
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
||||||
|
|
||||||
const MAX_BODY = 1_000_000;
|
const MAX_BODY = 1_000_000;
|
||||||
|
/** The largest image the admin page can add. */
|
||||||
|
const MAX_IMAGE = 15_000_000;
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
constructor(readonly status: number, message: string) {
|
constructor(readonly status: number, message: string) {
|
||||||
@@ -73,6 +76,15 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
|
|
||||||
const visible = (n: StoredNote | null, role: Role) => (n && !n.removed && (n.publish || can.readDrafts(role)) ? n : null);
|
const visible = (n: StoredNote | null, role: Role) => (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) {
|
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
@@ -196,6 +208,11 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
return send(res, 200, { ...heldView(h, { text: true }), current: current && !current.removed ? current.text : null });
|
return send(res, 200, { ...heldView(h, { text: true }), current: current && !current.removed ? current.text : null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments") {
|
||||||
|
need(can.review(role));
|
||||||
|
return send(res, 200, attachmentList());
|
||||||
|
}
|
||||||
|
|
||||||
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
let name = "";
|
let name = "";
|
||||||
@@ -250,6 +267,34 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
return send(res, 200, { ok: true });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments") {
|
||||||
|
need(can.review(role));
|
||||||
|
const folder = q("folder") ?? "";
|
||||||
|
const name = q("name") ?? "";
|
||||||
|
const bytes = await readBytes(req, MAX_IMAGE);
|
||||||
|
if (!bytes.length) throw new HttpError(400, "The image is empty");
|
||||||
|
const ext = extname(name).toLowerCase();
|
||||||
|
if (!uploadTypes[ext] || !looksLike(ext, bytes)) throw new HttpError(400, `${name || "That file"} is not a PNG, JPEG, WebP, GIF or AVIF image`);
|
||||||
|
try {
|
||||||
|
return send(res, 200, { name: service.vault.addAttachment(folder, name, bytes) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(400, (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments/delete") {
|
||||||
|
need(can.review(role));
|
||||||
|
const body = z.object({ name: z.string().min(1) }).safeParse(await readJson(req));
|
||||||
|
if (!body.success) throw new HttpError(400, z.prettifyError(body.error));
|
||||||
|
const one = attachmentList().find((a) => 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") {
|
if (path === "/api/scan") {
|
||||||
need(can.review(role));
|
need(can.review(role));
|
||||||
return send(res, 200, service.scan());
|
return send(res, 200, service.scan());
|
||||||
@@ -296,6 +341,36 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readBytes(req: IncomingMessage, max: number): Promise<Buffer> {
|
||||||
|
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) {
|
function send(res: ServerResponse, status: number, body: unknown) {
|
||||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* The Site folder on disk. Only the content service reads or writes it.
|
* The Site folder on disk. Only the content service reads or writes it.
|
||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
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";
|
import { basename, dirname, join, relative, sep } from "node:path";
|
||||||
|
|
||||||
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||||
@@ -22,6 +22,42 @@ export function isNotePath(rel: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Image types the admin page may add to `_attachments`. SVG stays out: it can carry script. */
|
||||||
|
export const uploadTypes: Record<string, string> = {
|
||||||
|
".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 {
|
export class Vault {
|
||||||
constructor(readonly dir: string) {}
|
constructor(readonly dir: string) {}
|
||||||
|
|
||||||
@@ -77,6 +113,56 @@ export class Vault {
|
|||||||
return out;
|
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/<folder>`. 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. */
|
/** Absolute path of an attachment by file name, for serving images. */
|
||||||
attachmentPath(name: string): string | null {
|
attachmentPath(name: string): string | null {
|
||||||
const walk = (d: string): string | null => {
|
const walk = (d: string): string | null => {
|
||||||
|
|||||||
@@ -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("<svg/>"))).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", () => {
|
describe("MCP server", () => {
|
||||||
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
|
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" });
|
const client = new Client({ name: "test", version: "1.0.0" });
|
||||||
|
|||||||
Reference in New Issue
Block a user