import { z } from "zod"; import { repoDb } from "../scoped-db"; import { randomId } from "../crypto"; import { conflict, HttpError, notFoundError } from "../errors"; import { commitFileChange } from "../git/web-commit"; import { assertBelowCap, CAPS, limitWrite } from "../limits"; import { toProtection } from "../protection"; import type { BranchProtection, BranchProtectionRow } from "../protection"; import { loadSecuritySettings, saveSecuritySettings } from "../security/settings"; import { noContent, readJson, requireScopedAdminWrite, requireScopedRepo, requireTokenWrite, unprocessable, } from "./http"; import { deleteProtectionRule, listProtectionRules, loadProtectionRule, saveProtectionRule, setCodeOwnerRequirement, } from "../protection-write"; import type { ProtectionInput } from "../protection-write"; import type { ApiContext } from "./http"; const TAG_PREFIX = "refs/tags/"; const HTTP_CONFLICT = 409; const MAX_CONTENT_BYTES = 5 * 1024 * 1024; function ruleJson(rule: BranchProtection) { const kind = rule.pattern.startsWith(TAG_PREFIX) ? "tag" : "branch"; return { id: rule.id, pattern: kind === "tag" ? rule.pattern.slice(TAG_PREFIX.length) : rule.pattern, kind, allow_force_push: rule.allowForcePushes, allow_deletions: rule.allowDeletions, require_pull_request: rule.requirePullRequest, required_approvals: rule.requiredApprovals, enforce_admins: rule.enforceAdmins, }; } export async function listProtections(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const rows = await repoDb(repo.id).all( "SELECT * FROM branch_protections WHERE repo_id = ? ORDER BY pattern", repo.id, ); return Response.json(rows.map((row) => ruleJson(toProtection(row)))); } const protectionInput = z.object({ pattern: z.string().trim().min(1).max(200), kind: z.enum(["branch", "tag"]), allow_force_push: z.boolean().default(false), }); export async function createProtection(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, protectionInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedAdminWrite(ctx, owner, name); const prefixed = data.pattern.startsWith(TAG_PREFIX); if (data.kind === "branch" && prefixed) throw unprocessable("Branch patterns cannot start with refs/tags/"); const pattern = data.kind === "tag" && !prefixed ? `${TAG_PREFIX}${data.pattern}` : data.pattern; if ( await repoDb(repo.id).first( "SELECT 1 AS ok FROM branch_protections WHERE repo_id = ? AND pattern = ?", repo.id, pattern, ) ) { throw conflict("A rule for that pattern already exists"); } assertBelowCap( ( await repoDb(repo.id).first<{ count: number }>( "SELECT COUNT(*) AS count FROM branch_protections WHERE repo_id = ?", repo.id, ) )?.count ?? 0, CAPS.protectionsPerRepo, "protection rules", ); const id = randomId(); await repoDb(repo.id).run( `INSERT INTO branch_protections (id, repo_id, pattern, require_pull_request, required_approvals, required_checks, dismiss_stale_reviews, allow_deletions, allow_force_pushes, enforce_admins, created_at) VALUES (?, ?, ?, 0, 0, '[]', 0, 0, ?, 0, ?)`, id, repo.id, pattern, data.allow_force_push ? 1 : 0, Date.now(), ); const row = await repoDb(repo.id).first("SELECT * FROM branch_protections WHERE id = ?", id); if (!row) throw notFoundError("Rule not found"); return Response.json(ruleJson(toProtection(row)), { status: 201 }); } const pushProtectionInput = z.object({ enabled: z.boolean() }); export async function setPushProtection(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, pushProtectionInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedAdminWrite(ctx, owner, name); const settings = await loadSecuritySettings(repo.id); await saveSecuritySettings(repo.id, { ...settings, pushProtection: data.enabled }); return noContent(); } const contentsInput = z.object({ branch: z.string().min(1), message: z.string().trim().min(1).max(10_000), content: z.string().max(Math.ceil((MAX_CONTENT_BYTES * 4) / 3) + 4), }); function decodeBase64(value: string): Uint8Array { try { return Uint8Array.from(atob(value.replace(/\s+/g, "")), (char) => char.charCodeAt(0)); } catch { throw unprocessable("content must be base64"); } } /** Mirrors the web editor's single-file commit; commitChanges enforces GIT-7 path safety. */ export async function putContents(ctx: ApiContext, owner: string, name: string, path: string): Promise { const data = await readJson(ctx.request, contentsInput); await limitWrite(ctx.viewer); const { repo, permission } = await requireScopedRepo(ctx, owner, name, "contents", "write"); const bytes = decodeBase64(data.content); if (bytes.byteLength > MAX_CONTENT_BYTES) throw unprocessable("File is too large"); try { const result = await commitFileChange({ repo, permission, viewer: ctx.viewer, branch: data.branch, path, previousPath: null, content: bytes, message: data.message, expectedHeadSha: null, newBranch: null, }); return Response.json({ ...result, path }); } catch (error) { if (error instanceof HttpError && error.status === HTTP_CONFLICT && error.message.startsWith("Invalid path")) { throw unprocessable(error.message); } throw error; } } function branchRuleJson(rule: BranchProtection) { return { id: rule.id, pattern: rule.pattern, require_pull_request: rule.requirePullRequest, required_approvals: rule.requiredApprovals, required_checks: rule.requiredChecks, dismiss_stale_reviews: rule.dismissStaleReviews, allow_deletions: rule.allowDeletions, allow_force_pushes: rule.allowForcePushes, enforce_admins: rule.enforceAdmins, require_code_owner_review: rule.requireCodeOwnerReview, }; } const branchRuleInput = z.object({ pattern: z.string().trim().min(1).max(200), require_pull_request: z.boolean().default(false), required_approvals: z.number().int().min(0).max(10).default(0), required_checks: z.array(z.string().min(1)).max(50).default([]), dismiss_stale_reviews: z.boolean().default(false), allow_deletions: z.boolean().default(false), allow_force_pushes: z.boolean().default(false), enforce_admins: z.boolean().default(false), require_code_owner_review: z.boolean().optional(), }); function toRuleInput(data: z.infer): ProtectionInput { return { pattern: data.pattern, requirePullRequest: data.require_pull_request, requiredApprovals: data.required_approvals, requiredChecks: data.required_checks, dismissStaleReviews: data.dismiss_stale_reviews, allowDeletions: data.allow_deletions, allowForcePushes: data.allow_force_pushes, enforceAdmins: data.enforce_admins, }; } async function adminRepo(ctx: ApiContext, owner: string, name: string, removal = false) { await limitWrite(ctx.viewer); const { repo } = removal ? await requireScopedRepo(ctx, owner, name, "admin", "admin") : await requireScopedAdminWrite(ctx, owner, name); await requireTokenWrite(ctx, repo, ["admin"]); return repo; } export async function listBranchRules(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); return Response.json((await listProtectionRules(repo.id)).map(branchRuleJson)); } export async function createBranchRule(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, branchRuleInput); const repo = await adminRepo(ctx, owner, name); const id = await saveProtectionRule(repo.id, null, toRuleInput(data)); if (data.require_code_owner_review !== undefined) { await setCodeOwnerRequirement(repo.id, id, data.require_code_owner_review); } const rule = await loadProtectionRule(repo.id, id); if (!rule) throw notFoundError("Rule not found"); return Response.json(branchRuleJson(rule), { status: 201 }); } export async function updateBranchRule(ctx: ApiContext, owner: string, name: string, id: string): Promise { const data = await readJson(ctx.request, branchRuleInput); const repo = await adminRepo(ctx, owner, name); if (!(await loadProtectionRule(repo.id, id))) throw notFoundError("Rule not found"); await saveProtectionRule(repo.id, id, toRuleInput(data)); if (data.require_code_owner_review !== undefined) { await setCodeOwnerRequirement(repo.id, id, data.require_code_owner_review); } const rule = await loadProtectionRule(repo.id, id); if (!rule) throw notFoundError("Rule not found"); return Response.json(branchRuleJson(rule)); } export async function deleteBranchRule(ctx: ApiContext, owner: string, name: string, id: string): Promise { const repo = await adminRepo(ctx, owner, name, true); if (!(await deleteProtectionRule(repo.id, id))) throw notFoundError("Rule not found"); return noContent(); }