Vectrum
Sign In
181 lines · 8.1 KB · TypeScript
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));
  });
}