Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

61 linesCodeBlameRaw
1/**
2 * An image pasted, dropped or chosen in a comment box (components/attach-images.tsx):
3 * `POST /-/attachments?repo=<owner>/<name>&name=<file name>`, or
4 * `?workspace=<slug>&number=<n>` for a work item, with the file as the
5 * body. The work service checks the viewer may comment there and keeps it
6 * (`upload_attachment`); the answer is its address on the usercontent
7 * origin, where it is served, never on the site. An attachment's address is
8 * 256 random bits, so a private repository's is seen only by whoever is
9 * given its Markdown.
10 */
11import type { Route } from "./+types/attachments";
12import { addresses } from "../lib/addresses.server";
13import { MAX_ATTACHMENT_BYTES, imageMarkdown } from "../lib/attachments";
14import { sniffImage, toBase64 } from "../lib/avatar-upload";
15import { work } from "../lib/services.server";
16import { assertSameOrigin, getViewer } from "../lib/session.server";
17
18type Answer = { ok: true; url: string; markdown: string; name: string } | { ok: false; error: { code: string; message: string } };
19
20const refused = (code: string, message: string, status: number) =>
21 Response.json({ ok: false, error: { code, message } } satisfies Answer, { status, headers: { "cache-control": "no-store" } });
22
23export async function action({ request, context }: Route.ActionArgs) {
24 assertSameOrigin(request);
25 // A fetch, not a page: an answer to say, never the sign-in page.
26 const user = getViewer(context);
27 if (!user) return refused("unauthenticated", "Sign in to attach images.", 401);
28 const url = new URL(request.url);
29 const repo = (url.searchParams.get("repo") ?? "").split("/");
30 const workspace = url.searchParams.get("workspace") ?? "";
31 const number = Number(url.searchParams.get("number"));
32 const scope =
33 repo.length === 2 && repo[0] && repo[1]
34 ? { repo: { namespace: repo[0], name: repo[1] } }
35 : workspace && Number.isInteger(number) && number > 0
36 ? { workspace: workspace.toLowerCase(), number }
37 : null;
38 if (!scope) return refused("invalid", "Say which repository or work item it is for.", 400);
39 const length = Number(request.headers.get("content-length") ?? "0");
40 if (length > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
41 const bytes = new Uint8Array(await request.arrayBuffer());
42 if (bytes.byteLength === 0) return refused("invalid", "The file is empty.", 400);
43 if (bytes.byteLength > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
44 // The service reads the type from the bytes again and is the one that decides.
45 if (!sniffImage(bytes)) return refused("unsupported_type", "An attachment must be a PNG, JPEG, GIF or WebP image.", 415);
46 const name = url.searchParams.get("name") || "image.png";
47 try {
48 const kept = await work.uploadAttachment(user, scope, { name, data: toBase64(bytes) });
49 if (!kept.ok) {
50 const status = kept.error.code === "too_large" ? 413 : kept.error.code === "unsupported_type" ? 415 : kept.error.code === "not_found" ? 404 : 403;
51 return refused(kept.error.code, kept.error.message, status);
52 }
53 const at = `${addresses().usercontent}${kept.value.path}`;
54 return Response.json({ ok: true, url: at, markdown: imageMarkdown(kept.value.name, at), name: kept.value.name } satisfies Answer, {
55 headers: { "cache-control": "no-store" },
56 });
57 } catch (error) {
58 console.error("attachments: upload", error);
59 return refused("unavailable", "The image didn't upload. Try again in a moment.", 503);
60 }
61}