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"; }