SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
5.9 KB · 183 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/releases.mjs8 *  Purpose : Release assets — binary artifacts (dmg, pkg…) attached to tags9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import {14  createWriteStream, createReadStream, existsSync, readdirSync, statSync,15  readFileSync, writeFileSync, rmSync, mkdirSync, renameSync,16} from 'node:fs';17import { join, dirname } from 'node:path';18import { createHash, randomBytes } from 'node:crypto';19import { pipeline } from 'node:stream/promises';20import { Transform } from 'node:stream';21import { safeJoin } from '../lib/util.mjs';2223/** Assets live OUTSIDE the bare repos: <dataDir>/releases/<repo>/<tag>/<file>. */24const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;25const TAG_NAME_RE = /^[\w][\w./-]{0,100}$/;26const MAX_ASSET_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB2728/** Download MIME types for common artifacts. */29export const ASSET_MIME = Object.freeze({30  '.dmg': 'application/x-apple-diskimage',31  '.pkg': 'application/octet-stream',32  '.zip': 'application/zip',33  '.gz': 'application/gzip',34  '.tgz': 'application/gzip',35  '.xz': 'application/x-xz',36  '.zst': 'application/zstd',37  '.exe': 'application/vnd.microsoft.portable-executable',38  '.msi': 'application/x-msi',39  '.deb': 'application/vnd.debian.binary-package',40  '.rpm': 'application/x-rpm',41  '.appimage': 'application/octet-stream',42  '.apk': 'application/vnd.android.package-archive',43  '.ipa': 'application/octet-stream',44  '.whl': 'application/octet-stream',45  '.jar': 'application/java-archive',46});4748/** @param {string} name @returns {boolean} */49export function isValidAssetName(name) {50  return typeof name === 'string' && ASSET_NAME_RE.test(name) && !name.endsWith('.sha256');51}5253/** @param {string} tag @returns {boolean} */54export function isValidTagName(tag) {55  return typeof tag === 'string' && TAG_NAME_RE.test(tag) && !tag.includes('..');56}5758/**59 * Filesystem store for release assets.60 */61export class ReleaseStore {62  /** @param {string} dataDir */63  constructor(dataDir) {64    this.root = join(dataDir, 'releases');65  }6667  /** @returns {string} absolute asset path (traversal-safe) */68  assetPath(repo, tag, name) {69    if (!isValidTagName(tag) || !isValidAssetName(name)) throw new Error('invalid tag or asset name');70    return safeJoin(this.root, repo, tag.replaceAll('/', '_'), name);71  }7273  /**74   * Persist an incoming stream as an asset. Computes sha256 while writing.75   * @param {string} repo76   * @param {string} tag77   * @param {string} name78   * @param {NodeJS.ReadableStream} stream79   * @returns {Promise<{name: string, size: number, sha256: string}>}80   */81  async save(repo, tag, name, stream) {82    const dest = this.assetPath(repo, tag, name);83    mkdirSync(dirname(dest), { recursive: true });84    const tmp = `${dest}.${randomBytes(4).toString('hex')}.tmp`;85    const hash = createHash('sha256');86    let size = 0;87    const counter = new Transform({88      transform(chunk, _enc, done) {89        size += chunk.length;90        if (size > MAX_ASSET_BYTES) {91          done(new Error('asset exceeds the 4 GiB limit'));92          return;93        }94        hash.update(chunk);95        done(null, chunk);96      },97    });98    try {99      await pipeline(stream, counter, createWriteStream(tmp));100    } catch (err) {101      rmSync(tmp, { force: true });102      throw err;103    }104    renameSync(tmp, dest);105    const sha256 = hash.digest('hex');106    writeFileSync(`${dest}.sha256`, `${sha256}  ${name}\n`);107    return { name, size, sha256 };108  }109110  /**111   * @returns {{name: string, size: number, sha256: string|null, uploaded: string}|null}112   */113  stat(repo, tag, name) {114    let path;115    try {116      path = this.assetPath(repo, tag, name);117    } catch {118      return null;119    }120    if (!existsSync(path)) return null;121    const st = statSync(path);122    let sha256 = null;123    try {124      sha256 = readFileSync(`${path}.sha256`, 'utf8').split(/\s+/)[0] || null;125    } catch {126      /* older asset without sidecar */127    }128    return { name, size: st.size, sha256, uploaded: st.mtime.toISOString() };129  }130131  /**132   * All assets of a repo grouped by tag.133   * @param {string} repo134   * @returns {Record<string, Array<{name: string, size: number, sha256: string|null, uploaded: string}>>}135   */136  list(repo) {137    const repoDir = safeJoin(this.root, repo);138    const result = {};139    if (!existsSync(repoDir)) return result;140    for (const tag of readdirSync(repoDir)) {141      const tagDir = join(repoDir, tag);142      if (!statSync(tagDir).isDirectory()) continue;143      const assets = readdirSync(tagDir)144        .filter((f) => !f.endsWith('.sha256') && !f.endsWith('.tmp'))145        .map((f) => this.stat(repo, tag, f))146        .filter(Boolean)147        .sort((a, b) => a.name.localeCompare(b.name));148      if (assets.length > 0) result[tag] = assets;149    }150    return result;151  }152153  /**154   * @returns {boolean} true when the asset existed and was removed155   */156  remove(repo, tag, name) {157    let path;158    try {159      path = this.assetPath(repo, tag, name);160    } catch {161      return false;162    }163    if (!existsSync(path)) return false;164    rmSync(path, { force: true });165    rmSync(`${path}.sha256`, { force: true });166    return true;167  }168169  /** Remove every asset of a repo (used by repo soft-delete). */170  removeRepo(repo) {171    try {172      rmSync(safeJoin(this.root, repo), { recursive: true, force: true });173    } catch {174      /* nothing to remove */175    }176  }177178  /** @returns {import('node:fs').ReadStream} */179  readStream(repo, tag, name) {180    return createReadStream(this.assetPath(repo, tag, name));181  }182}183