Skip to content
532 linesCodeBlameRaw
1// g1t's git store for self-hosting: plain bare repositories on disk.
2//
3// Hosted g1t keeps repositories in Cloudflare Artifacts. This server does
4// the same job with nothing but git: one bare repository per store key
5// under GITSTORE_ROOT, git's own smart HTTP (git http-backend) for clones,
6// fetches and pushes, and a small JSON API for the reads the repos service
7// makes (commits, trees, blobs, files) and for creating and forking.
8//
9// It is reached only by the Artifacts-compatible shim (workers/gitstore, the repos service's GITSTORE binding),
10// which the repos service is bound to in place of the Artifacts binding, and
11// by the repos service itself for git's smart HTTP. Nothing else should be
12// able to reach it: the API takes a shared secret, and git requests a
13// short-lived token the shim minted with the same secret.
14//
15// A key is a repository's name (`acme--rocket`), or a namespace and a name
16// (`g1t/acme--rocket`): hosted g1t's fallback store
17// (scripts/ops/restore-to-gitstore.mjs) keeps each Artifacts namespace's
18// repositories in a directory of their own, so a remote reads
19// `<GITSTORE_URL>/git/<namespace>/<name>.git`, the shape Artifacts gives
20// remotes.
21//
22// GITSTORE_READ_ONLY=1 refuses everything that writes: pushes, creating,
23// forking, deleting, and minting write tokens. As a fallback the store
24// serves reads until told otherwise; the repos service refuses writes too.
25//
26// No dependencies beyond Node and git.
27
28import { spawn } from "node:child_process";
29import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
30import { existsSync, mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
31import { createServer } from "node:http";
32import { rm } from "node:fs/promises";
33import { dirname, join } from "node:path";
34
35const ROOT = process.env.GITSTORE_ROOT ?? "/data/git";
36const PORT = Number(process.env.GITSTORE_PORT ?? 8080);
37const SECRET = loadSecret();
38// How the repos service reaches this server; it becomes each repository's
39// `remote`, exactly as Artifacts hands one out.
40const PUBLIC_URL = (process.env.GITSTORE_URL ?? `http://localhost:${PORT}`).replace(/\/$/, "");
41const READ_ONLY = ["1", "true", "yes"].includes(String(process.env.GITSTORE_READ_ONLY ?? "").toLowerCase());
42
43/**
44 * The secret shared with the Artifacts shim: GITSTORE_SECRET, or else the
45 * one in GITSTORE_SECRET_FILE, made on first start. The compose file shares
46 * that file with the g1t container, so nobody has to choose one.
47 */
48function loadSecret() {
49 if (process.env.GITSTORE_SECRET) return process.env.GITSTORE_SECRET;
50 const file = process.env.GITSTORE_SECRET_FILE;
51 if (!file) return "";
52 if (!existsSync(file)) {
53 mkdirSync(dirname(file), { recursive: true });
54 writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600 });
55 }
56 return readFileSync(file, "utf8").trim();
57}
58
59if (SECRET.length < 16) {
60 console.error("Set GITSTORE_SECRET (16 characters or more) or GITSTORE_SECRET_FILE.");
61 process.exit(1);
62}
63mkdirSync(ROOT, { recursive: true });
64
65const NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,199}$/;
66const HASH = /^[0-9a-f]{40}$/;
67
68class StoreError extends Error {
69 constructor(code, message, status = 400) {
70 super(message);
71 this.code = code;
72 this.status = status;
73 }
74}
75
76/** Whether `key` is a name, or a namespace and a name. */
77function validKey(key) {
78 if (typeof key !== "string" || key.includes("..")) return false;
79 const parts = key.split("/");
80 return parts.length <= 2 && parts.every((part) => NAME.test(part));
81}
82
83function repoDir(key) {
84 if (!validKey(key)) {
85 throw new StoreError("INVALID_REPO_NAME", `invalid repository name: ${key}`);
86 }
87 return join(ROOT, `${key}.git`);
88}
89
90function refuseWrites(what) {
91 if (READ_ONLY) throw new StoreError("READ_ONLY", `the git store is read-only: ${what} is refused`, 403);
92}
93
94function exists(key) {
95 return existsSync(join(repoDir(key), "HEAD"));
96}
97
98function requireRepo(key) {
99 if (!exists(key)) throw new StoreError("NOT_FOUND", `no repository ${key}`, 404);
100 return repoDir(key);
101}
102
103/** Runs git and resolves with its stdout as a Buffer. */
104function git(args, { cwd, input, allowFail = false } = {}) {
105 return new Promise((resolve, reject) => {
106 const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
107 const out = [];
108 const err = [];
109 child.stdout.on("data", (chunk) => out.push(chunk));
110 child.stderr.on("data", (chunk) => err.push(chunk));
111 child.on("error", reject);
112 child.on("close", (code) => {
113 if (code !== 0 && !allowFail) {
114 reject(new StoreError("INTERNAL_ERROR", `git ${args[0]} failed: ${Buffer.concat(err)}`, 500));
115 } else {
116 resolve({ code, stdout: Buffer.concat(out) });
117 }
118 });
119 child.stdin.end(input ?? undefined);
120 });
121}
122
123// ── Metadata kept beside each repository ────────────────────────────────
124
125function metaPath(key) {
126 return join(repoDir(key), "g1t.json");
127}
128
129function readMeta(key) {
130 try {
131 return JSON.parse(readFileSync(metaPath(key), "utf8"));
132 } catch {
133 return {};
134 }
135}
136
137function writeMeta(key, meta) {
138 writeFileSync(metaPath(key), JSON.stringify(meta, null, 2));
139}
140
141async function info(key) {
142 const dir = requireRepo(key);
143 const meta = readMeta(key);
144 const head = (await git(["symbolic-ref", "--short", "HEAD"], { cwd: dir, allowFail: true })).stdout
145 .toString()
146 .trim();
147 let lastPushAt = null;
148 try {
149 lastPushAt = statSync(join(dir, "g1t-pushed")).mtime.toISOString();
150 } catch {}
151 return {
152 id: meta.id ?? key,
153 name: key,
154 description: meta.description ?? null,
155 defaultBranch: head || "main",
156 createdAt: meta.createdAt ?? new Date(0).toISOString(),
157 updatedAt: lastPushAt ?? meta.createdAt ?? new Date(0).toISOString(),
158 lastPushAt,
159 source: meta.source ?? null,
160 readOnly: Boolean(meta.readOnly),
161 remote: `${PUBLIC_URL}/git/${key}.git`,
162 };
163}
164
165async function create(key, { description, defaultBranch, readOnly, source } = {}) {
166 refuseWrites("creating a repository");
167 const dir = repoDir(key);
168 if (exists(key)) throw new StoreError("ALREADY_EXISTS", `${key} already exists`, 409);
169 mkdirSync(dir, { recursive: true });
170 await git(["init", "--bare", "--quiet", `--initial-branch=${defaultBranch || "main"}`, dir]);
171 await configure(dir);
172 writeMeta(key, {
173 id: randomUUID(),
174 description: description ?? null,
175 createdAt: new Date().toISOString(),
176 readOnly: Boolean(readOnly),
177 source: source ?? null,
178 });
179 return info(key);
180}
181
182async function configure(dir) {
183 // Pushes arrive through git http-backend; the token has already been
184 // checked, so receive-pack is allowed for every write-scoped request.
185 await git(["config", "http.receivepack", "true"], { cwd: dir });
186 await git(["config", "receive.denyNonFastForwards", "false"], { cwd: dir });
187 await git(["config", "uploadpack.allowAnySHA1InWant", "true"], { cwd: dir });
188}
189
190async function fork(key, target, { description, readOnly, defaultBranchOnly = true } = {}) {
191 refuseWrites("forking");
192 const source = requireRepo(key);
193 const dir = repoDir(target);
194 if (exists(target)) throw new StoreError("ALREADY_EXISTS", `${target} already exists`, 409);
195 const args = ["clone", "--bare", "--quiet", "--no-tags"];
196 if (defaultBranchOnly) args.push("--single-branch");
197 // A local clone hard-links the objects: cheap, and independent of the
198 // source from then on.
199 args.push(source, dir);
200 await git(args);
201 await git(["remote", "remove", "origin"], { cwd: dir, allowFail: true });
202 await configure(dir);
203 writeMeta(target, {
204 id: randomUUID(),
205 description: description ?? readMeta(key).description ?? null,
206 createdAt: new Date().toISOString(),
207 readOnly: Boolean(readOnly),
208 source: `artifacts:${key}`,
209 });
210 return info(target);
211}
212
213// ── Reading objects ─────────────────────────────────────────────────────
214
215async function objectType(dir, spec) {
216 const { code, stdout } = await git(["cat-file", "-t", "--", spec], { cwd: dir, allowFail: true });
217 return code === 0 ? stdout.toString().trim() : null;
218}
219
220function person(line) {
221 // `Name <email> 1700000000 +0000`
222 const match = /^(.*) <([^>]*)> (\d+) [+-]\d{4}$/.exec(line);
223 return match ? { name: match[1], email: match[2], at: Number(match[3]) } : { name: line, email: "", at: 0 };
224}
225
226function parseCommit(hash, raw) {
227 const text = raw.toString("utf8");
228 const split = text.indexOf("\n\n");
229 const headers = (split === -1 ? text : text.slice(0, split)).split("\n");
230 let message = split === -1 ? "" : text.slice(split + 2);
231 if (message.endsWith("\n")) message = message.slice(0, -1);
232 const commit = { hash, treeHash: "", message, parents: [], author: null, committer: null };
233 for (const header of headers) {
234 const space = header.indexOf(" ");
235 const name = header.slice(0, space);
236 const value = header.slice(space + 1);
237 if (name === "tree") commit.treeHash = value;
238 else if (name === "parent") commit.parents.push(value);
239 else if (name === "author") commit.author = person(value);
240 else if (name === "committer") commit.committer = person(value);
241 }
242 const author = commit.author ?? { name: "", email: "", at: 0 };
243 const committer = commit.committer ?? author;
244 return {
245 hash,
246 treeHash: commit.treeHash,
247 message: commit.message,
248 author: { name: author.name, email: author.email },
249 committer: { name: committer.name, email: committer.email },
250 parents: commit.parents,
251 authoredAt: author.at,
252 committedAt: committer.at,
253 };
254}
255
256async function readCommit(key, hash) {
257 const dir = requireRepo(key);
258 if (!HASH.test(hash)) return null;
259 if ((await objectType(dir, hash)) !== "commit") return null;
260 return parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout);
261}
262
263async function log(key, { ref = "HEAD", limit = 50, offset = 0 } = {}) {
264 const dir = requireRepo(key);
265 if (typeof ref !== "string" || ref.startsWith("-")) return [];
266 const count = Math.max(1, Math.min(Number(limit) || 50, 1000));
267 const skip = Math.max(0, Number(offset) || 0);
268 const listed = await git(
269 ["rev-list", "--first-parent", `--max-count=${count}`, `--skip=${skip}`, ref, "--"],
270 { cwd: dir, allowFail: true },
271 );
272 if (listed.code !== 0) return [];
273 const hashes = listed.stdout.toString().split("\n").filter(Boolean);
274 const commits = [];
275 for (const hash of hashes) {
276 commits.push(parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout));
277 }
278 return commits;
279}
280
281const TYPES = { "040000": "tree", "100644": "blob", "100755": "exec", "120000": "symlink", "160000": "gitlink" };
282
283async function readTree(key, hash) {
284 const dir = requireRepo(key);
285 if (!HASH.test(hash)) return null;
286 if ((await objectType(dir, hash)) !== "tree") return null;
287 const { stdout } = await git(["ls-tree", "-z", hash], { cwd: dir });
288 return stdout
289 .toString("utf8")
290 .split("\0")
291 .filter(Boolean)
292 .map((line) => {
293 const tab = line.indexOf("\t");
294 const [mode, , object] = line.slice(0, tab).split(" ");
295 return {
296 name: line.slice(tab + 1),
297 mode: mode === "040000" ? "40000" : mode,
298 hash: object,
299 type: TYPES[mode] ?? "blob",
300 };
301 });
302}
303
304async function readBlob(key, hash) {
305 const dir = requireRepo(key);
306 if (!HASH.test(hash)) return null;
307 if ((await objectType(dir, hash)) !== "blob") return null;
308 return (await git(["cat-file", "blob", hash], { cwd: dir })).stdout;
309}
310
311async function readFile(key, ref, path) {
312 const dir = requireRepo(key);
313 if (!ref || !path || ref.startsWith("-") || ref.includes(":")) return null;
314 const spec = `${ref}:${path.replace(/^\/+/, "")}`;
315 if ((await objectType(dir, spec)) !== "blob") return null;
316 return (await git(["cat-file", "blob", spec], { cwd: dir })).stdout;
317}
318
319// ── Tokens for git's smart HTTP ─────────────────────────────────────────
320
321function sign(payload) {
322 return createHmac("sha256", SECRET).update(payload).digest("base64url");
323}
324
325function mintToken(key, scope = "write", ttl = 86400) {
326 if (scope === "write") refuseWrites("a write token");
327 const seconds = Math.max(60, Math.min(Number(ttl) || 86400, 31536000));
328 const expires = Math.floor(Date.now() / 1000) + seconds;
329 const id = randomUUID();
330 const payload = Buffer.from(JSON.stringify({ k: key, s: scope, e: expires, i: id })).toString("base64url");
331 return {
332 id,
333 plaintext: `${payload}.${sign(payload)}`,
334 scope,
335 expiresAt: new Date(expires * 1000).toISOString(),
336 };
337}
338
339function checkToken(token, key) {
340 const [payload, signature] = String(token ?? "").split(".");
341 if (!payload || !signature) return null;
342 const expected = Buffer.from(sign(payload));
343 const given = Buffer.from(signature);
344 if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
345 const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
346 if (claims.k !== key || claims.e < Date.now() / 1000) return null;
347 return claims;
348}
349
350function bearer(request) {
351 const header = request.headers.authorization ?? "";
352 if (/^bearer /i.test(header)) return header.slice(7).trim();
353 if (/^basic /i.test(header)) {
354 // A git client given the token as a password: `x:<token>`.
355 const decoded = Buffer.from(header.slice(6).trim(), "base64").toString();
356 return decoded.slice(decoded.indexOf(":") + 1);
357 }
358 return null;
359}
360
361// ── Smart HTTP through git http-backend ─────────────────────────────────
362
363function smartHttp(request, response, key, rest, query) {
364 if (!exists(key)) return send(response, 404, "not found");
365 const claims = checkToken(bearer(request), key);
366 if (!claims) {
367 response.writeHead(401, { "www-authenticate": 'Basic realm="g1t-gitstore"' });
368 return response.end("unauthorized");
369 }
370 const service = rest === "info/refs" ? new URLSearchParams(query).get("service") : rest;
371 if (service === "git-receive-pack" && claims.s !== "write") return send(response, 403, "read-only token");
372 if (service === "git-receive-pack" && READ_ONLY) return send(response, 403, "the git store is read-only");
373 if (service !== "git-upload-pack" && service !== "git-receive-pack") return send(response, 404, "not found");
374
375 const env = {
376 PATH: process.env.PATH,
377 GIT_PROJECT_ROOT: ROOT,
378 GIT_HTTP_EXPORT_ALL: "1",
379 REQUEST_METHOD: request.method,
380 PATH_INFO: `/${key}.git/${rest}`,
381 QUERY_STRING: query,
382 CONTENT_TYPE: request.headers["content-type"] ?? "",
383 REMOTE_USER: "g1t",
384 REMOTE_ADDR: request.socket.remoteAddress ?? "",
385 };
386 if (request.headers["git-protocol"]) env.GIT_PROTOCOL = request.headers["git-protocol"];
387 if (request.headers["content-encoding"]) env.HTTP_CONTENT_ENCODING = request.headers["content-encoding"];
388 if (request.headers["content-length"]) env.CONTENT_LENGTH = request.headers["content-length"];
389
390 const child = spawn("git", ["http-backend"], { env, stdio: ["pipe", "pipe", "pipe"] });
391 request.pipe(child.stdin);
392 child.stderr.on("data", (chunk) => process.stderr.write(chunk));
393
394 // CGI: headers, a blank line, then the body.
395 let buffered = Buffer.alloc(0);
396 let headersDone = false;
397 child.stdout.on("data", (chunk) => {
398 if (headersDone) return response.write(chunk);
399 buffered = Buffer.concat([buffered, chunk]);
400 let end = buffered.indexOf("\r\n\r\n");
401 let gap = 4;
402 if (end === -1) {
403 end = buffered.indexOf("\n\n");
404 gap = 2;
405 }
406 if (end === -1) return;
407 headersDone = true;
408 let status = 200;
409 const headers = {};
410 for (const line of buffered.slice(0, end).toString().split(/\r?\n/)) {
411 const colon = line.indexOf(":");
412 if (colon === -1) continue;
413 const name = line.slice(0, colon).trim().toLowerCase();
414 const value = line.slice(colon + 1).trim();
415 if (name === "status") status = Number.parseInt(value, 10);
416 else headers[name] = value;
417 }
418 response.writeHead(status, headers);
419 response.write(buffered.slice(end + gap));
420 });
421 child.on("close", (code) => {
422 if (!headersDone) {
423 send(response, 500, "git http-backend failed");
424 return;
425 }
426 if (service === "git-receive-pack" && request.method === "POST" && code === 0) {
427 const marker = join(repoDir(key), "g1t-pushed");
428 try {
429 utimesSync(marker, new Date(), new Date());
430 } catch {
431 writeFileSync(marker, "");
432 }
433 }
434 response.end();
435 });
436}
437
438// ── HTTP ────────────────────────────────────────────────────────────────
439
440function send(response, status, body, headers = {}) {
441 const isBuffer = Buffer.isBuffer(body);
442 const payload = isBuffer ? body : typeof body === "string" ? body : JSON.stringify(body);
443 response.writeHead(status, {
444 "content-type": isBuffer ? "application/octet-stream" : typeof body === "string" ? "text/plain" : "application/json",
445 ...headers,
446 });
447 response.end(payload);
448}
449
450async function readJson(request) {
451 const chunks = [];
452 for await (const chunk of request) chunks.push(chunk);
453 const text = Buffer.concat(chunks).toString();
454 return text ? JSON.parse(text) : {};
455}
456
457function authorized(request) {
458 const given = Buffer.from(request.headers["x-gitstore-secret"] ?? "");
459 const expected = Buffer.from(SECRET);
460 return given.length === expected.length && timingSafeEqual(given, expected);
461}
462
463async function api(request, response, parts, params) {
464 if (!authorized(request)) return send(response, 401, { code: "UNAUTHORIZED", message: "bad secret" });
465 const method = request.method;
466 // POST /api/repos create
467 if (parts.length === 0 && method === "POST") {
468 const body = await readJson(request);
469 return send(response, 200, await create(body.name, body));
470 }
471 const [key, action, arg] = parts;
472 if (method === "GET" && !action) return send(response, 200, await info(key));
473 // DELETE /api/repos/<key> delete (a purged repository)
474 if (method === "DELETE" && !action) {
475 refuseWrites("deleting a repository");
476 if (!exists(key)) return send(response, 404, { code: "NOT_FOUND", message: "no such repository" });
477 await rm(repoDir(key), { recursive: true, force: true });
478 return send(response, 200, { deleted: true });
479 }
480 if (method === "POST" && action === "tokens") {
481 requireRepo(key);
482 const body = await readJson(request);
483 return send(response, 200, mintToken(key, body.scope, body.ttl));
484 }
485 if (method === "POST" && action === "fork") {
486 const body = await readJson(request);
487 return send(response, 200, await fork(key, body.name, body));
488 }
489 if (method === "GET" && action === "commits") {
490 return send(response, 200, await readCommit(key, arg));
491 }
492 if (method === "GET" && action === "log") {
493 return send(response, 200, await log(key, Object.fromEntries(params)));
494 }
495 if (method === "GET" && action === "trees") {
496 return send(response, 200, await readTree(key, arg));
497 }
498 if (method === "GET" && (action === "blobs" || action === "file")) {
499 const bytes =
500 action === "blobs" ? await readBlob(key, arg) : await readFile(key, params.get("ref"), params.get("path"));
501 return bytes ? send(response, 200, bytes) : send(response, 404, { code: "NOT_FOUND", message: "no such object" });
502 }
503 return send(response, 404, { code: "NOT_FOUND", message: "no such route" });
504}
505
506const server = createServer(async (request, response) => {
507 const url = new URL(request.url, "http://gitstore");
508 try {
509 if (url.pathname === "/healthz") return send(response, 200, READ_ONLY ? "ok read-only" : "ok");
510 const git = /^\/git\/((?:[^/]+\/)?[^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(url.pathname);
511 if (git) return smartHttp(request, response, decodeURIComponent(git[1]), git[2], url.search.slice(1));
512 if (url.pathname === "/api/repos" || url.pathname.startsWith("/api/repos/")) {
513 const parts = url.pathname.slice("/api/repos".length).split("/").filter(Boolean).map(decodeURIComponent);
514 return await api(request, response, parts, url.searchParams);
515 }
516 send(response, 404, "not found");
517 } catch (error) {
518 const status = error instanceof StoreError ? error.status : 500;
519 const code = error instanceof StoreError ? error.code : "INTERNAL_ERROR";
520 if (status >= 500) console.error(error);
521 if (!response.headersSent) send(response, status, { code, message: error.message });
522 else response.end();
523 }
524});
525
526server.listen(PORT, () => {
527 console.log(`g1t gitstore: ${ROOT} on :${PORT} (remote ${PUBLIC_URL})${READ_ONLY ? ", read-only" : ""}`);
528});
529
530for (const signal of ["SIGINT", "SIGTERM"]) {
531 process.on(signal, () => server.close(() => process.exit(0)));
532}