import { env, waitUntil } from "cloudflare:workers"; import { handlePatchRoutes } from "./patches"; import { addCounter } from "./counters"; import { safeDecode } from "~/lib/safe-decode"; import { attachmentDisposition } from "~/lib/content-disposition"; import { meteredDownload } from "./metering/git"; import { paymentRequiredResponse, planBlock } from "./metering/quota"; import { openArtifactsRepo } from "./git/artifacts"; import { artifactsCall } from "./git/artifacts-call"; import { hasPermission, loadRepo, loadRepoFor } from "./repos"; import { requestCredential, scopedRepoPermission } from "./token-scope"; import { all } from "./db"; import { repoDb } from "./scoped-db"; import { registerLocator } from "./scoped-db/locators"; import { scopedId } from "./scoped-db/ids"; import { limitBrowse, limitHeavy, numberVar } from "./limits"; import { HttpError } from "./errors"; import { deleteObjects } from "./storage"; import { listRefs, resolveRevision, splitRevisionPath } from "./git/refs"; import { GitStore } from "./git/store"; import { isReachableCommit } from "./git/reachable"; import { lfsContentFor, lfsImageType } from "./lfs/content"; import { TAR_BLOCK, tarHeader } from "./tar"; import { MAX_POINTER_BYTES } from "./lfs/pointer"; import type { Permission, RepoRow, TreeEntry, Viewer } from "~/lib/types"; import { EMAIL_UNVERIFIED_MESSAGE, canWriteAsViewer } from "~/lib/email-verification"; const RAW_PATH = /^\/([^/]+)\/([^/]+)\/raw\/(.+)$/; const ARCHIVE_PATH = /^\/([^/]+)\/([^/]+)\/archive\/(.+)\.(tar\.gz|tgz)$/; const ASSET_DOWNLOAD_PATH = /^\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/([^/]+)$/; const ASSET_UPLOAD_PATH = /^\/([^/]+)\/([^/]+)\/releases\/([a-z0-9]+)\/assets$/; const MAX_ARCHIVE_FILES = 5000; const MAX_ARCHIVE_BYTES = 200 * 1024 * 1024; const MAX_ASSET_BYTES = 2 * 1024 * 1024 * 1024; const MAX_ASSETS_PER_RELEASE = 50; const GIB = 1024 * 1024 * 1024; const HTTP_INSUFFICIENT_STORAGE = 507; const SAFE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/x-icon"]); async function viewerFor(request: Request): Promise { return (await requestCredential(request, true)).viewer; } async function access(request: Request, owner: string, name: string, needed: Permission): Promise { const credential = await requestCredential(request, true); const decodedName = safeDecode(name); if (decodedName === null) return new Response("Bad request", { status: 400 }); const repo = await loadRepoFor(owner, decodedName, credential.viewer); if (!repo) return new Response("Not found", { status: 404 }); const permission = await scopedRepoPermission(repo, credential, "contents"); if (permission === "none") return new Response("Not found", { status: 404 }); if (!hasPermission(permission, needed)) return new Response("Forbidden", { status: 403 }); if (needed !== "read" && credential.viewer && !canWriteAsViewer(credential.viewer)) { return new Response(EMAIL_UNVERIFIED_MESSAGE, { status: 403 }); } return repo; } function contentTypeFor(path: string, sniffed: string): string { if (SAFE_IMAGE_TYPES.has(sniffed)) return sniffed; if (path.endsWith(".pdf")) return "application/pdf"; return sniffed.startsWith("text/") || sniffed === "" ? "text/plain; charset=utf-8" : "application/octet-stream"; } const PUBLIC_RAW_MAX_AGE_SECONDS = 24 * 60 * 60; /** * Commit-pinned content never changes, but a public repo can turn private and shared caches cannot be * purged per viewer, so public responses expire after a day instead of a year (GIT-P7). */ export function rawCacheControl(commitPinned: boolean, visibility: RepoRow["visibility"]): string { if (!commitPinned) return "no-cache"; return visibility === "public" ? `public, max-age=${PUBLIC_RAW_MAX_AGE_SECONDS}` : "private, max-age=31536000, immutable"; } async function serveRaw(request: Request, match: RegExpExecArray): Promise { try { await limitBrowse(await viewerFor(request), request); } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.status }); throw error; } const repo = await access(request, match[1], match[2], "read"); if (repo instanceof Response) return repo; if (repo.status !== "ready") return new Response(`Repository is ${repo.status}`, { status: 409 }); const refs = await listRefs(repo); const rawPath = safeDecode(match[3]); if (rawPath === null) return new Response("Bad request", { status: 400 }); const split = splitRevisionPath(refs, rawPath); if (!split || !split.path) return new Response("Not found", { status: 404 }); if (split.revision.kind === "commit" && !(await isReachableCommit(repo, split.revision.sha))) { return new Response("Not found", { status: 404 }); } const handle = await openArtifactsRepo(repo.artifacts_name); try { const file = await artifactsCall("read a file", `${repo.artifacts_name}:${split.path}`, () => handle.readFile({ ref: split.revision.sha, path: split.path }), ); if (!file) return new Response("Not found", { status: 404 }); let body: ReadableStream; let size = file.size; let type = file.type; if (file.size <= MAX_POINTER_BYTES) { const bytes = new Uint8Array(await file.arrayBuffer()); const lfs = await lfsContentFor(repo, bytes); body = lfs ? lfs.body : new Blob([bytes]).stream(); size = lfs ? lfs.size : bytes.byteLength; if (lfs) type = lfsImageType(split.path); } else { body = file.stream(); } return new Response(body, { headers: { "content-type": contentTypeFor(split.path, type), "content-length": String(size), "content-security-policy": "sandbox; default-src 'none'; img-src 'self'; style-src 'unsafe-inline'", "x-content-type-options": "nosniff", etag: `"${split.revision.sha}:${split.path}"`, "cache-control": rawCacheControl(split.revision.kind === "commit", repo.visibility), vary: "Authorization, Cookie", }, }); } finally { handle[Symbol.dispose](); } } async function lfsArchiveObject(repo: RepoRow, bytes: Uint8Array, total: number): Promise { if (bytes.byteLength > MAX_POINTER_BYTES) return null; const object = await lfsContentFor(repo, bytes); if (object && total + object.size > MAX_ARCHIVE_BYTES) { await object.body.cancel(); throw new Error("Archive exceeds the maximum size; clone the repository instead"); } return object; } async function* tarEntries( repo: RepoRow, store: GitStore, entries: TreeEntry[], root: string, ): AsyncGenerator> { let total = 0; for (const entry of entries) { if (entry.type === "gitlink") continue; const bytes = await store.blob(entry.sha); if (!bytes) continue; const lfs = entry.type === "symlink" ? null : await lfsArchiveObject(repo, bytes, total); if (lfs) { total += lfs.size; const header = tarHeader(`${root}/${entry.path}`, lfs.size, entry.type === "exec" ? 0o755 : 0o644, "0"); if (!header) { await lfs.body.cancel(); continue; } yield header; for await (const chunk of lfs.body) yield new Uint8Array(chunk); const lfsPadding = (TAR_BLOCK - (lfs.size % TAR_BLOCK)) % TAR_BLOCK; if (lfsPadding > 0) yield new Uint8Array(lfsPadding); continue; } total += bytes.byteLength; if (total > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds the maximum size; clone the repository instead"); const path = `${root}/${entry.path}`; const header = entry.type === "symlink" ? tarHeader(path, 0, 0o777, "2", new TextDecoder().decode(bytes)) : tarHeader(path, bytes.byteLength, entry.type === "exec" ? 0o755 : 0o644, "0"); if (!header) continue; yield header; if (entry.type === "symlink") continue; yield bytes; const padding = (TAR_BLOCK - (bytes.byteLength % TAR_BLOCK)) % TAR_BLOCK; if (padding > 0) yield new Uint8Array(padding); } yield new Uint8Array(TAR_BLOCK * 2); } async function serveArchive(request: Request, match: RegExpExecArray): Promise { const limited = await rateLimited(request); if (limited) return limited; const repo = await access(request, match[1], match[2], "read"); if (repo instanceof Response) return repo; if (repo.status !== "ready") return new Response(`Repository is ${repo.status}`, { status: 409 }); const refs = await listRefs(repo); const archiveRevision = safeDecode(match[3]); if (archiveRevision === null) return new Response("Bad request", { status: 400 }); const revision = resolveRevision(refs, archiveRevision); if (!revision) return new Response("Not found", { status: 404 }); if (revision.kind === "commit" && !(await isReachableCommit(repo, revision.sha))) { return new Response("Not found", { status: 404 }); } const store = await GitStore.open(repo.artifacts_name); const commit = await store.commit(revision.sha); if (!commit) { store.close(); return new Response("Not found", { status: 404 }); } const { entries, truncated } = await store.walk(commit.treeSha, MAX_ARCHIVE_FILES, { withContents: true }); if (truncated) { store.close(); return new Response(`Archives are limited to ${MAX_ARCHIVE_FILES} files; clone the repository instead`, { status: 413, }); } const root = `${repo.name}-${revision.name.replace(/\//g, "-")}`; const iterator = tarEntries(repo, store, entries, root); const tar = new ReadableStream>({ async pull(controller) { let next: IteratorResult>; try { next = await iterator.next(); } catch (error) { store.close(); controller.error(error); return; } if (next.done) { store.close(); controller.close(); } else { controller.enqueue(next.value); } }, cancel() { store.close(); }, }); return new Response(tar.pipeThrough(new CompressionStream("gzip")), { headers: { "content-type": "application/gzip", "content-disposition": attachmentDisposition(`${root}.tar.gz`), }, }); } async function serveAsset(request: Request, match: RegExpExecArray): Promise { const repo = await access(request, match[1], match[2], "read"); if (repo instanceof Response) return repo; const tag = safeDecode(match[3]); const assetName = safeDecode(match[4]); if (tag === null || assetName === null) return new Response("Bad request", { status: 400 }); const asset = await repoDb(repo.id).first<{ id: string; r2_key: string; content_type: string; name: string; draft: number; }>( `SELECT a.id, a.r2_key, a.content_type, a.name, r.draft FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE r.repo_id = ? AND r.tag = ? AND a.name = ?`, repo.id, tag, assetName, ); if (!asset) return new Response("Not found", { status: 404 }); if (asset.draft === 1 && (await access(request, match[1], match[2], "write")) instanceof Response) { return new Response("Not found", { status: 404 }); } const object = await env.BUCKET.get(asset.r2_key); if (!object) return new Response("Asset missing", { status: 410 }); await addCounter("release-downloads", repo.id, asset.id, [1, 0, 0]); return new Response(object.body, { headers: { "content-type": "application/octet-stream", "content-disposition": attachmentDisposition(asset.name), "content-length": String(object.size), "x-content-type-options": "nosniff", }, }); } async function rateLimited(request: Request): Promise { try { await limitHeavy(await viewerFor(request), request); return null; } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.status }); throw error; } } /** Release assets count against a per-owner byte budget like LFS and attachments do (GIT-A7). */ async function assetQuotaProblem(ownerId: string, incoming: number, replacing: number): Promise { const limit = numberVar(env.RELEASE_ASSET_BYTES_PER_OWNER, 10 * GIB); const repos = await all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", ownerId); const totals = await Promise.all( repos.map(async (repo) => { const row = await repoDb(repo.id).first<{ total: number | null }>( "SELECT SUM(size) AS total FROM release_assets", ); return row?.total ?? 0; }), ); const used = totals.reduce((sum, total) => sum + total, 0); if (used - replacing + incoming > limit) return "Release asset storage quota exceeded for this owner"; return null; } async function uploadAsset(request: Request, match: RegExpExecArray): Promise { if (request.method !== "POST") return new Response("Method not allowed", { status: 405 }); if (request.headers.get("x-gitfor-csrf") !== "1" && !request.headers.get("authorization")) { return new Response("Missing CSRF header", { status: 403 }); } const repo = await access(request, match[1], match[2], "write"); if (repo instanceof Response) return repo; const db = repoDb(repo.id); const release = await db.first<{ id: string }>( "SELECT id FROM releases WHERE id = ? AND repo_id = ?", match[3], repo.id, ); if (!release) return new Response("Release not found", { status: 404 }); const limited = await rateLimited(request); if (limited) return limited; const assets = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM release_assets WHERE release_id = ?", release.id, ); if ((assets?.count ?? 0) >= MAX_ASSETS_PER_RELEASE) { return new Response(`At most ${MAX_ASSETS_PER_RELEASE} assets per release`, { status: 429 }); } const name = (new URL(request.url).searchParams.get("name") ?? "").replace(/[^A-Za-z0-9._+-]/g, "_"); const size = Number(request.headers.get("content-length") ?? "0"); if (!name || !request.body || !Number.isFinite(size) || size <= 0 || size > MAX_ASSET_BYTES) { return new Response("A name and a non-empty body with content-length are required", { status: 400 }); } const previous = await db.first<{ r2_key: string; size: number }>( "SELECT r2_key, size FROM release_assets WHERE release_id = ? AND name = ?", release.id, name, ); const overQuota = await assetQuotaProblem(repo.owner_id, size, previous?.size ?? 0); if (overQuota) return new Response(overQuota, { status: HTTP_INSUFFICIENT_STORAGE }); const overPlan = await planBlock(repo.owner_id, "storage.r2", Math.max(0, size - (previous?.size ?? 0))); if (overPlan) return await paymentRequiredResponse(overPlan, (await requestCredential(request, true)).viewer?.id ?? null); const id = scopedId(repo.id); const key = `releases/${repo.id}/${release.id}/${id}`; const contentType = request.headers.get("content-type") ?? "application/octet-stream"; await env.BUCKET.put(key, request.body, { httpMetadata: { contentType } }); if (previous && previous.r2_key !== key) await env.BUCKET.delete(previous.r2_key); await db.run( `INSERT INTO release_assets (id, release_id, name, size, content_type, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (release_id, name) DO UPDATE SET size = excluded.size, r2_key = excluded.r2_key, content_type = excluded.content_type`, id, release.id, name, size, contentType, key, Date.now(), ); if (!previous) await registerLocator("release_asset", id, repo.id); // The replaced object is no longer referenced by any row; delete it rather than orphan it in R2. if (previous && previous.r2_key !== key) { waitUntil(deleteObjects([previous.r2_key]).catch((error: unknown) => console.warn("asset cleanup failed", error))); } return Response.json({ id, name, size }); } export async function handleRawRoutes(request: Request, url: URL): Promise { const upload = ASSET_UPLOAD_PATH.exec(url.pathname); if (upload) return uploadAsset(request, upload); if (request.method !== "GET" && request.method !== "HEAD") return null; const metered = (response: Response, kind: "raw" | "archive" | "asset", match: RegExpExecArray) => meteredDownload(response, kind, () => loadRepo(match[1], safeDecode(match[2]) ?? ""), waitUntil); const patch = await handlePatchRoutes( request, url.pathname, (req, owner, name) => access(req, owner, name, "read"), () => requestCredential(request, true), ); if (patch) return patch; const raw = RAW_PATH.exec(url.pathname); if (raw) return metered(await serveRaw(request, raw), "raw", raw); const archive = ARCHIVE_PATH.exec(url.pathname); if (archive) return metered(await serveArchive(request, archive), "archive", archive); const asset = ASSET_DOWNLOAD_PATH.exec(url.pathname); if (asset) return metered(await serveAsset(request, asset), "asset", asset); return null; }