spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/git/smart-http.mjs8 * Purpose : Git Smart HTTP — streamed upload-pack / receive-pack9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { spawn } from 'node:child_process';14import { createGunzip } from 'node:zlib';15import { readFileSync, existsSync } from 'node:fs';16import { join } from 'node:path';17import { atomicWriteJSON } from '../lib/util.mjs';18import { extractToken } from '../auth/token.mjs';1920const SERVICES = new Set(['git-upload-pack', 'git-receive-pack']);2122/**23 * Encode a git pkt-line.24 * @param {string} payload25 * @returns {Buffer}26 */27export function pktLine(payload) {28 const length = (payload.length + 4).toString(16).padStart(4, '0');29 return Buffer.from(length + payload, 'utf8');30}3132/** The pkt-line flush packet. */33export const FLUSH = Buffer.from('0000', 'utf8');3435/**36 * Parse `/:name.git` style params into a validated repo name.37 * @param {object} ctx registration context38 * @param {string} raw the `:repo` param39 * @returns {string|null}40 */41function repoFromParam(ctx, raw) {42 if (typeof raw !== 'string' || !raw.endsWith('.git')) return null;43 const name = raw.slice(0, -4);44 return ctx.repos.exists(name) ? name : null;45}4647/** Track clone/fetch counts in data/clones.json (best-effort). */48function bumpCloneCount(ctx, repo) {49 const path = join(ctx.config.dataDir, 'clones.json');50 let db = {};51 try {52 if (existsSync(path)) db = JSON.parse(readFileSync(path, 'utf8'));53 } catch {54 db = {};55 }56 db[repo] = (db[repo] ?? 0) + 1;57 try {58 atomicWriteJSON(path, db);59 } catch {60 /* non-critical */61 }62}6364/**65 * Register Smart HTTP routes on the Fastify app.66 * @param {import('fastify').FastifyInstance} app67 * @param {{config: object, repos: object, tokens: object, log?: object}} ctx68 */69export function registerSmartHttp(app, ctx) {70 // Pass git request bodies through untouched — never buffer packfiles.71 app.addContentTypeParser(72 ['application/x-git-upload-pack-request', 'application/x-git-receive-pack-request'],73 (_request, payload, done) => done(null, payload),74 );7576 /** Authenticate a push request (HTTP Basic, username `spb`, password = PAT). */77 async function authorizePush(request, reply) {78 const candidate = extractToken(request.headers.authorization);79 const record = candidate ? await ctx.tokens.verify(candidate) : null;80 if (!record) {81 reply82 .code(401)83 .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"')84 .type('text/plain')85 .send('Authentication required: username "spb", password = personal access token.');86 return false;87 }88 return true;89 }9091 app.get('/:repo/info/refs', async (request, reply) => {92 const repo = repoFromParam(ctx, request.params.repo);93 const service = request.query.service;94 if (!repo) return reply.code(404).type('text/plain').send('repository not found');95 if (!SERVICES.has(service)) {96 return reply.code(400).type('text/plain').send('smart HTTP only — dumb protocol is disabled');97 }98 if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply;99100 reply.hijack();101 const res = reply.raw;102 res.writeHead(200, {103 'Content-Type': `application/x-${service}-advertisement`,104 'Cache-Control': 'no-cache, max-age=0, must-revalidate',105 'X-Content-Type-Options': 'nosniff',106 });107 res.write(pktLine(`# service=${service}\n`));108 res.write(FLUSH);109 const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', '--advertise-refs', ctx.repos.dir(repo)], {110 env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' },111 });112 child.stdout.pipe(res);113 child.stderr.on('data', (d) => request.log.warn({ repo, service }, d.toString().trim()));114 child.on('close', () => res.end());115 child.on('error', (err) => {116 request.log.error({ err }, 'info/refs spawn failed');117 res.end();118 });119 return reply;120 });121122 for (const service of SERVICES) {123 app.post(`/:repo/${service}`, async (request, reply) => {124 const repo = repoFromParam(ctx, request.params.repo);125 if (!repo) return reply.code(404).type('text/plain').send('repository not found');126 if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply;127 if (service === 'git-upload-pack') bumpCloneCount(ctx, repo);128129 reply.hijack();130 const res = reply.raw;131 res.writeHead(200, {132 'Content-Type': `application/x-${service}-result`,133 'Cache-Control': 'no-cache, max-age=0, must-revalidate',134 'X-Content-Type-Options': 'nosniff',135 });136 const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', ctx.repos.dir(repo)], {137 env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' },138 });139140 let body = request.body ?? request.raw;141 if ((request.headers['content-encoding'] ?? '').includes('gzip')) {142 const gunzip = createGunzip();143 body.pipe(gunzip);144 body = gunzip;145 }146 body.pipe(child.stdin);147 child.stdout.pipe(res);148 child.stderr.on('data', (d) => request.log.info({ repo, service }, d.toString().trim()));149 child.on('close', (code) => {150 if (code !== 0) request.log.warn({ repo, service, code }, 'git service exited non-zero');151 res.end();152 });153 child.on('error', (err) => {154 request.log.error({ err }, 'git service spawn failed');155 res.end();156 });157 request.raw.on('aborted', () => child.kill('SIGTERM'));158 return reply;159 });160 }161}162