From 29bc8a721ab8719a345219ba13330be8320cfd75 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 23 Oct 2026 00:13:45 +0000 Subject: [PATCH] Paste a file with plain curl: multipart upload or raw bytes POST /api/v1/pastes now takes the file itself as well as JSON: curl -F file=@app.log uses the upload's name as the title (so the extension picks the language), and --data-binary takes title, expiresIn and language from the query string. PDFs and images are base64'd server side as the JSON form expects. The /paste page leads its curl block with these instead of the jq recipe. Co-Authored-By: Claude Opus 5.5 --- server/api.ts | 60 ++++++++++++++++++++++++++++++++------ server/test/pastes.test.ts | 38 ++++++++++++++++++++++++ site/build.ts | 18 ++++++++---- 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/server/api.ts b/server/api.ts index 18635c1..032e5bf 100644 --- a/server/api.ts +++ b/server/api.ts @@ -25,10 +25,53 @@ const MAX_SUMMARY_BODY = 28 * 1024 * 1024; async function body(request: Request, max = MAX_BODY): Promise> { if (!request.headers.get("content-type")?.startsWith("application/json")) throw new HttpError(415, "Use application/json."); + const bytes = await readBytes(request, max); + try { + const value = JSON.parse(new TextDecoder().decode(bytes)); + if (!value || typeof value !== "object" || Array.isArray(value)) throw 0; + return value; + } catch { + throw new HttpError(400, "A JSON object is required."); + } +} +/** + * A paste sent as the file itself: an upload (`curl -F file=@app.log`), whose file + * name is the title, or the bare bytes (`curl --data-binary @app.log`). Title, + * expiresIn and language come from the query string or the form's other fields. A + * PDF or image becomes the base64 a JSON paste would carry; anything else is UTF-8. + */ +async function filePaste(request: Request, url: URL): Promise> { + const args: Record = {}; + for (const key of ["title", "expiresIn", "language"]) + if (url.searchParams.has(key)) args[key] = url.searchParams.get(key); + let bytes: Uint8Array; + if (request.headers.get("content-type")?.startsWith("multipart/form-data")) { + if (Number(request.headers.get("content-length")) > MAX_BODY) + throw new HttpError(413, "Request too large."); + const form = await request.formData().catch(() => { + throw new HttpError(400, "The upload could not be read."); + }); + let file: File | undefined; + for (const [key, value] of form) { + if (typeof value !== "string") file ??= value; + else if (["title", "expiresIn", "language"].includes(key)) args[key] ??= value; + } + if (!file) throw new HttpError(400, "Send the file as a form field: -F file=@app.log"); + if (file.size > MAX_BODY) throw new HttpError(413, "Request too large."); + if (args.title === undefined && file.name) args.title = file.name; + bytes = new Uint8Array(await file.arrayBuffer()); + } else bytes = await readBytes(request, MAX_BODY); + const title = typeof args.title === "string" ? args.title : undefined; + args.source = binaryType(title) + ? Buffer.from(bytes).toString("base64") + : new TextDecoder().decode(bytes); + return args; +} +async function readBytes(request: Request, max: number): Promise { if (Number(request.headers.get("content-length")) > max) throw new HttpError(413, "Request too large."); const stream = request.body?.getReader(); - if (!stream) throw new HttpError(400, "A JSON body is required."); + if (!stream) throw new HttpError(400, "A request body is required."); const chunks: Uint8Array[] = []; let size = 0; try { @@ -51,13 +94,7 @@ async function body(request: Request, max = MAX_BODY): Promise(); function limit(key: string, max = 30) { @@ -208,7 +245,12 @@ export function createApi( if (paste) { if (!paste[1] && request.method === "POST") { limit(`paste-create:${ip}`, 10); - const created = createPaste(store, await body(request)); + const created = createPaste( + store, + request.headers.get("content-type")?.startsWith("application/json") + ? await body(request) + : await filePaste(request, url), + ); return json( { id: created.id, diff --git a/server/test/pastes.test.ts b/server/test/pastes.test.ts index 9a2e4ad..602638f 100644 --- a/server/test/pastes.test.ts +++ b/server/test/pastes.test.ts @@ -182,6 +182,44 @@ describe("anonymous pastes", () => { expect((await call("pastes", "POST", { source: "plain text, not base64!", title: "x.pdf" })).status).toBe(400); }); + test("a file is pasted as an upload or as bare bytes, as curl sends it", async () => { + const { store } = fixture(); + const api = createApi(store, "http://localhost"); + const post = async (body: BodyInit, query = "", headers: Record = {}) => { + const response = (await api( + new Request("http://localhost/api/v1/pastes" + query, { method: "POST", body, headers }), + id(), + ))!; + return { status: response.status, data: (await response.json()) as any }; + }; + const log = "2026-10-23 12:00:01 ERROR boom\n at main (app.js:1)\n"; + const form = new FormData(); + form.append("file", new File([log], "app.log")); + form.append("expiresIn", "1h"); + const uploaded = await post(form); + expect(uploaded.status).toBe(201); + expect(uploaded.data.title).toBe("app.log"); + expect(Date.parse(uploaded.data.expiresAt) - Date.now()).toBeLessThan(3601 * 1000); + const raw = (await api(new Request(uploaded.data.raw.replace(/^.*\/p\//, "http://localhost/api/v1/pastes/")), id()))!; + expect(await raw.text()).toBe(log); + + const named = await post(log, "?title=crash.log", { "content-type": "application/x-www-form-urlencoded" }); + expect(named.status).toBe(201); + expect(named.data.title).toBe("crash.log"); + + const pdf = Buffer.from("%PDF-1.4 not really"); + const upload = new FormData(); + upload.append("file", new File([pdf], "spec.pdf")); + const created = await post(upload); + expect(created.data.mime).toBe("application/pdf"); + const bytes = (await api(new Request(`http://localhost/api/v1/pastes/${tokenOf(created.data.url)}/raw`), id()))!; + expect(Buffer.from(await bytes.arrayBuffer()).equals(pdf)).toBe(true); + + const empty = new FormData(); + empty.append("title", "x.log"); + expect((await post(empty)).status).toBe(400); + }); + test("a paste stored before languages existed reads as Markdown", async () => { const { store, call } = fixture(); const created = await call("pastes", "POST", { source: "# Old\n" }); diff --git a/site/build.ts b/site/build.ts index b40a966..abcf6f0 100644 --- a/site/build.ts +++ b/site/build.ts @@ -584,7 +584,16 @@ function pastePage(): string { "readm3 paste delete https://readm3.com/p/", ]); const curl = codeBlock("curl", [ - "# create: any text; the language is detected,", + "# a file from disk: its name decides the language", + "curl -s -F file=@app.log https://readm3.com/api/v1/pastes", + "# any type: logs, code, config, Markdown, a PDF or image", + "curl -s -F file=@spec.pdf -F expiresIn=1d \\", + " https://readm3.com/api/v1/pastes", + "# or the bare bytes, with the name in the query", + "tail -n 500 app.log | curl -s --data-binary @- \\", + " 'https://readm3.com/api/v1/pastes?title=app.log&expiresIn=1h'", + "", + "# create from JSON: any text; the language is detected,", "# title and expiresIn (1h, 1d, 7d, 30d) are optional", "curl -s https://readm3.com/api/v1/pastes \\", " -H 'content-type: application/json' \\", @@ -603,15 +612,12 @@ function pastePage(): string { "", "# gone for everyone", "curl -X DELETE https://readm3.com/api/v1/pastes/", - "", - "# a file straight from disk, jq does the quoting", - "jq -Rs '{source: ., title: \"notes.md\"}' notes.md \\", - " | curl -s https://readm3.com/api/v1/pastes \\", - " -H 'content-type: application/json' -d @-", ]); const api = codeBlock("API", [ "POST /api/v1/pastes", " { source, title?, expiresIn?, language? }", + " or a file: multipart (-F file=@x) or raw", + " bytes, options in the query string", "GET /api/v1/pastes/ the paste as JSON", "GET /api/v1/pastes//raw the text, or the bytes", " of a PDF or image",