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/archive.mjs8 * Purpose : zip / tar.gz snapshot generation with per-sha caching9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { spawn } from 'node:child_process';14import { createWriteStream, createReadStream, existsSync, renameSync, statSync, rmSync } from 'node:fs';15import { mkdirSync } from 'node:fs';16import { dirname } from 'node:path';17import { randomBytes } from 'node:crypto';1819export const ARCHIVE_FORMATS = Object.freeze({20 zip: { ext: 'zip', mime: 'application/zip', gitFormat: 'zip' },21 'tar.gz': { ext: 'tar.gz', mime: 'application/gzip', gitFormat: 'tar.gz' },22});2324/**25 * Produce (or reuse) a snapshot archive for `<repo>@<sha>` and stream it.26 * @param {{repos: object, cache: object}} ctx27 * @param {string} repo28 * @param {string} sha resolved commit sha29 * @param {'zip'|'tar.gz'} format30 * @param {import('fastify').FastifyReply} reply31 * @param {string} downloadName filename presented to the client (no extension)32 */33export async function sendArchive(ctx, repo, sha, format, reply, downloadName) {34 const spec = ARCHIVE_FORMATS[format];35 if (!spec) {36 return reply.code(400).send({ error: { code: 'bad_format', message: 'zip or tar.gz only' } });37 }38 const cachePath = ctx.cache.repoPath(repo, sha, `archive.${spec.ext}`);3940 if (!existsSync(cachePath)) {41 mkdirSync(dirname(cachePath), { recursive: true });42 const tmp = `${cachePath}.${randomBytes(4).toString('hex')}.tmp`;43 const ok = await new Promise((resolve) => {44 const child = spawn(45 'git',46 ['archive', `--format=${spec.gitFormat}`, `--prefix=${downloadName}/`, sha],47 { cwd: ctx.repos.dir(repo) },48 );49 const out = createWriteStream(tmp);50 child.stdout.pipe(out);51 child.on('error', () => resolve(false));52 child.on('close', (code) => {53 out.close(() => resolve(code === 0));54 });55 });56 if (!ok) {57 rmSync(tmp, { force: true });58 return reply.code(500).send({ error: { code: 'archive_failed', message: 'could not create archive' } });59 }60 renameSync(tmp, cachePath);61 }6263 const size = statSync(cachePath).size;64 reply65 .header('Content-Type', spec.mime)66 .header('Content-Length', size)67 .header('Content-Disposition', `attachment; filename="${downloadName}.${spec.ext}"`)68 .header('Cache-Control', 'public, max-age=31536000, immutable')69 .header('X-Content-Type-Options', 'nosniff');70 return reply.send(createReadStream(cachePath));71}72