import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { config } from "./config.ts"; const execFileAsync = promisify(execFile); const META_FILE = "pub-repo.json"; const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; export interface RepoMeta { /** Publiek zichtbaar en anoniem te clonen. */ public: boolean; description: string; createdAt: string; } export interface Repo extends RepoMeta { name: string; /** Jouw repository, met volledige geschiedenis. Wordt nooit anoniem geserveerd. */ dir: string; /** De publieke momentopname: een losse bare repo die alleen snapshot-commits bevat. */ publishedDir: string; cloneUrl: string; fullCloneUrl: string; } const DEFAULT_META: RepoMeta = { public: false, description: "", createdAt: "1970-01-01T00:00:00.000Z", }; /** * Repo-namen komen uit URL's, dus dit is de enige plek waar we vertrouwen op * pad-veiligheid. Alles wat hier niet doorheen komt bestaat simpelweg niet. */ export function isValidRepoName(name: string): boolean { if (!NAME_PATTERN.test(name)) return false; if (name.includes("..")) return false; if (name.endsWith(".git")) return false; return true; } export function repoDir(name: string): string { return path.join(config.repoRoot, `${name}.git`); } export function publishedDir(name: string): string { return path.join(config.publishedRoot, `${name}.git`); } export function cloneUrl(name: string): string { return `${config.publicUrl}${config.gitPrefix}/${name}.git`; } /** Alleen voor jezelf: hetzelfde repository, maar met de volledige geschiedenis. */ export function fullCloneUrl(name: string): string { return `${config.publicUrl}${config.gitFullPrefix}/${name}.git`; } async function readMeta(dir: string): Promise { try { const raw = await fs.readFile(path.join(dir, META_FILE), "utf8"); const parsed = JSON.parse(raw) as Partial; return { public: parsed.public === true, description: typeof parsed.description === "string" ? parsed.description : "", createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : DEFAULT_META.createdAt, }; } catch { // Een bare repo die buiten deze applicatie om is aangemaakt telt als privé. return { ...DEFAULT_META }; } } async function writeMeta(dir: string, meta: RepoMeta): Promise { const target = path.join(dir, META_FILE); const tmp = `${target}.tmp`; await fs.writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); await fs.rename(tmp, target); } async function isBareRepo(dir: string): Promise { try { const stat = await fs.stat(path.join(dir, "HEAD")); return stat.isFile(); } catch { return false; } } export async function getRepo(name: string): Promise { if (!isValidRepoName(name)) return null; const dir = repoDir(name); if (!(await isBareRepo(dir))) return null; const meta = await readMeta(dir); return { ...meta, name, dir, publishedDir: publishedDir(name), cloneUrl: cloneUrl(name), fullCloneUrl: fullCloneUrl(name) }; } /** Alle repos op schijf, alfabetisch. Filter zelf op `.public` voor anonieme bezoekers. */ export async function listRepos(): Promise { let entries: string[]; try { entries = await fs.readdir(config.repoRoot); } catch { return []; } const repos: Repo[] = []; for (const entry of entries) { if (!entry.endsWith(".git")) continue; const name = entry.slice(0, -".git".length); const repo = await getRepo(name); if (repo) repos.push(repo); } return repos.sort((a, b) => a.name.localeCompare(b.name)); } export async function createRepo(name: string, description: string): Promise { if (!isValidRepoName(name)) { throw new Error( "Ongeldige naam. Gebruik kleine letters, cijfers, punt, streepje of underscore.", ); } const dir = repoDir(name); if (await isBareRepo(dir)) { throw new Error(`Repository "${name}" bestaat al.`); } await fs.mkdir(config.repoRoot, { recursive: true }); await execFileAsync("git", ["init", "--bare", "--initial-branch=main", dir]); // Push over HTTP is standaard uit in git; dit zet het aan voor deze repo. await execFileAsync("git", ["-C", dir, "config", "http.receivepack", "true"]); const meta: RepoMeta = { public: false, description, createdAt: new Date().toISOString(), }; await writeMeta(dir, meta); return { ...meta, name, dir, publishedDir: publishedDir(name), cloneUrl: cloneUrl(name), fullCloneUrl: fullCloneUrl(name) }; } export async function updateRepo( name: string, patch: Partial>, ): Promise { const repo = await getRepo(name); if (!repo) throw new Error(`Repository "${name}" bestaat niet.`); const meta: RepoMeta = { public: patch.public ?? repo.public, description: patch.description ?? repo.description, createdAt: repo.createdAt, }; await writeMeta(repo.dir, meta); return { ...meta, name, dir: repo.dir, publishedDir: repo.publishedDir, cloneUrl: repo.cloneUrl, fullCloneUrl: repo.fullCloneUrl }; } export async function deleteRepo(name: string): Promise { const repo = await getRepo(name); if (!repo) throw new Error(`Repository "${name}" bestaat niet.`); await fs.rm(repo.dir, { recursive: true, force: true }); await fs.rm(repo.publishedDir, { recursive: true, force: true }); }