import { env } from "cloudflare:workers"; import { first, run } from "../db"; import { hmacSha256Hex, randomId, randomToken, sha256Hex } from "../crypto"; import { HttpError, badRequest } from "../errors"; import { enforceRateLimit, numberVar } from "../limits"; import { DeployError } from "../pages/collect"; import { cookieSecret, labelUrl, SCRATCH_LIMITS } from "./config"; import { activateSiteDeployment, failSiteDeployment, insertDeployment, loadSiteById } from "./deploy"; import { scratchLabel } from "./naming"; import type { CollectedFiles } from "../pages/collect"; import type { SiteRow } from "./config"; const DAY = 24 * 60 * 60 * 1000; const HTTP_TOO_MANY = 429; export const CLAIM_PREFIX = "gitfor_claim"; export type ScratchResult = { url: string | null; claimToken: string; claimUrl: string; expiresAt: number; repo?: string; }; /** Keyed so stored hashes can't be reversed by enumerating the IPv4 space. */ export function ipHash(ip: string): Promise { return hmacSha256Hex(cookieSecret(), `site-ip:${ip}`); } /** Rate limit and per-IP daily cap shared by every scratch entry point (tarball, files, git). */ async function assertScratchQuota(ip: string): Promise { await enforceRateLimit("HEAVY_LIMITER", `scratch:${ip}`); const creator = await ipHash(ip); const recent = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM sites WHERE creator_ip_hash = ? AND created_at > ?", creator, Date.now() - DAY, ); const perDay = numberVar(env.SCRATCH_SITES_PER_IP_PER_DAY, SCRATCH_LIMITS.perIpPerDay); if ((recent?.count ?? 0) >= perDay) throw new HttpError(HTTP_TOO_MANY, `At most ${perDay} scratch sites per day`); return creator; } /** Inserts an unclaimed site with a TTL and a one-time claim token; `repoId` links a git-backed scratch site. */ export async function reserveScratch( ip: string, input: { id?: string; repoId?: string; source: "upload" | "branch"; branch?: string } = { source: "upload" }, ): Promise<{ site: SiteRow; claimToken: string; expiresAt: number }> { const creator = await assertScratchQuota(ip); const id = input.id ?? randomId(); const claimToken = randomToken(CLAIM_PREFIX); const now = Date.now(); const expiresAt = now + SCRATCH_LIMITS.ttlMs; await run( `INSERT INTO sites (id, repo_id, label, source, branch, previews, scratch, claim_token_hash, creator_ip_hash, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?)`, id, input.repoId ?? null, scratchLabel(id), input.source, input.branch ?? "main", await sha256Hex(claimToken), creator, expiresAt, now, now, ); const site = await loadSiteById(id); if (!site) throw new Error("Scratch site insert failed"); return { site, claimToken, expiresAt }; } export function scratchResult(site: SiteRow, claimToken: string, expiresAt: number): ScratchResult { return { url: labelUrl(site.label), expiresAt, claimToken, claimUrl: new URL(`/sites/claim?token=${encodeURIComponent(claimToken)}`, env.PUBLIC_URL).toString(), }; } /** Publishes an upload synchronously as the scratch site's production deployment. */ export async function deployUpload(site: SiteRow, collect: () => Promise): Promise { const deployment = await insertDeployment({ site, environment: "production", label: site.label, branch: null, ref: "upload", sha: "upload", artifactId: null, source: "upload", creatorId: null, }); try { const files = await collect(); const total = Object.values(files).reduce((sum, [, size]) => sum + size, 0); if (Object.keys(files).length > SCRATCH_LIMITS.maxFiles || total > SCRATCH_LIMITS.maxTotalBytes) { throw new DeployError("Scratch sites are limited to 500 files and 25 MiB"); } const outcome = await activateSiteDeployment(deployment.id, files); if (outcome !== "active") throw badRequest(`Deploy did not activate (${outcome})`); } catch (error) { const message = error instanceof DeployError || error instanceof HttpError ? error.message : "Deploy failed"; await failSiteDeployment(deployment.id, message); throw error instanceof DeployError ? badRequest(message) : error; } }