import { env } from "cloudflare:workers"; import { all, batch, first, stmt } from "../db"; import { rebillRepoLfs } from "../lfs/objects"; import { randomId } from "../crypto"; import { siteDb } from "./store"; import { randomToken, sha256Hex, timingSafeEqualStrings } from "../crypto"; import { conflict } from "../errors"; import { createRepository, deleteRepository } from "../repo-ops"; import { repoMoveStatements } from "../redirects"; import { disableRepoConfigOnTransfer, repoOwnershipChangeStatements } from "../integrations/ownership"; import { loadRepoById } from "../repos"; import { isScratchRepo, publishMeta, SCRATCH_ACCOUNT_ID, SCRATCH_LIMITS } from "./config"; import { loadSiteById, loadSiteForRepo } from "./deploy"; import { assertScratchQuota, insertScratch, scratchResult } from "./scratch-core"; import { syncSiteForRepo } from "./sync"; import type { ScratchResult } from "./scratch-core"; import type { SiteRow } from "./config"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; const PUSH_PREFIX = "gitfor_scratch"; /** createScratchRepo inserts the repo before linking it to its site; don't race that window. */ const ORPHAN_GRACE_MS = 10 * 60 * 1000; const DEFAULT_BRANCH = "main"; const MIB = 1024 * 1024; /** The reserved org that owns unclaimed scratch repositories; it has no members and no sessions. */ export const SCRATCH_ACTOR: Viewer = { id: SCRATCH_ACCOUNT_ID, name: "scratch", displayName: "Scratch sites", email: "scratch@invalid", isAdmin: false, emailVerified: true, }; export type ScratchRepoResult = ScratchResult & { remote: string; pushToken: string }; export { isScratchRepo }; /** * Creates an anonymous scratch repository and site. Every push to it deploys: `main` to `--scratch`, other * branches to `----scratch`. The push token works until the site is claimed or expires. */ export async function createScratchRepo(ip: string): Promise { // The repository comes first: the site lives in its RepoState. A site insert that fails after it leaves an // orphan repository, which sweepOrphanScratchRepos collects. const creator = await assertScratchQuota(ip); const id = randomId(); const pushToken = randomToken(PUSH_PREFIX); const repo: RepoRow = await createRepository({ owner: { id: SCRATCH_ACCOUNT_ID, name: SCRATCH_ACTOR.name }, name: id, description: "Scratch site", visibility: "private", initReadme: false, // Anonymous pushers must never get CI: the only thing a scratch push may do is deploy its site. hasActions: false, actor: SCRATCH_ACTOR, }); const { site, claimToken, expiresAt } = await insertScratch(creator, { id, repoId: repo.id, source: "branch", branch: repo.default_branch || DEFAULT_BRANCH, }); await siteDb(site).run("UPDATE sites SET push_token_hash = ? WHERE id = ?", await sha256Hex(pushToken), site.id); const updated = (await loadSiteById(site.id)) ?? site; await publishMeta(updated); const remote = new URL(`/${SCRATCH_ACTOR.name}/${repo.name}.git`, env.PUBLIC_URL).toString(); return { ...scratchResult(updated, claimToken, expiresAt), repo: `${SCRATCH_ACTOR.name}/${repo.name}`, remote, pushToken, }; } function basicPassword(request: Request): string | null { const header = request.headers.get("authorization") ?? ""; const [scheme, encoded] = header.split(" ", 2); if (scheme?.toLowerCase() !== "basic" || !encoded) return null; try { const decoded = atob(encoded); return decoded.slice(decoded.indexOf(":") + 1); } catch { return null; } } /** Git access granted by a scratch push token; null when the request doesn't carry one for this repo. */ export async function scratchGitPermission(request: Request, repo: RepoRow | null): Promise { if (!repo || !isScratchRepo(repo)) return null; const token = basicPassword(request); if (!token?.startsWith(`${PUSH_PREFIX}_`)) return null; const site = await loadSiteForRepo(repo.id); if (!site?.push_token_hash || site.scratch !== 1 || (site.expires_at ?? 0) < Date.now() || site.disabled_reason) { return "none"; } return timingSafeEqualStrings(await sha256Hex(token), site.push_token_hash) ? "write" : "none"; } /** Scratch-only push caps on top of the normal protections: few branch refs and a bounded pack size. */ export async function scratchPushPolicy( repo: RepoRow, refs: string[], body: ReadableStream, ): Promise<{ error: string } | { body: ReadableStream }> { if (!isScratchRepo(repo)) return { body }; const refuse = async (error: string) => { await body.cancel().catch(() => undefined); return { error }; }; if (refs.length > SCRATCH_LIMITS.maxPushRefs) { return refuse(`Scratch pushes may update at most ${SCRATCH_LIMITS.maxPushRefs} refs`); } if (refs.some((ref) => !ref.startsWith("refs/heads/"))) return refuse("Scratch repositories accept branch pushes only"); // Buffered up to the cap before anything reaches Artifacts, so an oversized pack is rejected cleanly instead of // being cut off mid-stream (chunked pushes carry no content-length to check up front). const reader = body.getReader(); const chunks: Uint8Array[] = []; let bytes = 0; for (;;) { let next: Awaited>; try { next = await reader.read(); } catch { return { error: "The push was interrupted" }; } if (next.done) break; bytes += next.value.byteLength; if (bytes > SCRATCH_LIMITS.maxPushBytes) { await reader.cancel(); return { error: `Scratch pushes are limited to ${Math.round(SCRATCH_LIMITS.maxPushBytes / MIB)} MiB` }; } chunks.push(new Uint8Array(next.value)); } return { body: new Blob(chunks).stream() }; } /** Checked before a claim is committed, so the common failure (a name clash) never strands a half-claimed site. */ export async function assertScratchRepoTransferable(site: SiteRow, viewer: Viewer): Promise { if (!site.repo_id) return; const repo = await loadRepoById(site.repo_id); if (!repo || !isScratchRepo(repo)) throw conflict("This scratch site's repository is no longer available"); const taken = await first<{ id: string }>( "SELECT id FROM repos WHERE owner_id = ? AND name = ?", viewer.id, repo.name, ); if (taken) throw conflict(`You already have a repository named ${repo.name}; rename it to claim this site`); } /** On claim: the scratch repository moves to the user (private unless they opt in to public) and the site follows its name. */ export async function transferScratchRepo(site: SiteRow, viewer: Viewer, makePublic: boolean): Promise { if (!site.repo_id) return; const repo = await loadRepoById(site.repo_id); if (!repo || !isScratchRepo(repo)) return; const taken = await first<{ id: string }>( "SELECT id FROM repos WHERE owner_id = ? AND name = ?", viewer.id, repo.name, ); if (taken) throw conflict(`You already have a repository named ${repo.name}`); await siteDb(site).run("UPDATE sites SET push_token_hash = NULL WHERE id = ?", site.id); await batch([ stmt( "UPDATE repos SET owner_id = ?, visibility = ?, updated_at = ? WHERE id = ?", viewer.id, makePublic ? "public" : "private", Date.now(), repo.id, ), ...repoOwnershipChangeStatements(repo.id, viewer.id), ...repoMoveStatements( repo.id, { owner: repo.owner_name, name: repo.name }, { owner: viewer.name, name: repo.name }, ), ]); await disableRepoConfigOnTransfer(repo.id, repo.owner_id); await rebillRepoLfs(repo.id, viewer.id); // The move above is committed; relabelling is retried by the reconcile cron if it fails here. await syncSiteForRepo(repo.id).catch((error: unknown) => console.warn("Scratch relabel deferred", site.id, error)); } const ORPHAN_BATCH = 20; /** Sweep: deletes scratch repositories whose site is gone (expired and swept, or never finished creating). */ export async function sweepOrphanScratchRepos(): Promise { const candidates = await all<{ id: string }>( "SELECT id FROM repos WHERE owner_id = ? AND created_at < ? ORDER BY created_at LIMIT ?", SCRATCH_ACCOUNT_ID, Date.now() - ORPHAN_GRACE_MS, ORPHAN_BATCH * 5, ); const orphans: Array<{ id: string }> = []; for (const candidate of candidates) { if (orphans.length >= ORPHAN_BATCH) break; if (!(await loadSiteForRepo(candidate.id))) orphans.push(candidate); } for (const orphan of orphans) { const repo = await loadRepoById(orphan.id); if (!repo || !isScratchRepo(repo)) continue; await deleteRepository(repo, SCRATCH_ACTOR).catch((error: unknown) => console.warn("Scratch repository sweep failed", repo.id, error), ); } }