import { parseLanguages } from "~/lib/languages"; import { parseTopics } from "~/lib/topics"; import { isTakenDown } from "~/lib/account-disable"; import { all, chunks, first, placeholders } from "./db"; import { isScratchRepo } from "./sites/config"; 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 }; /** What a plain org member gets on every repository the org owns; owners always get `admin`. */ export const ORG_DEFAULT_PERMISSIONS = ["none", "read", "write"] as const; export type OrgDefaultPermission = (typeof ORG_DEFAULT_PERMISSIONS)[number]; function orgDefaultPermission(value: string | null | undefined): OrgDefaultPermission { return value === "none" || value === "write" ? value : "read"; } /** * SQL predicate: the user `userExpr` can read the repo aliased `repoAlias` (public, owner, collaborator, org * owner, or org member when the org's default permission grants at least read). Site admins are not covered; * callers short-circuit them. */ export function repoReadableSql(repoAlias: string, userExpr: string): string { return `(${repoAlias}.visibility = 'public' OR ${repoAlias}.owner_id = ${userExpr} OR EXISTS (SELECT 1 FROM collaborators c_ WHERE c_.repo_id = ${repoAlias}.id AND c_.user_id = ${userExpr}) OR EXISTS (SELECT 1 FROM org_members m_ JOIN accounts o_ ON o_.id = m_.org_id WHERE m_.org_id = ${repoAlias}.owner_id AND m_.user_id = ${userExpr} AND (m_.role = 'owner' OR o_.org_default_permission != 'none')))`; } /** * SQL predicate for inbox rows (`alias.repo_id`, `alias.user_id`): account-level rows, or rows on a repository * the recipient can still read (site admins read everything). Reads filter on it so a revocation that has not * been purged yet never leaks private titles (authz-P7). */ export function inboxReadableSql(alias: string): string { return `(${alias}.repo_id IS NULL OR EXISTS (SELECT 1 FROM repos ir_ WHERE ir_.id = ${alias}.repo_id AND ir_.hidden_at IS NULL AND ${repoReadableSql("ir_", `${alias}.user_id`)}) OR EXISTS (SELECT 1 FROM users iu_ WHERE iu_.account_id = ${alias}.user_id AND iu_.is_admin = 1))`; } /** SQL predicate: the user `userExpr` has at least write on repo `repoAlias` (owner, write/admin collaborator, org owner, or member of a write-by-default org). */ export function repoWritableSql(repoAlias: string, userExpr: string): string { return `(${repoAlias}.owner_id = ${userExpr} OR EXISTS (SELECT 1 FROM collaborators c_ WHERE c_.repo_id = ${repoAlias}.id AND c_.user_id = ${userExpr} AND c_.permission IN ('write', 'admin')) OR EXISTS (SELECT 1 FROM org_members m_ JOIN accounts o_ ON o_.id = m_.org_id WHERE m_.org_id = ${repoAlias}.owner_id AND m_.user_id = ${userExpr} AND (m_.role = 'owner' OR o_.org_default_permission = 'write')))`; } export const REPO_SELECT = `SELECT r.*, a.name AS owner_name, a.disabled_at AS owner_disabled_at, a.disabled_reason AS owner_disabled_reason FROM repos r JOIN accounts a ON a.id = r.owner_id`; /** * The viewer's org membership and collaborator grant on a repository, loaded with the row so `repoPermission` * needs no further D1 reads. `access_viewer_id` records whose standing this is; any other viewer re-reads. */ type ViewerAccess = { access_viewer_id?: string | null; access_org_role?: "owner" | "member" | null; access_org_default?: string | null; access_collaborator?: Permission | null; }; const ACCESS_COLUMNS = `?3 AS access_viewer_id, (SELECT m.role FROM org_members m WHERE m.org_id = r.owner_id AND m.user_id = ?3) AS access_org_role, a.org_default_permission AS access_org_default, (SELECT c.permission FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?3) AS access_collaborator`; /** * `loadRepo` plus the viewer's standing in the same D1 statement: one round trip where `requireRepoAccess` used * to make three. Redirected names fall back to the two-step path. */ export async function loadRepoFor(owner: string, name: string, viewer: Viewer | null): Promise { if (!viewer) return loadRepo(owner, name); const row = await first( `SELECT r.*, a.name AS owner_name, a.disabled_at AS owner_disabled_at, a.disabled_reason AS owner_disabled_reason, ${ACCESS_COLUMNS} FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ?1 AND r.name = ?2`, owner, name, viewer.id, ); return row ?? loadRepo(owner, name); } /** 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); } /** * Starts an import relay window: `backfilling` counts relays in flight, and `backfill_expires_at` bounds how long * the count is trusted (an expired window restarts the count at 1). Bind ?1 = now, ?2 = the window's end. */ export const BACKFILL_START = `backfilling = CASE WHEN COALESCE(backfill_expires_at, 0) > ?1 THEN backfilling + 1 ELSE 1 END, backfill_expires_at = MAX(COALESCE(backfill_expires_at, 0), ?2)`; /** Whether relayed refs are still arriving: some relay is in flight and its window hasn't lapsed. */ export function isBackfilling(row: Pick, now = Date.now()): boolean { return row.backfilling > 0 && (row.backfill_expires_at == null || row.backfill_expires_at > now); } export function hasPermission(actual: Permission, needed: Permission): boolean { return PERMISSION_RANK[actual] >= PERMISSION_RANK[needed]; } /** A repository a site admin hid for abuse is invisible to everyone else, whatever credential they hold. */ export function repoHiddenFrom(repo: Pick, viewer: Viewer | null): boolean { return Boolean(repo.hidden_at) && !viewer?.isAdmin; } /** An admin-disabled (taken down) account's repositories stop serving; a payment dispute only makes them read-only. */ export function repoTakenDown(repo: Pick): boolean { return isTakenDown({ disabled_at: repo.owner_disabled_at, disabled_reason: repo.owner_disabled_reason }); } export async function repoPermission(repo: RepoRow, viewer: Viewer | null): Promise { if (repoHiddenFrom(repo, viewer)) return "none"; if (repoTakenDown(repo)) return viewer?.isAdmin ? "read" : "none"; const base: Permission = repo.visibility === "public" ? "read" : "none"; if (!viewer) return base; // Unclaimed scratch repositories are written only with their push token (git proxy); everyone else, site admins // included, gets at most read, which also covers LFS, the contents API, the web editor and settings. if (isScratchRepo(repo)) return viewer.isAdmin ? "read" : base; if (viewer.isAdmin || repo.owner_id === viewer.id) return "admin"; const preloaded = (repo as RepoRow & ViewerAccess).access_viewer_id === viewer.id ? (repo as ViewerAccess) : null; const [membership, collaborator] = preloaded ? [ preloaded.access_org_role ? { role: preloaded.access_org_role, org_default_permission: preloaded.access_org_default ?? null } : null, preloaded.access_collaborator ? { permission: preloaded.access_collaborator } : null, ] : await Promise.all([ first<{ role: "owner" | "member"; org_default_permission: string | null }>( `SELECT m.role, o.org_default_permission FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.org_id = ? AND m.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" : orgDefaultPermission(membership.org_default_permission)); } 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 loadRepoFor(owner, name, viewer); 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 }; } /** Why writes are refused: an archived repository, or one whose owner account is disabled. */ export function repoReadOnlyReason(repo: Pick): string | null { if (repo.owner_disabled_at) return "This repository's account is disabled and the repository is read-only"; if (repo.is_archived === 1) return "This repository is archived and read-only"; return null; } /** Archived repositories (and those of disabled accounts) are read-only: no issues, comments, pulls or merges. */ export function assertNotArchived(repo: Pick): void { const reason = repoReadOnlyReason(repo); if (reason) throw forbidden(reason); } /** * Settings, visibility, transfer and deletion of a disabled account's repositories are frozen for its own admins * (org owners keep their seats); only site admins can still change them. */ export function assertOwnerNotDisabled(repo: Pick, viewer: Viewer | null): void { if (repo.owner_disabled_at && !viewer?.isAdmin) { throw forbidden("This repository's account is disabled; its settings can't be changed"); } } /** `requireRepoAccess` for a mutation: additionally rejects archived repositories (settings use `requireRepoAccess`). */ export async function requireRepoMutation( owner: string, name: string, viewer: Viewer | null, needed: Permission, ): Promise { const access = await requireRepoAccess(owner, name, viewer, needed); assertNotArchived(access.repo); return access; } 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"; } /** * Whether the viewer may create a new repository in the account. Any org member may, by product intent: * creating, importing and forking public repositories into an org is everyday member work. Moving an * existing repository in (transfer) or placing a private fork there needs `canAdministerAccount`. */ 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, website: row.website, topics: parseTopics(row.topics), languages: parseLanguages(row.languages), language: row.language ?? null, visibility: row.visibility, defaultBranch: row.default_branch, status: row.status, statusError: row.status_error, importSourceUrl: row.import_source_url, backfilling: isBackfilling(row), forkOf, isArchived: row.is_archived === 1, hasIssues: row.has_issues === 1, hasActions: row.has_actions === 1, actionsPolicy: row.actions_policy ?? "all", allowForking: row.allow_forking === 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, }; } /** SQL predicate: the owner of the repo aliased `r` is not taken down (an admin-disabled account); see `repoTakenDown`. */ export const NOT_TAKEN_DOWN_SQL = `NOT EXISTS (SELECT 1 FROM accounts td_ WHERE td_.id = r.owner_id AND td_.disabled_at IS NOT NULL AND td_.disabled_reason = 'admin')`; /** * Repositories that belong in listings for `?1`: readable, not hidden or taken down, and either ready or still * owned by someone who can act on them. Failed and in-progress imports or forks are only the business of people with * write access, never of explore, landing, search or other users' profile pages. */ const VISIBLE_REPOS_CLAUSE = `(r.hidden_at IS NULL AND ${repoReadableSql("r", "?1")} AND (r.status = 'ready' OR ${repoWritableSql("r", "?1")}) AND ${NOT_TAKEN_DOWN_SQL})`; /** * SQL filter for repos `viewer` may see. Admins see every repo unless `adminSeesAll` is false, which * discovery surfaces use so private repos aren't paraded in front of admins. */ export function visibleReposClause( viewer: Viewer | null, { adminSeesAll = true }: { adminSeesAll?: boolean } = {}, ): { clause: string; viewerId: string } { if (viewer?.isAdmin && adminSeesAll) 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}`); }