import { env } from "cloudflare:workers"; import { viewerFromRequest, viewerFromSessionCookie } from "./auth"; import { hasPermission, loadRepo, repoPermission } from "./repos"; import { first, run } from "./db"; import { randomId } from "./crypto"; import { limitBrowse, limitHeavy } from "./limits"; import { HttpError } from "./errors"; import { listRefs, resolveRevision, splitRevisionPath } from "./git/refs"; import { GitStore } from "./git/store"; import { lfsContentFor, lfsImageType } from "./lfs/content"; import { MAX_POINTER_BYTES } from "./lfs/pointer"; import type { Permission, RepoRow, TreeEntry, Viewer } from "~/lib/types"; 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 TAR_BLOCK = 512; const TAR_NAME_BYTES = 100; const TAR_PREFIX_BYTES = 155; const SAFE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/x-icon"]); const encoder = new TextEncoder(); async function viewerFor(request: Request): Promise { return (await viewerFromSessionCookie(request)) ?? (await viewerFromRequest(request)); } async function access(request: Request, owner: string, name: string, needed: Permission): Promise { const viewer = await viewerFor(request); const repo = await loadRepo(owner, decodeURIComponent(name)); if (!repo) return new Response("Not found", { status: 404 }); const permission = await repoPermission(repo, viewer); if (permission === "none") return new Response("Not found", { status: 404 }); if (!hasPermission(permission, needed)) return new Response("Forbidden", { 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"; } 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; const refs = await listRefs(repo); const split = splitRevisionPath(refs, decodeURIComponent(match[3])); if (!split || !split.path) return new Response("Not found", { status: 404 }); const handle = await env.ARTIFACTS.get(repo.artifacts_name); try { const file = await 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": split.revision.kind === "commit" ? "public, max-age=31536000, immutable" : "no-cache", }, }); } finally { handle[Symbol.dispose](); } } function octal(value: number, length: number): string { return value.toString(8).padStart(length - 1, "0") + "\0"; } function writeString(target: Uint8Array, offset: number, length: number, value: string): void { target.set(encoder.encode(value).subarray(0, length), offset); } function tarHeader( path: string, size: number, mode: number, type: "0" | "2" | "5", linkTarget = "", ): Uint8Array | null { let name = path; let prefix = ""; if (encoder.encode(name).byteLength > TAR_NAME_BYTES) { const split = path.lastIndexOf("/", TAR_PREFIX_BYTES); if (split <= 0) return null; prefix = path.slice(0, split); name = path.slice(split + 1); if (encoder.encode(name).byteLength > TAR_NAME_BYTES || encoder.encode(prefix).byteLength > TAR_PREFIX_BYTES) return null; } const header = new Uint8Array(TAR_BLOCK); writeString(header, 0, 100, name); writeString(header, 100, 8, octal(mode, 8)); writeString(header, 108, 8, octal(0, 8)); writeString(header, 116, 8, octal(0, 8)); writeString(header, 124, 12, octal(size, 12)); writeString(header, 136, 12, octal(Math.floor(Date.now() / 1000), 12)); writeString(header, 148, 8, " "); writeString(header, 156, 1, type); writeString(header, 157, 100, linkTarget); writeString(header, 257, 6, "ustar\0"); writeString(header, 263, 2, "00"); writeString(header, 345, 155, prefix); const checksum = header.reduce((sum, byte) => sum + byte, 0); writeString(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `); return header; } 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; const refs = await listRefs(repo); const revision = resolveRevision(refs, decodeURIComponent(match[3])); if (!revision) 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); 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": `attachment; filename="${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 asset = await 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, decodeURIComponent(match[3]), decodeURIComponent(match[4]), ); if (!asset) 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 run("UPDATE release_assets SET download_count = download_count + 1 WHERE id = ?", asset.id); return new Response(object.body, { headers: { "content-type": "application/octet-stream", "content-disposition": `attachment; filename="${asset.name.replace(/"/g, "")}"`, "content-length": String(object.size), }, }); } 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; } } 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-stig-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 release = await 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 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 id = randomId(); 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 } }); await 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(), ); 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 raw = RAW_PATH.exec(url.pathname); if (raw) return serveRaw(request, raw); const archive = ARCHIVE_PATH.exec(url.pathname); if (archive) return serveArchive(request, archive); const asset = ASSET_DOWNLOAD_PATH.exec(url.pathname); if (asset) return serveAsset(request, asset); return null; }