/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/git/smart-http.mjs * Purpose : Git Smart HTTP — streamed upload-pack / receive-pack * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { spawn } from 'node:child_process'; import { createGunzip } from 'node:zlib'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { atomicWriteJSON } from '../lib/util.mjs'; import { extractToken } from '../auth/token.mjs'; const SERVICES = new Set(['git-upload-pack', 'git-receive-pack']); /** * Encode a git pkt-line. * @param {string} payload * @returns {Buffer} */ export function pktLine(payload) { const length = (payload.length + 4).toString(16).padStart(4, '0'); return Buffer.from(length + payload, 'utf8'); } /** The pkt-line flush packet. */ export const FLUSH = Buffer.from('0000', 'utf8'); /** * Parse `/:name.git` style params into a validated repo name. * @param {object} ctx registration context * @param {string} raw the `:repo` param * @returns {string|null} */ function repoFromParam(ctx, raw) { if (typeof raw !== 'string' || !raw.endsWith('.git')) return null; const name = raw.slice(0, -4); return ctx.repos.exists(name) ? name : null; } /** Track clone/fetch counts in data/clones.json (best-effort). */ function bumpCloneCount(ctx, repo) { const path = join(ctx.config.dataDir, 'clones.json'); let db = {}; try { if (existsSync(path)) db = JSON.parse(readFileSync(path, 'utf8')); } catch { db = {}; } db[repo] = (db[repo] ?? 0) + 1; try { atomicWriteJSON(path, db); } catch { /* non-critical */ } } /** * Register Smart HTTP routes on the Fastify app. * @param {import('fastify').FastifyInstance} app * @param {{config: object, repos: object, tokens: object, log?: object}} ctx */ export function registerSmartHttp(app, ctx) { // Pass git request bodies through untouched — never buffer packfiles. app.addContentTypeParser( ['application/x-git-upload-pack-request', 'application/x-git-receive-pack-request'], (_request, payload, done) => done(null, payload), ); /** Authenticate a push request (HTTP Basic, username `spb`, password = PAT). */ async function authorizePush(request, reply) { const candidate = extractToken(request.headers.authorization); const record = candidate ? await ctx.tokens.verify(candidate) : null; if (!record) { reply .code(401) .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"') .type('text/plain') .send('Authentication required: username "spb", password = personal access token.'); return false; } return true; } app.get('/:repo/info/refs', async (request, reply) => { const repo = repoFromParam(ctx, request.params.repo); const service = request.query.service; if (!repo) return reply.code(404).type('text/plain').send('repository not found'); if (!SERVICES.has(service)) { return reply.code(400).type('text/plain').send('smart HTTP only — dumb protocol is disabled'); } if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply; reply.hijack(); const res = reply.raw; res.writeHead(200, { 'Content-Type': `application/x-${service}-advertisement`, 'Cache-Control': 'no-cache, max-age=0, must-revalidate', 'X-Content-Type-Options': 'nosniff', }); res.write(pktLine(`# service=${service}\n`)); res.write(FLUSH); const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', '--advertise-refs', ctx.repos.dir(repo)], { env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' }, }); child.stdout.pipe(res); child.stderr.on('data', (d) => request.log.warn({ repo, service }, d.toString().trim())); child.on('close', () => res.end()); child.on('error', (err) => { request.log.error({ err }, 'info/refs spawn failed'); res.end(); }); return reply; }); for (const service of SERVICES) { app.post(`/:repo/${service}`, async (request, reply) => { const repo = repoFromParam(ctx, request.params.repo); if (!repo) return reply.code(404).type('text/plain').send('repository not found'); if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply; if (service === 'git-upload-pack') bumpCloneCount(ctx, repo); reply.hijack(); const res = reply.raw; res.writeHead(200, { 'Content-Type': `application/x-${service}-result`, 'Cache-Control': 'no-cache, max-age=0, must-revalidate', 'X-Content-Type-Options': 'nosniff', }); const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', ctx.repos.dir(repo)], { env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' }, }); let body = request.body ?? request.raw; if ((request.headers['content-encoding'] ?? '').includes('gzip')) { const gunzip = createGunzip(); body.pipe(gunzip); body = gunzip; } body.pipe(child.stdin); child.stdout.pipe(res); child.stderr.on('data', (d) => request.log.info({ repo, service }, d.toString().trim())); child.on('close', (code) => { if (code !== 0) request.log.warn({ repo, service, code }, 'git service exited non-zero'); res.end(); }); child.on('error', (err) => { request.log.error({ err }, 'git service spawn failed'); res.end(); }); request.raw.on('aborted', () => child.kill('SIGTERM')); return reply; }); } }