import "server-only"; import { Readable } from "node:stream"; import { createGunzip } from "node:zlib"; import { onRefsUpdated, startWorkflowsInBackground } from "./ci"; import { normalizeRepoName, snapshotRefs, spawnService, type GitService } from "./git"; import { canWrite, findRepo, markPushed, repoAccess, type RepoRecord } from "./repos"; import { authenticateJobToken } from "./runs"; import { authenticateToken, tokenCovers } from "./tokens"; function pktLine(s: string) { return (s.length + 4).toString(16).padStart(4, "0") + s; } function noCache(headers: Record) { return { ...headers, "Cache-Control": "no-cache, max-age=0, must-revalidate", Pragma: "no-cache", Expires: "Fri, 01 Jan 1980 00:00:00 GMT", }; } const unauthorized = (message: string) => new Response(`${message}\n`, { status: 401, headers: { "WWW-Authenticate": 'Basic realm="Vectrum"', "Content-Type": "text/plain" }, }); const notFound = () => new Response("Repository not found.\n", { status: 404 }); /** * Credentials are HTTP Basic auth with an API token as the password (username is ignored), or a * running workflow job's checkout token. */ async function credentials(request: Request) { const header = request.headers.get("authorization"); if (!header?.startsWith("Basic ")) return null; const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); const password = decoded.slice(decoded.indexOf(":") + 1); const job = await authenticateJobToken(password); if (job) return { job }; return (await authenticateToken(password)) ?? "invalid"; } type Authorized = { repo: RepoRecord; userId: string | null }; /** * Decides whether a git request may proceed. Anonymous users can clone public * repositories; everything else needs a token that covers the repository. */ async function authorize(request: Request, owner: string, repoParam: string, service: GitService): Promise { const repo = await findRepo(owner, normalizeRepoName(repoParam)); const pushing = service === "git-receive-pack"; const creds = await credentials(request); if (creds === "invalid") return unauthorized("Invalid or expired token."); if (!creds) { if (repo && !pushing && repo.visibility === "public") return { repo, userId: null }; // Ask for credentials; don't reveal whether private repositories exist. return unauthorized("Authentication required. Use an API token as the password."); } // A job's token can only fetch the repository the job runs for. if ("job" in creds) { if (!repo || repo.id !== creds.job.repoId) return notFound(); if (pushing) return new Response("Workflow job tokens are read-only.\n", { status: 403 }); return { repo, userId: null }; } const access = repo ? await repoAccess(repo, creds.user.id) : null; if (!repo || !access) return notFound(); if (pushing && creds.token.permission !== "write") { return new Response("This token is read-only. Create a read & write token to push.\n", { status: 403 }); } if (!tokenCovers(creds.token, repo.id, pushing)) { return new Response("This token does not grant access to this repository.\n", { status: 403 }); } if (pushing && !canWrite(access)) { return new Response(`Permission to ${repo.owner}/${repo.name} denied.\n`, { status: 403 }); } return { repo, userId: creds.user.id }; } /** GET /:owner/:repo.git/info/refs?service=git-upload-pack|git-receive-pack */ export async function handleInfoRefs(request: Request, owner: string, repoParam: string) { const service = new URL(request.url).searchParams.get("service"); if (service !== "git-upload-pack" && service !== "git-receive-pack") { return new Response("Dumb HTTP protocol is not supported.\n", { status: 403 }); } const auth = await authorize(request, owner, repoParam, service); if (auth instanceof Response) return auth; const { repo } = auth; const protocol = request.headers.get("git-protocol"); const child = spawnService(service, repo.dir, { advertise: true, protocol }); const stdout = Readable.toWeb(child.stdout) as ReadableStream; // Protocol v2 responses start directly with the capability advertisement. const isV2 = service === "git-upload-pack" && protocol?.includes("version=2"); const prefix = isV2 ? "" : pktLine(`# service=${service}\n`) + "0000"; const body = prefix ? new ReadableStream({ async start(controller) { controller.enqueue(new TextEncoder().encode(prefix)); const reader = stdout.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; controller.enqueue(value); } controller.close(); }, }) : stdout; return new Response(body, { headers: noCache({ "Content-Type": `application/x-${service}-advertisement` }), }); } /** POST /:owner/:repo.git/git-upload-pack and /git-receive-pack */ export async function handleServiceRpc(request: Request, owner: string, repoParam: string, service: GitService) { if (request.headers.get("content-type") !== `application/x-${service}-request`) { return new Response("Bad content type.\n", { status: 415 }); } const auth = await authorize(request, owner, repoParam, service); if (auth instanceof Response) return auth; const { repo, userId } = auth; const refsBefore = service === "git-receive-pack" ? await snapshotRefs(repo.dir) : null; const child = spawnService(service, repo.dir, { protocol: request.headers.get("git-protocol") }); child.stderr.on("data", (d) => console.error(`[${service}] ${d}`)); if (refsBefore) onPushed(child, repo, refsBefore, userId); if (request.body) { let input: NodeJS.ReadableStream = Readable.fromWeb(request.body as never); if (request.headers.get("content-encoding") === "gzip") input = input.pipe(createGunzip()); input.pipe(child.stdin); input.on("error", () => child.kill()); } else { child.stdin.end(); } return new Response(Readable.toWeb(child.stdout) as ReadableStream, { headers: noCache({ "Content-Type": `application/x-${service}-result` }), }); } function onPushed(child: ReturnType, repo: RepoRecord, refsBefore: Map, userId: string | null) { child.on("close", (code) => { if (code !== 0) return; startWorkflowsInBackground( markPushed(repo.id).then(() => snapshotRefs(repo.dir)).then((after) => onRefsUpdated(repo, refsBefore, after, userId)), ); }); }