import { existsSync } from "node:fs"; import path from "node:path"; import * as argon2 from "argon2"; import { Elysia, t } from "elysia"; import { ADMIN_USERNAME, GIT_AUTH_MAX_ATTEMPTS, GIT_AUTH_RATE_WINDOW_MS, paths, VALID_REPO_NAME_RE, } from "../constants.ts"; import { db } from "../db"; import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts"; import { parseCiConfig, shouldTriggerPush, shouldTriggerTag, triggerRun, } from "../services/ci.ts"; import { git, invalidateRefCache } from "../services/git.ts"; /** Parse ref updates from git receive-pack request body (pkt-line format). */ function parseRefUpdates( body: Uint8Array, ): Array<{ oldSha: string; newSha: string; refname: string }> { const text = new TextDecoder().decode(body.slice(0, 4096)); const refs: Array<{ oldSha: string; newSha: string; refname: string }> = []; let pos = 0; while (pos + 4 <= text.length) { const lenStr = text.slice(pos, pos + 4); const len = parseInt(lenStr, 16); if (Number.isNaN(len) || len === 0) break; if (len < 4 || pos + len > text.length) break; // Strip capabilities (after first NUL) and trim const line = text .slice(pos + 4, pos + len) .replace(/\0.*$/, "") .trim(); pos += len; const parts = line.split(" "); if (parts.length >= 3) { const oldSha = parts[0] ?? ""; const newSha = parts[1] ?? ""; const refname = parts[2] ?? ""; if (refname) refs.push({ oldSha, newSha, refname }); } } return refs; } /** Fire CI runs for any updated refs that match the pipeline config. */ async function triggerCiForPush( repoName: string, refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>, ): Promise { for (const { newSha, refname } of refUpdates) { // Skip deletions if (/^0+$/.test(newSha)) continue; const isBranch = refname.startsWith("refs/heads/"); const isTag = refname.startsWith("refs/tags/"); if (!isBranch && !isTag) continue; const tomlBuf = await git .show(repoName, newSha, ".hearthforge-ci.toml") .catch(() => null); if (!tomlBuf) continue; const cfg = parseCiConfig(tomlBuf.toString("utf-8")); if (!cfg) continue; if (isBranch) { const branch = refname.slice("refs/heads/".length); if (shouldTriggerPush(cfg, branch)) { triggerRun(repoName, { triggerSource: "push", commitSha: newSha, commitBranch: branch, }).catch((e) => console.error(`CI push trigger failed for ${repoName}:`, e), ); } } else if (isTag && shouldTriggerTag(cfg)) { const tag = refname.slice("refs/tags/".length); triggerRun(repoName, { triggerSource: "tag", commitSha: newSha, commitTag: tag, }).catch((e) => console.error(`CI tag trigger failed for ${repoName}:`, e), ); } } } function pktLine(str: string): Buffer { const len = Buffer.byteLength(str, "utf-8") + 4; return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8"); } const PKT_FLUSH = Buffer.from("0000"); async function verifyBasicAuth( authHeader: string | null, adminOnly: boolean, ): Promise { if (!authHeader?.startsWith("Basic ")) return false; const decoded = Buffer.from(authHeader.slice(6), "base64").toString( "utf-8", ); const sep = decoded.indexOf(":"); if (sep === -1) return false; const username = decoded.slice(0, sep); const password = decoded.slice(sep + 1); if (adminOnly && username !== ADMIN_USERNAME) return false; const user = await db .selectFrom("users") .select("password_hash") .where("username", "=", username) .where("is_pending", "=", 0) .executeTakeFirst(); if (!user?.password_hash) return false; try { return await argon2.verify(user.password_hash, password); } catch { return false; } } function unauthorized(): Response { return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="Hearthforge"', "Content-Type": "text/plain", }, }); } function tooManyRequests(): Response { return new Response("Too Many Requests", { status: 429, headers: { "Content-Type": "text/plain" }, }); } /** * Rate-limit the per-IP cost of `verifyBasicAuth`. Each call costs * ~100ms of argon2 work on the single event-loop thread, so without * this an unauthenticated attacker can pin the CPU and use the same * endpoint as a password-spray oracle around the /login limiter. * * Counts even non-Basic-header requests against the bucket: a private * repo is only listed in the UI for admins, so a non-admin only ever * pokes these endpoints intentionally and shouldn't get a free retry * by omitting the header. */ function checkGitAuthLimit( request: Request, server: Bun.Server | null, ): boolean { const ip = getClientIp(request, server); return checkRateLimit( ip, "git-auth", GIT_AUTH_MAX_ATTEMPTS, GIT_AUTH_RATE_WINDOW_MS, ); } async function getRepo( slug: string, ): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> { const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug; if (!VALID_REPO_NAME_RE.test(repoName)) return null; const repo = await db .selectFrom("repositories") .select(["name", "is_private"]) .where("name", "=", repoName) .executeTakeFirst(); if (!repo) return null; const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`); if (!existsSync(repoPath)) return null; return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 }; } interface GitResult { ok: boolean; stdout: Uint8Array; stderr: string; } async function spawnGit( args: string[], stdinBytes?: Uint8Array, ): Promise { const proc = Bun.spawn(args, { stdin: stdinBytes ?? "ignore", stdout: "pipe", stderr: "pipe", }); // Drain stdout/stderr concurrently with waiting on exit — reading only // after `exited` can deadlock once git's output exceeds the OS pipe buffer. const [stdout, stderr, exitCode] = await Promise.all([ Bun.readableStreamToArrayBuffer(proc.stdout), new Response(proc.stderr).text(), proc.exited, ]); return { ok: exitCode === 0, stdout: new Uint8Array(stdout), stderr, }; } export const gitRoutes = new Elysia() // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push) .get( "/:repo/info/refs", async ({ params, query, request, server }) => { const service = query.service; if ( service !== "git-upload-pack" && service !== "git-receive-pack" ) { return new Response("Bad Request", { status: 400 }); } const repo = await getRepo(params.repo); if (!repo) return new Response("Not Found", { status: 404 }); const authHeader = request.headers.get("Authorization"); const requiresAuth = service === "git-receive-pack" || repo.isPrivate; if (requiresAuth) { if (!checkGitAuthLimit(request, server)) return tooManyRequests(); // Match the UI: private repos are admin-only. The web UI // returns 404 to non-admins via getRepo() in repos.tsx, so // the smart-HTTP path must do the same — otherwise any // logged-in user could clone a "private" repo despite the // UI hiding it. if (!(await verifyBasicAuth(authHeader, true))) return unauthorized(); } const gitCmd = service === "git-receive-pack" ? "receive-pack" : "upload-pack"; const refs = await spawnGit([ "git", gitCmd, "--stateless-rpc", "--advertise-refs", repo.repoPath, ]); if (!refs.ok) { console.error( `git ${gitCmd} --advertise-refs failed for ${repo.name}: ${refs.stderr}`, ); return new Response("Git backend error", { status: 500 }); } const body = Buffer.concat([ pktLine(`# service=git-${gitCmd}\n`), PKT_FLUSH, refs.stdout, ]); return new Response(body, { headers: { "Content-Type": `application/x-git-${gitCmd}-advertisement`, "Cache-Control": "no-cache", }, }); }, { query: t.Object({ service: t.Optional(t.String()) }), }, ) // upload-pack POST — clone/fetch pack transfer (public for public repos) .post("/:repo/git-upload-pack", async ({ params, request, server }) => { const repo = await getRepo(params.repo); if (!repo) return new Response("Not Found", { status: 404 }); if (repo.isPrivate) { // Admin-only: see info/refs branch above. if (!checkGitAuthLimit(request, server)) return tooManyRequests(); if ( !(await verifyBasicAuth( request.headers.get("Authorization"), true, )) ) return unauthorized(); } // Oversized bodies are rejected with 413 by the server's // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs, // so the cap surfaces as an explicit error rather than a truncated read. const body = new Uint8Array(await request.arrayBuffer()); const result = await spawnGit( ["git", "upload-pack", "--stateless-rpc", repo.repoPath], body, ); if (!result.ok) { console.error( `git upload-pack failed for ${repo.name}: ${result.stderr}`, ); return new Response("Git backend error", { status: 500 }); } return new Response(result.stdout, { headers: { "Content-Type": "application/x-git-upload-pack-result", "Cache-Control": "no-cache", }, }); }) // receive-pack POST — push pack transfer (admin only) .post("/:repo/git-receive-pack", async ({ params, request, server }) => { if (!checkGitAuthLimit(request, server)) return tooManyRequests(); if ( !(await verifyBasicAuth(request.headers.get("Authorization"), true)) ) return unauthorized(); const repo = await getRepo(params.repo); if (!repo) return new Response("Not Found", { status: 404 }); // Oversized pushes are rejected with 413 by the server's // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs, // so the cap surfaces as an explicit error rather than a truncated read. const body = new Uint8Array(await request.arrayBuffer()); const refUpdates = parseRefUpdates(body); const result = await spawnGit( ["git", "receive-pack", "--stateless-rpc", repo.repoPath], body, ); if (!result.ok) { console.error( `git receive-pack failed for ${repo.name}: ${result.stderr}`, ); return new Response("Git backend error", { status: 500 }); } invalidateRefCache(repo.name); // Trigger CI in background — don't block the git push response triggerCiForPush(repo.name, refUpdates).catch(() => {}); return new Response(result.stdout, { headers: { "Content-Type": "application/x-git-receive-pack-result", "Cache-Control": "no-cache", }, }); });