import { env } from "cloudflare:workers"; import { claimHost, forgetDomainSite, forgetSite, hostHolder, loadSiteRow, registerDomainSite, registerSite, releaseHost, siteDb, } from "./store"; import { encryptSecret, randomId, randomToken } from "../crypto"; import { stmt } from "../scoped-db"; import { badRequest, conflict } from "../errors"; import { assertBelowCap } from "../limits"; import { createCustomHostname, customDomainsEnabled, deleteCustomHostname, domainStatus, getCustomHostname, } from "./domains"; import { customHostKey, isForgeRelatedHost, isValidHostname, metaKey, publishMeta, SITE_LIMITS, sitesDomain, } from "./config"; import { assertLabelFree, loadSiteForRepo, unpublishSite } from "./deploy"; import { siteLabel } from "./naming"; import { hashSitePassword } from "./password"; import { assertPublicHttps } from "./oidc"; import type { SiteAccess, SiteRow } from "./config"; import { assertCanAddCustomDomain, syncDomainAddonQuantity } from "../billing/domain-addon"; import type { RepoRow } from "~/lib/types"; const UNVERIFIED_CLAIM_MS = 72 * 60 * 60 * 1000; const MIN_PASSWORD = 8; const FOLDER = /^[A-Za-z0-9._\-/]*$/; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; const RESERVED_ENV = new Set(["ASSETS"]); export type SiteSettings = { source: "branch" | "workflow"; branch: string; folder: string; artifactName: string; spa: boolean; previews: boolean; previewEnv: boolean; commitPreviews?: boolean; }; export type AccessSettings = { access: SiteAccess; audience: "readers" | "members"; password: string | null; oidc: { issuer: string; clientId: string; clientSecret: string | null; allowed: string } | null; }; async function reload(repoId: string): Promise { const site = await loadSiteForRepo(repoId); if (!site) throw badRequest("Sites are not enabled for this repository"); await publishMeta(site); return site; } export async function saveSite(repo: RepoRow, settings: SiteSettings): Promise { if (!sitesDomain()) throw badRequest("Sites are not configured on this forge"); const folder = settings.folder.trim().replace(/^\/+|\/+$/g, ""); if (!FOLDER.test(folder) || folder.split("/").includes("..")) throw badRequest("Invalid folder"); const existing = await loadSiteForRepo(repo.id); const label = existing?.label ?? siteLabel(repo.owner_name, repo.name, sitesDomain()); const now = Date.now(); const db = siteDb({ id: "", repo_id: repo.id }); if (!existing) { await assertLabelFree("", label); const id = randomId(); await registerSite({ id, repo_id: repo.id, label }); await db.run( `INSERT INTO sites (id, owner_id, repo_id, label, source, branch, folder, artifact_name, spa, previews, preview_env, commit_previews, access, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.owner_id, repo.id, label, settings.source, settings.branch, folder, settings.artifactName, settings.spa ? 1 : 0, settings.previews ? 1 : 0, settings.previewEnv ? 1 : 0, settings.commitPreviews ? 1 : 0, repo.visibility === "public" ? "public" : "gitfor", now, now, ); } else { // preview_env is part of the variable set a preview isolate sees, so it bumps env_version like a variable edit. const previewEnv = settings.previewEnv ? 1 : 0; await db.run( `UPDATE sites SET source = ?, branch = ?, folder = ?, artifact_name = ?, spa = ?, previews = ?, preview_env = ?, commit_previews = ?, env_version = env_version + ?, updated_at = ? WHERE id = ?`, settings.source, settings.branch, folder, settings.artifactName, settings.spa ? 1 : 0, settings.previews ? 1 : 0, previewEnv, settings.commitPreviews ? 1 : 0, previewEnv === existing.preview_env ? 0 : 1, now, existing.id, ); } return reload(repo.id); } /** Every access change bumps `access_version`, which is in the cookie MAC, so existing sessions are revoked. */ export async function saveAccess(repo: RepoRow, site: SiteRow, input: AccessSettings): Promise { if (input.access === "public" && repo.visibility !== "public") { throw badRequest("Sites of private repositories must require sign-in or a password"); } let passwordHash = site.password_hash; if (input.access === "password") { if (input.password) { if (input.password.length < MIN_PASSWORD) throw badRequest("Use a password of at least 8 characters"); passwordHash = await hashSitePassword(input.password); } if (!passwordHash) throw badRequest("Set a password"); } let oidc = { issuer: site.oidc_issuer, clientId: site.oidc_client_id, ct: site.oidc_secret_ct, iv: site.oidc_secret_iv, allowed: site.oidc_allowed, }; if (input.access === "oidc") { if (!input.oidc) throw badRequest("Configure the OIDC provider"); try { assertPublicHttps(input.oidc.issuer); } catch (error) { throw badRequest(error instanceof Error ? error.message : "Invalid OIDC issuer"); } if (!input.oidc.allowed.trim()) throw badRequest("List the allowed emails or @domains"); const secret = input.oidc.clientSecret ? await encryptSecret(env.SECRETS_KEY, input.oidc.clientSecret) : null; oidc = { issuer: input.oidc.issuer.replace(/\/$/, ""), clientId: input.oidc.clientId, ct: secret?.ciphertext ?? site.oidc_secret_ct, iv: secret?.iv ?? site.oidc_secret_iv, allowed: input.oidc.allowed, }; if (!oidc.ct) throw badRequest("Enter the OIDC client secret"); } await siteDb(site).run( `UPDATE sites SET access = ?, audience = ?, password_hash = ?, oidc_issuer = ?, oidc_client_id = ?, oidc_secret_ct = ?, oidc_secret_iv = ?, oidc_allowed = ?, access_version = access_version + 1, updated_at = ? WHERE id = ?`, input.access, input.audience, passwordHash, oidc.issuer, oidc.clientId, oidc.ct, oidc.iv, oidc.allowed, Date.now(), site.id, ); return reload(repo.id); } export type EnvEntry = { name: string; secret: boolean; preview: boolean; updatedAt: number }; export async function listEnv(siteId: string): Promise { const site = await loadSiteRow(siteId); if (!site) return []; const rows = await siteDb(site).all<{ name: string; secret: number; preview: number; updated_at: number }>( "SELECT name, secret, preview, updated_at FROM site_env WHERE site_id = ? ORDER BY name LIMIT ?", siteId, SITE_LIMITS.envEntries, ); return rows.map((row) => ({ name: row.name, secret: row.secret === 1, preview: row.preview === 1, updatedAt: row.updated_at, })); } /** * Variables are production-only by default: previews deploy from any branch a writer can push, so handing them the * production environment would let `write` read the admin's secrets (CI-A3). `preview` opts a variable in. */ export async function setEnv( repo: RepoRow, site: SiteRow, name: string, value: string, secret: boolean, preview = false, ): Promise { if (!ENV_NAME.test(name) || RESERVED_ENV.has(name)) throw badRequest("Use a valid variable name"); if (new TextEncoder().encode(value).byteLength > SITE_LIMITS.envValueBytes) throw badRequest("Value is too large"); const db = siteDb(site); const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM site_env WHERE site_id = ? AND name != ?", site.id, name, ); assertBelowCap(count?.count ?? 0, SITE_LIMITS.envEntries, "variables per site"); const encrypted = await encryptSecret(env.SECRETS_KEY, value); await db.batch([ stmt( `INSERT INTO site_env (site_id, name, secret, preview, value_ct, value_iv, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (site_id, name) DO UPDATE SET secret = excluded.secret, preview = excluded.preview, value_ct = excluded.value_ct, value_iv = excluded.value_iv, updated_at = excluded.updated_at`, site.id, name, secret ? 1 : 0, preview ? 1 : 0, encrypted.ciphertext, encrypted.iv, Date.now(), ), stmt("UPDATE sites SET env_version = env_version + 1 WHERE id = ?", site.id), ]); await reload(repo.id); } export async function deleteEnv(repo: RepoRow, site: SiteRow, name: string): Promise { await siteDb(site).batch([ stmt("DELETE FROM site_env WHERE site_id = ? AND name = ?", site.id, name), stmt("UPDATE sites SET env_version = env_version + 1 WHERE id = ?", site.id), ]); await reload(repo.id); } async function clearDomain(site: SiteRow): Promise { if (site.custom_domain) { await env.KV.delete(customHostKey(site.custom_domain)); await releaseHost(site.custom_domain, site.id); } if (site.custom_hostname_id && customDomainsEnabled()) await deleteCustomHostname(site.custom_hostname_id); } export async function disableSite(site: SiteRow): Promise { await clearDomain(site); await unpublishSite(site.id); await env.KV.delete(metaKey(site.id)); await siteDb(site).run("DELETE FROM sites WHERE id = ?", site.id); await forgetSite(site); if (site.custom_domain) await syncDomainAddonQuantity(site.owner_id); } /** Unmaps a site's custom domain and deletes its Cloudflare hostname (lapsed add-on, expired claim). */ export async function releaseSiteDomain(site: SiteRow): Promise { await clearDomain(site); const db = siteDb(site); await db.run( "UPDATE sites SET custom_domain = NULL, custom_hostname_id = NULL, domain_status = NULL, domain_claimed_at = NULL, domain_verified_at = NULL, domain_token = NULL, updated_at = ? WHERE id = ?", Date.now(), site.id, ); await forgetDomainSite(site.id); const updated = await db.first("SELECT * FROM sites WHERE id = ?", site.id); if (updated) await publishMeta(updated); await syncDomainAddonQuantity(site.owner_id); } function isStaleClaim(status: string | null, claimedAt: number | null, updatedAt: number): boolean { return status !== "active" && Date.now() - (claimedAt ?? updatedAt) > UNVERIFIED_CLAIM_MS; } type Holder = { stale: boolean; release: () => Promise }; async function siteHolder(hostname: string, siteId: string | null): Promise { const holderId = await hostHolder(hostname); const holder = holderId ? await loadSiteRow(holderId) : null; if (!holder || holder.id === siteId || holder.custom_domain !== hostname) return null; return { stale: isStaleClaim(holder.domain_status, holder.domain_claimed_at, holder.updated_at), release: () => releaseSiteDomain(holder), }; } /** Domains are unique across Sites; every holder is checked before any stale (72 h) claim is released. */ async function claimDomain(holders: (Holder | null)[]): Promise { const found = holders.filter((holder): holder is Holder => holder !== null); if (found.some((holder) => !holder.stale)) throw conflict("That domain is already used by another site"); for (const holder of found) await holder.release(); } /** Registers a custom hostname; nothing is served on it until `refreshSiteDomain` sees TXT proofs and an active cert. */ export async function setSiteDomain(site: SiteRow, input: string | null): Promise { const hostname = input?.trim().toLowerCase().replace(/\.$/, "") || null; if (hostname === site.custom_domain) return; if (site.scratch === 1) throw badRequest("Claim the site before adding a custom domain"); if (hostname) { if (!customDomainsEnabled()) throw badRequest("Custom domains are not configured on this forge"); await assertCanAddCustomDomain(site.owner_id, site.custom_domain !== null); const blocked = sitesDomain(); if ( !isValidHostname(hostname) || isForgeRelatedHost(hostname) || (blocked && (hostname === blocked || hostname.endsWith(`.${blocked}`))) ) { throw badRequest("Enter a valid domain you control"); } await claimDomain([await siteHolder(hostname, site.id)]); } await clearDomain(site); const now = Date.now(); const db = siteDb(site); if (!hostname) { await db.run( "UPDATE sites SET custom_domain = NULL, custom_hostname_id = NULL, domain_status = NULL, domain_claimed_at = NULL, domain_verified_at = NULL, domain_token = NULL, updated_at = ? WHERE id = ?", now, site.id, ); await forgetDomainSite(site.id); await syncDomainAddonQuantity(site.owner_id); return; } const created = await createCustomHostname(hostname); await claimHost(hostname, site.id); await registerDomainSite(site); await db.run( "UPDATE sites SET custom_domain = ?, custom_hostname_id = ?, domain_status = ?, domain_verified_at = NULL, domain_claimed_at = ?, domain_token = ?, updated_at = ? WHERE id = ?", hostname, created.id, domainStatus(created), now, randomToken("gitfor-site"), now, site.id, ); await syncDomainAddonQuantity(site.owner_id); } /** Resolves `_gitfor-site.` TXT over DNS-over-HTTPS. */ async function hasOwnershipTxt(hostname: string, token: string): Promise { const url = new URL("https://cloudflare-dns.com/dns-query"); url.searchParams.set("name", `_gitfor-site.${hostname}`); url.searchParams.set("type", "TXT"); const response = await fetch(url, { headers: { accept: "application/dns-json" } }); if (!response.ok) return false; const body = (await response.json().catch(() => null)) as { Answer?: { data?: string }[] } | null; return (body?.Answer ?? []).some((answer) => (answer.data ?? "").replace(/"/g, "").trim() === token); } export type SiteDomainDetails = { status: string; ownershipVerified: boolean; records: { type: string; name: string; value: string }[]; }; export async function refreshSiteDomain(site: SiteRow): Promise { if (!site.custom_hostname_id || !site.custom_domain || !site.domain_token || !customDomainsEnabled()) return null; const hostname = await getCustomHostname(site.custom_hostname_id); const owned = await hasOwnershipTxt(site.custom_domain, site.domain_token); const cloudflare = domainStatus(hostname); const status = cloudflare === "active" && !owned ? "awaiting ownership TXT" : cloudflare; const db = siteDb(site); if (status !== site.domain_status) await db.run("UPDATE sites SET domain_status = ? WHERE id = ?", status, site.id); const key = customHostKey(site.custom_domain); if (status === "active" && hostname.hostname === site.custom_domain) { await env.KV.put(key, JSON.stringify({ siteId: site.id, label: site.label })); await db.run( "UPDATE sites SET domain_verified_at = COALESCE(domain_verified_at, ?) WHERE id = ?", Date.now(), site.id, ); } else if (hostname.status !== "active" || hostname.hostname !== site.custom_domain || !owned) { await env.KV.delete(key); } const records = [{ type: "TXT", name: `_gitfor-site.${site.custom_domain}`, value: site.domain_token }]; const ssl = hostname.ssl; const dcv = ssl?.txt_name && ssl.txt_value ? ssl : ssl?.validation_records?.find((item) => item.txt_name && item.txt_value); if (cloudflare !== "active" && dcv?.txt_name && dcv.txt_value) { records.push({ type: "TXT", name: dcv.txt_name, value: dcv.txt_value }); } return { status, ownershipVerified: owned, records }; }