import { env } from "cloudflare:workers"; import { viewerFromRequest } from "../auth"; import { randomToken, sha256Hex } from "../crypto"; import { HttpError } from "../errors"; import { callerKey, enforceRateLimit, limitWrite } from "../limits"; import { hasPermission, loadRepo, repoPermission } from "../repos"; import { clampLimit, createLock, deleteLock, listLocks, lockJson } from "./locks"; import { findObject, findObjects, openObject, storeObject } from "./objects"; import { isValidOid } from "./pointer"; import { assertStorageAvailable, chargeBandwidth, maxObjectBytes } from "./quota"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; const LFS_PATH = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/info\/lfs\/(.+)$/; const OBJECT_PATH = /^objects\/([0-9a-f]{64})(\/verify)?$/; const UNLOCK_PATH = /^locks\/([a-z0-9]+)\/unlock$/; const LFS_JSON = "application/vnd.git-lfs+json"; const MAX_JSON_BYTES = 1024 * 1024; const MAX_BATCH_OBJECTS = 100; const TRANSFER_TTL_SECONDS = 3600; const TRANSFER_HEADER = "x-stig-lfs-transfer"; const HTTP_UNAUTHORIZED = 401; const BASIC_CHALLENGE = 'Basic realm="stig"'; type LfsRoute = { owner: string; repo: string; rest: string }; type Operation = "upload" | "download"; type TransferGrant = { repoId: string; op: Operation; objects: Record }; type Caller = { viewer: Viewer | null; permission: Permission; repo: RepoRow }; type BatchObject = { oid: string; size: number }; export function matchLfsRoute(url: URL): LfsRoute | null { const match = LFS_PATH.exec(url.pathname); return match ? { owner: match[1], repo: match[2], rest: match[3] } : null; } function lfsJson(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": LFS_JSON } }); } function lfsError(status: number, message: string): Response { const headers = new Headers({ "content-type": LFS_JSON }); if (status === HTTP_UNAUTHORIZED) { headers.set("lfs-authenticate", BASIC_CHALLENGE); headers.set("www-authenticate", BASIC_CHALLENGE); } return new Response(JSON.stringify({ message }), { status, headers }); } async function readJson(request: Request): Promise> { const length = Number(request.headers.get("content-length") ?? "0"); if (length > MAX_JSON_BYTES) throw new HttpError(413, "Request body too large"); const text = await request.text(); if (text.length > MAX_JSON_BYTES) throw new HttpError(413, "Request body too large"); if (!text) return {}; let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new HttpError(400, "Invalid JSON"); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "Expected object"); return Object.fromEntries(Object.entries(parsed)); } function stringField(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function refName(body: Record): string | null { const ref = body.ref; if (typeof ref !== "object" || ref === null || !("name" in ref)) return null; return typeof ref.name === "string" ? ref.name : null; } async function authorize(request: Request, route: LfsRoute, needed: Permission): Promise { const viewer = await viewerFromRequest(request); const repo = await loadRepo(route.owner, decodeURIComponent(route.repo)); if (!repo) throw new HttpError(viewer ? 404 : 401, viewer ? "Repository not found" : "Authentication required"); const permission = await repoPermission(repo, viewer); if (!hasPermission(permission, needed)) { if (!viewer) throw new HttpError(401, "Authentication required"); throw new HttpError( permission === "none" ? 404 : 403, permission === "none" ? "Repository not found" : "Permission denied", ); } await enforceRateLimit("GIT_LIMITER", `lfs:${callerKey(viewer, request)}`); if (repo.status !== "ready") throw new HttpError(503, `Repository is ${repo.status}`); if (needed !== "read" && repo.is_archived === 1) throw new HttpError(403, "Repository is archived and read-only"); return { viewer, permission, repo }; } function parseObjects(value: unknown): BatchObject[] { if (!Array.isArray(value)) throw new HttpError(422, "objects must be an array"); if (value.length > MAX_BATCH_OBJECTS) throw new HttpError(413, `At most ${MAX_BATCH_OBJECTS} objects per batch`); const seen = new Map(); for (const item of value) { if (typeof item !== "object" || item === null || !("oid" in item) || !("size" in item)) continue; const { oid, size } = item; if (typeof oid !== "string" || typeof size !== "number") continue; seen.set(oid, { oid, size }); } return [...seen.values()]; } async function issueGrant(grant: TransferGrant): Promise { const token = randomToken("lfsx"); await env.KV.put(`lfs:xfer:${await sha256Hex(token)}`, JSON.stringify(grant), { expirationTtl: TRANSFER_TTL_SECONDS, }); return token; } async function readGrant(request: Request): Promise { const token = request.headers.get(TRANSFER_HEADER); const grant = token ? await env.KV.get(`lfs:xfer:${await sha256Hex(token)}`, "json") : null; if (!grant) throw new HttpError(401, "Transfer token is missing or expired"); return grant; } function objectError(object: BatchObject, code: number, message: string) { return { oid: object.oid, size: object.size, error: { code, message } }; } async function handleBatch(request: Request, route: LfsRoute): Promise { if (request.method !== "POST") return lfsError(405, "Method not allowed"); const body = await readJson(request); const operation = body.operation; if (operation !== "upload" && operation !== "download") throw new HttpError(422, "Unknown operation"); const transfers = Array.isArray(body.transfers) ? body.transfers : ["basic"]; if (!transfers.includes("basic")) throw new HttpError(422, "Only the basic transfer adapter is supported"); const { repo } = await authorize(request, route, operation === "upload" ? "write" : "read"); const objects = parseObjects(body.objects); const limit = maxObjectBytes(); const valid = objects.filter( (object) => isValidOid(object.oid) && Number.isSafeInteger(object.size) && object.size >= 0, ); const existing = await findObjects( repo, valid.map((object) => object.oid), ); const granted: Record = {}; for (const object of valid) { const found = existing.get(object.oid); if (operation === "download" && found) granted[object.oid] = found.size; if (operation === "upload" && !found && object.size <= limit) granted[object.oid] = object.size; } const grantedBytes = Object.values(granted).reduce((sum, size) => sum + size, 0); if (operation === "upload") await assertStorageAvailable(repo.owner_id, grantedBytes); else await chargeBandwidth(repo.owner_id, grantedBytes); const token = Object.keys(granted).length > 0 ? await issueGrant({ repoId: repo.id, op: operation, objects: granted }) : null; const base = `${new URL(request.url).origin}/${route.owner}/${route.repo}.git/info/lfs/objects`; const responses = objects.map((object) => { if (!isValidOid(object.oid)) return objectError(object, 422, "Invalid oid"); if (!Number.isSafeInteger(object.size) || object.size < 0) return objectError(object, 422, "Invalid size"); const found = existing.get(object.oid); if (operation === "download" && !found) return objectError(object, 404, "Object does not exist"); if (operation === "upload" && !found && object.size > limit) { return objectError(object, 422, `Objects may be at most ${limit} bytes`); } const size = found?.size ?? object.size; if (!token || !(object.oid in granted)) return { oid: object.oid, size, authenticated: true }; const href = `${base}/${object.oid}`; const action = { href, header: { [TRANSFER_HEADER]: token }, expires_in: TRANSFER_TTL_SECONDS }; const actions = operation === "download" ? { download: action } : { upload: action, verify: { ...action, href: `${href}/verify` } }; return { oid: object.oid, size, authenticated: true, actions }; }); return lfsJson({ transfer: "basic", objects: responses, hash_algo: "sha256" }); } async function handleObject(request: Request, route: LfsRoute, oid: string, verify: boolean): Promise { const grant = await readGrant(request); const repo = await loadRepo(route.owner, decodeURIComponent(route.repo)); if (!repo || repo.id !== grant.repoId) throw new HttpError(404, "Repository not found"); const size = grant.objects[oid]; if (size === undefined) throw new HttpError(403, "Transfer token does not cover this object"); await enforceRateLimit("GIT_LIMITER", `lfs:xfer:${grant.repoId}`); if (verify) { if (request.method !== "POST") return lfsError(405, "Method not allowed"); const found = await findObject(repo, oid); return found && found.size === size ? lfsJson({ oid, size }) : lfsError(404, "Object not found"); } if (grant.op === "download") { if (request.method !== "GET") return lfsError(405, "Method not allowed"); const object = await openObject(repo, oid); if (!object) return lfsError(404, "Object not found"); return new Response(object.body, { headers: { "content-type": "application/octet-stream", "content-length": String(object.size) }, }); } if (request.method !== "PUT") return lfsError(405, "Method not allowed"); if (!request.body) throw new HttpError(400, "Missing body"); const length = request.headers.get("content-length"); if (length !== null && Number(length) !== size) throw new HttpError(422, "content-length does not match size"); if (await findObject(repo, oid)) { await request.body.cancel(); return new Response(null, { status: 200 }); } await assertStorageAvailable(repo.owner_id, size); await storeObject(repo, oid, size, request.body); return new Response(null, { status: 200 }); } function requireViewer(caller: Caller): Viewer { if (!caller.viewer) throw new HttpError(401, "Authentication required"); return caller.viewer; } async function handleLocks(request: Request, route: LfsRoute, url: URL): Promise { const rest = route.rest; if (rest === "locks" && request.method === "GET") { const { repo } = await authorize(request, route, "read"); const page = await listLocks(repo.id, { path: stringField(url.searchParams.get("path")), id: stringField(url.searchParams.get("id")), cursor: stringField(url.searchParams.get("cursor")), limit: clampLimit(url.searchParams.get("limit")), }); return lfsJson({ locks: page.rows.map(lockJson), next_cursor: page.nextCursor ?? undefined }); } if (request.method !== "POST") return lfsError(405, "Method not allowed"); const caller = await authorize(request, route, "write"); const viewer = requireViewer(caller); const body = await readJson(request); if (rest === "locks") { await limitWrite(viewer); const path = stringField(body.path); if (!path) throw new HttpError(422, "path is required"); const { lock, created } = await createLock(caller.repo.id, viewer.id, path, refName(body)); if (!created) return lfsJson({ lock: lockJson(lock), message: "already created lock" }, 409); return lfsJson({ lock: lockJson(lock) }, 201); } if (rest === "locks/verify") { const page = await listLocks(caller.repo.id, { cursor: stringField(body.cursor), limit: clampLimit(body.limit), }); const ours = page.rows.filter((row) => row.owner_id === viewer.id).map(lockJson); const theirs = page.rows.filter((row) => row.owner_id !== viewer.id).map(lockJson); return lfsJson({ ours, theirs, next_cursor: page.nextCursor ?? undefined }); } const unlock = UNLOCK_PATH.exec(rest); if (unlock) { await limitWrite(viewer); const lock = await deleteLock(caller.repo.id, unlock[1], viewer.id, { force: body.force === true, isAdmin: hasPermission(caller.permission, "admin"), }); return lfsJson({ lock: lockJson(lock) }); } return lfsError(404, "Not found"); } /** Git LFS Batch, basic transfer and locking APIs, served beside the git smart-HTTP proxy. */ export async function handleLfsRequest(request: Request, route: LfsRoute): Promise { const url = new URL(request.url); try { if (route.rest === "objects/batch") return await handleBatch(request, route); const object = OBJECT_PATH.exec(route.rest); if (object) return await handleObject(request, route, object[1], object[2] !== undefined); if (route.rest === "locks" || route.rest.startsWith("locks/")) return await handleLocks(request, route, url); return lfsError(404, "Not found"); } catch (error) { if (error instanceof HttpError) return lfsError(error.status, error.message); throw error; } }