import { sameOriginReport } from "./report-origin"; import { env } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { forgetLocator, locate, locatedScopes, locatorsBefore, registerLocator } from "../scoped-db/locators"; import { backfillDone, backfillOnce } from "../backfill"; import { claimHost, forgetSite, registerOwnedSite, releaseHost, siteDb } from "./store"; import { sha256Hex } from "../crypto"; import { HttpError, badRequest } from "../errors"; import { enforceRateLimit, requestIp } from "../limits"; import { extractArtifact, storeFile } from "./collect"; import { manifestKey, metaKey, publishMeta, SCRATCH_LIMITS, slotKey } from "./config"; import { assertLabelFree, loadSiteById, unpublishSite } from "./deploy"; import { claimedLabel } from "./naming"; import { onScratchClaimed } from "./metering"; import { CLAIM_PREFIX, deployUpload, forgetScratchExpiry, registerScratchExpiry, reserveScratch, scratchExpiryKey, scratchResult, } from "./scratch-core"; import { assertScratchRepoTransferable, createScratchRepo, sweepOrphanScratchRepos, transferScratchRepo, } from "./scratch-git"; import type { CollectedFiles } from "./collect"; import type { ScratchResult } from "./scratch-core"; import { plain } from "./static"; import { guessAbuseCategory, recordAbuseReport } from "../abuse"; import type { SiteMeta, SiteRow } from "./config"; import type { Viewer } from "~/lib/types"; const HTTP_TOO_LARGE = 413; const MIB = 1024 * 1024; const SWEEP_BATCH = 50; async function readCapped( request: Request, limit: number = SCRATCH_LIMITS.maxUploadBytes, allowEmpty = false, ): Promise> { const tooLarge = () => new HttpError(HTTP_TOO_LARGE, `Upload exceeds ${Math.round(limit / MIB)} MiB`); const declared = Number(request.headers.get("content-length") ?? "0"); if (declared > limit) throw tooLarge(); if (!request.body) { if (allowEmpty) return new Uint8Array(0); throw badRequest("Send a .tar.gz archive as the request body"); } const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; for (;;) { const next = await reader.read(); if (next.done) break; size += next.value.byteLength; if (size > limit) { await reader.cancel(); throw tooLarge(); } chunks.push(next.value); } const out = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } return out; } /** `POST /__scratch`: anonymous, rate-limited, time-boxed deploy that returns a one-time claim token. */ export async function handleScratchUpload(request: Request): Promise { if (request.method !== "POST") return plain(405, "POST a .tar.gz archive to deploy a scratch site"); const ip = requestIp(request); await enforceRateLimit("HEAVY_LIMITER", `scratch-upload:${ip}`); if (new URL(request.url).searchParams.get("mode") === "git") { return Response.json(await createScratchRepo(ip), { status: 201, headers: { "cache-control": "no-store" } }); } const body = await readCapped(request); const { site, claimToken, expiresAt } = await reserveScratch(ip); const key = `sites/uploads/${site.id}.tar.gz`; await env.BUCKET.put(key, body); try { await deployUpload(site, () => extractArtifact(key, "", SCRATCH_LIMITS)); } finally { await env.BUCKET.delete(key); } return Response.json(scratchResult(site, claimToken, expiresAt), { status: 201, headers: { "cache-control": "no-store" }, }); } const MAX_JSON_BYTES = 12 * 1024 * 1024; /** * `POST /api/v1/scratch` on the forge, no auth: a `.tar.gz` body deploys it, `{"files": {...}}` deploys those * files, and an empty body creates a scratch git repository to push to. Responses carry the claim token. */ export async function handleScratchApi(request: Request): Promise { if (request.method !== "POST") return Response.json({ error: "Method not allowed" }, { status: 405 }); const type = (request.headers.get("content-type") ?? "").toLowerCase(); const created = (body: unknown) => Response.json(body, { status: 201, headers: { "cache-control": "no-store" } }); if (type.includes("gzip") || type.includes("tar")) return handleScratchUpload(request); const ip = requestIp(request); await enforceRateLimit("HEAVY_LIMITER", `scratch-upload:${ip}`); const body = await readCapped(request, MAX_JSON_BYTES, true); if (body.byteLength === 0 && !type.includes("json")) return created(await createScratchRepo(ip)); if (!type.includes("json")) throw badRequest('Send a .tar.gz, a JSON {"files": {...}} body, or an empty body for a git repository'); let raw: unknown; try { raw = JSON.parse(new TextDecoder().decode(body)); } catch { throw badRequest("Request body must be valid JSON"); } const files = raw && typeof raw === "object" && "files" in raw ? raw.files : null; if (!files || typeof files !== "object") throw badRequest('Expected {"files": {"path": "content"}}'); const entries = Object.entries(files); if (entries.some(([, value]) => typeof value !== "string")) throw badRequest("File contents must be strings"); return created(await createScratchFromFiles(Object.fromEntries(entries) as Record, ip)); } /** Scratch site from an in-memory file map (MCP `create_scratch_site`); same caps as a tarball upload. */ export async function createScratchFromFiles( files: Record, ip: string, ): Promise { const entries = Object.entries(files); if (entries.length === 0) throw badRequest("Provide at least one file"); if (entries.length > SCRATCH_LIMITS.maxFiles) throw badRequest(`Scratch sites are limited to ${SCRATCH_LIMITS.maxFiles} files`); for (const [path] of entries) { if (!path || path.startsWith("/") || path.split("/").some((part) => part === ".." || part === "")) { throw badRequest(`Invalid file path: ${path}`); } } const encoded = entries.map( ([path, content]) => [path, typeof content === "string" ? new TextEncoder().encode(content) : new Uint8Array(content)] as const, ); const total = encoded.reduce((sum, [, bytes]) => sum + bytes.byteLength, 0); if (total > SCRATCH_LIMITS.maxTotalBytes) throw new HttpError(HTTP_TOO_LARGE, "Scratch sites are limited to 10 MiB"); const { site, claimToken, expiresAt } = await reserveScratch(ip); await deployUpload(site, async () => { const out: CollectedFiles = {}; for (const [path, bytes] of encoded) out[path] = await storeFile(bytes); return out; }); return scratchResult(site, claimToken, expiresAt); } /** `POST /__gitfor/report` on any site; files into the shared abuse queue, which auto-pauses on enough reporters. */ export async function handleAbuseReport(request: Request, site: SiteMeta): Promise { if (request.method !== "POST") return plain(405, "POST a reason to report this site"); if (!sameOriginReport(request)) return plain(403, "Reports must come from this site's own pages"); await enforceRateLimit("REPORT_LIMITER", `site-report:${requestIp(request)}`); const reason = (await request.text()).slice(0, 500).trim() || "unspecified"; await recordAbuseReport({ source: "site", targetUrl: request.url, category: guessAbuseCategory(reason), details: reason, reporterEmail: null, reporterKey: `ip:${requestIp(request)}`, reporterKind: "ip", siteId: site.id, }); return plain(202, "Thanks, the report was recorded."); } /** * Transfers an unexpired scratch site (and its repository, if git-backed) to the signed-in user. Upload sites move * to `--`; repo-backed sites follow the repository's new name via `syncSiteForRepo`. */ export async function claimScratchSite(token: string, viewer: Viewer, makePublic = false): Promise { if (!token.startsWith(`${CLAIM_PREFIX}_`)) throw badRequest("Invalid claim token"); const claimHash = await sha256Hex(token); const scope = await locate("site_claim", claimHash); const db = scope ? repoDb(scope) : null; const site = db ? await db.first("SELECT * FROM sites WHERE claim_token_hash = ? AND scratch = 1", claimHash) : null; if (!site || (site.expires_at ?? 0) < Date.now()) throw badRequest("This claim token is invalid or expired"); if (site.disabled_reason) throw badRequest("This site was disabled and can't be claimed"); const label = site.repo_id ? site.label : claimedLabel(site.id, viewer.name); if (site.repo_id) await assertScratchRepoTransferable(site, viewer); else await assertLabelFree(site.id, label); if (!db) throw badRequest("This claim token is invalid or expired"); // Indexed before the claim lands so the new owner always finds it; a lost race leaves a harmless stale entry. if (!site.repo_id) await registerOwnedSite(viewer.id, site.id); const claimed = await db.run( `UPDATE sites SET owner_id = ?, scratch = 0, claim_token_hash = NULL, expires_at = NULL, label = ?, updated_at = ? WHERE id = ? AND scratch = 1 AND claim_token_hash IS NOT NULL`, viewer.id, label, Date.now(), site.id, ); if (!claimed.meta.changes) throw badRequest("This site was already claimed"); await forgetLocator("site_claim", claimHash); if (site.expires_at !== null) await forgetScratchExpiry(site.id, site.expires_at); if (site.repo_id) { try { await transferScratchRepo(site, viewer, makePublic); } catch (error) { // The repository move is one atomic batch, so on failure nothing moved: put the site back as an unclaimed // scratch site so the same claim token can be retried. await registerLocator("site_claim", claimHash, db.scopeId); if (site.expires_at !== null) await registerScratchExpiry(site, site.expires_at); await db .run( `UPDATE sites SET owner_id = NULL, scratch = 1, claim_token_hash = ?, expires_at = ?, label = ?, updated_at = ? WHERE id = ? AND owner_id = ? AND scratch = 0`, site.claim_token_hash, site.expires_at, site.label, site.updated_at, site.id, viewer.id, ) .catch((restore: unknown) => console.error("Scratch claim rollback failed", site.id, restore)); throw error; } } else { if (site.active_deployment_id) { await env.KV.put(slotKey(label), JSON.stringify({ siteId: site.id, deploymentId: site.active_deployment_id })); await db.run("UPDATE site_deployments SET label = ? WHERE site_id = ? AND label = ?", label, site.id, site.label); } await claimHost(label, site.id); await env.KV.delete(slotKey(site.label)); await releaseHost(site.label, site.id); } await onScratchClaimed(site.id, viewer.id); const updated = await loadSiteById(site.id); if (!updated) throw new Error("Claimed site vanished"); await publishMeta(updated); return updated; } type ExpiredScratch = Pick< SiteRow, "id" | "repo_id" | "owner_id" | "label" | "custom_domain" | "claim_token_hash" | "expires_at" >; const EXPIRED_SQL = "SELECT id, repo_id, owner_id, label, custom_domain, claim_token_hash, expires_at FROM sites WHERE scratch = 1 AND expires_at < ?"; const SCRATCH_EXPIRY_INDEX = "scratch-expiry-locators"; /** Cron step until done: queues scratch sites created before `scratch_expiry` locators existed. */ export async function backfillScratchExpiry(): Promise { await backfillOnce(SCRATCH_EXPIRY_INDEX, async () => { for (const scope of await locatedScopes("site_claim")) { const rows = await repoDb(scope).all<{ id: string; repo_id: string | null; expires_at: number }>( "SELECT id, repo_id, expires_at FROM sites WHERE scratch = 1 AND expires_at IS NOT NULL", ); for (const row of rows) await registerScratchExpiry(row, row.expires_at); } }); } /** Expired unclaimed scratch sites, read from the expiry queue; entries whose site was claimed or gone are dropped. */ async function expiredScratchSites(now: number): Promise { const expired: ExpiredScratch[] = []; if (!(await backfillDone(SCRATCH_EXPIRY_INDEX))) { // Every unclaimed scratch site holds a `site_claim` locator, so its scopes are exactly the candidates. for (const scope of await locatedScopes("site_claim")) { if (expired.length >= SWEEP_BATCH) break; expired.push( ...(await repoDb(scope).all(`${EXPIRED_SQL} LIMIT ?`, now, SWEEP_BATCH - expired.length)), ); } return expired; } for (const entry of await locatorsBefore("scratch_expiry", scratchExpiryKey(now, ""), SWEEP_BATCH)) { const siteId = entry.key.slice(entry.key.indexOf(":") + 1); const row = await repoDb(entry.scope_id).first(`${EXPIRED_SQL} AND id = ?`, now, siteId); if (row) expired.push(row); else await forgetLocator("scratch_expiry", entry.key); } return expired; } /** Cron: deletes expired, unclaimed scratch sites (and scratch repositories); shared R2 objects are left for GC. */ export async function sweepScratchSites(): Promise { const expired = await expiredScratchSites(Date.now()); for (const site of expired) { const db = siteDb(site); const deployments = await db.all<{ id: string }>( "SELECT id FROM site_deployments WHERE site_id = ? LIMIT 100", site.id, ); await unpublishSite(site.id); await env.KV.delete(metaKey(site.id)); // Rows before manifests, so object GC never sees a retained deployment whose manifest is already gone. await db.run("DELETE FROM sites WHERE id = ? AND scratch = 1", site.id); if (site.claim_token_hash) await forgetLocator("site_claim", site.claim_token_hash); await forgetSite(site); if (site.expires_at !== null) await forgetScratchExpiry(site.id, site.expires_at); await Promise.all(deployments.map((row) => env.KV.delete(manifestKey(row.id)))); } // The site row goes first so a slow Artifacts delete can't keep an expired site serving; repositories left // behind (this run's, or a previous run that was cut off) are collected here. await sweepOrphanScratchRepos(); return expired.length; }