Minimale git-remote met publiek/prive-toggle
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 });
}