Vectrum
Sign In
244 lines · 9.2 KB · TypeScript
import { rename, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import { db, transaction } from "./db";
import { GitError, initBareRepo, isValidName, lastActivity, pathExists, repoPath } from "./git";
import { commitFiles } from "./git-write";
import { pruneRegistry } from "./registry";
import { getUserByUsername, type User } from "./users";

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

export type RepoRecord = {
  id: number;
  ownerId: number;
  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;
  dir: string;
};

type RepoRow = {
  id: number;
  owner_id: number;
  owner: string;
  name: string;
  description: string;
  visibility: Visibility;
  created_at: number;
  pushed_at: number | null;
  image_updated_at: number | null;
};

const SELECT = `SELECT r.*, u.username AS owner, i.updated_at AS image_updated_at FROM repos r
  JOIN users u ON u.id = r.owner_id LEFT JOIN repo_images i ON i.repo_id = r.id`;

function toRepo(row: RepoRow): RepoRecord {
  return {
    id: row.id,
    ownerId: row.owner_id,
    owner: row.owner,
    name: row.name,
    description: row.description,
    visibility: row.visibility,
    createdAt: row.created_at,
    pushedAt: row.pushed_at,
    imageVersion: row.image_updated_at,
    dir: repoPath(row.owner, row.name),
  };
}

export class RepoError extends Error {}

export function findRepo(owner: string, name: string) {
  const row = db.prepare(`${SELECT} WHERE u.username = ? AND r.name = ?`).get(owner, name) as RepoRow | undefined;
  return row ? toRepo(row) : null;
}

export function findRepoById(id: number) {
  const row = db.prepare(`${SELECT} WHERE r.id = ?`).get(id) as RepoRow | undefined;
  return row ? toRepo(row) : null;
}

export function repoAccess(repo: RepoRecord, userId: number | null | undefined): Access {
  if (userId && repo.ownerId === userId) return "admin";
  if (userId && db.prepare("SELECT 1 FROM collaborators WHERE repo_id = ? AND user_id = ?").get(repo.id, userId)) {
    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 VISIBLE = `(r.visibility = 'public' OR r.owner_id = :viewer OR r.id IN (SELECT repo_id FROM collaborators WHERE user_id = :viewer))`;

export function listReposByOwner(ownerId: number, viewerId: number | null) {
  const rows = db
    .prepare(`${SELECT} WHERE r.owner_id = :owner AND ${VISIBLE} ORDER BY COALESCE(r.pushed_at, r.created_at) DESC`)
    .all({ owner: ownerId, viewer: viewerId ?? -1 }) as RepoRow[];
  return rows.map(toRepo);
}

/** Repositories the user owns or collaborates on. */
export function listReposForUser(userId: number) {
  const rows = db
    .prepare(
      `${SELECT} WHERE r.owner_id = :u OR r.id IN (SELECT repo_id FROM collaborators WHERE user_id = :u)
       ORDER BY COALESCE(r.pushed_at, r.created_at) DESC`,
    )
    .all({ u: userId }) as RepoRow[];
  return rows.map(toRepo);
}

export function searchRepos(query: string, viewerId: number | null, limit = 50) {
  const rows = db
    .prepare(
      `${SELECT} WHERE ${VISIBLE} AND (r.name LIKE :q OR r.description LIKE :q OR u.username LIKE :q)
       ORDER BY COALESCE(r.pushed_at, r.created_at) DESC LIMIT :limit`,
    )
    .all({ viewer: viewerId ?? -1, q: `%${query}%`, limit }) as RepoRow[];
  return rows.map(toRepo);
}

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 (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();
  const result = db
    .prepare("INSERT INTO repos (owner_id, name, description, visibility, created_at) VALUES (?, ?, ?, ?, ?)")
    .run(owner.id, name, input.description.trim().slice(0, 350), input.visibility, now);

  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 },
    });
    markPushed(Number(result.lastInsertRowid));
  }
  return findRepoById(Number(result.lastInsertRowid))!;
}

export function markPushed(repoId: number) {
  db.prepare("UPDATE repos SET pushed_at = ? WHERE id = ?").run(Date.now(), repoId);
}

export function updateRepo(repoId: number, data: { description: string; visibility: Visibility }) {
  db.prepare("UPDATE repos SET description = ?, visibility = ? WHERE id = ?").run(
    data.description.trim().slice(0, 350),
    data.visibility,
    repoId,
  );
}

export async function renameRepo(repo: RepoRecord, newName: string) {
  newName = newName.trim();
  if (!isValidName(newName)) throw new RepoError("That repository name is invalid.");
  if (newName === repo.name) return repo;
  const existing = 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);
  db.prepare("UPDATE repos SET name = ? WHERE id = ?").run(newName, repo.id);
  return findRepoById(repo.id)!;
}

export async function deleteRepo(repo: RepoRecord) {
  transaction(() => {
    db.prepare("DELETE FROM repos WHERE id = ?").run(repo.id);
  });
  // Frees the container images' blobs no other repository uses.
  pruneRegistry();
  await rm(repo.dir, { recursive: true, force: true });
}

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

export const MAX_REPO_IMAGE_BYTES = 1024 * 1024;

/** 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 function setRepoImage(repoId: number, 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.");
  db.prepare(
    `INSERT INTO repo_images (repo_id, content_type, data, updated_at) VALUES (?, ?, ?, ?)
     ON CONFLICT (repo_id) DO UPDATE SET content_type = excluded.content_type, data = excluded.data, updated_at = excluded.updated_at`,
  ).run(repoId, contentType, data, Date.now());
}

export function removeRepoImage(repoId: number) {
  db.prepare("DELETE FROM repo_images WHERE repo_id = ?").run(repoId);
}

export function getRepoImage(repoId: number) {
  return db.prepare("SELECT content_type AS contentType, data FROM repo_images WHERE repo_id = ?").get(repoId) as
    | { contentType: string; data: Uint8Array }
    | undefined;
}

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

export function listCollaborators(repoId: number) {
  return db
    .prepare(
      `SELECT u.id, u.username, u.name, c.created_at AS addedAt FROM collaborators c
       JOIN users u ON u.id = c.user_id WHERE c.repo_id = ? ORDER BY u.username`,
    )
    .all(repoId) as { id: number; username: string; name: string; addedAt: number }[];
}

export function addCollaborator(repo: RepoRecord, username: string) {
  const user = 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.");
  db.prepare("INSERT OR IGNORE INTO collaborators (repo_id, user_id, created_at) VALUES (?, ?, ?)").run(
    repo.id,
    user.id,
    Date.now(),
  );
}

export function removeCollaborator(repoId: number, userId: number) {
  db.prepare("DELETE FROM collaborators WHERE repo_id = ? AND user_id = ?").run(repoId, userId);
}

/** 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 };