refactor: split GitOps renderer by responsibility
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createGunzip, createInflate } from "node:zlib";
|
||||
|
||||
const cacheRoot = process.env.GIT_MIRROR_CACHE_ROOT || "/cache";
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
|
||||
createServer((req, res) => {
|
||||
handle(req, res).catch((error) => {
|
||||
if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: error.message }));
|
||||
});
|
||||
}).listen(port, "0.0.0.0", () => {
|
||||
console.log(JSON.stringify({ event: "git-mirror-smart-http-listening", port, cacheRoot }));
|
||||
});
|
||||
|
||||
async function handle(req, res) {
|
||||
const url = new URL(req.url || "/", "http://git-mirror-http");
|
||||
const writeHost = isWriteHost(req.headers.host || "");
|
||||
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-cache" });
|
||||
res.end(JSON.stringify({ ok: true, service: "git-mirror-smart-http", writeHost }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-upload-pack") {
|
||||
await runUploadPackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname.endsWith("/git-upload-pack")) {
|
||||
await runUploadPackRpc(repoPathFromGitServicePath(url.pathname, "/git-upload-pack"), req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-receive-pack") {
|
||||
if (!writeHost) return rejectReadOnly(res);
|
||||
await runReceivePackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname.endsWith("/git-receive-pack")) {
|
||||
if (!writeHost) return rejectReadOnly(res);
|
||||
await runReceivePackRpc(repoPathFromGitServicePath(url.pathname, "/git-receive-pack"), req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
serveStaticFile(url.pathname, req, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(405, { "content-type": "text/plain" });
|
||||
res.end("method not allowed\n");
|
||||
}
|
||||
|
||||
function isWriteHost(hostHeader) {
|
||||
const host = String(hostHeader || "").split(":", 1)[0].toLowerCase();
|
||||
return host === "git-mirror-write" || host.startsWith("git-mirror-write.");
|
||||
}
|
||||
|
||||
function rejectReadOnly(res) {
|
||||
res.writeHead(403, { "content-type": "application/json", "cache-control": "no-cache" });
|
||||
res.end(JSON.stringify({ ok: false, error: "git mirror receive-pack is only available through git-mirror-write" }));
|
||||
}
|
||||
|
||||
function repoPathFromGitServicePath(urlPath, suffix) {
|
||||
const repoUrlPath = urlPath.slice(0, -suffix.length);
|
||||
if (!repoUrlPath.endsWith(".git")) throw new Error(`unsupported git repo path: ${urlPath}`);
|
||||
return safePath(repoUrlPath);
|
||||
}
|
||||
|
||||
function safePath(urlPath) {
|
||||
const decoded = decodeURIComponent(urlPath.replace(/^[/]+/u, ""));
|
||||
const fullPath = path.resolve(cacheRoot, decoded);
|
||||
const normalizedRoot = path.resolve(cacheRoot);
|
||||
if (fullPath !== normalizedRoot && !fullPath.startsWith(`${normalizedRoot}${path.sep}`)) throw new Error("path escapes cache root");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function smartHeaders(contentType) {
|
||||
return {
|
||||
"content-type": contentType,
|
||||
"cache-control": "no-cache, max-age=0, must-revalidate",
|
||||
expires: "Fri, 01 Jan 1980 00:00:00 GMT",
|
||||
pragma: "no-cache"
|
||||
};
|
||||
}
|
||||
|
||||
function pktLine(text) {
|
||||
const length = Buffer.byteLength(text) + 4;
|
||||
return `${length.toString(16).padStart(4, "0")}${text}`;
|
||||
}
|
||||
|
||||
function requestBodyStream(req) {
|
||||
const encoding = String(req.headers["content-encoding"] || "identity").toLowerCase();
|
||||
if (encoding === "identity" || encoding === "") return req;
|
||||
if (encoding === "gzip" || encoding === "x-gzip") return req.pipe(createGunzip());
|
||||
if (encoding === "deflate") return req.pipe(createInflate());
|
||||
throw new Error(`unsupported git upload-pack content-encoding: ${encoding}`);
|
||||
}
|
||||
|
||||
function runUploadPackAdvertisement(repoPath, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git-upload-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && !res.headersSent) reject(new Error(`git-upload-pack advertise failed: ${stderr.trim()}`));
|
||||
else resolve();
|
||||
});
|
||||
res.writeHead(200, smartHeaders("application/x-git-upload-pack-advertisement"));
|
||||
res.write(pktLine("# service=git-upload-pack\n"));
|
||||
res.write("0000");
|
||||
child.stdout.pipe(res, { end: false });
|
||||
child.stdout.on("end", () => res.end());
|
||||
});
|
||||
}
|
||||
|
||||
function runUploadPackRpc(repoPath, req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git-upload-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && !res.headersSent) reject(new Error(`git-upload-pack rpc failed: ${stderr.trim()}`));
|
||||
else resolve();
|
||||
});
|
||||
res.writeHead(200, smartHeaders("application/x-git-upload-pack-result"));
|
||||
const body = requestBodyStream(req);
|
||||
body.on("error", (error) => child.stdin.destroy(error));
|
||||
body.pipe(child.stdin);
|
||||
child.stdout.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
function runReceivePackAdvertisement(repoPath, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git-receive-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && !res.headersSent) reject(new Error(`git-receive-pack advertise failed: ${stderr.trim()}`));
|
||||
else resolve();
|
||||
});
|
||||
res.writeHead(200, smartHeaders("application/x-git-receive-pack-advertisement"));
|
||||
res.write(pktLine("# service=git-receive-pack\n"));
|
||||
res.write("0000");
|
||||
child.stdout.pipe(res, { end: false });
|
||||
child.stdout.on("end", () => res.end());
|
||||
});
|
||||
}
|
||||
|
||||
function runReceivePackRpc(repoPath, req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git-receive-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && !res.headersSent) reject(new Error(`git-receive-pack rpc failed: ${stderr.trim()}`));
|
||||
else resolve();
|
||||
});
|
||||
res.writeHead(200, smartHeaders("application/x-git-receive-pack-result"));
|
||||
const body = requestBodyStream(req);
|
||||
body.on("error", (error) => child.stdin.destroy(error));
|
||||
body.pipe(child.stdin);
|
||||
child.stdout.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
function serveStaticFile(urlPath, req, res) {
|
||||
const fullPath = safePath(urlPath);
|
||||
let stat;
|
||||
try { stat = statSync(fullPath); } catch {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found\n");
|
||||
return;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found\n");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-length": stat.size, "content-type": contentType(fullPath), "cache-control": "no-cache" });
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
createReadStream(fullPath).pipe(res);
|
||||
}
|
||||
|
||||
function contentType(filePath) {
|
||||
if (filePath.endsWith(".pack")) return "application/x-git-packed-objects";
|
||||
if (filePath.endsWith(".idx")) return "application/x-git-packed-objects-toc";
|
||||
return "text/plain";
|
||||
}
|
||||
Reference in New Issue
Block a user