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 { invalidateRefCache } from "../services/git.ts";
8
9function pktLine(str: string): Buffer {
10 const len = Buffer.byteLength(str, "utf-8") + 4;
11 return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8");
12}
13
14const PKT_FLUSH = Buffer.from("0000");
15
16async function verifyBasicAuth(
17 authHeader: string | null,
18 adminOnly: boolean,
19): Promise<boolean> {
20 if (!authHeader?.startsWith("Basic ")) return false;
21 const decoded = Buffer.from(authHeader.slice(6), "base64").toString(
22 "utf-8",
23 );
24 const sep = decoded.indexOf(":");
25 if (sep === -1) return false;
26 const username = decoded.slice(0, sep);
27 const password = decoded.slice(sep + 1);
28 if (adminOnly && username !== ADMIN_USERNAME) return false;
29 const user = await db
30 .selectFrom("users")
31 .select("password_hash")
32 .where("username", "=", username)
33 .executeTakeFirst();
34 if (!user?.password_hash) return false;
35 try {
36 return await argon2.verify(user.password_hash, password);
37 } catch {
38 return false;
39 }
40}
41
42function unauthorized(): Response {
43 return new Response("Unauthorized", {
44 status: 401,
45 headers: {
46 "WWW-Authenticate": 'Basic realm="Hearthforge"',
47 "Content-Type": "text/plain",
48 },
49 });
50}
51
52async function getRepo(
53 slug: string,
54): Promise<{ repoPath: string; isPrivate: boolean } | null> {
55 const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
56 if (!VALID_REPO_NAME_RE.test(repoName)) return null;
57 const repo = await db
58 .selectFrom("repositories")
59 .select(["name", "is_private"])
60 .where("name", "=", repoName)
61 .executeTakeFirst();
62 if (!repo) return null;
63 const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
64 if (!existsSync(repoPath)) return null;
65 return { repoPath, isPrivate: repo.is_private === 1 };
66}
67
68async function spawnGit(
69 args: string[],
70 stdinBytes?: Uint8Array,
71): Promise<Uint8Array> {
72 const proc = Bun.spawn(args, {
73 stdin: stdinBytes ?? "ignore",
74 stdout: "pipe",
75 stderr: "pipe",
76 });
77 await proc.exited;
78 return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
79}
80
81export const gitRoutes = new Elysia()
82 // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
83 .get(
84 "/:repo/info/refs",
85 async ({ params, query, request }) => {
86 const service = query.service;
87 if (
88 service !== "git-upload-pack" &&
89 service !== "git-receive-pack"
90 ) {
91 return new Response("Bad Request", { status: 400 });
92 }
93
94 const repo = await getRepo(params.repo);
95 if (!repo) return new Response("Not Found", { status: 404 });
96
97 const authHeader = request.headers.get("Authorization");
98 if (service === "git-receive-pack") {
99 if (!(await verifyBasicAuth(authHeader, true)))
100 return unauthorized();
101 } else if (repo.isPrivate) {
102 if (!(await verifyBasicAuth(authHeader, false)))
103 return unauthorized();
104 }
105
106 const gitCmd =
107 service === "git-receive-pack" ? "receive-pack" : "upload-pack";
108 const refs = await spawnGit([
109 "git",
110 gitCmd,
111 "--stateless-rpc",
112 "--advertise-refs",
113 repo.repoPath,
114 ]);
115 const body = Buffer.concat([
116 pktLine(`# service=git-${gitCmd}\n`),
117 PKT_FLUSH,
118 refs,
119 ]);
120
121 return new Response(body, {
122 headers: {
123 "Content-Type": `application/x-git-${gitCmd}-advertisement`,
124 "Cache-Control": "no-cache",
125 },
126 });
127 },
128 {
129 query: t.Object({ service: t.Optional(t.String()) }),
130 },
131 )
132
133 // upload-pack POST — clone/fetch pack transfer (public for public repos)
134 .post("/:repo/git-upload-pack", async ({ params, request }) => {
135 const repo = await getRepo(params.repo);
136 if (!repo) return new Response("Not Found", { status: 404 });
137 if (repo.isPrivate) {
138 if (
139 !(await verifyBasicAuth(
140 request.headers.get("Authorization"),
141 false,
142 ))
143 )
144 return unauthorized();
145 }
146 const body = new Uint8Array(await request.arrayBuffer());
147 const result = await spawnGit(
148 ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
149 body,
150 );
151 return new Response(result, {
152 headers: {
153 "Content-Type": "application/x-git-upload-pack-result",
154 "Cache-Control": "no-cache",
155 },
156 });
157 })
158
159 // receive-pack POST — push pack transfer (admin only)
160 .post("/:repo/git-receive-pack", async ({ params, request }) => {
161 if (
162 !(await verifyBasicAuth(request.headers.get("Authorization"), true))
163 )
164 return unauthorized();
165 const repo = await getRepo(params.repo);
166 if (!repo) return new Response("Not Found", { status: 404 });
167 const body = new Uint8Array(await request.arrayBuffer());
168 const result = await spawnGit(
169 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
170 body,
171 );
172 invalidateRefCache(repo.name);
173 return new Response(result, {
174 headers: {
175 "Content-Type": "application/x-git-receive-pack-result",
176 "Cache-Control": "no-cache",
177 },
178 });
179 });
180