import { env } from "cloudflare:workers"; import { all, first, run } from "../db"; import { hmacSha256Hex, randomId, randomToken, sha256Hex } from "../crypto"; import { HttpError, badRequest } from "../errors"; import { enforceRateLimit, numberVar, requestIp } from "../limits"; import { DeployError, extractArtifact } from "../pages/collect"; import { cookieSecret, labelUrl, manifestKey, metaKey, publishMeta, SCRATCH_LIMITS, slotKey } from "./config"; import { activateSiteDeployment, assertLabelFree, failSiteDeployment, insertDeployment, loadSiteById, unpublishSite, } from "./deploy"; import { claimedLabel, scratchLabel } from "./naming"; import { plain } from "./static"; import type { SiteMeta, SiteRow } from "./config"; import type { Viewer } from "~/lib/types"; const DAY = 24 * 60 * 60 * 1000; const HTTP_TOO_MANY = 429; const HTTP_TOO_LARGE = 413; const SWEEP_BATCH = 50; const CLAIM_PREFIX = "gitfor_claim"; /** Keyed so stored hashes can't be reversed by enumerating the IPv4 space. */ function ipHash(request: Request): Promise { return hmacSha256Hex(cookieSecret(), `site-ip:${requestIp(request)}`); } async function readCapped(request: Request): Promise> { const declared = Number(request.headers.get("content-length") ?? "0"); if (declared > SCRATCH_LIMITS.maxUploadBytes) throw new HttpError(HTTP_TOO_LARGE, "Upload exceeds 10 MiB"); if (!request.body) 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 > SCRATCH_LIMITS.maxUploadBytes) { await reader.cancel(); throw new HttpError(HTTP_TOO_LARGE, "Upload exceeds 10 MiB"); } 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"); await enforceRateLimit("HEAVY_LIMITER", `scratch:${requestIp(request)}`); const creator = await ipHash(request); 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`); } const body = await readCapped(request); const id = randomId(); const label = scratchLabel(id); const claimToken = randomToken(CLAIM_PREFIX); const now = Date.now(); const expiresAt = now + SCRATCH_LIMITS.ttlMs; await run( `INSERT INTO sites (id, label, source, scratch, claim_token_hash, creator_ip_hash, expires_at, created_at, updated_at) VALUES (?, ?, 'upload', 1, ?, ?, ?, ?, ?)`, id, label, await sha256Hex(claimToken), creator, expiresAt, now, now, ); const site = await loadSiteById(id); if (!site) throw new Error("Scratch site insert failed"); const deployment = await insertDeployment({ site, environment: "production", label, branch: null, ref: "upload", sha: "upload", artifactId: null, source: "upload", creatorId: null, }); const key = `sites/uploads/${deployment.id}.tar.gz`; await env.BUCKET.put(key, body); try { const files = await extractArtifact(key, "", SCRATCH_LIMITS); 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; } finally { await env.BUCKET.delete(key); } return Response.json( { url: labelUrl(label), expiresAt, claimToken, claimUrl: new URL(`/sites/claim?token=${encodeURIComponent(claimToken)}`, env.PUBLIC_URL).toString(), }, { status: 201, headers: { "cache-control": "no-store" } }, ); } /** `POST /__gitfor/report` on any site; enough distinct reporters disable an unclaimed scratch site. */ export async function handleAbuseReport(request: Request, site: SiteMeta): Promise { if (request.method !== "POST") return plain(405, "POST a reason to report this site"); await enforceRateLimit("AUTH_LIMITER", `site-report:${requestIp(request)}`); const reason = (await request.text()).slice(0, 500).trim() || "unspecified"; await run( "INSERT OR IGNORE INTO site_abuse_reports (id, site_id, reporter_ip_hash, reason, created_at) VALUES (?, ?, ?, ?, ?)", randomId(), site.id, await ipHash(request), reason, Date.now(), ); if (site.scratch) { const reports = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM site_abuse_reports WHERE site_id = ?", site.id, ); if ((reports?.count ?? 0) >= SCRATCH_LIMITS.reportsToDisable) await disableSite(site.id, "Disabled after abuse reports"); } return plain(202, "Thanks, the report was recorded."); } export async function disableSite(siteId: string, reason: string): Promise { await run("UPDATE sites SET disabled_reason = ?, updated_at = ? WHERE id = ?", reason, Date.now(), siteId); const site = await loadSiteById(siteId); if (site) await publishMeta(site); } /** Transfers an unexpired scratch site to the signed-in user and moves it to `--`. */ export async function claimScratchSite(token: string, viewer: Viewer): Promise { if (!token.startsWith(`${CLAIM_PREFIX}_`)) throw badRequest("Invalid claim token"); const site = await first( "SELECT * FROM sites WHERE claim_token_hash = ? AND scratch = 1", await sha256Hex(token), ); 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 = claimedLabel(site.id, viewer.name); await assertLabelFree(site.id, label); const claimed = await 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"); if (site.active_deployment_id) { await env.KV.put(slotKey(label), JSON.stringify({ siteId: site.id, deploymentId: site.active_deployment_id })); await run("UPDATE site_deployments SET label = ? WHERE site_id = ? AND label = ?", label, site.id, site.label); } await env.KV.delete(slotKey(site.label)); const updated = await loadSiteById(site.id); if (!updated) throw new Error("Claimed site vanished"); await publishMeta(updated); return updated; } /** Cron: deletes expired, unclaimed scratch sites and their KV entries; shared R2 objects are left for GC. */ export async function sweepScratchSites(): Promise { const expired = await all<{ id: string }>( "SELECT id FROM sites WHERE scratch = 1 AND expires_at < ? LIMIT ?", Date.now(), SWEEP_BATCH, ); for (const site of expired) { const deployments = await all<{ id: string }>( "SELECT id FROM site_deployments WHERE site_id = ? LIMIT 100", site.id, ); await unpublishSite(site.id); await Promise.all(deployments.map((row) => env.KV.delete(manifestKey(row.id)))); await env.KV.delete(metaKey(site.id)); await run("DELETE FROM sites WHERE id = ? AND scratch = 1", site.id); } return expired.length; }