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
187async function spawnGit(
188 args: string[],
189 stdinBytes?: Uint8Array,
190): Promise<Uint8Array> {
191 const proc = Bun.spawn(args, {
192 stdin: stdinBytes ?? "ignore",
193 stdout: "pipe",
194 stderr: "pipe",
195 });
196 await proc.exited;
197 return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
198}
199
200export const gitRoutes = new Elysia()
201 // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
202 .get(
203 "/:repo/info/refs",
204 async ({ params, query, request, server }) => {
205 const service = query.service;
206 if (
207 service !== "git-upload-pack" &&
208 service !== "git-receive-pack"
209 ) {
210 return new Response("Bad Request", { status: 400 });
211 }
212
213 const repo = await getRepo(params.repo);
214 if (!repo) return new Response("Not Found", { status: 404 });
215
216 const authHeader = request.headers.get("Authorization");
217 const requiresAuth =
218 service === "git-receive-pack" || repo.isPrivate;
219 if (requiresAuth) {
220 if (!checkGitAuthLimit(request, server))
221 return tooManyRequests();
222 // Match the UI: private repos are admin-only. The web UI
223 // returns 404 to non-admins via getRepo() in repos.tsx, so
224 // the smart-HTTP path must do the same — otherwise any
225 // logged-in user could clone a "private" repo despite the
226 // UI hiding it.
227 if (!(await verifyBasicAuth(authHeader, true)))
228 return unauthorized();
229 }
230
231 const gitCmd =
232 service === "git-receive-pack" ? "receive-pack" : "upload-pack";
233 const refs = await spawnGit([
234 "git",
235 gitCmd,
236 "--stateless-rpc",
237 "--advertise-refs",
238 repo.repoPath,
239 ]);
240 const body = Buffer.concat([
241 pktLine(`# service=git-${gitCmd}\n`),
242 PKT_FLUSH,
243 refs,
244 ]);
245
246 return new Response(body, {
247 headers: {
248 "Content-Type": `application/x-git-${gitCmd}-advertisement`,
249 "Cache-Control": "no-cache",
250 },
251 });
252 },
253 {
254 query: t.Object({ service: t.Optional(t.String()) }),
255 },
256 )
257
258 // upload-pack POST — clone/fetch pack transfer (public for public repos)
259 .post("/:repo/git-upload-pack", async ({ params, request, server }) => {
260 const repo = await getRepo(params.repo);
261 if (!repo) return new Response("Not Found", { status: 404 });
262 if (repo.isPrivate) {
263 // Admin-only: see info/refs branch above.
264 if (!checkGitAuthLimit(request, server)) return tooManyRequests();
265 if (
266 !(await verifyBasicAuth(
267 request.headers.get("Authorization"),
268 true,
269 ))
270 )
271 return unauthorized();
272 }
273 const body = new Uint8Array(await request.arrayBuffer());
274 const result = await spawnGit(
275 ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
276 body,
277 );
278 return new Response(result, {
279 headers: {
280 "Content-Type": "application/x-git-upload-pack-result",
281 "Cache-Control": "no-cache",
282 },
283 });
284 })
285
286 // receive-pack POST — push pack transfer (admin only)
287 .post("/:repo/git-receive-pack", async ({ params, request, server }) => {
288 if (!checkGitAuthLimit(request, server)) return tooManyRequests();
289 if (
290 !(await verifyBasicAuth(request.headers.get("Authorization"), true))
291 )
292 return unauthorized();
293 const repo = await getRepo(params.repo);
294 if (!repo) return new Response("Not Found", { status: 404 });
295 const body = new Uint8Array(await request.arrayBuffer());
296 const refUpdates = parseRefUpdates(body);
297 const result = await spawnGit(
298 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
299 body,
300 );
301 invalidateRefCache(repo.name);
302 // Trigger CI in background — don't block the git push response
303 triggerCiForPush(repo.name, refUpdates).catch(() => {});
304 return new Response(result, {
305 headers: {
306 "Content-Type": "application/x-git-receive-pack-result",
307 "Cache-Control": "no-cache",
308 },
309 });
310 });
311