import { z } from "zod"; import { badRequest } from "../errors"; import { limitWrite } from "../limits"; import { branchSha, listRefs } from "../git/refs"; import { customDomainsEnabled, customDomainsTarget } from "../sites/domains"; import { labelUrl, sitesDomain } from "../sites/config"; import { createSiteDeployment, listSiteDeployments, loadSiteForRepo, rollbackSite } from "../sites/deploy"; import { siteLabel } from "../sites/naming"; import { deleteEnv, disableSite, listEnv, refreshSiteDomain, saveAccess, saveSite, setEnv, setSiteDomain, } from "../sites/site"; import { noContent, readJson, requireScopedAdminWrite, requireScopedRepo, unprocessable } from "./http"; import type { ApiContext } from "./http"; const settingsInput = z.object({ source: z.enum(["branch", "workflow"]).default("branch"), branch: z.string().min(1).max(255), folder: z.string().max(200).default(""), artifact_name: z .string() .regex(/^[A-Za-z0-9._-]{1,100}$/) .default("github-pages"), spa: z.boolean().default(false), previews: z.boolean().default(false), preview_env: z.boolean().default(false), }); const domainInput = z.object({ domain: z.string().max(253).nullable() }); const rollbackInput = z.object({ deployment_id: z.string().min(1) }); const accessInput = z.object({ access: z.enum(["public", "password", "gitfor", "oidc"]), audience: z.enum(["readers", "members"]).default("readers"), password: z.string().max(200).nullable().default(null), }); const variableInput = z.object({ value: z.string().max(4096), secret: z.boolean().default(false), preview: z.boolean().default(false), }); function toSettings(data: z.infer) { return { source: data.source, branch: data.branch, folder: data.folder, artifactName: data.artifact_name, spa: data.spa, previews: data.previews, previewEnv: data.preview_env, }; } async function adminRepo(ctx: ApiContext, owner: string, name: string) { return (await requireScopedRepo(ctx, owner, name, "admin", "admin")).repo; } /** `removal` lets disabling, preview removal and variable deletion through on archived repositories. */ async function writableRepo(ctx: ApiContext, owner: string, name: string, removal = false) { const { repo } = removal ? await requireScopedRepo(ctx, owner, name, "admin", "admin") : await requireScopedAdminWrite(ctx, owner, name); await limitWrite(ctx.viewer); return repo; } /** Workflow-sourced deploys pick artifacts from CI runs; the API only triggers branch deploys. */ function branchOnly(source: string): void { if (source === "workflow") throw unprocessable("Workflow-sourced deploys are triggered by CI, not this endpoint"); } async function requireSite(ctx: ApiContext, owner: string, name: string, removal = false) { const repo = await writableRepo(ctx, owner, name, removal); const site = await loadSiteForRepo(repo.id); if (!site) throw badRequest("Enable the site first"); return { repo, site }; } export async function getSiteApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await adminRepo(ctx, owner, name); const site = await loadSiteForRepo(repo.id); const label = site?.label ?? siteLabel(repo.owner_name, repo.name, sitesDomain()); const deployments = site ? await listSiteDeployments(site.id) : []; return Response.json({ enabled: sitesDomain() !== null, domain: sitesDomain(), custom_domains_enabled: customDomainsEnabled(), custom_domains_target: customDomainsTarget(), label, url: labelUrl(label), site: site ? { source: site.source === "workflow" ? "workflow" : "branch", branch: site.branch, folder: site.folder, spa: site.spa === 1, previews: site.previews === 1, access: site.access, audience: site.audience, has_password: site.password_hash !== null, custom_domain: site.custom_domain, domain_status: site.domain_status, active_deployment_id: site.active_deployment_id, } : null, variables: site ? await listEnv(site.id) : [], deployments: deployments.map((row) => ({ id: row.id, environment: row.environment, status: row.status, sha: row.sha, error: row.error, url: labelUrl(row.label), })), }); } export async function putSiteApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, settingsInput); const repo = await writableRepo(ctx, owner, name); await saveSite(repo, toSettings(data)); return noContent(); } export async function deleteSiteApi(ctx: ApiContext, owner: string, name: string): Promise { const { site } = await requireSite(ctx, owner, name, true); await disableSite(site); return noContent(); } export async function putSiteAccessApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, accessInput); if (data.access === "oidc") throw unprocessable("OIDC access is configured in the web UI"); const { repo, site } = await requireSite(ctx, owner, name); await saveAccess(repo, site, { ...data, oidc: null }); return noContent(); } export async function putSiteVariableApi( ctx: ApiContext, owner: string, name: string, variable: string, ): Promise { const data = await readJson(ctx.request, variableInput); const { repo, site } = await requireSite(ctx, owner, name); await setEnv(repo, site, variable, data.value, data.secret, data.preview); return noContent(); } export async function deleteSiteVariableApi( ctx: ApiContext, owner: string, name: string, variable: string, ): Promise { const { repo, site } = await requireSite(ctx, owner, name, true); await deleteEnv(repo, site, variable); return noContent(); } export async function deploySiteApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo, site } = await requireSite(ctx, owner, name); branchOnly(site.source); const sha = branchSha(await listRefs(repo), site.branch); if (!sha) throw badRequest(`Branch ${site.branch} does not exist`); const row = await createSiteDeployment({ site, environment: "production", label: site.label, branch: site.branch, ref: `refs/heads/${site.branch}`, sha, artifactId: null, source: "branch", creatorId: ctx.viewer.id, }); return Response.json({ id: row.id, status: row.status, sha: row.sha }, { status: 201 }); } export async function rollbackSiteApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, rollbackInput); const { site } = await requireSite(ctx, owner, name); await rollbackSite(site, data.deployment_id); return noContent(); } export async function putSiteDomainApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, domainInput); const { site } = await requireSite(ctx, owner, name); await setSiteDomain(site, data.domain); return noContent(); } export async function checkSiteDomainApi(ctx: ApiContext, owner: string, name: string): Promise { const { site } = await requireSite(ctx, owner, name); return Response.json({ domain: await refreshSiteDomain(site) }); }