import { env } from "cloudflare:workers"; import { all } from "../db"; import { repoDb } from "../scoped-db"; import { ownerSiteScopes } from "./store"; import { sitesDomain } from "./config"; const API = "https://api.cloudflare.com/client/v4"; const HOSTS_PER_CALL = 30; const SITE_LABELS_MAX = 500; /** The account's Sites: labels (production, previews, PR aliases) under `SITES_DOMAIN`, and their custom domains. */ export async function accountSitesHosts(accountId: string): Promise<{ labels: string[]; custom: string[] }> { // A site counts when the account owns it or its repository; repository scopes are already the account's. const repos = new Set( (await all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", accountId)).map((repo) => repo.id), ); const rows: Array<{ host: string; custom: number }> = []; for (const scope of await ownerSiteScopes(accountId)) { if (rows.length >= SITE_LABELS_MAX) break; const owned = repos.has(scope) ? "SELECT id FROM sites" : "SELECT id FROM sites WHERE owner_id = ?1"; rows.push( ...(await repoDb(scope).all<{ host: string; custom: number }>( `SELECT label AS host, 0 AS custom FROM sites WHERE id IN (${owned}) UNION SELECT label, 0 FROM site_deployments WHERE site_id IN (${owned}) UNION SELECT label, 0 FROM site_pr_aliases WHERE site_id IN (${owned}) UNION SELECT custom_domain, 1 FROM sites WHERE id IN (${owned}) AND custom_domain IS NOT NULL LIMIT ${SITE_LABELS_MAX}`, accountId, )), ); } const domain = sitesDomain(); const labels = domain ? rows.filter((row) => row.custom === 0).map((row) => `${row.host.toLowerCase()}.${domain}`) : []; const custom = rows.filter((row) => row.custom === 1).map((row) => row.host.toLowerCase()); return { labels: [...new Set(labels)], custom: [...new Set(custom)] }; } async function purgeHosts(zoneId: string, hosts: string[], accountId: string): Promise { let purged = 0; for (let index = 0; index < hosts.length; index += HOSTS_PER_CALL) { const chunk = hosts.slice(index, index + HOSTS_PER_CALL); const response = await fetch(`${API}/zones/${zoneId}/purge_cache`, { method: "POST", headers: { authorization: `Bearer ${env.CF_API_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ hosts: chunk }), }); if (!response.ok) { console.warn("Cache purge failed", accountId, zoneId, response.status, await response.text().catch(() => "")); continue; } purged += chunk.length; } return purged; } /** * Best effort: drops edge-cached responses of a suspended account's Sites so a takedown doesn't wait out * `max-age`. Needs `CF_API_TOKEN` with Cache Purge; custom domains (Cloudflare for SaaS) go through * `CUSTOM_DOMAINS_ZONE_ID`, `SITES_DOMAIN` labels through `SITES_ZONE_ID`. Returns how many hosts were purged. */ export async function purgeAccountHostingCache(accountId: string): Promise { if (!env.CF_API_TOKEN) return 0; let purged = 0; const sites = await accountSitesHosts(accountId); if (env.CUSTOM_DOMAINS_ZONE_ID) purged += await purgeHosts(env.CUSTOM_DOMAINS_ZONE_ID, sites.custom, accountId); if (env.SITES_ZONE_ID) purged += await purgeHosts(env.SITES_ZONE_ID, sites.labels, accountId); return purged; }