import { all, chunks, first, placeholders } from "./db"; import { HttpError, forbidden, notFoundError } from "./errors"; import type { Permission, RepoRow, RepoSummary, Viewer } from "~/lib/types"; const PERMISSION_RANK: Record = { none: 0, read: 1, write: 2, admin: 3 }; export const REPO_SELECT = `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id`; /** Follows rename and transfer redirects so old URLs (web, raw and git) keep working. */ export async function loadRepo(owner: string, name: string): Promise { return ( (await first(`${REPO_SELECT} WHERE a.name = ? AND r.name = ?`, owner, name)) ?? (await first( `${REPO_SELECT} WHERE r.id = COALESCE( (SELECT repo_id FROM repo_redirects WHERE owner_name = ?1 AND repo_name = ?2), (SELECT r2.id FROM account_redirects ar JOIN repos r2 ON r2.owner_id = ar.account_id WHERE ar.old_name = ?1 AND r2.name = ?2 COLLATE NOCASE))`, owner, name, )) ); } export async function loadRepoById(id: string): Promise { return first(`${REPO_SELECT} WHERE r.id = ?`, id); } export async function loadRepoByArtifactsName(artifactsName: string): Promise { return first(`${REPO_SELECT} WHERE r.artifacts_name = ?`, artifactsName); } export function hasPermission(actual: Permission, needed: Permission): boolean { return PERMISSION_RANK[actual] >= PERMISSION_RANK[needed]; } export async function repoPermission(repo: RepoRow, viewer: Viewer | null): Promise { const base: Permission = repo.visibility === "public" ? "read" : "none"; if (!viewer) return base; if (viewer.isAdmin || repo.owner_id === viewer.id) return "admin"; const [membership, collaborator] = await Promise.all([ first<{ role: "owner" | "member" }>( "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", repo.owner_id, viewer.id, ), first<{ permission: Permission }>( "SELECT permission FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, viewer.id, ), ]); const candidates: Permission[] = [base]; if (membership) candidates.push(membership.role === "owner" ? "admin" : "write"); if (collaborator) candidates.push(collaborator.permission); return candidates.reduce((best, next) => (PERMISSION_RANK[next] > PERMISSION_RANK[best] ? next : best), "none"); } export type RepoAccess = { repo: RepoRow; permission: Permission }; export async function requireRepoAccess( owner: string, name: string, viewer: Viewer | null, needed: Permission, ): Promise { const repo = await loadRepo(owner, name); if (!repo) throw notFoundError("Repository not found"); const permission = await repoPermission(repo, viewer); if (permission === "none") throw notFoundError("Repository not found"); if (!hasPermission(permission, needed)) throw forbidden(); return { repo, permission }; } export async function canAdministerAccount(accountId: string, viewer: Viewer | null): Promise { if (!viewer) return false; if (viewer.isAdmin || viewer.id === accountId) return true; const membership = await first<{ role: string }>( "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", accountId, viewer.id, ); return membership?.role === "owner"; } export async function canCreateInAccount(accountId: string, viewer: Viewer | null): Promise { if (!viewer) return false; if (viewer.isAdmin || viewer.id === accountId) return true; const membership = await first<{ role: string }>( "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", accountId, viewer.id, ); return membership !== null; } export async function toRepoSummaries(rows: RepoRow[]): Promise { const forkIds = [...new Set(rows.map((row) => row.fork_of_id).filter((id): id is string => id !== null))]; const parents = new Map(); for (const part of chunks(forkIds)) { const parentRows = await all<{ id: string; name: string; owner_name: string }>( `SELECT r.id, r.name, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id IN (${placeholders(part.length)})`, ...part, ); for (const parent of parentRows) { parents.set(parent.id, { owner: parent.owner_name, name: parent.name }); } } return rows.map((row) => toRepoSummary(row, row.fork_of_id ? (parents.get(row.fork_of_id) ?? null) : null)); } export function toRepoSummary(row: RepoRow, forkOf: { owner: string; name: string } | null): RepoSummary { return { id: row.id, owner: row.owner_name, name: row.name, description: row.description, visibility: row.visibility, defaultBranch: row.default_branch, status: row.status, statusError: row.status_error, importSourceUrl: row.import_source_url, forkOf, isArchived: row.is_archived === 1, hasIssues: row.has_issues === 1, hasActions: row.has_actions === 1, starsCount: row.stars_count, forksCount: row.forks_count, openIssuesCount: row.open_issues_count, openPullsCount: row.open_pulls_count, pushedAt: row.pushed_at, createdAt: row.created_at, }; } const VISIBLE_REPOS_CLAUSE = `(r.visibility = 'public' OR r.owner_id = ?1 OR EXISTS (SELECT 1 FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?1) OR EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = r.owner_id AND m.user_id = ?1))`; export function visibleReposClause(viewer: Viewer | null): { clause: string; viewerId: string } { if (viewer?.isAdmin) return { clause: "(?1 = ?1)", viewerId: viewer.id }; return { clause: VISIBLE_REPOS_CLAUSE, viewerId: viewer?.id ?? "" }; } const MAX_FORK_DEPTH = 20; /** * Whether any repository this one was forked from (directly or transitively) is private, or was private * when it was deleted (`private_network`, set by `deleteRepository` since `fork_of_id` becomes NULL). * Fails closed when the chain is too deep. */ export async function hasPrivateForkAncestor(repo: Pick): Promise { const self = await first<{ private_network: number }>("SELECT private_network FROM repos WHERE id = ?", repo.id); if (self?.private_network === 1) return true; let parentId = repo.fork_of_id; for (let depth = 0; parentId && depth < MAX_FORK_DEPTH; depth++) { const parent = await first<{ visibility: string; fork_of_id: string | null; private_network: number }>( "SELECT visibility, fork_of_id, private_network FROM repos WHERE id = ?", parentId, ); if (!parent) return false; if (parent.visibility !== "public" || parent.private_network === 1) return true; parentId = parent.fork_of_id; } return parentId !== null; } const HTTP_CONFLICT = 409; /** Content reads need a provisioned repository; importing, forking or failed repos have nothing to read yet. */ export function assertRepoReady(repo: Pick): void { if (repo.status !== "ready") throw new HttpError(HTTP_CONFLICT, `Repository is ${repo.status}`); }