Vectrum
Sign In
JjohannSoAdd wikisHistory
296 lines · 12.1 KB · TypeScript
import { rename, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import { db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
import { GitError, initBareRepo, isValidName, lastActivity, normalizeRepoName, pathExists, repoPath, wikiPath } from "./git";
import { commitFiles } from "./git-write";
import { deleteRepoImages } from "./registry";
import { deleteRepoReleaseFiles } from "./releases";
import { getUserByUsername, type User } from "./users";

export type Visibility = "public" | "private";
export type Access = "admin" | "write" | "read" | null;

export type RepoRecord = {
  id: string;
  ownerId: string;
  owner: string;
  name: string;
  description: string;
  visibility: Visibility;
  createdAt: number;
  pushedAt: number | null;
  /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */
  imageVersion: number | null;
  wikiEnabled: boolean;
  dir: string;
  /** The wiki's git repository; it only exists once the first page is written. */
  wikiDir: string;
};

const EXPAND = "owner";

function toRepo(r: RecordModel): RepoRecord {
  const owner = (r.expand?.owner as RecordModel).username as string;
  return {
    id: r.id,
    ownerId: r.owner,
    owner,
    name: r.name,
    description: r.description,
    visibility: r.visibility,
    createdAt: r.created_at,
    pushedAt: orNull(r.pushed_at),
    imageVersion: r.image ? orNull(r.image_updated_at) : null,
    wikiEnabled: !r.wiki_disabled,
    dir: repoPath(owner, r.name),
    wikiDir: wikiPath(r.id),
  };
}

export class RepoError extends Error {}

const repos = async () => (await db()).collection("repos");

export async function findRepo(owner: string, name: string) {
  const row = await findFirst(
    "repos",
    filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }),
    { expand: EXPAND },
  );
  return row ? toRepo(row) : null;
}

export async function findRepoById(id: string) {
  const row = await findById("repos", id, { expand: EXPAND });
  return row ? toRepo(row) : null;
}

/**
 * What a git URL names: a repository, or the wiki of one as <name>.wiki.git (like GitHub). A
 * repository that is itself named "<name>.wiki" (from before such names were refused) wins.
 */
export async function findGitTarget(owner: string, repoParam: string) {
  const name = normalizeRepoName(repoParam);
  const repo = await findRepo(owner, name);
  if (repo) return { repo, dir: repo.dir, wiki: false };
  if (!name.toLowerCase().endsWith(".wiki")) return null;
  const parent = await findRepo(owner, name.slice(0, -5));
  return parent?.wikiEnabled ? { repo: parent, dir: parent.wikiDir, wiki: true } : null;
}

/** Creates the wiki's git repository if nothing has been written to it yet. */
export async function ensureWikiRepo(repo: RepoRecord) {
  if (await pathExists(repo.wikiDir)) return;
  await mkdir(path.dirname(repo.wikiDir), { recursive: true });
  await initBareRepo(repo.wikiDir);
}

export async function repoAccess(repo: RepoRecord, userId: string | null | undefined): Promise<Access> {
  if (userId && repo.ownerId === userId) return "admin";
  if (userId && (await findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }))) {
    return "write";
  }
  return repo.visibility === "public" ? "read" : null;
}

export const canWrite = (access: Access) => access === "write" || access === "admin";

/** Repos a user may see: public ones, their own, and ones they collaborate on. */
const visibleTo = (viewerId: string | null) =>
  viewerId
    ? filter("(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer})", { viewer: viewerId })
    : "visibility = 'public'";

async function list(where: string, limit?: number) {
  const options = { filter: where, sort: "-activity_at", expand: EXPAND };
  const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options);
  return rows.map(toRepo);
}

export async function listReposByOwner(ownerId: string, viewerId: string | null) {
  return list(`${filter("owner = {:owner}", { owner: ownerId })} && ${visibleTo(viewerId)}`);
}

/** Repositories the user owns or collaborates on. */
export async function listReposForUser(userId: string) {
  return list(filter("owner = {:u} || collaborators_via_repo.user ?= {:u}", { u: userId }));
}

export async function searchRepos(query: string, viewerId: string | null, limit = 50) {
  return list(`${visibleTo(viewerId)} && ${filter("(name ~ {:q} || description ~ {:q} || owner.username ~ {:q})", { q: query })}`, limit);
}

// <name>.wiki.git is the clone URL of a repository's wiki.
const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki");

export async function createRepo(
  owner: User,
  input: { name: string; description: string; visibility: Visibility; readme: boolean },
) {
  const name = input.name.trim();
  if (!isValidName(name)) {
    throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git.");
  }
  if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
  if (await findRepo(owner.username, name)) throw new RepoError(`You already have a repository named ${name}.`);

  const dir = repoPath(owner.username, name);
  if (await pathExists(dir)) throw new RepoError(`A repository directory for ${name} already exists on disk.`);
  await mkdir(path.dirname(dir), { recursive: true });
  await initBareRepo(dir);

  const now = Date.now();
  let record: RecordModel;
  try {
    record = await (await repos()).create({
      owner: owner.id,
      name,
      description: input.description.trim().slice(0, 350),
      visibility: input.visibility,
      created_at: now,
      activity_at: now,
    });
  } catch (e) {
    await rm(dir, { recursive: true, force: true });
    if (isUniqueViolation(e)) throw new RepoError(`You already have a repository named ${name}.`);
    throw e;
  }

  if (input.readme) {
    const body = `# ${name}\n${input.description ? `\n${input.description.trim()}\n` : ""}`;
    await commitFiles(dir, {
      branch: "main",
      parent: null,
      changes: [{ path: "README.md", content: Buffer.from(body) }],
      message: "Initial commit",
      author: { name: owner.name || owner.username, email: owner.email },
    });
    await markPushed(record.id);
  }
  return (await findRepoById(record.id))!;
}

export async function markPushed(repoId: string) {
  const now = Date.now();
  await (await repos()).update(repoId, { pushed_at: now, activity_at: now });
}

export async function updateRepo(repoId: string, data: { description: string; visibility: Visibility }) {
  await (await repos()).update(repoId, { description: data.description.trim().slice(0, 350), visibility: data.visibility });
}

/** Turning the wiki off hides it and refuses git access to it, but keeps its pages. */
export async function setWikiEnabled(repoId: string, enabled: boolean) {
  await (await repos()).update(repoId, { wiki_disabled: !enabled });
}

export async function renameRepo(repo: RepoRecord, newName: string) {
  newName = newName.trim();
  if (!isValidName(newName)) throw new RepoError("That repository name is invalid.");
  if (isWikiName(newName)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
  if (newName === repo.name) return repo;
  const existing = await findRepo(repo.owner, newName);
  if (existing && existing.id !== repo.id) throw new RepoError(`You already have a repository named ${newName}.`);
  const newDir = repoPath(repo.owner, newName);
  if (newDir !== repo.dir) await rename(repo.dir, newDir);
  try {
    await (await repos()).update(repo.id, { name: newName });
  } catch (e) {
    if (newDir !== repo.dir) await rename(newDir, repo.dir);
    if (isUniqueViolation(e)) throw new RepoError(`You already have a repository named ${newName}.`);
    throw e;
  }
  return (await findRepoById(repo.id))!;
}

/** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
export async function deleteRepo(repo: RepoRecord) {
  await (await repos()).delete(repo.id);
  // Frees the container images and the blobs no other repository uses.
  deleteRepoImages(repo.id);
  await deleteRepoReleaseFiles(repo.id);
  await rm(repo.dir, { recursive: true, force: true });
  await rm(repo.wikiDir, { recursive: true, force: true });
}

// ---------------------------------------------------------------------------
// Repository image

export const MAX_REPO_IMAGE_BYTES = 1024 * 1024;

const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };

/** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
function sniffImageType(data: Uint8Array) {
  const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
  if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
  if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
  if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
  if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
  return null;
}

export async function setRepoImage(repoId: string, data: Uint8Array) {
  if (data.byteLength === 0) throw new RepoError("Choose an image to upload.");
  if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB.");
  const contentType = sniffImageType(data);
  if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image.");
  await (await repos()).update(repoId, {
    image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }),
    image_type: contentType,
    image_updated_at: Date.now(),
  });
}

export async function removeRepoImage(repoId: string) {
  await (await repos()).update(repoId, { image: null, image_type: "", image_updated_at: 0 });
}

export async function getRepoImage(repoId: string) {
  const row = await findById("repos", repoId);
  if (!row?.image) return null;
  const pb = await db();
  // The file field is protected: downloads need a short-lived file token.
  const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() }));
  if (!res.ok) return null;
  return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) };
}

// ---------------------------------------------------------------------------
// Collaborators

export async function listCollaborators(repoId: string) {
  const rows = await (await db())
    .collection("collaborators")
    .getFullList({ filter: filter("repo = {:repoId}", { repoId }), expand: "user" });
  return rows
    .map((r) => {
      const user = r.expand?.user as RecordModel;
      return { id: user.id as string, username: user.username as string, name: user.name as string, addedAt: r.created_at as number };
    })
    .sort((a, b) => a.username.localeCompare(b.username));
}

export async function addCollaborator(repo: RepoRecord, username: string) {
  const user = await getUserByUsername(username.trim());
  if (!user) throw new RepoError(`No user named ${username}.`);
  if (user.id === repo.ownerId) throw new RepoError("The owner already has full access.");
  try {
    await (await db()).collection("collaborators").create({ repo: repo.id, user: user.id, created_at: Date.now() });
  } catch (e) {
    // Already a collaborator.
    if (!isUniqueViolation(e)) throw e;
  }
}

export async function removeCollaborator(repoId: string, userId: string) {
  const row = await findFirst("collaborators", filter("repo = {:repoId} && user = {:userId}", { repoId, userId }), { fields: "id" });
  if (row) await (await db()).collection("collaborators").delete(row.id);
}

/** Last commit time for display, falling back to the creation time. */
export async function repoUpdatedAt(repo: RepoRecord) {
  return (await lastActivity(repo.dir)) ?? repo.createdAt;
}

export { GitError };