git.ts
Raw
1import { existsSync } from "node:fs";
2import path from "node:path";
3import * as argon2 from "argon2";
4import { Elysia, t } from "elysia";
5import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
6import { db } from "../db";
7import {
8 parseCiConfig,
9 shouldTriggerPush,
10 shouldTriggerTag,
11 triggerRun,
12} from "../services/ci.ts";
13import { git, invalidateRefCache } from "../services/git.ts";
14
15/** Parse ref updates from git receive-pack request body (pkt-line format). */
16function parseRefUpdates(
17 body: Uint8Array,
18): Array<{ oldSha: string; newSha: string; refname: string }> {
19 const text = new TextDecoder().decode(body.slice(0, 4096));
20 const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
21 let pos = 0;
22 while (pos + 4 <= text.length) {
23 const lenStr = text.slice(pos, pos + 4);
24 const len = parseInt(lenStr, 16);
25 if (Number.isNaN(len) || len === 0) break;
26 if (len < 4 || pos + len > text.length) break;
27 // Strip capabilities (after first NUL) and trim
28 const line = text
29 .slice(pos + 4, pos + len)
30 .replace(/\0.*$/, "")
31 .trim();
32 pos += len;
33 const parts = line.split(" ");
34 if (parts.length >= 3) {
35 const oldSha = parts[0] ?? "";
36 const newSha = parts[1] ?? "";
37 const refname = parts[2] ?? "";
38 if (refname) refs.push({ oldSha, newSha, refname });
39 }
40 }
41 return refs;
42}
43
44/** Fire CI runs for any updated refs that match the pipeline config. */
45async function triggerCiForPush(
46 repoName: string,
47 refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
48): Promise<void> {
49 for (const { newSha, refname } of refUpdates) {
50 // Skip deletions
51 if (/^0+$/.test(newSha)) continue;
52
53 const isBranch = refname.startsWith("refs/heads/");
54 const isTag = refname.startsWith("refs/tags/");
55 if (!isBranch && !isTag) continue;
56
57 const tomlBuf = await git
58 .show(repoName, newSha, ".hearthforge-ci.toml")
59 .catch(() => null);
60 if (!tomlBuf) continue;
61
62 const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
63 if (!cfg) continue;
64
65 if (isBranch) {
66 const branch = refname.slice("refs/heads/".length);
67 if (shouldTriggerPush(cfg, branch)) {
68 triggerRun(repoName, {
69 triggerSource: "push",
70 commitSha: newSha,
71 commitBranch: branch,
72 }).catch(() => {});
73 }
74 } else if (isTag && shouldTriggerTag(cfg)) {
75 const tag = refname.slice("refs/tags/".length);
76 triggerRun(repoName, {
77 triggerSource: "tag",
78 commitSha: newSha,
79 commitTag: tag,
80 }).catch(() => {});
81 }
82 }
83}
84
85function pktLine(str: string): Buffer {
86 const len = Buffer.byteLength(str, "utf-8") + 4;
87 return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8");
88}
89
90const PKT_FLUSH = Buffer.from("0000");
91
92async function verifyBasicAuth(
93 authHeader: string | null,
94 adminOnly: boolean,
95): Promise<boolean> {
96 if (!authHeader?.startsWith("Basic ")) return false;
97 const decoded = Buffer.from(authHeader.slice(6), "base64").toString(
98 "utf-8",
99 );
100 const sep = decoded.indexOf(":");
101 if (sep === -1) return false;
102 const username = decoded.slice(0, sep);
103 const password = decoded.slice(sep + 1);
104 if (adminOnly && username !== ADMIN_USERNAME) return false;
105 const user = await db
106 .selectFrom("users")
107 .select("password_hash")
108 .where("username", "=", username)
109 .executeTakeFirst();
110 if (!user?.password_hash) return false;
111 try {
112 return await argon2.verify(user.password_hash, password);
113 } catch {
114 return false;
115 }
116}
117
118function unauthorized(): Response {
119 return new Response("Unauthorized", {
120 status: 401,
121 headers: {
122 "WWW-Authenticate": 'Basic realm="Hearthforge"',
123 "Content-Type": "text/plain",
124 },
125 });
126}
127
128async function getRepo(
129 slug: string,
130): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> {
131 const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
132 if (!VALID_REPO_NAME_RE.test(repoName)) return null;
133 const repo = await db
134 .selectFrom("repositories")
135 .select(["name", "is_private"])
136 .where("name", "=", repoName)
137 .executeTakeFirst();
138 if (!repo) return null;
139 const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
140 if (!existsSync(repoPath)) return null;
141 return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
142}
143
144async function spawnGit(
145 args: string[],
146 stdinBytes?: Uint8Array,
147): Promise<Uint8Array> {
148 const proc = Bun.spawn(args, {
149 stdin: stdinBytes ?? "ignore",
150 stdout: "pipe",
151 stderr: "pipe",
152 });
153 await proc.exited;
154 return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
155}
156
157export const gitRoutes = new Elysia()
158 // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
159 .get(
160 "/:repo/info/refs",
161 async ({ params, query, request }) => {
162 const service = query.service;
163 if (
164 service !== "git-upload-pack" &&
165 service !== "git-receive-pack"
166 ) {
167 return new Response("Bad Request", { status: 400 });
168 }
169
170 const repo = await getRepo(params.repo);
171 if (!repo) return new Response("Not Found", { status: 404 });
172
173 const authHeader = request.headers.get("Authorization");
174 if (service === "git-receive-pack") {
175 if (!(await verifyBasicAuth(authHeader, true)))
176 return unauthorized();
177 } else if (repo.isPrivate) {
178 if (!(await verifyBasicAuth(authHeader, false)))
179 return unauthorized();
180 }
181
182 const gitCmd =
183 service === "git-receive-pack" ? "receive-pack" : "upload-pack";
184 const refs = await spawnGit([
185 "git",
186 gitCmd,
187 "--stateless-rpc",
188 "--advertise-refs",
189 repo.repoPath,
190 ]);
191 const body = Buffer.concat([
192 pktLine(`# service=git-${gitCmd}\n`),
193 PKT_FLUSH,
194 refs,
195 ]);
196
197 return new Response(body, {
198 headers: {
199 "Content-Type": `application/x-git-${gitCmd}-advertisement`,
200 "Cache-Control": "no-cache",
201 },
202 });
203 },
204 {
205 query: t.Object({ service: t.Optional(t.String()) }),
206 },
207 )
208
209 // upload-pack POST — clone/fetch pack transfer (public for public repos)
210 .post("/:repo/git-upload-pack", async ({ params, request }) => {
211 const repo = await getRepo(params.repo);
212 if (!repo) return new Response("Not Found", { status: 404 });
213 if (repo.isPrivate) {
214 if (
215 !(await verifyBasicAuth(
216 request.headers.get("Authorization"),
217 false,
218 ))
219 )
220 return unauthorized();
221 }
222 const body = new Uint8Array(await request.arrayBuffer());
223 const result = await spawnGit(
224 ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
225 body,
226 );
227 return new Response(result, {
228 headers: {
229 "Content-Type": "application/x-git-upload-pack-result",
230 "Cache-Control": "no-cache",
231 },
232 });
233 })
234
235 // receive-pack POST — push pack transfer (admin only)
236 .post("/:repo/git-receive-pack", async ({ params, request }) => {
237 if (
238 !(await verifyBasicAuth(request.headers.get("Authorization"), true))
239 )
240 return unauthorized();
241 const repo = await getRepo(params.repo);
242 if (!repo) return new Response("Not Found", { status: 404 });
243 const body = new Uint8Array(await request.arrayBuffer());
244 const refUpdates = parseRefUpdates(body);
245 const result = await spawnGit(
246 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
247 body,
248 );
249 invalidateRefCache(repo.name);
250 // Trigger CI in background — don't block the git push response
251 triggerCiForPush(repo.name, refUpdates).catch(() => {});
252 return new Response(result, {
253 headers: {
254 "Content-Type": "application/x-git-receive-pack-result",
255 "Cache-Control": "no-cache",
256 },
257 });
258 });
259