import { env } from "cloudflare:workers"; import { batch, first, run, stmt } from "../db"; import { randomToken, sha256Hex, timingSafeEqualStrings } from "../crypto"; import { badRequest, conflict } from "../errors"; import { createRepository, deleteRepository } from "../repo-ops"; import { repoMoveStatements } from "../redirects"; import { loadRepoById } from "../repos"; import { publishMeta, SCRATCH_ACCOUNT_ID, SCRATCH_LIMITS } from "./config"; import { loadSiteById, loadSiteForRepo } from "./deploy"; import { reserveScratch, 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"; const DEFAULT_BRANCH = "main"; /** The reserved org that owns unclaimed scratch repositories; it has no members and no sessions. */ const SCRATCH_ACTOR: Viewer = { id: SCRATCH_ACCOUNT_ID, name: "scratch", displayName: "Scratch sites", email: "scratch@invalid", isAdmin: false, }; export type ScratchRepoResult = ScratchResult & { remote: string; pushToken: string }; export function isScratchRepo(repo: Pick): boolean { return repo.owner_id === SCRATCH_ACCOUNT_ID; } /** * 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 { const { site, claimToken, expiresAt } = await reserveScratch(ip, { source: "branch", branch: DEFAULT_BRANCH }); const pushToken = randomToken(PUSH_PREFIX); let repo: RepoRow; try { repo = await createRepository({ owner: { id: SCRATCH_ACCOUNT_ID, name: SCRATCH_ACTOR.name }, name: site.id, description: "Scratch site", visibility: "private", initReadme: false, actor: SCRATCH_ACTOR, }); } catch (error) { await run("DELETE FROM sites WHERE id = ?", site.id); throw error; } await run( "UPDATE sites SET repo_id = ?, branch = ?, push_token_hash = ? WHERE id = ?", repo.id, repo.default_branch || DEFAULT_BRANCH, 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 function scratchPushPolicy( repo: RepoRow, refs: string[], body: ReadableStream, ): { error: string } | { body: ReadableStream } { if (!isScratchRepo(repo)) return { body }; if (refs.length > SCRATCH_LIMITS.maxPushRefs) return { error: `Scratch pushes may update at most ${SCRATCH_LIMITS.maxPushRefs} refs` }; if (refs.some((ref) => !ref.startsWith("refs/heads/"))) return { error: "Scratch repositories accept branch pushes only" }; let bytes = 0; const capped = new TransformStream({ transform(chunk, controller) { bytes += chunk.byteLength; if (bytes > SCRATCH_LIMITS.maxPushBytes) { controller.error(new Error("Scratch pushes are limited to 10 MiB")); return; } controller.enqueue(chunk); }, }); return { body: body.pipeThrough(capped) }; } /** On claim: the scratch repository moves to the user (optionally made 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 batch([ stmt( "UPDATE repos SET owner_id = ?, visibility = ?, updated_at = ? WHERE id = ?", viewer.id, makePublic ? "public" : "private", Date.now(), repo.id, ), stmt("UPDATE sites SET push_token_hash = NULL WHERE id = ?", site.id), ...repoMoveStatements(repo.id, { owner: repo.owner_name, name: repo.name }, { owner: viewer.name, name: repo.name }), ]); await syncSiteForRepo(repo.id); } /** Sweep: deletes an expired scratch repository, which tears its site down with it. */ export async function deleteScratchRepo(repoId: string): Promise { const repo = await loadRepoById(repoId); if (!repo) return; if (!isScratchRepo(repo)) throw badRequest("Refusing to sweep a claimed repository"); await deleteRepository(repo, SCRATCH_ACTOR); }