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.

60 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) => Response.json({ ok: false, error: { code, message } } satisfies Answer, { status, headers: { "cache-control": "no-store" } });
21
22export async function action({ request, context }: Route.ActionArgs) {
23 assertSameOrigin(request);
24 // A fetch, not a page: an answer to say, never the sign-in page.
25 const user = getViewer(context);
26 if (!user) return refused("unauthenticated", "Sign in to attach images.", 401);
27 const url = new URL(request.url);
28 const repo = (url.searchParams.get("repo") ?? "").split("/");
29 const workspace = url.searchParams.get("workspace") ?? "";
30 const number = Number(url.searchParams.get("number"));
31 const scope =
32 repo.length === 2 && repo[0] && repo[1]
33 ? { repo: { namespace: repo[0], name: repo[1] } }
34 : workspace && Number.isInteger(number) && number > 0
35 ? { workspace: workspace.toLowerCase(), number }
36 : null;
37 if (!scope) return refused("invalid", "Say which repository or work item it is for.", 400);
38 const length = Number(request.headers.get("content-length") ?? "0");
39 if (length > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
40 const bytes = new Uint8Array(await request.arrayBuffer());
41 if (bytes.byteLength === 0) return refused("invalid", "The file is empty.", 400);
42 if (bytes.byteLength > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
43 // The service reads the type from the bytes again and is the one that decides.
44 if (!sniffImage(bytes)) return refused("unsupported_type", "An attachment must be a PNG, JPEG, GIF or WebP image.", 415);
45 const name = url.searchParams.get("name") || "image.png";
46 try {
47 const kept = await work.uploadAttachment(user, scope, { name, data: toBase64(bytes) });
48 if (!kept.ok) {
49 const status = kept.error.code === "too_large" ? 413 : kept.error.code === "unsupported_type" ? 415 : kept.error.code === "not_found" ? 404 : 403;
50 return refused(kept.error.code, kept.error.message, status);
51 }
52 const at = `${addresses().usercontent}${kept.value.path}`;
53 return Response.json({ ok: true, url: at, markdown: imageMarkdown(kept.value.name, at), name: kept.value.name } satisfies Answer, {
54 headers: { "cache-control": "no-store" },
55 });
56 } catch (error) {
57 console.error("attachments: upload", error);
58 return refused("unavailable", "The image didn't upload. Try again in a moment.", 503);
59 }
60}