import { env } from "cloudflare:workers"; import { viewerFromSessionCookie } from "./auth"; import { requestCredential } from "./token-scope"; import { loadRepo, loadRepoById, repoPermission } from "./repos"; import { first, run } from "./db"; import { randomId } from "./crypto"; import { limitBrowse, limitWrite } from "./limits"; import { HttpError } from "./errors"; import type { Viewer } from "~/lib/types"; const UPLOAD_PATH = /^\/([^/]+)\/([^/]+)\/attachments$/; const SERVE_PATH = /^\/attachments\/([a-z0-9]+)\/([^/]+)$/; const MB = 1024 * 1024; const MAX_IMAGE_BYTES = 10 * MB; const MAX_FILE_BYTES = 25 * MB; const OWNER_QUOTA_BYTES = 1024 * MB; const UPLOADER_QUOTA_BYTES = 250 * MB; const WIDTH_BUCKETS = [320, 640, 1280, 2560]; const MAX_IMAGE_WIDTH = WIDTH_BUCKETS[WIDTH_BUCKETS.length - 1]; const HTTP_FORBIDDEN = 403; const HTTP_TOO_LARGE = 413; const HTTP_UNSUPPORTED = 415; const TRANSFORMABLE_IMAGES = new Set(["image/png", "image/jpeg", "image/webp", "image/avif"]); const ALLOWED_TYPES = new Set([ ...TRANSFORMABLE_IMAGES, "image/gif", "video/mp4", "video/webm", "application/pdf", "application/zip", "application/gzip", "application/json", "text/plain", "text/csv", "text/markdown", ]); async function viewerFor(request: Request): Promise { return (await viewerFromSessionCookie(request)) ?? (await requestCredential(request, false)).viewer; } function fail(status: number, message: string): Response { return Response.json({ error: message }, { status }); } function baseType(header: string | null): string { return (header ?? "").split(";")[0].trim().toLowerCase(); } /** Per-owner and per-uploader byte quotas, so readers of a public repo cannot exhaust its owner's storage. */ async function quotaProblem(ownerId: string, uploaderId: string, incoming: number): Promise { const used = await first<{ owner_total: number | null; uploader_total: number | null }>( `SELECT (SELECT SUM(size) FROM attachments WHERE owner_id = ?1) AS owner_total, (SELECT SUM(size) FROM attachments WHERE uploader_id = ?2) AS uploader_total`, ownerId, uploaderId, ); if ((used?.uploader_total ?? 0) + incoming > UPLOADER_QUOTA_BYTES) return "Your attachment storage quota is used up"; if ((used?.owner_total ?? 0) + incoming > OWNER_QUOTA_BYTES) return "The attachment storage quota for this owner is used up"; return null; } function widthBucket(requested: number): number { return WIDTH_BUCKETS.find((bucket) => bucket >= requested) ?? MAX_IMAGE_WIDTH; } async function upload(request: Request, match: RegExpExecArray): Promise { if (request.method !== "POST") return fail(405, "Method not allowed"); if (request.headers.get("x-gitfor-csrf") !== "1" && !request.headers.get("authorization")) return fail(HTTP_FORBIDDEN, "Missing CSRF header"); const viewer = await viewerFor(request); if (!viewer) return fail(401, "Sign in to upload files"); await limitWrite(viewer); const repo = await loadRepo(match[1], decodeURIComponent(match[2])); if (!repo || (await repoPermission(repo, viewer)) === "none") return fail(404, "Not found"); if (repo.is_archived === 1) return fail(HTTP_FORBIDDEN, "This repository is archived"); const contentType = baseType(request.headers.get("content-type")); if (!ALLOWED_TYPES.has(contentType)) return fail(HTTP_UNSUPPORTED, `Files of type ${contentType} are not allowed`); const size = Number(request.headers.get("content-length") ?? "0"); const cap = contentType.startsWith("image/") ? MAX_IMAGE_BYTES : MAX_FILE_BYTES; if (!request.body || !Number.isFinite(size) || size <= 0) return fail(400, "A non-empty body is required"); if (size > cap) return fail(HTTP_TOO_LARGE, `Files of this type are limited to ${cap / MB} MB`); const overQuota = await quotaProblem(repo.owner_id, viewer.id, size); if (overQuota) return fail(HTTP_TOO_LARGE, overQuota); const rawName = new URL(request.url).searchParams.get("name") ?? "file"; const name = rawName.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100) || "file"; const id = randomId(); const key = `attachments/${repo.owner_id}/${id}`; const stored = await env.BUCKET.put(key, request.body.pipeThrough(new FixedLengthStream(size)), { httpMetadata: { contentType }, }); await run( `INSERT INTO attachments (id, owner_id, repo_id, uploader_id, name, content_type, size, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.owner_id, repo.id, viewer.id, name, contentType, stored.size, key, Date.now(), ); if (await quotaProblem(repo.owner_id, viewer.id, 0)) { await Promise.all([run("DELETE FROM attachments WHERE id = ?", id), env.BUCKET.delete(key)]); return fail(HTTP_TOO_LARGE, "Attachment storage quota exceeded"); } const url = `/attachments/${id}/${encodeURIComponent(name)}`; const markdown = contentType.startsWith("image/") ? `![${name}](${url})` : `[${name}](${url})`; return Response.json({ id, url, markdown }); } async function serve(request: Request, match: RegExpExecArray): Promise { const viewer = await viewerFor(request); await limitBrowse(viewer, request); const attachment = await first<{ repo_id: string; r2_key: string; content_type: string; name: string }>( "SELECT repo_id, r2_key, content_type, name FROM attachments WHERE id = ?", match[1], ); if (!attachment) return new Response("Not found", { status: 404 }); const repo = await loadRepoById(attachment.repo_id); if (!repo || (await repoPermission(repo, viewer)) === "none") return new Response("Not found", { status: 404 }); const object = await env.BUCKET.get(attachment.r2_key); if (!object) return new Response("Attachment missing", { status: 410 }); const cache = repo.visibility === "public" ? "public, max-age=3600" : "private, max-age=3600"; const headers = { "content-security-policy": "sandbox; default-src 'none'", "x-content-type-options": "nosniff", "cache-control": cache, }; if (TRANSFORMABLE_IMAGES.has(attachment.content_type)) { const requested = Number(new URL(request.url).searchParams.get("w") ?? MAX_IMAGE_WIDTH); const width = widthBucket(Number.isFinite(requested) && requested > 0 ? requested : MAX_IMAGE_WIDTH); const result = await env.IMAGES.input(object.body) .transform({ width, fit: "scale-down" }) .output({ format: "image/webp" }); return result.response({ headers }); } const inline = attachment.content_type.startsWith("image/") || attachment.content_type.startsWith("video/"); return new Response(object.body, { headers: { ...headers, "content-type": attachment.content_type, "content-length": String(object.size), "content-disposition": `${inline ? "inline" : "attachment"}; filename="${attachment.name}"`, }, }); } /** Markdown attachment upload (`POST ///attachments`) and download (`GET /attachments//`). */ export async function handleAttachmentRoutes(request: Request, url: URL): Promise { const uploadMatch = UPLOAD_PATH.exec(url.pathname); try { if (uploadMatch) return await upload(request, uploadMatch); const serveMatch = SERVE_PATH.exec(url.pathname); if (serveMatch && (request.method === "GET" || request.method === "HEAD")) return await serve(request, serveMatch); } catch (error) { if (error instanceof HttpError) return fail(error.status, error.message); throw error; } return null; }