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<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 });
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<Credentials | "invalid" | null> {
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 (<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, 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<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, 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<Uint8Array>, {
headers: noCache({ "Content-Type": `application/x-${service}-result` }),
});
}
function onPushed(child: ReturnType<typeof spawnService>, repo: RepoRecord, refsBefore: Map<string, string>, 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));
});
}