| 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 | */ |
| 11 | import type { Route } from "./+types/attachments"; |
| 12 | import { addresses } from "../lib/addresses.server"; |
| 13 | import { MAX_ATTACHMENT_BYTES, imageMarkdown } from "../lib/attachments"; |
| 14 | import { sniffImage, toBase64 } from "../lib/avatar-upload"; |
| 15 | import { work } from "../lib/services.server"; |
| 16 | import { assertSameOrigin, getViewer } from "../lib/session.server"; |
| 17 | |
| 18 | type Answer = { ok: true; url: string; markdown: string; name: string } | { ok: false; error: { code: string; message: string } }; |
| 19 | |
| 20 | const refused = (code: string, message: string, status: number) => |
| 21 | Response.json({ ok: false, error: { code, message } } satisfies Answer, { status, headers: { "cache-control": "no-store" } }); |
| 22 | |
| 23 | export 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 | } |