Minimale git-remote met publiek/prive-toggle
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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");
});
}