SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
6.0 KB · 162 lines javascript
Raw Blame History
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