import { env } from "cloudflare:workers"; import { z } from "zod"; import { peekAuthToken, storeAuthToken } from "../auth-tokens"; import { randomToken } from "../crypto"; import { badRequest, forbidden, HttpError, unauthorized } from "../errors"; import { publicUrl } from "../events/emit"; import { authenticatedRemote, mintRepoToken } from "../git/artifacts"; import { limitWrite } from "../limits"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import { loadUserById, toViewer } from "../auth"; import { containerLimits, containersEnabled } from "./container-config"; import type { EditorContainer } from "./container-do"; import { containerMinutesThisMonth } from "./container-meter"; import { json } from "./http"; import { editorOriginFor } from "./origin"; import { editorRoomStub } from "./room-client"; import { canWrite, EDITOR_TOKEN_PREFIX, resolveEditorToken } from "./session"; import type { EditorSession } from "./session"; import { EDITOR_WS_PROTOCOL } from "~/lib/editor-protocol"; const PREVIEW_PREFIX = "gitfor_pvw"; const PREVIEW_TTL_SECONDS = 60 * 60; const UNLIMITED_ATTEMPTS = Number.MAX_SAFE_INTEGER; const HTTP_PAYMENT_REQUIRED = 402; const MAX_PORT = 65535; /** Containers are per (account, repo, branch): co-editors each get their own, billed to themselves. */ function containerStub(accountId: string, repoId: string, branch: string): DurableObjectStub { return env.EDITOR_CONTAINER.getByName(`${accountId}:${repoId}:${branch}`); } function requireContainerAccess(session: EditorSession): void { if (!containersEnabled()) throw forbidden("Containers aren't enabled on this forge"); if (!canWrite(session)) throw forbidden("Containers need write access to a branch"); } function stubFor(session: EditorSession) { return containerStub(session.viewer.id, session.repo.id, session.revision.name); } async function statusBody(session: EditorSession) { const limits = containerLimits(); return { ...(await stubFor(session).status()), idleMinutes: limits.idleMinutes, capHours: limits.capHours, quotaMinutes: limits.quotaMinutes, minutesUsed: await containerMinutesThisMonth(session.viewer.id), }; } async function start(session: EditorSession): Promise { await limitWrite(session.viewer); const stub = stubFor(session); const current = await stub.status(); if (current.state !== "running") { const limits = containerLimits(); if ((await containerMinutesThisMonth(session.viewer.id)) >= limits.quotaMinutes) { throw new HttpError(HTTP_PAYMENT_REQUIRED, `This month's ${limits.quotaMinutes} container minutes are used up`); } const secret = await mintRepoToken(session.repo.artifacts_name, "read", limits.capHours * 3600); await stub.start({ accountId: session.viewer.id, repoId: session.repo.id, branch: session.revision.name, repoName: session.repo.name, cloneUrl: authenticatedRemote(session.repo.artifacts_remote, secret), publicRemote: publicUrl(`/${session.repo.owner_name}/${session.repo.name}.git`), author: { name: session.viewer.displayName ?? session.viewer.name, email: session.viewer.email }, }); } return json(await statusBody(session)); } async function sync(session: EditorSession): Promise { const pending = await editorRoomStub(session.repo.id, session.revision.name).pending( session.repo.id, session.revision.name, session.revision.sha, ); const written = await stubFor(session).sync(pending.changes.map(({ path, content }) => ({ path, content }))); return json({ written }); } async function preview(session: EditorSession, request: Request, url: URL): Promise { const { port } = z.object({ port: z.number().int().min(1).max(MAX_PORT) }).parse(await request.json()); const origin = editorOriginFor(url); if (!origin) throw badRequest("The editor origin isn't configured"); const ticket = randomToken(PREVIEW_PREFIX); const payload = JSON.stringify({ repoId: session.repo.id, branch: session.revision.name, port }); await storeAuthToken("editor-preview", ticket, session.viewer.id, payload, PREVIEW_TTL_SECONDS); return json({ url: `${origin}/preview/${ticket}/` }); } /** `/api/editor/container/*` for a resolved editor session (HTTP). */ export async function handleContainerRoute( request: Request, url: URL, action: string, session: EditorSession, ): Promise { if (!action.startsWith("container/")) return null; requireContainerAccess(session); if (request.method === "GET" && action === "container/status") return json(await statusBody(session)); if (request.method !== "POST") return null; if (action === "container/start") return start(session); if (action === "container/stop") return json({ minutes: await stubFor(session).stop("stopped") }); if (action === "container/sync") return sync(session); if (action === "container/preview") return preview(session, request, url); return null; } /** Terminal WebSocket: the token rides as a subprotocol, as for the co-editing room. */ export async function handleContainerTerminal(request: Request): Promise { const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((part) => part.trim()); const token = protocols.find((part) => part.startsWith(`${EDITOR_TOKEN_PREFIX}_`)); if (!protocols.includes(EDITOR_WS_PROTOCOL) || !token) throw unauthorized("An editor session token is required"); const session = await resolveEditorToken(token); requireContainerAccess(session); const headers = new Headers(request.headers); headers.set("x-editor-protocol", EDITOR_WS_PROTOCOL); return stubFor(session).fetch(new Request(request, { headers })); } /** * Port previews on the editor origin. The ticket is re-checked against the user's current access on * every request, and the page is served under a CSP sandbox (opaque origin) without cookies, so the * previewed app can't touch the editor's storage or set cookies on the editor host. */ export async function handlePreview(request: Request, url: URL): Promise { const [, , ticket, ...rest] = url.pathname.split("/"); const stored = ticket?.startsWith(`${PREVIEW_PREFIX}_`) ? await peekAuthToken("editor-preview", ticket, UNLIMITED_ATTEMPTS) : null; if (!stored?.userId) return new Response("Preview link expired\n", { status: 404 }); const { repoId, branch, port } = JSON.parse(stored.payload) as { repoId: string; branch: string; port: number }; const [user, repo] = await Promise.all([loadUserById(stored.userId), loadRepoById(repoId)]); const revoked = user?.sessions_revoked_at != null && stored.createdAt < user.sessions_revoked_at; if (!user || revoked || !repo || !hasPermission(await repoPermission(repo, toViewer(user)), "write")) { return new Response("Preview access revoked\n", { status: 403 }); } const target = new URL(`http://container/${rest.join("/")}${url.search}`); const headers = new Headers(request.headers); headers.delete("cookie"); headers.delete("authorization"); const upstream = await containerStub(stored.userId, repoId, branch).proxy( port, new Request(target, { method: request.method, headers, body: request.body, redirect: "manual" }), ); const response = new Response(upstream.body, upstream); response.headers.delete("set-cookie"); response.headers.set("content-security-policy", "sandbox allow-scripts allow-forms allow-popups allow-modals"); response.headers.set("cache-control", "no-store"); response.headers.set("x-content-type-options", "nosniff"); return response; }