import { env } from "cloudflare:workers"; import { forgetLocator, registerLocator } from "../scoped-db/locators"; import { registerSite, siteDb, siteScope } from "./store"; import { hmacSha256Hex, randomId, randomToken, sha256Hex } from "../crypto"; import { HttpError, badRequest } from "../errors"; import { enforceRateLimit, numberVar } from "../limits"; import { DeployError } from "./collect"; import { cookieSecret, labelUrl, SCRATCH_LIMITS } from "./config"; import { activateSiteDeployment, failSiteDeployment, insertDeployment } from "./deploy"; import { scratchLabel } from "./naming"; import { ipBucket } from "~/lib/ip"; import type { CollectedFiles } from "./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; }; /** Orders unclaimed scratch sites by expiry, so the sweep reads only expired ones (`scratch_expiry`). */ export function scratchExpiryKey(expiresAt: number, siteId: string): string { return `${String(expiresAt).padStart(15, "0")}:${siteId}`; } export async function registerScratchExpiry(site: Pick, expiresAt: number): Promise { await registerLocator("scratch_expiry", scratchExpiryKey(expiresAt, site.id), siteScope(site)); } export async function forgetScratchExpiry(siteId: string, expiresAt: number): Promise { await forgetLocator("scratch_expiry", scratchExpiryKey(expiresAt, siteId)); } /** 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). IPv6 is bucketed to * its /64 here too, so a caller passing a raw address can't rotate past the cap. */ export async function assertScratchQuota(ip: string): Promise { const bucket = ipBucket(ip); await enforceRateLimit("HEAVY_LIMITER", `scratch:${bucket}`); const creator = await ipHash(bucket); // Scratch sites live in separate objects, so the per-IP count is a KV counter per UTC day. const key = `scratch:ip-day:${creator}:${Math.floor(Date.now() / DAY)}`; const count = Number((await env.KV.get(key)) ?? "0"); const perDay = numberVar(env.SCRATCH_SITES_PER_IP_PER_DAY, SCRATCH_LIMITS.perIpPerDay); if (count >= perDay) throw new HttpError(HTTP_TOO_MANY, `At most ${perDay} scratch sites per day`); await env.KV.put(key, String(count + 1), { expirationTtl: (2 * DAY) / 1000 }); return creator; } type ScratchInput = { id?: string; repoId?: string; source: "upload" | "branch"; branch?: string }; /** 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: ScratchInput = { source: "upload" }, ): Promise<{ site: SiteRow; claimToken: string; expiresAt: number }> { return insertScratch(await assertScratchQuota(ip), input); } /** reserveScratch after the quota was already charged (`creator` from assertScratchQuota). */ export async function insertScratch( creator: string, input: ScratchInput, ): Promise<{ site: SiteRow; claimToken: string; expiresAt: number }> { const id = input.id ?? randomId(); const claimToken = randomToken(CLAIM_PREFIX); const now = Date.now(); const expiresAt = now + SCRATCH_LIMITS.ttlMs; const claimHash = await sha256Hex(claimToken); const db = siteDb({ id, repo_id: input.repoId ?? null }); await registerSite({ id, repo_id: input.repoId ?? null, label: scratchLabel(id) }); await registerLocator("site_claim", claimHash, db.scopeId); await registerScratchExpiry({ id, repo_id: input.repoId ?? null }, expiresAt); await db.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", claimHash, creator, expiresAt, now, now, ); const site = await db.first("SELECT * FROM sites WHERE id = ?", 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: claimUrl(claimToken), }; } /** The token rides in the fragment, so it never reaches server logs, proxies or Referer headers. */ export function claimUrl(claimToken: string): string { return 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 10 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; } }