git.ts
Raw
1import { existsSync } from "node:fs";
2import path from "node:path";
3import * as argon2 from "argon2";
4import { Elysia, t } from "elysia";
5import {
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";
12import { db } from "../db";
13import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
14import {
15 parseCiConfig,
16 shouldTriggerPush,
17 shouldTriggerTag,
18 triggerRun,
19} from "../services/ci.ts";
20import { git, invalidateRefCache } from "../services/git.ts";
21
22/** Parse ref updates from git receive-pack request body (pkt-line format). */
23function 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. */
52async 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
96function 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
101const PKT_FLUSH = Buffer.from("0000");
102
103async 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
130function 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
140function 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 */
158function 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
171async 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
187interface GitResult {
188 ok: boolean;
189 stdout: Uint8Array;
190 stderr: string;
191}
192
193async 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
216export 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