import "server-only"; import { Readable } from "node:stream"; import { createGunzip } from "node:zlib"; import { normalizeRepoName, spawnService, type GitService } from "./git"; import { canWrite, findRepo, markPushed, repoAccess, type RepoRecord } from "./repos"; import { basicAuthToken, 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 }); /** * Decides whether a git request may proceed. Anonymous users can clone public * repositories; everything else needs a token that covers the repository. */ function authorize(request: Request, owner: string, repoParam: string, service: GitService): RepoRecord | Response { const repo = findRepo(owner, normalizeRepoName(repoParam)); const pushing = service === "git-receive-pack"; const creds = basicAuthToken(request); if (creds === "invalid") return unauthorized("Invalid or expired token."); if (!creds) { if (repo && !pushing && repo.visibility === "public") return repo; // Ask for credentials; don't reveal whether private repositories exist. return unauthorized("Authentication required. Use an API token as the password."); } const access = repo ? 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; } /** 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 repo = authorize(request, owner, repoParam, service); if (repo instanceof Response) return repo; 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 repo = authorize(request, owner, repoParam, service); if (repo instanceof Response) return repo; const child = spawnService(service, repo.dir, { protocol: request.headers.get("git-protocol") }); child.stderr.on("data", (d) => console.error(`[${service}] ${d}`)); if (service === "git-receive-pack") onPushed(child, repo); 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) { child.on("close", (code) => { if (code === 0) markPushed(repo.id); }); }