Minimale git-remote met publiek/prive-toggle

main

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

12.1 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);

/**
 * In jouw eigen repository houden we per branch bij welke momentopname er
 * gepubliceerd is. Die commit heeft geen ouders en wordt naar een losse bare
 * repository geduwd; alleen die tweede repository wordt anoniem geserveerd.
 *
 * Afschermen binnen één repository werkt niet: refs verbergen (via namespaces of
 * `transfer.hideRefs`) houdt objecten alleen uit de advertentie, maar een client
 * kan ze daarna alsnog per object-id opvragen. Zie gitnamespaces(7).
 */
const PUBLISHED_PREFIX = "refs/published/";

const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._\/-]*$/;

export interface TreeEntry {
  mode: string;
  type: "blob" | "tree" | "commit";
  oid: string;
  size: number | null;
  name: string;
}

export class GitError extends Error {}

async function exists(target: string): Promise<boolean> {
  try {
    await fs.stat(target);
    return true;
  } catch {
    return false;
  }
}

export function isValidBranch(name: string): boolean {
  if (!BRANCH_PATTERN.test(name)) return false;
  if (name.includes("..")) return false;
  if (name.endsWith("/") || name.endsWith(".lock")) return false;
  return true;
}

export function normalizePath(input: string): string {
  const segments = input
    .split("/")
    .filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
  return segments.join("/");
}

async function git(
  dir: string,
  args: string[],
  options: { maxBuffer?: number; env?: NodeJS.ProcessEnv } = {},
) {
  try {
    return await execFileAsync("git", ["-C", dir, ...args], {
      maxBuffer: options.maxBuffer ?? 8 * 1024 * 1024,
      encoding: "buffer",
      windowsHide: true,
      ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
    });
  } catch (error) {
    const stderr = (error as { stderr?: Buffer }).stderr?.toString("utf8") ?? "";
    throw new GitError(stderr.trim() || `git ${args[0]} mislukt`);
  }
}

async function gitText(dir: string, args: string[]): Promise<string> {
  const { stdout } = await git(dir, args);
  return stdout.toString("utf8");
}

// --- publiceren ---------------------------------------------------------------

/**
 * Engels, in tegenstelling tot de rest van dit project: deze tekst staat straks in
 * de repository van iedereen die je code cloont, niet op jouw site.
 */
const SNAPSHOT_MESSAGE = (branch: string) =>
  `Snapshot of ${branch}\n\n` +
  `This repository is published without its history. This commit has no parents\n` +
  `and contains the full contents as currently published.\n`;

async function refMap(dir: string, prefix: string): Promise<Map<string, string>> {
  const out = await gitText(dir, ["for-each-ref", "--format=%(refname) %(objectname)", prefix]);
  const map = new Map<string, string>();
  for (const line of out.split("\n")) {
    if (!line) continue;
    const space = line.indexOf(" ");
    if (space === -1) continue;
    map.set(line.slice(0, space), line.slice(space + 1));
  }
  return map;
}

/**
 * Bouwt de publieke repository opnieuw op uit `refs/heads/` van je eigen
 * repository: per branch één commit zonder ouders met exact dezelfde boom, die
 * naar `publishedDir` geduwd wordt. Omdat die commit geen ouders heeft, gaan
 * alleen de boom en de bestanden mee — de geschiedenis komt er nooit in.
 *
 * Draait na elke push, en bij het starten voor repositories die nog geen
 * momentopname hebben.
 */
export async function publishSnapshot(dir: string, publishedDir: string): Promise<void> {
  await fs.mkdir(path.dirname(publishedDir), { recursive: true });
  if (!(await exists(path.join(publishedDir, "HEAD")))) {
    await execFileAsync("git", ["init", "--bare", "--initial-branch=main", publishedDir]);
  }

  const heads = await refMap(dir, "refs/heads");
  const alreadyPublished = await refMap(dir, PUBLISHED_PREFIX);
  const refspecs: string[] = [];

  for (const [refName, tip] of heads) {
    const branch = refName.slice("refs/heads/".length);
    if (!isValidBranch(branch)) continue;

    const marker = `${PUBLISHED_PREFIX}${branch}`;
    const tree = (await gitText(dir, ["rev-parse", `${tip}^{tree}`])).trim();
    const message = SNAPSHOT_MESSAGE(branch);

    // Ongewijzigde inhoud levert geen nieuwe momentopname op, zodat de gepubliceerde
    // commit-id stabiel blijft als er niets veranderd is. Auteur en boodschap horen
    // bij die vergelijking: verandert het formaat, dan moeten bestaande
    // momentopnames opnieuw gemaakt worden zonder dat iemand dat met de hand doet.
    const existing = alreadyPublished.get(marker);
    let reusable = false;
    if (existing) {
      const [existingTree, existingEmail, existingMessage] = (
        await gitText(dir, ["show", "-s", "--format=%T%x1f%ae%x1f%B", existing])
      ).split("\x1f");
      reusable =
        existingTree?.trim() === tree &&
        existingEmail === config.snapshotAuthor.email &&
        existingMessage === message;
    }

    if (!reusable) {
      const date = (await gitText(dir, ["show", "-s", "--format=%cI", tip])).trim();
      const { stdout } = await git(dir, ["commit-tree", tree, "-m", message], {
        env: {
          GIT_AUTHOR_NAME: config.snapshotAuthor.name,
          GIT_AUTHOR_EMAIL: config.snapshotAuthor.email,
          GIT_COMMITTER_NAME: config.snapshotAuthor.name,
          GIT_COMMITTER_EMAIL: config.snapshotAuthor.email,
          GIT_AUTHOR_DATE: date,
          GIT_COMMITTER_DATE: date,
        },
      });
      await git(dir, ["update-ref", marker, stdout.toString("utf8").trim()]);
    }

    refspecs.push(`+${marker}:refs/heads/${branch}`);
  }

  // Branches die je verwijderd hebt, verdwijnen ook uit de publieke repository.
  for (const marker of alreadyPublished.keys()) {
    const branch = marker.slice(PUBLISHED_PREFIX.length);
    if (!heads.has(`refs/heads/${branch}`)) {
      await git(dir, ["update-ref", "-d", marker]);
    }
  }
  for (const refName of (await refMap(publishedDir, "refs/heads")).keys()) {
    const branch = refName.slice("refs/heads/".length);
    if (!heads.has(`refs/heads/${branch}`)) refspecs.push(`:refs/heads/${branch}`);
  }

  if (refspecs.length > 0) {
    await git(dir, ["push", "--force", publishedDir, ...refspecs]);
  }

  await syncPublishedHead(dir, publishedDir);

  // Oude momentopnames zijn ook geschiedenis. Ze zijn na de force-push
  // onbereikbaar, maar blijven per object-id opvraagbaar tot ze echt weg zijn.
  await git(publishedDir, ["reflog", "expire", "--expire=now", "--all"]).catch(() => undefined);
  await git(publishedDir, ["gc", "--prune=now", "--quiet"]).catch(() => undefined);
}

/** HEAD van de publieke repository bepaalt welke branch een clone uitcheckt. */
async function syncPublishedHead(dir: string, publishedDir: string): Promise<void> {
  const branches = await listPublishedBranches(publishedDir);
  if (branches.length === 0) return;

  let head = "main";
  try {
    head = (await gitText(dir, ["symbolic-ref", "--short", "HEAD"])).trim() || "main";
  } catch {
    /* verse repo zonder HEAD-symref */
  }
  const branch = branches.includes(head) ? head : branches[0]!;
  await git(publishedDir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]);
}

// --- lezen voor de webweergave ------------------------------------------------

/**
 * De blader-UI leest uitsluitend uit de gepubliceerde repository, ook als jij zelf
 * ingelogd bent. Zo kan er via het web niets uit de geschiedenis komen, en zie je
 * precies wat een bezoeker ziet.
 */
export async function listPublishedBranches(publishedDir: string): Promise<string[]> {
  try {
    const out = await gitText(publishedDir, ["for-each-ref", "--format=%(refname)", "refs/heads"]);
    return out
      .split("\n")
      .filter(Boolean)
      .map((refName) => refName.slice("refs/heads/".length))
      .sort();
  } catch {
    // Nog niets gepubliceerd: de repository bestaat wel, maar is leeg.
    return [];
  }
}

export async function publishedHeadBranch(publishedDir: string): Promise<string | null> {
  const branches = await listPublishedBranches(publishedDir);
  if (branches.length === 0) return null;
  try {
    const branch = (await gitText(publishedDir, ["symbolic-ref", "--short", "HEAD"])).trim();
    if (branches.includes(branch)) return branch;
  } catch {
    /* geen bruikbare HEAD */
  }
  return branches[0]!;
}

/**
 * Bouwt de revspec zelf op uit een branch die aantoonbaar bestaat. Een bezoeker
 * kan dus geen willekeurige commit-sha opvragen — losse objecten uit de
 * geschiedenis zijn via het web onbereikbaar.
 */
function revspec(branch: string, filePath: string): string {
  return `refs/heads/${branch}:${filePath}`;
}

/**
 * `rest` uit de URL is `<branch>/<pad>`, en een branchnaam mag zelf schuine
 * strepen bevatten. We matchen daarom op de langst passende gepubliceerde branch;
 * matcht er geen, dan bestaat de pagina niet.
 */
export async function splitBranchAndPath(
  publishedDir: string,
  rest: string,
): Promise<{ branch: string; filePath: string } | null> {
  const cleaned = rest.replace(/^\/+/, "");
  const branches = (await listPublishedBranches(publishedDir)).sort((a, b) => b.length - a.length);

  for (const branch of branches) {
    if (cleaned === branch) return { branch, filePath: "" };
    if (cleaned.startsWith(`${branch}/`)) {
      return { branch, filePath: normalizePath(cleaned.slice(branch.length + 1)) };
    }
  }
  return null;
}

export interface CommitSummary {
  date: string;
}

export async function snapshotDate(publishedDir: string, branch: string): Promise<CommitSummary | null> {
  if (!isValidBranch(branch)) return null;
  try {
    const out = await gitText(publishedDir, [
      "show",
      "-s",
      "--format=%cI",
      `refs/heads/${branch}`,
    ]);
    return { date: out.trim() };
  } catch {
    return null;
  }
}

export async function objectType(
  publishedDir: string,
  branch: string,
  filePath: string,
): Promise<"blob" | "tree" | null> {
  if (!isValidBranch(branch)) return null;
  try {
    const out = (await gitText(publishedDir, ["cat-file", "-t", revspec(branch, filePath)])).trim();
    if (out === "blob" || out === "tree") return out;
    return null;
  } catch {
    return null;
  }
}

export async function listTree(
  publishedDir: string,
  branch: string,
  filePath: string,
): Promise<TreeEntry[]> {
  if (!isValidBranch(branch)) throw new GitError("Ongeldige branch");
  const { stdout } = await git(publishedDir, ["ls-tree", "-l", "-z", revspec(branch, filePath)]);
  const entries: TreeEntry[] = [];

  for (const record of stdout.toString("utf8").split("\0")) {
    if (!record) continue;
    const tab = record.indexOf("\t");
    if (tab === -1) continue;
    const fields = record.slice(0, tab).split(/\s+/);
    const [mode, type, oid, size] = fields;
    if (!mode || !type || !oid) continue;
    entries.push({
      mode,
      type: type as TreeEntry["type"],
      oid,
      size: size && size !== "-" ? Number.parseInt(size, 10) : null,
      name: record.slice(tab + 1),
    });
  }

  // Mappen eerst, daarna bestanden, elk alfabetisch.
  return entries.sort((a, b) => {
    const aDir = a.type === "tree" ? 0 : 1;
    const bDir = b.type === "tree" ? 0 : 1;
    if (aDir !== bDir) return aDir - bDir;
    return a.name.localeCompare(b.name);
  });
}

export async function blobSize(publishedDir: string, branch: string, filePath: string): Promise<number> {
  if (!isValidBranch(branch)) throw new GitError("Ongeldige branch");
  const out = await gitText(publishedDir, ["cat-file", "-s", revspec(branch, filePath)]);
  return Number.parseInt(out.trim(), 10);
}

export async function readBlob(
  publishedDir: string,
  branch: string,
  filePath: string,
  maxBytes: number,
): Promise<Buffer> {
  if (!isValidBranch(branch)) throw new GitError("Ongeldige branch");
  const { stdout } = await git(publishedDir, ["cat-file", "blob", revspec(branch, filePath)], {
    maxBuffer: maxBytes + 1,
  });
  return stdout;
}

/** Heuristiek van git zelf: een NUL-byte in het begin betekent binair. */
export function looksBinary(buffer: Buffer): boolean {
  const window = buffer.subarray(0, 8000);
  return window.includes(0);
}