Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>.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/<slug>
```

## 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.
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.webp
```

Deletion is permanent. Pages lose all versions and guides lose all revisions.

## Check credentials and permissions

```sh
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import { parseArgs, promisify } from "node:util";
import {
addGuideStep,
deleteGuideStep,
deletePublication,
finishGuide,
type GuideResult,
getGuide,
parseDeleteTarget,
replaceGuideScreenshot,
setGuideVideo,
startGuide,
Expand Down Expand Up @@ -72,13 +74,17 @@ Usage:
schaffa guide replace-screenshot --step <number|id> --screenshot <path> [--json]
schaffa guide sync [--manifest <path>] [--json]
schaffa guide finish [--json]
schaffa delete <page|file|guide> <id> [--token <token>] [--json]
schaffa delete <public-url> [--token <token>] [--json]

Environment:
SCHAFFA_TOKEN Required for permanent publishing, files, presentations, and guides.
SCHAFFA_URL Server origin. Defaults to https://schaffa.dev.

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
Expand Down Expand Up @@ -150,6 +156,7 @@ async function main(): Promise<void> {
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;
Expand Down Expand Up @@ -178,6 +185,29 @@ async function runDoctor(args: string[]): Promise<void> {
if (!report.ready) process.exitCode = 1;
}

async function runDelete(args: string[]): Promise<void> {
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
strict: true,
options: {
help: { type: "boolean", short: "h" },
json: { type: "boolean" },
token: { type: "string" },
},
});
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<void> {
const { values } = parseArgs({
args,
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeleteKind, { publicPrefix: string; apiPrefix: string }> = {
page: { publicPrefix: "p", apiPrefix: "/api/pages/" },
file: { publicPrefix: "f", apiPrefix: "/api/files/" },
guide: { publicPrefix: "g", apiPrefix: "/api/guides/" },
};

/**
* Accepts either `<kind> <id>` 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 <page|file|guide> <id> or <url>.");
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 <page|file|guide> <id> or <url>.");
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<void> {
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;
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/test/client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { addPresentationDownloads, parseCliArgs } from "../dist/cli.js";
import {
addGuideStep,
deleteGuideStep,
deletePublication,
finishGuide,
getGuide,
parseDeleteTarget,
replaceGuideScreenshot,
startGuide,
updateGuideStep,
Expand Down Expand Up @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions src/guides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -589,6 +593,15 @@ export async function deleteGuide(slug: string): Promise<void> {
await removeGuide(slug);
}

export async function deleteOwnedGuide(
slug: string,
tokenId: string,
isAdmin: boolean,
): Promise<void> {
requireOwnedGuide(slug, tokenId, isAdmin);
await deleteGuide(slug);
}

export type GuideSummary = GuideRow & {
step_count: number;
uploader_id: string;
Expand Down
55 changes: 55 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading