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