Vectrum
Sign In
156 lines · 6.4 KB · TypeScript
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<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; 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<Authorized | Response> {
  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<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, 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<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)),
    );
  });
}