import { z } from "zod"; import { badRequest, notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { branchSha, listRefs } from "../git/refs"; import { pagesDomain, siteUrl } from "../pages/config"; import { customDomainsEnabled } from "../pages/domains"; import { createDeployment, loadSite, removePreview, rollbackTo } from "../pages/deploy"; import { disableSite, listDeployments, refreshDomain, saveSite, setDomain } from "../pages/site"; import { labelUrl, sitesDomain } from "../sites/config"; import { createSiteDeployment, listSiteDeployments, loadSiteForRepo, rollbackSite } from "../sites/deploy"; import { siteLabel } from "../sites/naming"; import { deleteEnv, disableSite as disableSiteHosting, listEnv, refreshSiteDomain, saveAccess, saveSite as saveSiteSettings, setEnv, setSiteDomain, } from "../sites/site"; import { noContent, readJson, 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; } async function writableRepo(ctx: ApiContext, owner: string, name: string) { const repo = await adminRepo(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"); } export async function getPagesApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await adminRepo(ctx, owner, name); const site = await loadSite(repo.id); return Response.json({ enabled: pagesDomain() !== null, domain: pagesDomain(), custom_domains_enabled: customDomainsEnabled(), url: siteUrl(repo.owner_name, repo.name, null), site: site ? { source: site.source, branch: site.branch, folder: site.folder, artifact_name: site.artifact_name, spa: site.spa === 1, previews: site.previews === 1, custom_domain: site.custom_domain, domain_status: site.domain_status, active_deployment_id: site.active_deployment_id, } : null, deployments: site ? await listDeployments(repo) : [], }); } export async function putPagesApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, settingsInput); const repo = await writableRepo(ctx, owner, name); if (!pagesDomain()) throw badRequest("Pages hosting is not configured on this forge"); await saveSite(repo, toSettings(data)); return noContent(); } export async function deletePagesApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await writableRepo(ctx, owner, name); const site = await loadSite(repo.id); if (site) await disableSite(repo, site); return noContent(); } export async function deployPagesApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await writableRepo(ctx, owner, name); const site = await loadSite(repo.id); if (!site) throw badRequest("Enable Pages first"); branchOnly(site.source); const sha = branchSha(await listRefs(repo), site.branch); if (!sha) throw badRequest(`Branch ${site.branch} does not exist`); const row = await createDeployment({ repo, site, environment: "production", prNumber: null, ref: `refs/heads/${site.branch}`, sha, artifactId: null, creatorId: ctx.viewer.id, }); return Response.json({ id: row.id, status: row.status, sha: row.sha }, { status: 201 }); } export async function rollbackPagesApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, rollbackInput); const repo = await writableRepo(ctx, owner, name); const site = await loadSite(repo.id); if (!site) throw notFoundError("Pages is not enabled"); await rollbackTo(repo, site, data.deployment_id); return noContent(); } export async function deletePagesPreviewApi( ctx: ApiContext, owner: string, name: string, prNumber: number, ): Promise { const repo = await writableRepo(ctx, owner, name); await removePreview(repo, prNumber); return noContent(); } export async function putPagesDomainApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, domainInput); const repo = await writableRepo(ctx, owner, name); const site = await loadSite(repo.id); if (!site) throw badRequest("Enable Pages first"); await setDomain(repo, site, data.domain); return noContent(); } export async function checkPagesDomainApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await writableRepo(ctx, owner, name); const site = await loadSite(repo.id); if (!site) throw badRequest("Enable Pages first"); return Response.json({ domain: await refreshDomain(repo, site) }); } async function requireSite(ctx: ApiContext, owner: string, name: string) { const repo = await writableRepo(ctx, owner, name); 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(), 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 saveSiteSettings(repo, toSettings(data)); return noContent(); } export async function deleteSiteApi(ctx: ApiContext, owner: string, name: string): Promise { const { site } = await requireSite(ctx, owner, name); await disableSiteHosting(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); 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) }); }