import "server-only";
import { Readable } from "node:stream";
import { createGunzip } from "node:zlib";
import { onRefsUpdated, startWorkflowsInBackground } from "./ci";
import { pathExists, snapshotRefs, spawnService, type GitService } from "./git";
import { canWrite, ensureWikiRepo, findGitTarget, 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<string, string>) {
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; dir: string; wiki: boolean; 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. A
* repository's wiki (<name>.wiki.git) has the repository's permissions.
*/
async function authorize(request: Request, owner: string, repoParam: string, service: GitService): Promise<Authorized | Response> {
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<Authorized | Response> {
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, 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 (!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, userId: 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, 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 protocol = request.headers.get("git-protocol");
const child = spawnService(service, auth.dir, { advertise: true, protocol });
const stdout = Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
// 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<Uint8Array>({
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, userId } = auth;
// Pushes to the code start workflows; pushes to the wiki don't.
const refsBefore = service === "git-receive-pack" && !wiki ? await snapshotRefs(repo.dir) : null;
const child = spawnService(service, 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<Uint8Array>, {
headers: noCache({ "Content-Type": `application/x-${service}-result` }),
});
}
function onPushed(child: ReturnType<typeof spawnService>, repo: RepoRecord, refsBefore: Map<string, string>, 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)),
);
});
}