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 => handleGitRequest(req, res, full); } async function handleGitRequest(req: Request, res: Response, full: boolean): Promise { 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"); }); }