From 749d143f71423edbc8f85f927d2a42649882d096 Mon Sep 17 00:00:00 2001 From: beasty Date: Thu, 24 Sep 2026 13:16:25 +0200 Subject: [PATCH 1/3] feat: delete pages, files, and guides with the owning token Add DELETE /api/pages/:slug, /api/files/:id, and /api/guides/:slug. The token that created the content, or an admin token, may delete it; other tokens get 403. Deletion stays available during publishing lockdown, matching admin takedowns. The CLI gains `schaffa delete ` and `schaffa delete `. URLs must match SCHAFFA_URL, and version or revision URLs are rejected so a whole page is never removed by mistake. --- docs/api.md | 25 +++++++++++ packages/cli/README.md | 11 +++++ packages/cli/src/cli.ts | 31 +++++++++++++ packages/cli/src/client.ts | 70 +++++++++++++++++++++++++++++ packages/cli/test/client.test.mjs | 73 +++++++++++++++++++++++++++++++ src/guides.ts | 9 ++++ src/openapi.ts | 55 +++++++++++++++++++++++ src/server.ts | 25 +++++++++++ src/service.ts | 35 +++++++++++++++ test/publications.test.ts | 65 +++++++++++++++++++++++++++ 10 files changed, 399 insertions(+) diff --git a/docs/api.md b/docs/api.md index f6d8383..07ccbcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -95,6 +95,31 @@ Recognized images are scanned in quarantine, then auto-oriented, resized, stripp File reads support byte ranges. Public file and version responses use a five-minute cache lifetime so an admin takedown is not hidden behind a year-long immutable cache. Potentially active types such as HTML, SVG, XML, JavaScript, and PDF are served as downloads rather than rendered inline; file responses also carry a sandboxed, deny-by-default CSP. +## Delete content + +The token that created a page, file, or guide can delete it permanently. Admin tokens can delete any of them. Deletion also works during publishing lockdown and returns `204 No Content`: + +```sh +curl --fail-with-body --silent --show-error -X DELETE \ + -H "Authorization: Bearer $SCHAFFA_TOKEN" \ + "$SCHAFFA_URL/api/guides/$ID" +``` + +| Endpoint | Removes | +| --- | --- | +| `DELETE /api/pages/:slug` | The page and all of its versions | +| `DELETE /api/files/:id` | One file, addressed by ID or by its public filename such as `.webp` | +| `DELETE /api/guides/:slug` | The guide with all revisions and screenshots | + +A token that does not own the content receives `403`. An unknown ID returns `404`. Anonymous pages cannot be deleted through the API. A guide's attached video is a separate file and stays until it is deleted itself. + +The CLI provides the same operation and accepts either the type with an ID or a public URL: + +```sh +npx schaffa delete guide "$ID" +npx schaffa delete https://schaffa.dev/p/ +``` + ## Administration Administration is intentionally not part of the public HTTP API or OpenAPI contract. Use the protected `/admin` interface to list and remove pages, individual page versions, and files; create and revoke upload or admin tokens; delete users; grant interactive publishing per user; and control publishing lockdown, interactive publishing, signups, and logins. diff --git a/packages/cli/README.md b/packages/cli/README.md index 18b5603..aa0ecbf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -125,6 +125,17 @@ Queue time can exceed this wait. If the deadline expires, the CLI prints the existing file and status URLs. Check them before uploading again; the pending upload remains on the server and is not cancelled by the CLI deadline. +## Delete something + +Delete a page, file, or guide with the token that created it, or with an admin token: + +```sh +npx schaffa delete guide abc234def567 +npx schaffa delete https://schaffa.dev/f/.webp +``` + +Deletion is permanent. Pages lose all versions and guides lose all revisions. + ## Check credentials and permissions ```sh diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 585d9e2..de501a6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,9 +22,11 @@ import { parseArgs, promisify } from "node:util"; import { addGuideStep, deleteGuideStep, + deletePublication, finishGuide, type GuideResult, getGuide, + parseDeleteTarget, replaceGuideScreenshot, setGuideVideo, startGuide, @@ -72,6 +74,8 @@ Usage: schaffa guide replace-screenshot --step --screenshot [--json] schaffa guide sync [--manifest ] [--json] schaffa guide finish [--json] + schaffa delete [--token ] [--json] + schaffa delete [--token ] [--json] Environment: SCHAFFA_TOKEN Required for permanent publishing, files, presentations, and guides. @@ -79,6 +83,8 @@ Environment: The guide commands persist the active random slug and edit revision in .schaffa/guide-session.json so an interrupted recording can be resumed. +delete permanently removes a page with all versions, a file, or a guide with all +revisions. It requires the token that created the content or an admin token. Add --video to record or guide record to export and attach a video walkthrough. Video export requires local ffmpeg with libvpx-vp9 and Chromium. Standalone video is local unless --upload is requested. Automatic recordings also keep every original screenshot and a manifest under @@ -150,6 +156,7 @@ async function main(): Promise { return runGuide(args.slice(1)); } if (args[0] === "publish") return runPresentation(args.slice(1)); + if (args[0] === "delete") return runDelete(args.slice(1)); const options = parseCliArgs(args); if ("help" in options) return void process.stdout.write(help); const { json, command: _command, ...uploadOptions } = options; @@ -178,6 +185,30 @@ async function runDoctor(args: string[]): Promise { if (!report.ready) process.exitCode = 1; } +async function runDelete(args: string[]): Promise { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + strict: true, + options: { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + token: { type: "string" }, + "ignore-token": { type: "boolean" }, + }, + }); + if (values.help) return void process.stdout.write(help); + const baseUrl = process.env.SCHAFFA_URL || "https://schaffa.dev"; + const target = parseDeleteTarget(positionals, baseUrl); + const token = resolveToken(values); + await deletePublication({ ...target, baseUrl, ...(token ? { token } : {}) }); + process.stdout.write( + values.json + ? `${JSON.stringify({ deleted: true, ...target })}\n` + : `Deleted ${target.kind} ${target.id}.\n`, + ); +} + async function runAutomaticRecorder(args: string[], legacy: boolean): Promise { const { values } = parseArgs({ args, diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 8ba66ed..dc495ae 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -364,6 +364,76 @@ export async function waitForVideoScan(options: { ); } +export type DeleteKind = "page" | "file" | "guide"; + +export interface DeleteTarget { + kind: DeleteKind; + id: string; +} + +const deleteRoutes: Record = { + page: { publicPrefix: "p", apiPrefix: "/api/pages/" }, + file: { publicPrefix: "f", apiPrefix: "/api/files/" }, + guide: { publicPrefix: "g", apiPrefix: "/api/guides/" }, +}; + +/** + * Accepts either ` ` or a public Schaffa URL (`/p/…`, `/f/…`, `/g/…`). + * URLs must belong to the configured origin so an ID is never deleted on the wrong instance. + */ +export function parseDeleteTarget(args: string[], baseUrl = "https://schaffa.dev"): DeleteTarget { + const [first, second] = args; + if (!first || args.length > 2) + throw new Error("delete requires or ."); + const kind = second === undefined ? undefined : first; + if (kind !== undefined && !Object.hasOwn(deleteRoutes, kind)) { + throw new Error("delete kind must be page, file, or guide."); + } + const value = second ?? first; + if (!/^https?:\/\//i.test(value)) { + if (!kind) throw new Error("delete requires or ."); + if (!/^(?!\.+$)[A-Za-z0-9._-]+$/.test(value)) throw new Error("Invalid ID."); + return { kind: kind as DeleteKind, id: value }; + } + const url = new URL(value); + if (url.origin !== canonicalOrigin(baseUrl)) { + throw new Error(`URL does not belong to ${canonicalOrigin(baseUrl)}. Set SCHAFFA_URL.`); + } + const segments = url.pathname.split("/").filter(Boolean); + const [prefix, id] = segments; + const detected = (Object.keys(deleteRoutes) as DeleteKind[]).find( + (candidate) => deleteRoutes[candidate].publicPrefix === prefix, + ); + // Version and revision URLs are rejected so they are not mistaken for a partial delete. + if (!detected || !id || segments.length !== 2) { + throw new Error("URL must be the public URL of a Schaffa page, file, or guide."); + } + if (kind && kind !== detected) throw new Error(`URL points to a ${detected}, not a ${kind}.`); + return { kind: detected, id: decodeURIComponent(id) }; +} + +export async function deletePublication( + options: DeleteTarget & { token?: string; baseUrl?: string; fetch?: typeof fetch }, +): Promise { + if (!options.token) throw new Error("SCHAFFA_TOKEN is required to delete content."); + const response = await (options.fetch || fetch)( + new URL( + `${deleteRoutes[options.kind].apiPrefix}${encodeURIComponent(options.id)}`, + canonicalOrigin(options.baseUrl || "https://schaffa.dev"), + ), + { method: "DELETE", headers: { Authorization: `Bearer ${options.token}` } }, + ); + if (!response.ok) { + const result = parseResponse(await response.text()); + const detail = typeof result.message === "string" ? ` ${result.message}` : ""; + throw new SchaffaRequestError( + response.status, + `Schaffa request failed with HTTP ${response.status}.${detail}`, + typeof result.error === "string" ? result.error : undefined, + ); + } +} + export interface GuideMutationOptions { slug: string; editRevision: number; diff --git a/packages/cli/test/client.test.mjs b/packages/cli/test/client.test.mjs index b79f015..58c918f 100644 --- a/packages/cli/test/client.test.mjs +++ b/packages/cli/test/client.test.mjs @@ -10,8 +10,10 @@ import { addPresentationDownloads, parseCliArgs } from "../dist/cli.js"; import { addGuideStep, deleteGuideStep, + deletePublication, finishGuide, getGuide, + parseDeleteTarget, replaceGuideScreenshot, startGuide, updateGuideStep, @@ -238,6 +240,77 @@ test("reports API errors without exposing the bearer token", async () => { ); }); +test("parses delete targets from kind and ID or from a same-origin public URL", () => { + assert.deepEqual(parseDeleteTarget(["guide", "abc234def567"]), { + kind: "guide", + id: "abc234def567", + }); + assert.deepEqual(parseDeleteTarget(["https://schaffa.dev/p/0123456789abcdef"]), { + kind: "page", + id: "0123456789abcdef", + }); + assert.deepEqual( + parseDeleteTarget(["file", "https://schaffa.dev/f/AAAAAAAAAAAAAAAAAAAAAA.webp"]), + { + kind: "file", + id: "AAAAAAAAAAAAAAAAAAAAAA.webp", + }, + ); + assert.deepEqual( + parseDeleteTarget(["https://self.example/g/abc234def567"], "https://self.example"), + { kind: "guide", id: "abc234def567" }, + ); + assert.throws(() => parseDeleteTarget(["abc234def567"]), /page\|file\|guide/); + assert.throws(() => parseDeleteTarget(["video", "abc"]), /page, file, or guide/); + assert.throws(() => parseDeleteTarget(["guide", "../pages/x"]), /Invalid ID/); + assert.throws(() => parseDeleteTarget(["guide", ".."]), /Invalid ID/); + assert.throws(() => parseDeleteTarget(["toString", "abc"]), /page, file, or guide/); + assert.throws(() => parseDeleteTarget(["https://other.example/g/abc234def567"]), /SCHAFFA_URL/); + assert.throws(() => parseDeleteTarget(["https://schaffa.dev/p/abc/2"]), /public URL/); + assert.throws( + () => parseDeleteTarget(["page", "https://schaffa.dev/g/abc234def567"]), + /points to a guide, not a page/, + ); +}); + +test("deletes content with the bearer token and reports API errors", async () => { + const requests = []; + await deletePublication({ + kind: "guide", + id: "abc234def567", + token, + baseUrl: "https://self.example", + fetch: async (url, init) => { + requests.push({ url: String(url), init }); + return new Response(null, { status: 204 }); + }, + }); + assert.equal(requests[0].url, "https://self.example/api/guides/abc234def567"); + assert.equal(requests[0].init.method, "DELETE"); + assert.equal(requests[0].init.headers.Authorization, `Bearer ${token}`); + + await assert.rejects( + deletePublication({ kind: "page", id: "abc", fetch: async () => new Response(null) }), + /SCHAFFA_TOKEN is required/, + ); + await assert.rejects( + deletePublication({ + kind: "file", + id: "abc", + token, + fetch: async () => + jsonResponse({ error: "forbidden", message: "This token does not own the file." }, 403), + }), + (error) => { + assert.equal(error.status, 403); + assert.equal(error.code, "forbidden"); + assert.match(error.message, /HTTP 403.*does not own the file/); + assert.doesNotMatch(error.message, new RegExp(token)); + return true; + }, + ); +}); + test("drives the incremental guide API with revisions and authorization", async () => { const requests = []; const fakeFetch = async (url, init) => { diff --git a/src/guides.ts b/src/guides.ts index 4a1be11..7a4e05a 100644 --- a/src/guides.ts +++ b/src/guides.ts @@ -589,6 +589,15 @@ export async function deleteGuide(slug: string): Promise { await removeGuide(slug); } +export async function deleteOwnedGuide( + slug: string, + tokenId: string, + isAdmin: boolean, +): Promise { + requireOwnedGuide(slug, tokenId, isAdmin); + await deleteGuide(slug); +} + export type GuideSummary = GuideRow & { step_count: number; uploader_id: string; diff --git a/src/openapi.ts b/src/openapi.ts index 7a7a169..3c72213 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -117,6 +117,22 @@ export function openApiDocument() { "503": errorResponse("Publishing is locked or virus scanning is not configured"), }, }, + delete: { + tags: ["Pages"], + summary: "Delete a page", + description: + "Permanently deletes the page and all of its versions. Only the token that created the page or an admin token may delete it.", + operationId: "deletePage", + security: [{ bearerAuth: [] }], + parameters: [slugParameter], + responses: { + "204": { description: "Page deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this page"), + "404": errorResponse("Page not found"), + "422": errorResponse("Invalid slug"), + }, + }, }, "/p/{slug}": pageRead("Read the latest page version", "getLatestPage", [slugParameter]), "/p/{slug}/{version}": pageRead("Read a specific page version", "getPageVersion", [ @@ -172,6 +188,30 @@ export function openApiDocument() { }, }, }, + "/api/files/{id}": { + delete: { + tags: ["Files"], + summary: "Delete a file", + description: + "Permanently deletes an uploaded file. Accepts the file ID or its public filename. Only the token that uploaded the file or an admin token may delete it.", + operationId: "deleteFile", + security: [{ bearerAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "204": { description: "File deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this file"), + "404": errorResponse("File not found"), + }, + }, + }, "/f/{filename}": { get: { tags: ["Files"], @@ -282,6 +322,21 @@ export function openApiDocument() { "409": errorResponse("Edit revision conflict"), }, }, + delete: { + tags: ["Guides"], + summary: "Delete a guide", + description: + "Permanently deletes the guide, all of its revisions, and its screenshots. Only the token that created the guide or an admin token may delete it.", + operationId: "deleteGuide", + security: [{ bearerAuth: [] }], + parameters: [slugParameter], + responses: { + "204": { description: "Guide deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this guide"), + "404": errorResponse("Guide not found"), + }, + }, }, "/api/guides/{slug}/steps": { post: { diff --git a/src/server.ts b/src/server.ts index 4913452..06ac40a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { createGuide, deleteGuide, deleteGuideStep, + deleteOwnedGuide, finishGuide, getGuideImage, getOwnedGuide, @@ -48,8 +49,10 @@ import { pendingScanCount, processNextPendingScan, resetInterruptedScans } from import { canRunInteractivePage, deleteFile, + deleteFileForToken, deleteFileForUser, deletePage, + deletePageForToken, deletePageForUser, deletePageVersion, deletePageVersionForUser, @@ -510,6 +513,11 @@ export function buildServer( if (scanIntervalMs > 0) void runScan(); return reply.code(202).send(result); }); + app.delete<{ Params: { slug: string } }>("/api/pages/:slug", async (request, reply) => { + const auth = requireTokenAuth(request); + await deletePageForToken(auth.id, auth.scopes.has("admin"), request.params.slug); + return reply.code(204).send(); + }); app.post<{ Querystring: { title?: string | string[]; type?: string | string[] } }>( "/api/pages", @@ -557,6 +565,11 @@ export function buildServer( if (scanIntervalMs > 0) void runScan(); return reply.code(202).send(result); }); + app.delete<{ Params: { id: string } }>("/api/files/:id", async (request, reply) => { + const auth = requireTokenAuth(request); + await deleteFileForToken(auth.id, auth.scopes.has("admin"), request.params.id); + return reply.code(204).send(); + }); app.post<{ Body: { title?: unknown; description?: unknown; targetUrl?: unknown; language?: unknown }; @@ -591,6 +604,11 @@ export function buildServer( return guide; }, ); + app.delete<{ Params: { slug: string } }>("/api/guides/:slug", async (request, reply) => { + const auth = requireTokenAuth(request); + await deleteOwnedGuide(request.params.slug, auth.id, auth.scopes.has("admin")); + return reply.code(204).send(); + }); app.post<{ Params: { slug: string }; Body: Record }>( "/api/guides/:slug/steps", async (request, reply) => { @@ -1292,6 +1310,13 @@ function requireApiAuth(request: FastifyRequest, scope: TokenScope) { return requireScope(authenticateToken(token), scope); } +// Deletion is authorized by ownership, so any valid token scope may remove its own content. +function requireTokenAuth(request: FastifyRequest) { + const auth = authenticateToken(bearerToken(request.headers.authorization)); + if (!auth) throw new AppError("A valid bearer token is required.", 401, "unauthorized"); + return auth; +} + function requireInteractiveApiAuth(request: FastifyRequest) { const auth = requireApiAuth(request, "interactive"); const settings = getInstanceSettings(); diff --git a/src/service.ts b/src/service.ts index 35e4e2d..35a37ff 100644 --- a/src/service.ts +++ b/src/service.ts @@ -506,6 +506,24 @@ export async function deletePageForUser(userId: string, slugValue: string): Prom }); } +export async function deletePageForToken( + tokenId: string, + isAdmin: boolean, + slugValue: string, +): Promise { + return serializeMetadataWrite(async () => { + const slug = validateSlug(slugValue); + const page = db().prepare("SELECT owner_token_id FROM pages WHERE slug = ?").get(slug) as + | { owner_token_id: string | null } + | undefined; + if (!page) throw new AppError("Page not found.", 404, "not_found"); + if (page.owner_token_id !== tokenId && !isAdmin) { + throw new AppError("This token does not own the page.", 403, "forbidden"); + } + await deletePageLocked(slug); + }); +} + async function deletePageLocked(slugValue: string): Promise { const slug = validateSlug(slugValue); const result = db().prepare("DELETE FROM pages WHERE slug = ?").run(slug); @@ -598,6 +616,23 @@ export async function deleteFileForUser(userId: string, id: string): Promise { + return serializeMetadataWrite(async () => { + const file = db() + .prepare("SELECT id, created_by_token_id FROM files WHERE id = ? OR filename = ?") + .get(idOrFilename, idOrFilename) as { id: string; created_by_token_id: string } | undefined; + if (!file) throw new AppError("File not found.", 404, "not_found"); + if (file.created_by_token_id !== tokenId && !isAdmin) { + throw new AppError("This token does not own the file.", 403, "forbidden"); + } + await deleteFileLocked(file.id); + }); +} + async function deleteFileLocked(id: string): Promise { if (!isFileId(id)) throw new AppError("File not found.", 404, "not_found"); const result = db().prepare("DELETE FROM files WHERE id = ?").run(id); diff --git a/test/publications.test.ts b/test/publications.test.ts index f45442b..98c731a 100644 --- a/test/publications.test.ts +++ b/test/publications.test.ts @@ -592,6 +592,71 @@ test("supports emergency page, version, and file takedown", async () => { ); }); +test("deletes pages, files, and guides through the API only for the owning or admin token", async () => { + const owner = createToken("delete owner"); + const stranger = createToken("delete stranger"); + const remove = (url: string, token?: string) => + app.inject({ + method: "DELETE", + url, + headers: { + host: "schaffa.test", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + const read = async (url: string) => + (await app.inject({ method: "GET", url, headers: { host: "schaffa.test" } })).statusCode; + + const page = await publishHtmlWithToken("api-delete-page", "

Delete me

", owner.token); + const pageSlug = String(page.json().slug); + assert.equal((await remove(`/api/pages/${pageSlug}`)).statusCode, 401); + const foreignPage = await remove(`/api/pages/${pageSlug}`, stranger.token); + assert.equal(foreignPage.statusCode, 403); + assert.equal(foreignPage.json().error, "forbidden"); + assert.equal(await read(`/p/${pageSlug}`), 200); + const deletedPage = await remove(`/api/pages/${pageSlug}`, owner.token); + assert.equal(deletedPage.statusCode, 204); + assert.equal(deletedPage.body, ""); + assert.equal(await read(`/p/${pageSlug}`), 404); + assert.equal((await remove(`/api/pages/${pageSlug}`, owner.token)).statusCode, 404); + + const body = multipart("file", "remove.txt", "text/plain", "remove me"); + const file = await app.inject({ + method: "POST", + url: "/api/files", + headers: { + host: "schaffa.test", + authorization: `Bearer ${owner.token}`, + "content-type": body.contentType, + }, + payload: body.payload, + }); + await finishPendingScans(); + const filePath = new URL(file.json().publicUrl).pathname; + const filename = filePath.split("/").at(-1) as string; + assert.equal((await remove(`/api/files/${filename}`, stranger.token)).statusCode, 403); + assert.equal(await read(filePath), 200); + assert.equal((await remove(`/api/files/${filename}`, owner.token)).statusCode, 204); + assert.equal(await read(filePath), 404); + assert.equal((await remove(`/api/files/${file.json().id}`, owner.token)).statusCode, 404); + + const guide = await app.inject({ + method: "POST", + url: "/api/guides", + headers: { + host: "schaffa.test", + authorization: `Bearer ${owner.token}`, + "content-type": "application/json", + }, + payload: { title: "Delete me" }, + }); + const guideSlug = String(guide.json().slug); + assert.equal((await remove(`/api/guides/${guideSlug}`, stranger.token)).statusCode, 403); + assert.equal((await remove(`/api/guides/${guideSlug}`, bootstrapToken)).statusCode, 204); + assert.equal(db().prepare("SELECT 1 FROM guides WHERE slug = ?").get(guideSlug), undefined); + assert.equal((await remove(`/api/guides/${guideSlug}`, owner.token)).statusCode, 404); +}); + test("enforces a persistent per-token upload rate limit", async () => { const limited = createToken("rate-limited"); for (let index = 0; index < config.authenticatedUploadsPerHour; index += 1) { From e52f4ff148cbace9f41c4d3659461baef3c61ff5 Mon Sep 17 00:00:00 2001 From: beasty Date: Thu, 24 Sep 2026 13:35:17 +0200 Subject: [PATCH 2/3] fix: return 404 when a guide is deleted during screenshot scanning Screenshot uploads await virus scanning before their transaction. An owner can now delete the guide in that window, which made the insert fail on the foreign key with HTTP 500. Recheck the guide inside the transaction so the upload fails with 404 and its stored image is removed. --- src/guides.ts | 4 +++ test/guides.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/guides.ts b/src/guides.ts index 7a4e05a..75e1071 100644 --- a/src/guides.ts +++ b/src/guides.ts @@ -212,6 +212,8 @@ export async function addGuideStep( .get(guide.id) as unknown as { position: number }; db().exec("BEGIN IMMEDIATE"); try { + // The guide may have been deleted while the screenshot was scanned. + loadGuide(guide.id); if (image) insertImage(image); db() .prepare( @@ -315,6 +317,8 @@ export async function replaceGuideScreenshot( const image = await prepareGuideImage(guide, screenshot); db().exec("BEGIN IMMEDIATE"); try { + // The guide may have been deleted while the screenshot was scanned. + loadGuide(guide.id); insertImage(image); db() .prepare( diff --git a/test/guides.test.ts b/test/guides.test.ts index 16773ab..6543993 100644 --- a/test/guides.test.ts +++ b/test/guides.test.ts @@ -1,12 +1,19 @@ +import { readdir } from "node:fs/promises"; import { app, assert, assertLightNear, assertRedNear, createToken, + dataDir, + db, multipartFields, + path, + releaseStalledScans, + scannerState, sharp, test, + waitForStalledScanner, } from "./server-fixture.js"; test("records, edits, publishes, and revisions a guide incrementally", async () => { @@ -277,6 +284,63 @@ test("records, edits, publishes, and revisions a guide incrementally", async () assert.doesNotMatch(JSON.stringify(unchanged.json()), /must-not-become-public/); }); +test("rejects a screenshot upload cleanly when its guide is deleted during scanning", async () => { + const owner = createToken("guide delete race"); + const auth = { host: "schaffa.test", authorization: `Bearer ${owner.token}` }; + const created = await app.inject({ + method: "POST", + url: "/api/guides", + headers: { ...auth, "content-type": "application/json" }, + payload: { title: "Delete during upload" }, + }); + const slug = created.json().slug as string; + const screenshot = await sharp({ + create: { width: 64, height: 64, channels: 4, background: "#4b5563" }, + }) + .png() + .toBuffer(); + const body = multipartFields( + { step: JSON.stringify({ title: "Capture", description: "Capture the screen." }) }, + "screenshot", + "capture.png", + "image/png", + screenshot, + ); + + scannerState.mode = "stall"; + try { + const upload = app.inject({ + method: "POST", + url: `/api/guides/${slug}/steps`, + headers: { ...auth, "content-type": body.contentType, "if-match": '"1"' }, + payload: body.payload, + }); + await waitForStalledScanner(); + const deleted = await app.inject({ + method: "DELETE", + url: `/api/guides/${slug}`, + headers: auth, + }); + assert.equal(deleted.statusCode, 204); + scannerState.mode = "ok"; + releaseStalledScans(); + const response = await upload; + assert.equal(response.statusCode, 404); + assert.equal(response.json().error, "not_found"); + } finally { + scannerState.mode = "ok"; + releaseStalledScans(); + } + const leftovers = await readdir(path.join(dataDir, "guides", slug), { recursive: true }).catch( + () => [], + ); + assert.deepEqual( + leftovers.filter((name) => name.endsWith(".webp")), + [], + ); + assert.equal(db().prepare("SELECT 1 FROM guide_steps WHERE title = 'Capture'").get(), undefined); +}); + test("accepts only safe web destinations for guides", async () => { const owner = createToken("guide URL owner"); const headers = { From 4cd8e6c639717f8665e440425ac32a5dfd2866bf Mon Sep 17 00:00:00 2001 From: beasty Date: Thu, 24 Sep 2026 13:47:44 +0200 Subject: [PATCH 3/3] fix(cli): drop --ignore-token from delete Deletion always needs a token, so the flag could only produce an error. --- packages/cli/src/cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index de501a6..a301a4d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -194,7 +194,6 @@ async function runDelete(args: string[]): Promise { help: { type: "boolean", short: "h" }, json: { type: "boolean" }, token: { type: "string" }, - "ignore-token": { type: "boolean" }, }, }); if (values.help) return void process.stdout.write(help);