git.ts
| 1 | import { existsSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import * as argon2 from "argon2"; |
| 4 | import { Elysia, t } from "elysia"; |
| 5 | import { |
| 6 | ADMIN_USERNAME, |
| 7 | GIT_AUTH_MAX_ATTEMPTS, |
| 8 | GIT_AUTH_RATE_WINDOW_MS, |
| 9 | paths, |
| 10 | VALID_REPO_NAME_RE, |
| 11 | } from "../constants.ts"; |
| 12 | import { db } from "../db"; |
| 13 | import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts"; |
| 14 | import { |
| 15 | parseCiConfig, |
| 16 | shouldTriggerPush, |
| 17 | shouldTriggerTag, |
| 18 | triggerRun, |
| 19 | } from "../services/ci.ts"; |
| 20 | import { git, invalidateRefCache } from "../services/git.ts"; |
| 21 | |
| 22 | /** Parse ref updates from git receive-pack request body (pkt-line format). */ |
| 23 | function parseRefUpdates( |
| 24 | body: Uint8Array, |
| 25 | ): Array<{ oldSha: string; newSha: string; refname: string }> { |
| 26 | const text = new TextDecoder().decode(body.slice(0, 4096)); |
| 27 | const refs: Array<{ oldSha: string; newSha: string; refname: string }> = []; |
| 28 | let pos = 0; |
| 29 | while (pos + 4 <= text.length) { |
| 30 | const lenStr = text.slice(pos, pos + 4); |
| 31 | const len = parseInt(lenStr, 16); |
| 32 | if (Number.isNaN(len) || len === 0) break; |
| 33 | if (len < 4 || pos + len > text.length) break; |
| 34 | // Strip capabilities (after first NUL) and trim |
| 35 | const line = text |
| 36 | .slice(pos + 4, pos + len) |
| 37 | .replace(/\0.*$/, "") |
| 38 | .trim(); |
| 39 | pos += len; |
| 40 | const parts = line.split(" "); |
| 41 | if (parts.length >= 3) { |
| 42 | const oldSha = parts[0] ?? ""; |
| 43 | const newSha = parts[1] ?? ""; |
| 44 | const refname = parts[2] ?? ""; |
| 45 | if (refname) refs.push({ oldSha, newSha, refname }); |
| 46 | } |
| 47 | } |
| 48 | return refs; |
| 49 | } |
| 50 | |
| 51 | /** Fire CI runs for any updated refs that match the pipeline config. */ |
| 52 | async function triggerCiForPush( |
| 53 | repoName: string, |
| 54 | refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>, |
| 55 | ): Promise<void> { |
| 56 | for (const { newSha, refname } of refUpdates) { |
| 57 | // Skip deletions |
| 58 | if (/^0+$/.test(newSha)) continue; |
| 59 | |
| 60 | const isBranch = refname.startsWith("refs/heads/"); |
| 61 | const isTag = refname.startsWith("refs/tags/"); |
| 62 | if (!isBranch && !isTag) continue; |
| 63 | |
| 64 | const tomlBuf = await git |
| 65 | .show(repoName, newSha, ".hearthforge-ci.toml") |
| 66 | .catch(() => null); |
| 67 | if (!tomlBuf) continue; |
| 68 | |
| 69 | const cfg = parseCiConfig(tomlBuf.toString("utf-8")); |
| 70 | if (!cfg) continue; |
| 71 | |
| 72 | if (isBranch) { |
| 73 | const branch = refname.slice("refs/heads/".length); |
| 74 | if (shouldTriggerPush(cfg, branch)) { |
| 75 | triggerRun(repoName, { |
| 76 | triggerSource: "push", |
| 77 | commitSha: newSha, |
| 78 | commitBranch: branch, |
| 79 | }).catch((e) => |
| 80 | console.error(`CI push trigger failed for ${repoName}:`, e), |
| 81 | ); |
| 82 | } |
| 83 | } else if (isTag && shouldTriggerTag(cfg)) { |
| 84 | const tag = refname.slice("refs/tags/".length); |
| 85 | triggerRun(repoName, { |
| 86 | triggerSource: "tag", |
| 87 | commitSha: newSha, |
| 88 | commitTag: tag, |
| 89 | }).catch((e) => |
| 90 | console.error(`CI tag trigger failed for ${repoName}:`, e), |
| 91 | ); |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | function pktLine(str: string): Buffer { |
| 97 | const len = Buffer.byteLength(str, "utf-8") + 4; |
| 98 | return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8"); |
| 99 | } |
| 100 | |
| 101 | const PKT_FLUSH = Buffer.from("0000"); |
| 102 | |
| 103 | async function verifyBasicAuth( |
| 104 | authHeader: string | null, |
| 105 | adminOnly: boolean, |
| 106 | ): Promise<boolean> { |
| 107 | if (!authHeader?.startsWith("Basic ")) return false; |
| 108 | const decoded = Buffer.from(authHeader.slice(6), "base64").toString( |
| 109 | "utf-8", |
| 110 | ); |
| 111 | const sep = decoded.indexOf(":"); |
| 112 | if (sep === -1) return false; |
| 113 | const username = decoded.slice(0, sep); |
| 114 | const password = decoded.slice(sep + 1); |
| 115 | if (adminOnly && username !== ADMIN_USERNAME) return false; |
| 116 | const user = await db |
| 117 | .selectFrom("users") |
| 118 | .select("password_hash") |
| 119 | .where("username", "=", username) |
| 120 | .where("is_pending", "=", 0) |
| 121 | .executeTakeFirst(); |
| 122 | if (!user?.password_hash) return false; |
| 123 | try { |
| 124 | return await argon2.verify(user.password_hash, password); |
| 125 | } catch { |
| 126 | return false; |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | function unauthorized(): Response { |
| 131 | return new Response("Unauthorized", { |
| 132 | status: 401, |
| 133 | headers: { |
| 134 | "WWW-Authenticate": 'Basic realm="Hearthforge"', |
| 135 | "Content-Type": "text/plain", |
| 136 | }, |
| 137 | }); |
| 138 | } |
| 139 | |
| 140 | function tooManyRequests(): Response { |
| 141 | return new Response("Too Many Requests", { |
| 142 | status: 429, |
| 143 | headers: { "Content-Type": "text/plain" }, |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Rate-limit the per-IP cost of `verifyBasicAuth`. Each call costs |
| 149 | * ~100ms of argon2 work on the single event-loop thread, so without |
| 150 | * this an unauthenticated attacker can pin the CPU and use the same |
| 151 | * endpoint as a password-spray oracle around the /login limiter. |
| 152 | * |
| 153 | * Counts even non-Basic-header requests against the bucket: a private |
| 154 | * repo is only listed in the UI for admins, so a non-admin only ever |
| 155 | * pokes these endpoints intentionally and shouldn't get a free retry |
| 156 | * by omitting the header. |
| 157 | */ |
| 158 | function checkGitAuthLimit( |
| 159 | request: Request, |
| 160 | server: Bun.Server<unknown> | null, |
| 161 | ): boolean { |
| 162 | const ip = getClientIp(request, server); |
| 163 | return checkRateLimit( |
| 164 | ip, |
| 165 | "git-auth", |
| 166 | GIT_AUTH_MAX_ATTEMPTS, |
| 167 | GIT_AUTH_RATE_WINDOW_MS, |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | async function getRepo( |
| 172 | slug: string, |
| 173 | ): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> { |
| 174 | const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug; |
| 175 | if (!VALID_REPO_NAME_RE.test(repoName)) return null; |
| 176 | const repo = await db |
| 177 | .selectFrom("repositories") |
| 178 | .select(["name", "is_private"]) |
| 179 | .where("name", "=", repoName) |
| 180 | .executeTakeFirst(); |
| 181 | if (!repo) return null; |
| 182 | const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`); |
| 183 | if (!existsSync(repoPath)) return null; |
| 184 | return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 }; |
| 185 | } |
| 186 | |
| 187 | interface GitResult { |
| 188 | ok: boolean; |
| 189 | stdout: Uint8Array; |
| 190 | stderr: string; |
| 191 | } |
| 192 | |
| 193 | async function spawnGit( |
| 194 | args: string[], |
| 195 | stdinBytes?: Uint8Array, |
| 196 | ): Promise<GitResult> { |
| 197 | const proc = Bun.spawn(args, { |
| 198 | stdin: stdinBytes ?? "ignore", |
| 199 | stdout: "pipe", |
| 200 | stderr: "pipe", |
| 201 | }); |
| 202 | // Drain stdout/stderr concurrently with waiting on exit — reading only |
| 203 | // after `exited` can deadlock once git's output exceeds the OS pipe buffer. |
| 204 | const [stdout, stderr, exitCode] = await Promise.all([ |
| 205 | Bun.readableStreamToArrayBuffer(proc.stdout), |
| 206 | new Response(proc.stderr).text(), |
| 207 | proc.exited, |
| 208 | ]); |
| 209 | return { |
| 210 | ok: exitCode === 0, |
| 211 | stdout: new Uint8Array(stdout), |
| 212 | stderr, |
| 213 | }; |
| 214 | } |
| 215 | |
| 216 | export const gitRoutes = new Elysia() |
| 217 | // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push) |
| 218 | .get( |
| 219 | "/:repo/info/refs", |
| 220 | async ({ params, query, request, server }) => { |
| 221 | const service = query.service; |
| 222 | if ( |
| 223 | service !== "git-upload-pack" && |
| 224 | service !== "git-receive-pack" |
| 225 | ) { |
| 226 | return new Response("Bad Request", { status: 400 }); |
| 227 | } |
| 228 | |
| 229 | const repo = await getRepo(params.repo); |
| 230 | if (!repo) return new Response("Not Found", { status: 404 }); |
| 231 | |
| 232 | const authHeader = request.headers.get("Authorization"); |
| 233 | const requiresAuth = |
| 234 | service === "git-receive-pack" || repo.isPrivate; |
| 235 | if (requiresAuth) { |
| 236 | if (!checkGitAuthLimit(request, server)) |
| 237 | return tooManyRequests(); |
| 238 | // Match the UI: private repos are admin-only. The web UI |
| 239 | // returns 404 to non-admins via getRepo() in repos.tsx, so |
| 240 | // the smart-HTTP path must do the same — otherwise any |
| 241 | // logged-in user could clone a "private" repo despite the |
| 242 | // UI hiding it. |
| 243 | if (!(await verifyBasicAuth(authHeader, true))) |
| 244 | return unauthorized(); |
| 245 | } |
| 246 | |
| 247 | const gitCmd = |
| 248 | service === "git-receive-pack" ? "receive-pack" : "upload-pack"; |
| 249 | const refs = await spawnGit([ |
| 250 | "git", |
| 251 | gitCmd, |
| 252 | "--stateless-rpc", |
| 253 | "--advertise-refs", |
| 254 | repo.repoPath, |
| 255 | ]); |
| 256 | if (!refs.ok) { |
| 257 | console.error( |
| 258 | `git ${gitCmd} --advertise-refs failed for ${repo.name}: ${refs.stderr}`, |
| 259 | ); |
| 260 | return new Response("Git backend error", { status: 500 }); |
| 261 | } |
| 262 | const body = Buffer.concat([ |
| 263 | pktLine(`# service=git-${gitCmd}\n`), |
| 264 | PKT_FLUSH, |
| 265 | refs.stdout, |
| 266 | ]); |
| 267 | |
| 268 | return new Response(body, { |
| 269 | headers: { |
| 270 | "Content-Type": `application/x-git-${gitCmd}-advertisement`, |
| 271 | "Cache-Control": "no-cache", |
| 272 | }, |
| 273 | }); |
| 274 | }, |
| 275 | { |
| 276 | query: t.Object({ service: t.Optional(t.String()) }), |
| 277 | }, |
| 278 | ) |
| 279 | |
| 280 | // upload-pack POST — clone/fetch pack transfer (public for public repos) |
| 281 | .post("/:repo/git-upload-pack", async ({ params, request, server }) => { |
| 282 | const repo = await getRepo(params.repo); |
| 283 | if (!repo) return new Response("Not Found", { status: 404 }); |
| 284 | if (repo.isPrivate) { |
| 285 | // Admin-only: see info/refs branch above. |
| 286 | if (!checkGitAuthLimit(request, server)) return tooManyRequests(); |
| 287 | if ( |
| 288 | !(await verifyBasicAuth( |
| 289 | request.headers.get("Authorization"), |
| 290 | true, |
| 291 | )) |
| 292 | ) |
| 293 | return unauthorized(); |
| 294 | } |
| 295 | // Oversized bodies are rejected with 413 by the server's |
| 296 | // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs, |
| 297 | // so the cap surfaces as an explicit error rather than a truncated read. |
| 298 | const body = new Uint8Array(await request.arrayBuffer()); |
| 299 | const result = await spawnGit( |
| 300 | ["git", "upload-pack", "--stateless-rpc", repo.repoPath], |
| 301 | body, |
| 302 | ); |
| 303 | if (!result.ok) { |
| 304 | console.error( |
| 305 | `git upload-pack failed for ${repo.name}: ${result.stderr}`, |
| 306 | ); |
| 307 | return new Response("Git backend error", { status: 500 }); |
| 308 | } |
| 309 | return new Response(result.stdout, { |
| 310 | headers: { |
| 311 | "Content-Type": "application/x-git-upload-pack-result", |
| 312 | "Cache-Control": "no-cache", |
| 313 | }, |
| 314 | }); |
| 315 | }) |
| 316 | |
| 317 | // receive-pack POST — push pack transfer (admin only) |
| 318 | .post("/:repo/git-receive-pack", async ({ params, request, server }) => { |
| 319 | if (!checkGitAuthLimit(request, server)) return tooManyRequests(); |
| 320 | if ( |
| 321 | !(await verifyBasicAuth(request.headers.get("Authorization"), true)) |
| 322 | ) |
| 323 | return unauthorized(); |
| 324 | const repo = await getRepo(params.repo); |
| 325 | if (!repo) return new Response("Not Found", { status: 404 }); |
| 326 | // Oversized pushes are rejected with 413 by the server's |
| 327 | // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs, |
| 328 | // so the cap surfaces as an explicit error rather than a truncated read. |
| 329 | const body = new Uint8Array(await request.arrayBuffer()); |
| 330 | const refUpdates = parseRefUpdates(body); |
| 331 | const result = await spawnGit( |
| 332 | ["git", "receive-pack", "--stateless-rpc", repo.repoPath], |
| 333 | body, |
| 334 | ); |
| 335 | if (!result.ok) { |
| 336 | console.error( |
| 337 | `git receive-pack failed for ${repo.name}: ${result.stderr}`, |
| 338 | ); |
| 339 | return new Response("Git backend error", { status: 500 }); |
| 340 | } |
| 341 | invalidateRefCache(repo.name); |
| 342 | // Trigger CI in background — don't block the git push response |
| 343 | triggerCiForPush(repo.name, refUpdates).catch(() => {}); |
| 344 | return new Response(result.stdout, { |
| 345 | headers: { |
| 346 | "Content-Type": "application/x-git-receive-pack-result", |
| 347 | "Cache-Control": "no-cache", |
| 348 | }, |
| 349 | }); |
| 350 | }); |
| 351 |