import { z } from "zod"; import { all, first, run } from "../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, requireScopedRepo, unprocessable } from "./http"; 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 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); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); 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 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 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 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 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 requireScopedRepo(ctx, owner, name, "admin", "admin"); 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; } }