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/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