import { z } from "zod"; import { peekAuthToken, storeAuthToken } from "../auth-tokens"; import { randomToken } from "../crypto"; import { badRequest, forbidden, HttpError, unauthorized, paymentRequired } from "../errors"; import { publicUrl } from "../events/emit"; import { authenticatedRemote, mintRepoToken, revokeRepoToken } from "../git/artifacts"; import { limitWrite } from "../limits"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import { loadActiveUserById, toViewer } from "../auth"; import { containerLimits, containersEnabled, containerStub } from "./container-config"; import { CONTAINERS_PAID_ONLY, containerUsage, EDITOR_METER } from "./container-meter"; import { remainingQuota } from "../metering/quota"; import { json } from "./http"; import { previewResponse } from "./preview-headers"; 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"; /** Short: the ticket is a bearer in the URL, so a leaked link must go stale before it can be reused. */ const PREVIEW_TTL_SECONDS = 5 * 60; const UNLIMITED_ATTEMPTS = Number.MAX_SAFE_INTEGER; const HTTP_TOO_MANY = 429; const MAX_PORT = 65535; const SECONDS_PER_MINUTE = 60; const SECONDS_PER_HOUR = 3600; /** Covers the boot and a large initial clone; the token is revoked as soon as start returns. */ const CLONE_TOKEN_TTL_SECONDS = 30 * 60; /** Starting for less than this isn't worth a boot; the user is told the month's quota is used up. */ const MIN_BUDGET_SECONDS = 60; 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"); } /** Plan gate for anything that boots or talks to a container; status stays readable so the UI can explain. */ async function requirePlanAllows(session: EditorSession): Promise { if (!(await containerUsage(session.viewer.id)).allowed) throw paymentRequired(CONTAINERS_PAID_ONLY, { meter: EDITOR_METER, reason: "paid_feature", accountId: session.viewer.id, }); } function stubFor(session: EditorSession) { return containerStub(session.viewer.id, session.repo.id, session.revision.name); } async function statusBody(session: EditorSession) { const limits = containerLimits(); const [status, usage] = await Promise.all([stubFor(session).status(), containerUsage(session.viewer.id)]); const quotaSeconds = usage.hardCapSeconds ?? usage.includedSeconds; return { ...status, allowed: usage.allowed, idleMinutes: limits.idleMinutes, capHours: limits.capHours, quotaMinutes: Math.round(quotaSeconds / SECONDS_PER_MINUTE), minutesUsed: Math.round(usage.usedSeconds / SECONDS_PER_MINUTE), }; } /** Seconds to hold for a new session: the cap, or what's left of the month's hard cap if that's less. */ async function startBudget(accountId: string): Promise { const capSeconds = containerLimits().capHours * SECONDS_PER_HOUR; const remaining = await remainingQuota(accountId, EDITOR_METER); if (remaining === null) return capSeconds; if (remaining < MIN_BUDGET_SECONDS) { throw paymentRequired("This month's editor container time is used up", { meter: EDITOR_METER, reason: "plan_limit", accountId, }); } return Math.min(capSeconds, remaining); } async function start(session: EditorSession): Promise { await limitWrite(session.viewer); await requirePlanAllows(session); const stub = stubFor(session); const current = await stub.status(); if (current.state !== "running") { const budgetSeconds = await startBudget(session.viewer.id); // Only the initial clone uses it (origin is then pointed at the public remote), so it is revoked right after. const cloneToken = await mintRepoToken(session.repo.artifacts_name, "read", CLONE_TOKEN_TTL_SECONDS); const secret = cloneToken.secret; const result = 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 }, budgetSeconds, }) .finally(() => revokeRepoToken(cloneToken)); if (!result.ok) { if (result.code === "quota") { throw paymentRequired(result.message, { meter: EDITOR_METER, reason: "plan_limit", accountId: session.viewer.id, }); } throw new HttpError(result.code === "cap" ? HTTP_TOO_MANY : 503, result.message); } } return json(await statusBody(session)); } async function sync(session: EditorSession): Promise { await requirePlanAllows(session); 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 { await requirePlanAllows(session); 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({ seconds: 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); await requirePlanAllows(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([loadActiveUserById(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 }); } if (!(await containerUsage(user.id)).allowed) return new Response(`${CONTAINERS_PAID_ONLY}\n`, { status: 402 }); 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" }), ); return previewResponse(upstream, `/preview/${ticket}/`); }