import { z } from "zod"; import { badRequest, conflict, forbidden, HttpError, notFoundError } from "../errors"; import { editorRoomStub } from "./room-client"; import { handleRoomRoute, handleRoomSocket } from "./room-api"; import { handleContainerRoute, handleContainerTerminal } from "./container-api"; import { CORS_HEADERS, json } from "./http"; import { branchSha, listRefs } from "../git/refs"; import { withStore } from "../git/store"; import type { GitStore } from "../git/store"; import { commitFileChanges } from "../git/web-commit"; import { limitBrowse, limitWrite } from "../limits"; import { MAX_POINTER_BYTES, parseLfsPointer } from "../lfs/pointer"; import { assertEditableChanges } from "./write-guard"; import { readBounded } from "./blob-read"; import type { BoundedBlob } from "./blob-read"; import { openPull } from "../pull-create"; import { publicUrl } from "../events/emit"; import { canWrite, editorSessionFromRequest, needsPullRequest } from "./session"; import type { EditorSession } from "./session"; export const EDITOR_API_PREFIX = "/api/editor/"; const MAX_TREE_ENTRIES = 5000; const MAX_BLOB_BYTES = 1024 * 1024; const MAX_CHANGES = 100; const MAX_COMMIT_BYTES = 5 * 1024 * 1024; const MAX_BODY_BYTES = MAX_COMMIT_BYTES * 2; const MAX_PATCH_BRANCH_TRIES = 50; const decoder = new TextDecoder(); const encoder = new TextEncoder(); async function headTree(store: GitStore, session: EditorSession): Promise { const commit = await store.commit(session.revision.sha); if (!commit) throw notFoundError("Commit not found"); return commit.treeSha; } async function sessionInfo(session: EditorSession): Promise { const writable = canWrite(session); return json({ owner: session.repo.owner_name, repo: session.repo.name, ref: session.revision.name, refKind: session.revision.kind, sha: session.revision.sha, defaultBranch: session.repo.default_branch, viewer: session.viewer.name, writable, protected: writable ? await needsPullRequest(session) : false, repoUrl: publicUrl(`/${session.repo.owner_name}/${session.repo.name}`), }); } async function tree(session: EditorSession): Promise { return withStore(session.repo.artifacts_name, async (store) => { const walked = await store.walk(await headTree(store, session), MAX_TREE_ENTRIES); return json({ sha: session.revision.sha, truncated: walked.truncated, entries: walked.entries.map((entry) => ({ path: entry.path, type: entry.type, sha: entry.sha })), }); }); } type BlobKind = "text" | "binary" | "lfs" | "too-large" | "symlink" | "gitlink"; function classify(blob: BoundedBlob): BlobKind { if (blob.bytes && blob.size <= MAX_POINTER_BYTES && parseLfsPointer(blob.bytes)) return "lfs"; if (blob.binary) return "binary"; if (!blob.bytes) return "too-large"; return "text"; } async function blob(session: EditorSession, path: string): Promise { if (!path) throw badRequest("path is required"); return withStore(session.repo.artifacts_name, async (store) => { const entry = await store.entryAtPath(await headTree(store, session), path); if (!entry || entry.type === "tree") throw notFoundError("File not found"); if (entry.type === "gitlink") return json({ path, kind: "gitlink", size: 0, content: null }); const read = await readBounded(store, entry.sha, MAX_BLOB_BYTES); if (!read) throw notFoundError("File not found"); const kind: BlobKind = entry.type === "symlink" ? "symlink" : classify(read); const readable = (kind === "text" || kind === "symlink") && read.bytes !== null; return json({ path, kind, size: read.size, content: readable && read.bytes ? decoder.decode(read.bytes) : null }); }); } const commitInput = z.object({ message: z.string().trim().min(1, "A commit message is required").max(10_000), expectedHeadSha: z.string().length(40).optional(), changes: z .array(z.object({ path: z.string().min(1).max(4096), content: z.string().max(MAX_COMMIT_BYTES).nullable() })) .max(MAX_CHANGES, `Commit at most ${MAX_CHANGES} files at a time`) .optional(), /** Commit whatever the live co-editing room holds for this branch instead of an explicit change list. */ fromRoom: z.boolean().optional(), newBranch: z.string().min(1).max(250).optional(), }); type CommitSource = { changes: Array<{ path: string; content: string | null }>; expectedHeadSha: string }; async function commitSource(session: EditorSession, data: z.infer): Promise { if (!data.fromRoom) { if (!data.changes?.length || !data.expectedHeadSha) throw badRequest("Nothing to commit"); return { changes: data.changes, expectedHeadSha: data.expectedHeadSha }; } const room = editorRoomStub(session.repo.id, session.revision.name); const pending = await room.pending(session.repo.id, session.revision.name, session.revision.sha); if (pending.conflicts.length > 0) { throw conflict(`Resolve conflicts with upstream changes first: ${pending.conflicts.slice(0, 5).join(", ")}`); } if (pending.changes.length === 0) throw badRequest("Nothing to commit"); if (pending.changes.length > MAX_CHANGES) throw badRequest(`Commit at most ${MAX_CHANGES} files at a time`); return { changes: pending.changes.map(({ path, content }) => ({ path, content })), expectedHeadSha: data.expectedHeadSha ?? pending.head, }; } /** Reads at most MAX_BODY_BYTES so an oversized body is rejected before it is buffered and parsed. */ async function readCappedText(request: Request): Promise { if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) throw tooLarge(); const reader = request.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let size = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_BODY_BYTES) { await reader.cancel(); throw tooLarge(); } chunks.push(value); } const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return decoder.decode(bytes); } function tooLarge(): HttpError { return new HttpError(413, "This commit is too large for the browser editor"); } async function readCommitInput(request: Request): Promise> { const text = await readCappedText(request); let raw: unknown; try { raw = JSON.parse(text); } catch { throw badRequest("Request body must be valid JSON"); } const parsed = commitInput.safeParse(raw); if (!parsed.success) throw new HttpError(422, parsed.error.issues.map((issue) => issue.message).join("; ")); return parsed.data; } async function freePatchBranch(session: EditorSession): Promise { const refs = await listRefs(session.repo); for (let attempt = 1; attempt <= MAX_PATCH_BRANCH_TRIES; attempt++) { const name = `${session.viewer.name}-patch-${attempt}`; if (!branchSha(refs, name)) return name; } throw badRequest("Pick a branch name for this commit"); } async function commit(session: EditorSession, request: Request): Promise { if (!canWrite(session)) throw forbidden("You can't commit to this ref"); await limitWrite(session.viewer); const data = await readCommitInput(request); if (data.fromRoom && session.revision.kind !== "branch") throw badRequest("Live editing needs a branch"); const source = await commitSource(session, data); const encoded = source.changes.map((change) => ({ path: change.path, content: change.content === null ? null : encoder.encode(change.content), })); const total = encoded.reduce((sum, change) => sum + (change.content?.byteLength ?? 0), 0); if (total > MAX_COMMIT_BYTES) throw tooLarge(); await withStore(session.repo.artifacts_name, async (store) => assertEditableChanges(store, await headTree(store, session), encoded), ); const mustBranch = !data.newBranch && (await needsPullRequest(session)); const newBranch = data.newBranch ?? (mustBranch ? await freePatchBranch(session) : null); const result = await commitFileChanges({ repo: session.repo, permission: session.permission, viewer: session.viewer, branch: session.revision.name, changes: encoded, message: data.message, expectedHeadSha: source.expectedHeadSha, newBranch, }); let pullUrl: string | null = null; let pullError: string | null = null; if (newBranch && !session.canOpenPull) pullError = "This session can't open pull requests"; else if (newBranch) { const [title, ...rest] = data.message.split("\n"); const number = await openPull({ repo: session.repo, headRepo: session.repo, headRef: newBranch, base: session.revision.name, title: title.slice(0, 250), body: rest.join("\n").trim(), draft: false, viewer: session.viewer, }).catch((error: unknown) => { // The commit already landed; report the PR failure instead of inviting a duplicate retry. if (!(error instanceof HttpError)) console.error("Editor pull request failed", error); pullError = error instanceof HttpError ? error.message : "Couldn't open a pull request"; return null; }); if (number !== null) pullUrl = publicUrl(`/${session.repo.owner_name}/${session.repo.name}/pull/${number}`); } if (data.fromRoom) { await editorRoomStub(session.repo.id, session.revision.name).committed({ sha: result.sha, branch: result.branch, pullUrl, by: session.viewer.name, changes: source.changes, }); } return json({ sha: result.sha, branch: result.branch, pullUrl, pullError, files: source.changes.length }, 201); } async function dispatch(request: Request, url: URL): Promise { const action = url.pathname.slice(EDITOR_API_PREFIX.length); const session = await editorSessionFromRequest(request); if (request.method === "GET") { await limitBrowse(session.viewer, request); if (action === "session") return sessionInfo(session); if (action === "tree") return tree(session); if (action === "blob") return blob(session, url.searchParams.get("path") ?? ""); } if (request.method === "POST" && action === "commit") return commit(session, request); const room = await handleRoomRoute(request, url, action, session); if (room) return room; const container = await handleContainerRoute(request, url, action, session); if (container) return container; return json({ error: "Not found" }, 404); } /** * Token-only API for the sandboxed editor frame. It never reads cookies; CORS is open because the * bearer token, which browsers never attach ambiently, is the only authority. */ export async function handleEditorRoutes(request: Request, url: URL): Promise { if (!url.pathname.startsWith(EDITOR_API_PREFIX)) return null; if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS }); const socketRoutes: Record Promise> = { [`${EDITOR_API_PREFIX}room`]: handleRoomSocket, [`${EDITOR_API_PREFIX}container/terminal`]: handleContainerTerminal, }; const socketRoute = socketRoutes[url.pathname]; if (socketRoute && request.headers.get("upgrade") === "websocket") { return socketRoute(request, url).catch((error: unknown) => { if (error instanceof HttpError) return json({ error: error.message }, error.status); throw error; }); } try { return await dispatch(request, url); } catch (error) { if (error instanceof HttpError) return json({ error: error.message }, error.status); console.error("Editor API error", error); return json({ error: "Internal error" }, 500); } }