import { env } from "cloudflare:workers"; import { safeDecode } from "~/lib/safe-decode"; import { requestCredential, scopedRepoPermission } from "../token-scope"; import type { RequestCredential, TokenGrant } from "../token-scope"; import { loadActiveUserById, toViewer } from "../auth"; import { accountDb, repoDb } from "../scoped-db"; import { randomToken, sha256Hex } from "../crypto"; import { HttpError } from "../errors"; import { callerKey, enforceRateLimit, limitWrite, requestIp } from "../limits"; import { assertVerifiedForWrites } from "../verified-writes"; import { hasPermission, loadRepo, loadRepoFor, repoReadOnlyReason } from "../repos"; import { clampLimit, createLock, deleteLock, listLocks, lockJson } from "./locks"; import { findObject, findObjects, linkObjects, openObject, storeObject } from "./objects"; import { isValidOid } from "./pointer"; import { assertStorageAvailable, billableBytes, 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 = 600; const TRANSFER_HEADER = "x-gitfor-lfs-transfer"; const HTTP_UNAUTHORIZED = 401; const BASIC_CHALLENGE = 'Basic realm="gitfor"'; type LfsRoute = { owner: string; repo: string; rest: string }; type Operation = "upload" | "download"; /** * Who the grant was issued to: the user id plus the token's scope descriptor (never token or password * material). Re-resolved on every transfer, so removed access stops working before the grant expires. */ type GrantIdentity = { userId: string | null; grant: TokenGrant | null }; type TransferGrant = { repoId: string; op: Operation; objects: Record; identity: GrantIdentity }; type Caller = { viewer: Viewer | null; permission: Permission; repo: RepoRow; credential: RequestCredential }; 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 credential = await requestCredential(request, false); const viewer = credential.viewer; const repoName = safeDecode(route.repo); if (repoName === null) throw new HttpError(400, "Malformed repository name"); const repo = await loadRepoFor(route.owner, repoName, viewer); if (!repo) throw new HttpError(viewer ? 404 : 401, viewer ? "Repository not found" : "Authentication required"); const permission = await scopedRepoPermission(repo, credential, "contents"); 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}`); const readOnly = needed === "read" ? null : repoReadOnlyReason(repo); if (readOnly) throw new HttpError(403, readOnly); return { viewer, permission, repo, credential }; } 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; } const REVOKED: RequestCredential = { viewer: null, grant: null }; /** Whether the token row behind a grant still exists, is unexpired and still belongs to the same principal. */ async function tokenStillValid(grant: TokenGrant, userId: string | null): Promise { if (grant.kind !== "deploy" && !userId) return false; const row = grant.kind === "deploy" ? await repoDb(grant.repoId).first<{ owner: string; expires_at: number | null }>( "SELECT repo_id AS owner, expires_at FROM deploy_tokens WHERE id = ?", grant.tokenId, ) : await accountDb(userId ?? "").first<{ owner: string; expires_at: number | null }>( "SELECT user_id AS owner, expires_at FROM access_tokens WHERE id = ?", grant.tokenId, ); if (!row || (row.expires_at !== null && row.expires_at < Date.now())) return false; return row.owner === (grant.kind === "deploy" ? grant.repoId : userId); } /** Rebuilds the credential from current rows; a deleted user or a revoked/expired token (any kind) yields none. */ function transferCallerKey(identity: GrantIdentity | undefined, request: Request): string { if (identity?.grant) return `token:${identity.grant.tokenId}`; if (identity?.userId) return `user:${identity.userId}`; return `ip:${requestIp(request)}`; } async function resolveIdentity(identity: GrantIdentity): Promise { const user = identity.userId ? await loadActiveUserById(identity.userId) : null; if (identity.userId && !user) return REVOKED; const grant = identity.grant; if (grant && !(await tokenStillValid(grant, identity.userId))) return REVOKED; return { viewer: user ? toViewer(user) : null, 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, credential } = await authorize(request, route, operation === "upload" ? "write" : "read"); if (operation === "upload" && credential.viewer) assertVerifiedForWrites(credential.viewer); 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 grantedObjects = Object.entries(granted).map(([oid, size]) => ({ oid, size })); if (operation === "upload") { const inherited = valid .map((object) => ({ oid: object.oid, found: existing.get(object.oid) })) .filter((entry) => entry.found && entry.found.repo_id !== repo.id) .map((entry) => ({ oid: entry.oid, size: entry.found?.size ?? 0 })); await assertStorageAvailable(repo.owner_id, await billableBytes(repo.owner_id, [...grantedObjects, ...inherited])); await linkObjects(repo, inherited); } else { await chargeBandwidth( repo.owner_id, grantedObjects.reduce((sum, object) => sum + object.size, 0), repo.id, ); } const identity = { userId: credential.viewer?.id ?? null, grant: credential.grant }; const token = grantedObjects.length > 0 ? await issueGrant({ repoId: repo.id, op: operation, objects: granted, identity }) : 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 repoName = safeDecode(route.repo); if (repoName === null) throw new HttpError(400, "Malformed repository name"); const repo = await loadRepo(route.owner, repoName); 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"); const permission = await scopedRepoPermission( repo, await resolveIdentity(grant.identity ?? { userId: null, grant: null }), "contents", ); if (!hasPermission(permission, grant.op === "upload" ? "write" : "read")) { throw new HttpError(403, "Access to this repository was revoked"); } const readOnly = grant.op === "upload" ? repoReadOnlyReason(repo) : null; if (readOnly) throw new HttpError(403, readOnly); // Per caller (user, token or IP) and repo: one downloader cannot exhaust a public repo's transfer budget. await enforceRateLimit("GIT_LIMITER", `lfs:xfer:${grant.repoId}:${transferCallerKey(grant.identity, request)}`); 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"); const found = await findObject(repo, oid); if (found) { await request.body.cancel(); if (found.repo_id !== repo.id) await linkObjects(repo, [{ oid, size: found.size }]); return new Response(null, { status: 200 }); } await assertStorageAvailable(repo.owner_id, await billableBytes(repo.owner_id, [{ oid, 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; } }