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:
@@ -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<Config, "
|
||||
|
||||
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) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
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 });
|
||||
}
|
||||
|
||||
if (path === "/api/attachments") {
|
||||
need(can.review(role));
|
||||
return send(res, 200, attachmentList());
|
||||
}
|
||||
|
||||
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
||||
if (attachment) {
|
||||
let name = "";
|
||||
@@ -250,6 +267,34 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
||||
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") {
|
||||
need(can.review(role));
|
||||
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) {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body));
|
||||
|
||||
@@ -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<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 {
|
||||
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/<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. */
|
||||
attachmentPath(name: 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", () => {
|
||||
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" });
|
||||
|
||||
Reference in New Issue
Block a user