Minimale git-remote met publiek/prive-toggle

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.4 kB Ruw bestand
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<RepoMeta> {
  try {
    const raw = await fs.readFile(path.join(dir, META_FILE), "utf8");
    const parsed = JSON.parse(raw) as Partial<RepoMeta>;
    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<void> {
  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<boolean> {
  try {
    const stat = await fs.stat(path.join(dir, "HEAD"));
    return stat.isFile();
  } catch {
    return false;
  }
}

export async function getRepo(name: string): Promise<Repo | null> {
  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<Repo[]> {
  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<Repo> {
  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<Pick<RepoMeta, "public" | "description">>,
): Promise<Repo> {
  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<void> {
  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 });
}