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