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/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('&', '&')117 .replaceAll('<', '<')118 .replaceAll('>', '>')119 .replaceAll('"', '"')120 .replaceAll("'", ''');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