/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/git/archive.mjs * Purpose : zip / tar.gz snapshot generation with per-sha caching * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { spawn } from 'node:child_process'; import { createWriteStream, createReadStream, existsSync, renameSync, statSync, rmSync } from 'node:fs'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { randomBytes } from 'node:crypto'; export const ARCHIVE_FORMATS = Object.freeze({ zip: { ext: 'zip', mime: 'application/zip', gitFormat: 'zip' }, 'tar.gz': { ext: 'tar.gz', mime: 'application/gzip', gitFormat: 'tar.gz' }, }); /** * Produce (or reuse) a snapshot archive for `@` and stream it. * @param {{repos: object, cache: object}} ctx * @param {string} repo * @param {string} sha resolved commit sha * @param {'zip'|'tar.gz'} format * @param {import('fastify').FastifyReply} reply * @param {string} downloadName filename presented to the client (no extension) */ export async function sendArchive(ctx, repo, sha, format, reply, downloadName) { const spec = ARCHIVE_FORMATS[format]; if (!spec) { return reply.code(400).send({ error: { code: 'bad_format', message: 'zip or tar.gz only' } }); } const cachePath = ctx.cache.repoPath(repo, sha, `archive.${spec.ext}`); if (!existsSync(cachePath)) { mkdirSync(dirname(cachePath), { recursive: true }); const tmp = `${cachePath}.${randomBytes(4).toString('hex')}.tmp`; const ok = await new Promise((resolve) => { const child = spawn( 'git', ['archive', `--format=${spec.gitFormat}`, `--prefix=${downloadName}/`, sha], { cwd: ctx.repos.dir(repo) }, ); const out = createWriteStream(tmp); child.stdout.pipe(out); child.on('error', () => resolve(false)); child.on('close', (code) => { out.close(() => resolve(code === 0)); }); }); if (!ok) { rmSync(tmp, { force: true }); return reply.code(500).send({ error: { code: 'archive_failed', message: 'could not create archive' } }); } renameSync(tmp, cachePath); } const size = statSync(cachePath).size; reply .header('Content-Type', spec.mime) .header('Content-Length', size) .header('Content-Disposition', `attachment; filename="${downloadName}.${spec.ext}"`) .header('Cache-Control', 'public, max-age=31536000, immutable') .header('X-Content-Type-Options', 'nosniff'); return reply.send(createReadStream(cachePath)); }