Minimale git-remote met publiek/prive-toggle

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

8.4 kB Ruw bestand
import { spawn } from "node:child_process";
import type { Request, Response } from "express";

import { config } from "../config.ts";
import { requireOwnerGit } from "../auth.ts";
import { publishSnapshot } from "../git.ts";
import { getRepo, isValidRepoName } from "../repos.ts";

type Service = "upload-pack" | "receive-pack";

/**
 * Alles onder `config.gitPrefix` gaat door `git http-backend`, het CGI-programma dat
 * git zelf meelevert. Dat scheelt het herimplementeren van het pack-protocol; wij doen
 * alleen de toegangscontrole ervoor en vertalen CGI-uitvoer terug naar een HTTP-antwoord.
 *
 * `full` onderscheidt de twee transporten, en dat verschil is domweg een andere map:
 * `/git` serveert de gepubliceerde momentopnames, `/git-full` jouw repositories met
 * geschiedenis. Filteren binnen één repository zou niet werken — zie git.ts.
 */
export function gitHttpHandler(full: boolean) {
  return (req: Request, res: Response): Promise<void> => handleGitRequest(req, res, full);
}

async function handleGitRequest(req: Request, res: Response, full: boolean): Promise<void> {
  const url = new URL(req.originalUrl, "http://localhost");
  const prefix = full ? config.gitFullPrefix : config.gitPrefix;
  const relative = url.pathname.startsWith(prefix)
    ? url.pathname.slice(prefix.length)
    : url.pathname;

  const segments = relative.split("/").filter(Boolean);
  const first = segments[0];
  if (!first || !first.endsWith(".git")) {
    res.status(404).type("text/plain").send("Niet gevonden.\n");
    return;
  }

  const name = first.slice(0, -".git".length);
  const endpoint = segments.slice(1).join("/");

  // Precies drie vormen mogen door naar git; al het andere (inclusief elk pad met
  // `..` erin) valt hier af, zodat PATH_INFO nooit buiten de repository kan wijzen.
  const isInfoRefs = req.method === "GET" && endpoint === "info/refs";
  const isUploadPack = req.method === "POST" && endpoint === "git-upload-pack";
  const isReceivePack = req.method === "POST" && endpoint === "git-receive-pack";
  if (!isInfoRefs && !isUploadPack && !isReceivePack) {
    res.status(404).type("text/plain").send("Niet gevonden.\n");
    return;
  }

  const service: Service = isReceivePack
    ? "receive-pack"
    : isUploadPack
      ? "upload-pack"
      : url.searchParams.get("service") === "git-receive-pack"
        ? "receive-pack"
        : "upload-pack";

  // Push vereist altijd het owner-token, en het volledige transport ook. We
  // controleren dat vóór we kijken of de repository bestaat, zodat een 401 niets
  // over de inhoud van de server verklapt.
  if ((service === "receive-pack" || full) && !req.isOwner) {
    requireOwnerGit(res);
    return;
  }

  // Het publieke transport is uitsluitend leesbaar. Anders zou je een remote
  // kunnen hebben die je eigen werk naar boven duwt maar de momentopname naar
  // beneden haalt — en dan verzet één klik op "sync" je lokale historie naar een
  // commit zonder ouders. Eén URL, één betekenis: /git geeft wat de wereld ziet,
  // /git-full is jouw remote.
  if (!full && service === "receive-pack") {
    res
      .status(403)
      .type("text/plain")
      .send(
        `Deze URL is alleen om te lezen; hij levert de gepubliceerde momentopname.\n` +
          `Gebruik voor pushen en pullen je eigen transport:\n\n` +
          `  git remote set-url origin ${config.publicUrl}${config.gitFullPrefix}/${name}.git\n`,
      );
    return;
  }

  const repo = isValidRepoName(name) ? await getRepo(name) : null;

  if (!repo || (!repo.public && !req.isOwner)) {
    // Niet-bestaand en privé geven exact hetzelfde antwoord. Een git-client die 401
    // krijgt vraagt om inloggegevens, zodat jij je eigen privé-repos wél kunt clonen.
    if (!req.isOwner) {
      requireOwnerGit(res);
    } else {
      res.status(404).type("text/plain").send("Repository niet gevonden.\n");
    }
    return;
  }

  const pathInfo = `/${name}.git/${endpoint}`;

  const env: NodeJS.ProcessEnv = {
    PATH: process.env["PATH"],
    // Hier zit de hele scheiding, en het is domweg een andere map. Anoniem ophalen
    // kijkt alleen in de momentopnames en kan de repositories met geschiedenis niet
    // eens zien. Pushen gaat altijd naar jouw eigen repository, ook via /git — daar
    // komt je geschiedenis binnen, en daaruit wordt de momentopname afgeleid.
    GIT_PROJECT_ROOT:
      full || service === "receive-pack" ? config.repoRoot : config.publishedRoot,
    GIT_HTTP_EXPORT_ALL: "1",
    GIT_HTTP_MAX_REQUEST_BUFFER: config.maxRequestBuffer,
    PATH_INFO: pathInfo,
    QUERY_STRING: url.search.startsWith("?") ? url.search.slice(1) : "",
    REQUEST_METHOD: req.method,
    REMOTE_ADDR: req.ip ?? "",
    // Push over HTTP staat standaard uit; via GIT_CONFIG_* erven de kindprocessen dit mee.
    GIT_CONFIG_COUNT: "2",
    GIT_CONFIG_KEY_0: "http.receivepack",
    GIT_CONFIG_VALUE_0: "true",
    // Alleen opruimen van de advertentie: de markers waarmee we bijhouden wat er
    // gepubliceerd is, hoeven niet in `git ls-remote` op te duiken. Dit is geen
    // afscherming — daarvoor dient de gescheiden repository.
    GIT_CONFIG_KEY_1: "uploadpack.hideRefs",
    GIT_CONFIG_VALUE_1: "refs/published",
  };

  if (req.isOwner) env["REMOTE_USER"] = "owner";
  if (req.headers["content-type"]) env["CONTENT_TYPE"] = String(req.headers["content-type"]);
  if (req.headers["content-length"]) env["CONTENT_LENGTH"] = String(req.headers["content-length"]);
  if (req.headers["content-encoding"]) {
    env["HTTP_CONTENT_ENCODING"] = String(req.headers["content-encoding"]);
  }
  // Zonder dit valt de verbinding terug op protocol v0 in plaats van v2.
  if (req.headers["git-protocol"]) {
    env["HTTP_GIT_PROTOCOL"] = String(req.headers["git-protocol"]);
  }

  const child = spawn("git", ["http-backend"], { env, windowsHide: true });

  let headersDone = false;
  let buffered = Buffer.alloc(0);

  const flushHeaders = (chunk: Buffer): void => {
    buffered = Buffer.concat([buffered, chunk]);
    const separator = buffered.indexOf("\r\n\r\n");
    if (separator === -1) {
      // Een CGI-header mag niet oneindig groot zijn; bescherm tegen een vastlopend proces.
      if (buffered.length > 64 * 1024) {
        child.kill("SIGKILL");
        if (!res.headersSent) res.status(500).type("text/plain").send("Ongeldig CGI-antwoord.\n");
      }
      return;
    }

    headersDone = true;
    const rawHeaders = buffered.subarray(0, separator).toString("utf8");
    const body = buffered.subarray(separator + 4);
    buffered = Buffer.alloc(0);

    let status = 200;
    for (const line of rawHeaders.split("\r\n")) {
      const colon = line.indexOf(":");
      if (colon === -1) continue;
      const key = line.slice(0, colon).trim();
      const value = line.slice(colon + 1).trim();
      if (key.toLowerCase() === "status") {
        status = Number.parseInt(value, 10) || 200;
      } else {
        res.setHeader(key, value);
      }
    }
    res.status(status);
    if (body.length > 0) res.write(body);
  };

  child.stdout.on("data", (chunk: Buffer) => {
    if (headersDone) res.write(chunk);
    else flushHeaders(chunk);
  });

  child.stderr.on("data", (chunk: Buffer) => {
    console.error(`[git http-backend] ${chunk.toString("utf8").trimEnd()}`);
  });

  child.on("error", (error) => {
    console.error("[git http-backend] kon niet starten:", error);
    if (!res.headersSent) res.status(500).type("text/plain").send("Interne fout.\n");
    else res.end();
  });

  child.on("close", (code) => {
    if (!headersDone && !res.headersSent) {
      res.status(500).type("text/plain").send("Leeg antwoord van git.\n");
      return;
    }

    // Na een geslaagde push de publieke momentopname opnieuw opbouwen. Dit gebeurt
    // in de applicatie en niet in een git-hook, zodat de logica op één plek staat
    // en ook geldt voor repositories die al bestonden.
    //
    // We ronden de push pas af als dat klaar is. Anders bestaat er een venster
    // waarin jouw branch al bijgewerkt is maar de gepubliceerde repository nog niet,
    // en een clone in dat venster levert een verouderde of lege werkkopie op.
    if (service !== "receive-pack" || code !== 0) {
      res.end();
      return;
    }

    publishSnapshot(repo.dir, repo.publishedDir)
      .catch((error: unknown) => {
        console.error(`[publish] ${repo.name}: momentopname bijwerken mislukt`, error);
      })
      .finally(() => res.end());
  });

  req.pipe(child.stdin);
  req.on("aborted", () => child.kill("SIGKILL"));
  res.on("close", () => {
    if (child.exitCode === null) child.kill("SIGKILL");
  });
}