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
60 changes: 51 additions & 9 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,53 @@ const MAX_SUMMARY_BODY = 28 * 1024 * 1024;
async function body(request: Request, max = MAX_BODY): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
const args: Record<string, unknown> = {};
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<Uint8Array> {
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 {
Expand All @@ -51,13 +94,7 @@ async function body(request: Request, max = MAX_BODY): Promise<Record<string, un
bytes.set(chunk, offset);
offset += chunk.length;
}
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.");
}
return bytes;
}
const buckets = new Map<string, { count: number; until: number }>();
function limit(key: string, max = 30) {
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions server/test/pastes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}) => {
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" });
Expand Down
18 changes: 12 additions & 6 deletions site/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,16 @@ function pastePage(): string {
"readm3 paste delete https://readm3.com/p/<token>",
]);
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' \\",
Expand All @@ -603,15 +612,12 @@ function pastePage(): string {
"",
"# gone for everyone",
"curl -X DELETE https://readm3.com/api/v1/pastes/<token>",
"",
"# 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/<token> the paste as JSON",
"GET /api/v1/pastes/<token>/raw the text, or the bytes",
" of a PDF or image",
Expand Down
Loading