import "server-only"; import { Readable } from "node:stream"; import { createGunzip } from "node:zlib"; import { person, type Actor } from "./actor"; import { pathExists, snapshotRefs, spawnService, type GitService } from "./git"; import { authenticateAccessToken } from "./oauth"; import { afterRefsUpdatedInBackground } from "./push"; import { pushSession } from "./push-hooks"; import { canWrite, ensureWikiRepo, findGitTarget, markPushed, repoAccess, type RepoRecord } from "./repos"; import { authenticateJobToken } from "./runs"; import { authenticateToken, tokenCovers, type TokenScope } 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 }); type Credentials = { job: { repoId: string } } | { user: { id: string }; token: TokenScope; actor: Actor }; /** * Credentials are HTTP Basic auth with an API token as the password (username is ignored), an * agent's OAuth access token (its pushes are the agent's), or a running workflow job's checkout token. */ async function credentials(request: Request): Promise { 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 }; const grant = await authenticateAccessToken(password); if (grant) return { user: grant.user, token: grant.scope, actor: { userId: grant.user.id, agent: grant.agent } }; const token = await authenticateToken(password); return token ? { user: token.user, token: token.token, actor: person(token.user.id) } : "invalid"; } type Authorized = { repo: RepoRecord; dir: string; wiki: boolean; actor: Actor | null }; /** * Decides whether a git request may proceed. Anonymous users can clone public * repositories; everything else needs a token that covers the repository. A * repository's wiki (.wiki.git) has the repository's permissions. */ async function authorize(request: Request, owner: string, repoParam: string, service: GitService): Promise { const auth = await authorizeRepo(request, owner, repoParam, service); if (auth instanceof Response || !auth.wiki) return auth; // The first push to a wiki creates it; until then there is nothing to fetch. if (service === "git-receive-pack") await ensureWikiRepo(auth.repo); else if (!(await pathExists(auth.dir))) return notFound(); return auth; } async function authorizeRepo(request: Request, owner: string, repoParam: string, service: GitService): Promise { const target = await findGitTarget(owner, repoParam); const repo = target?.repo; const pushing = service === "git-receive-pack"; const creds = await credentials(request); if (creds === "invalid") return unauthorized("Invalid or expired token."); if (!creds) { if (target && !pushing && target.repo.visibility === "public") return { ...target, actor: 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 (!target || target.repo.id !== creds.job.repoId) return notFound(); if (pushing) return new Response("Workflow job tokens are read-only.\n", { status: 403 }); return { ...target, actor: null }; } const access = repo ? await repoAccess(repo, creds.user.id) : null; if (!target || !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 { ...target, actor: creds.actor }; } /** 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 protocol = request.headers.get("git-protocol"); const child = spawnService(service, auth.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, dir, wiki, actor } = auth; // Pushes to the code are checked against the branch rules and start workflows; pushes to the wiki aren't and don't. const pushing = service === "git-receive-pack" && !wiki; const refsBefore = pushing ? await snapshotRefs(repo.dir) : null; const session = pushing ? await pushSession(repo.id, actor) : null; const child = spawnService(service, dir, { protocol: request.headers.get("git-protocol"), env: session?.env }); child.stderr.on("data", (d) => console.error(`[${service}] ${d}`)); if (refsBefore) onPushed(child, repo, refsBefore, actor, () => session?.end()); 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, actor: Actor | null, end: () => void) { child.on("close", (code) => { end(); // Even a failed push may have updated some refs (git pushes them one by one unless --atomic). snapshotRefs(repo.dir) .then(async (after) => { if (code === 0) await markPushed(repo.id); afterRefsUpdatedInBackground(repo, refsBefore, after, actor); }) .catch((e) => console.error("[git] could not process a push:", e)); }); }