import { waitUntil } from "cloudflare:workers"; import { syncHostingForRepo } from "../sites/sync"; import { z } from "zod"; import { all, first, run } from "../db"; import { conflict, forbidden, notFoundError } from "../errors"; import { listRefs } from "../git/refs"; import { syncRefsFromSource } from "../git/ref-sync"; import { dropHiddenRefsForRepo, requestHiddenRefCleanup } from "../git/pull-bridge"; import { assertBelowCap, CAPS, limitHeavy, limitWrite } from "../limits"; import { canCreateInAccount, hasPrivateForkAncestor, loadRepo, REPO_SELECT, visibleReposClause } from "../repos"; import { createRepository, deleteRepository, forkRepository, importRepository, refreshRepoStatus, reindexRepo, } from "../repo-ops"; import { credentialAllows } from "../token-scope"; import { syncSiteForRepo } from "../sites/sync"; import { findCollaboratorUser, setCollaborator } from "../collaborators"; import { repoName } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; import { noContent, ownerAccount, readJson, repoJson, requireAccountScope, requireScopedRepo, unprocessable, } from "./http"; import type { ApiContext } from "./http"; const LIST_LIMIT = 100; async function assertOwnerCap(ownerId: string): Promise { assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM repos WHERE owner_id = ?", ownerId))?.count ?? 0, CAPS.reposPerOwner, "repositories", ); } export async function listRepos(ctx: ApiContext): Promise { const ownerName = ctx.url.searchParams.get("owner") ?? ctx.viewer.name; const owner = await ownerAccount(ownerName); const { clause, viewerId } = visibleReposClause(ctx.viewer); const rows = await all( `${REPO_SELECT} WHERE ${clause} AND r.owner_id = ?2 ORDER BY r.name LIMIT ${LIST_LIMIT}`, viewerId, owner.id, ); const allowed: RepoRow[] = []; for (const row of rows) if (await credentialAllows(row, ctx.credential, "contents", "read")) allowed.push(row); return Response.json(await Promise.all(allowed.map(repoJson))); } const createInput = z.object({ name: repoName, description: z.string().max(350).nullable().optional(), private: z.boolean().optional(), owner: z.string().min(1).optional(), }); export async function createRepo(ctx: ApiContext): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, createInput); await limitWrite(ctx.viewer); const owner = await ownerAccount(data.owner ?? ctx.viewer.name); if (!(await canCreateInAccount(owner.id, ctx.viewer))) throw forbidden("You cannot create repositories here"); await assertOwnerCap(owner.id); const repo = await createRepository({ owner, name: data.name, description: data.description ?? null, visibility: data.private ? "private" : "public", initReadme: false, actor: ctx.viewer, }); return Response.json(await repoJson(repo), { status: 201 }); } export async function getRepo(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); return Response.json(await repoJson(await refreshRepoStatus(repo))); } const updateInput = z.object({ description: z.string().max(350).nullable().optional(), private: z.boolean().optional(), default_branch: z.string().min(1).optional(), }); export async function updateRepo(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, updateInput); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const visibility = data.private === undefined ? repo.visibility : data.private ? "private" : "public"; if (visibility === "public" && repo.visibility !== "public" && (await hasPrivateForkAncestor(repo))) { throw unprocessable("Forks of private repositories cannot be made public"); } const defaultBranch = data.default_branch ?? repo.default_branch; if (data.default_branch !== undefined) { const refs = await listRefs(repo); if (refs.branches.length > 0 && !refs.branches.some((branch) => branch.name === defaultBranch)) { throw unprocessable("Default branch must be an existing branch"); } } const description = data.description === undefined ? repo.description : data.description; await run( "UPDATE repos SET description = ?, visibility = ?, default_branch = ?, updated_at = ? WHERE id = ?", description, visibility, defaultBranch, Date.now(), repo.id, ); await syncSiteForRepo(repo.id); if (visibility !== repo.visibility) { await requestHiddenRefCleanup(repo.id); waitUntil(dropHiddenRefsForRepo(repo.id)); await syncHostingForRepo(repo.id); } await reindexRepo({ id: repo.id, owner_name: repo.owner_name, name: repo.name, description }); return Response.json( await repoJson({ ...repo, description, visibility, default_branch: defaultBranch } satisfies RepoRow), ); } export async function deleteRepo(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await deleteRepository(repo, ctx.viewer); return noContent(); } const forkInput = z.object({ owner: z.string().min(1).optional(), name: repoName.optional() }); export async function forkRepo(ctx: ApiContext, owner: string, name: string): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, forkInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); const target = await ownerAccount(data.owner ?? ctx.viewer.name); if (!(await canCreateInAccount(target.id, ctx.viewer))) throw forbidden("You cannot create repositories here"); await assertOwnerCap(target.id); const fork = await forkRepository({ source: repo, owner: target, name: data.name ?? repo.name, actor: ctx.viewer }); return Response.json(await repoJson(fork), { status: 201 }); } export async function getCollaborator(ctx: ApiContext, owner: string, name: string, username: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const user = await findCollaboratorUser(username); const row = await first("SELECT 1 AS ok FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, user.id); if (!row) throw notFoundError("Not a collaborator"); return noContent(); } const collaboratorInput = z.object({ permission: z.enum(["read", "write", "admin"]) }); export async function putCollaborator(ctx: ApiContext, owner: string, name: string, username: string) { const data = await readJson(ctx.request, collaboratorInput); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await setCollaborator(repo, username, data.permission); return noContent(); } const importInput = z.object({ url: z.url(), sync: z.boolean().optional() }); /** `sync: true` re-syncs refs of an existing import (ref-sync applies GIT-6 checks); otherwise creates a new import. */ export async function importRepo(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, importInput); if (data.sync) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); if (!repo.import_source_url) throw unprocessable("This repository was not imported"); if (repo.import_source_url !== data.url) throw unprocessable("URL does not match the repository's import source"); if (repo.status !== "ready") throw conflict("The repository is still being provisioned"); await limitHeavy(ctx.viewer); return Response.json(await syncRefsFromSource(repo, repo.import_source_url)); } requireAccountScope(ctx); const repoNameResult = repoName.safeParse(name); if (!repoNameResult.success) throw unprocessable("Invalid repository name"); await limitWrite(ctx.viewer); const target = await ownerAccount(owner); if (!(await canCreateInAccount(target.id, ctx.viewer))) throw forbidden("You cannot create repositories here"); if (await loadRepo(target.name, repoNameResult.data)) throw conflict("A repository with that name already exists"); await assertOwnerCap(target.id); const repo = await importRepository({ owner: target, name: repoNameResult.data, description: null, visibility: "private", sourceUrl: data.url, branch: null, actor: ctx.viewer, }); return Response.json(await repoJson(repo), { status: 201 }); }