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 · 168 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/lib/util.mjs8 *  Purpose : Small shared helpers (validation, formatting, atomic IO)9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { writeFileSync, renameSync, mkdirSync } from 'node:fs';14import { dirname, join, normalize } from 'node:path';15import { randomBytes, createHash } from 'node:crypto';1617/** Repo naming rule from CLAUDE.md §3.1 — reject everything else hard. */18export const REPO_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;1920/**21 * @param {string} name22 * @returns {boolean} true when the name is a safe, valid repository name23 */24export function isValidRepoName(name) {25  if (typeof name !== 'string' || !REPO_NAME_RE.test(name)) return false;26  if (name === '.' || name === '..' || name.includes('..')) return false;27  if (name.endsWith('.git')) return false;28  return true;29}3031/**32 * Join `child` under `root` and guarantee the result stays inside `root`.33 * @param {string} root absolute base directory34 * @param {...string} segments path pieces (may come from user input)35 * @returns {string} normalized absolute path36 * @throws when the resolved path escapes the root37 */38export function safeJoin(root, ...segments) {39  const joined = normalize(join(root, ...segments));40  const normalizedRoot = normalize(root).replace(/\/+$/, '');41  if (joined !== normalizedRoot && !joined.startsWith(normalizedRoot + '/')) {42    throw new Error('path traversal rejected');43  }44  return joined;45}4647/**48 * Validate a repo-relative tree path (no traversal, no absolute, no NUL).49 * @param {string} path50 * @returns {boolean}51 */52export function isValidTreePath(path) {53  if (typeof path !== 'string') return false;54  if (path.includes('\0') || path.startsWith('/')) return false;55  const parts = path.split('/');56  return parts.every((p) => p !== '' && p !== '.' && p !== '..');57}5859/**60 * Write JSON atomically (tmp file + rename) so readers never see partial data.61 * @param {string} path62 * @param {unknown} value63 */64export function atomicWriteJSON(path, value) {65  mkdirSync(dirname(path), { recursive: true });66  const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;67  writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });68  renameSync(tmp, path);69}7071/**72 * @param {number} bytes73 * @returns {string} human size, e.g. "4.2 MB"74 */75export function formatBytes(bytes) {76  if (!Number.isFinite(bytes) || bytes < 0) return '0 B';77  const units = ['B', 'KB', 'MB', 'GB', 'TB'];78  let i = 0;79  let value = bytes;80  while (value >= 1024 && i < units.length - 1) {81    value /= 1024;82    i += 1;83  }84  return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;85}8687/**88 * @param {Date|number|string} input89 * @returns {string} GitHub-style relative time ("3 h ago")90 */91export function relativeTime(input) {92  const date = input instanceof Date ? input : new Date(input);93  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);94  if (!Number.isFinite(seconds)) return '';95  if (seconds < 0) return 'just now';96  if (seconds < 45) return 'just now';97  if (seconds < 90) return '1 min ago';98  const minutes = Math.floor(seconds / 60);99  if (minutes < 60) return `${minutes} min ago`;100  const hours = Math.floor(minutes / 60);101  if (hours < 24) return `${hours} h ago`;102  const days = Math.floor(hours / 24);103  if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;104  const months = Math.floor(days / 30);105  if (months < 12) return `${months} mo ago`;106  const years = Math.floor(days / 365);107  return years <= 1 ? '1 year ago' : `${years} years ago`;108}109110/**111 * @param {string} text112 * @returns {string} HTML-escaped text113 */114export function escapeHtml(text) {115  return String(text)116    .replaceAll('&', '&amp;')117    .replaceAll('<', '&lt;')118    .replaceAll('>', '&gt;')119    .replaceAll('"', '&quot;')120    .replaceAll("'", '&#39;');121}122123/**124 * Deterministic 5×5 identicon SVG for an author email.125 * @param {string} email126 * @param {number} [size] rendered square size in px127 * @returns {string} inline SVG markup128 */129export function identiconSvg(email, size = 32) {130  const hash = createHash('sha256').update(email.trim().toLowerCase()).digest();131  const hue = ((hash[0] << 8) | hash[1]) % 360;132  const fg = `hsl(${hue} 55% 55%)`;133  const bg = 'transparent';134  const cell = size / 5;135  let rects = '';136  for (let x = 0; x < 3; x += 1) {137    for (let y = 0; y < 5; y += 1) {138      if (hash[2 + x * 5 + y] % 2 === 0) continue;139      for (const cx of x === 2 ? [2] : [x, 4 - x]) {140        rects += `<rect x="${(cx * cell).toFixed(2)}" y="${(y * cell).toFixed(2)}" width="${cell.toFixed(2)}" height="${cell.toFixed(2)}"/>`;141      }142    }143  }144  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="identicon"><rect width="${size}" height="${size}" fill="${bg}"/><g fill="${fg}">${rects}</g></svg>`;145}146147/**148 * Run at most `limit` async jobs concurrently.149 * @template T,R150 * @param {T[]} items151 * @param {number} limit152 * @param {(item: T, index: number) => Promise<R>} worker153 * @returns {Promise<R[]>} results in input order154 */155export async function mapLimit(items, limit, worker) {156  const results = new Array(items.length);157  let next = 0;158  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {159    while (next < items.length) {160      const index = next;161      next += 1;162      results[index] = await worker(items[index], index);163    }164  });165  await Promise.all(runners);166  return results;167}168