diff --git a/containers/gotenberg/Dockerfile b/containers/gotenberg/Dockerfile index f5843cf4..2470b465 100644 --- a/containers/gotenberg/Dockerfile +++ b/containers/gotenberg/Dockerfile @@ -1,3 +1,3 @@ -FROM gotenberg/gotenberg:8.34.0-libreoffice +FROM gotenberg/gotenberg:8.34.0 EXPOSE 3000 diff --git a/package.json b/package.json index eddf2417..649c40b0 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "partyserver": "^0.5.8", "posthog-js": "^1.396.6", "posthog-node": "^5.39.4", + "prosemirror-transform": "1.12.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3e4769c..fc6bbcbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: posthog-node: specifier: ^5.39.4 version: 5.39.4 + prosemirror-transform: + specifier: 1.12.0 + version: 1.12.0 react: specifier: ^19.2.7 version: 19.2.7 diff --git a/src/features/workspaces/components/WorkspaceTopBar.tsx b/src/features/workspaces/components/WorkspaceTopBar.tsx index f73d227d..20b3ad6f 100644 --- a/src/features/workspaces/components/WorkspaceTopBar.tsx +++ b/src/features/workspaces/components/WorkspaceTopBar.tsx @@ -1,5 +1,6 @@ -import { MessageSquare, Share2 } from "lucide-react"; +import { Download, MessageSquare, Share2 } from "lucide-react"; import { type ReactNode, useState } from "react"; +import { toast } from "sonner"; import UserProfileDropdown from "#/components/UserProfileDropdown"; import { Kbd } from "#/components/ui/kbd"; @@ -20,6 +21,7 @@ import { useWorkspaceAiChatSurfaceMode, useWorkspaceUiStore, } from "#/features/workspaces/state/workspace-ui-store"; +import { getErrorMessage } from "#/lib/error-message"; import { formatAppHotkey, getAppHotkey } from "#/lib/hotkeys-core"; type PresenceStatus = "connecting" | "connected" | "disconnected"; @@ -61,7 +63,41 @@ export default function WorkspaceTopBar({ const chatSurfaceMode = useWorkspaceAiChatSurfaceMode(workspace.id); const setChatSurfaceMode = useWorkspaceUiStore((state) => state.setChatSurfaceMode); const [shareOpen, setShareOpen] = useState(false); + const [exporting, setExporting] = useState(false); const aiChatHotkey = formatAppHotkey(getAppHotkey("workspace.aiChat.toggle").hotkey); + const handleExport = async () => { + if (exporting) { + return; + } + + setExporting(true); + + try { + const response = await fetch(`/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`); + + if (!response.ok) { + throw new Error( + (await getExportErrorMessage(response)) ?? "Unable to export this workspace right now.", + ); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = getDownloadFileName(response.headers) ?? `${workspace.name}.zip`; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + toast.error(getErrorMessage(error, "Unable to export this workspace right now.")); + } finally { + setExporting(false); + } + }; return ( <> @@ -75,6 +111,16 @@ export default function WorkspaceTopBar({ > + void handleExport()} + variant="outline" + className="border-border bg-background shadow-xs hover:bg-muted" + > + + Export + {chatSurfaceMode === "hidden" ? ( @@ -126,3 +172,22 @@ export default function WorkspaceTopBar({ ); } + +function getDownloadFileName(headers: Headers) { + const contentDisposition = headers.get("content-disposition"); + const match = + contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i) ?? + contentDisposition?.match(/filename="([^"]+)"/i) ?? + contentDisposition?.match(/filename=([^;]+)/i); + + return match?.[1] ? decodeURIComponent(match[1].trim()) : null; +} + +async function getExportErrorMessage(response: Response) { + try { + const body = (await response.json()) as { message?: unknown }; + return typeof body.message === "string" ? body.message : null; + } catch { + return null; + } +} diff --git a/src/features/workspaces/export/workspace-export.test.ts b/src/features/workspaces/export/workspace-export.test.ts new file mode 100644 index 00000000..4dcfd7f6 --- /dev/null +++ b/src/features/workspaces/export/workspace-export.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkspaceItemSummary, + WorkspacePage, + WorkspaceSummary, +} from "#/features/workspaces/contracts"; +import { + buildWorkspaceExportEntries, + buildWorkspaceExportPathIndex, + extractTextFromHtml, + reconstructWorkspaceState, + sanitizeExportName, +} from "./workspace-export"; +import { createZipArchive } from "./workspace-zip"; + +describe("workspace export helpers", () => { + it("replays events after a snapshot in chronological order", () => { + const page = createPage({ + items: [ + createItem({ + id: "doc", + name: "Draft", + sortOrder: 1000, + type: "document", + }), + createItem({ + id: "folder", + name: "Folder", + sortOrder: 2000, + type: "folder", + }), + ], + revision: 1, + }); + + const moved = createItem({ + id: "doc", + name: "Final", + parentId: "folder", + sortOrder: 1000, + type: "document", + }); + const result = reconstructWorkspaceState({ + snapshot: page, + eventsAfterSnapshot: [ + { + actorUserId: "user", + clientMutationId: null, + createdAt: "2026-07-28T00:00:02.000Z", + id: "event-2", + payload: { items: [moved] }, + revision: 3, + type: "workspace.items.moved", + workspaceId: "workspace", + }, + { + actorUserId: "user", + clientMutationId: null, + createdAt: "2026-07-28T00:00:01.000Z", + id: "event-1", + payload: { + item: createItem({ + id: "doc", + name: "Final", + sortOrder: 1000, + type: "document", + }), + }, + revision: 2, + type: "workspace.item.renamed", + workspaceId: "workspace", + }, + ], + }); + + expect(result.revision).toBe(3); + expect(result.items.find((item) => item.id === "doc")).toMatchObject({ + name: "Final", + parentId: "folder", + }); + }); + + it("creates nested, collision-safe export paths", () => { + const paths = buildWorkspaceExportPathIndex("My Workspace", [ + createItem({ id: "folder", name: "Biology", sortOrder: 1000, type: "folder" }), + createItem({ + id: "doc-a", + name: "Cell Notes", + parentId: "folder", + sortOrder: 1000, + type: "document", + }), + createItem({ + id: "doc-b", + name: "Cell Notes", + parentId: "folder", + sortOrder: 2000, + type: "document", + }), + createItem({ + id: "unsafe", + name: "../bad:name*", + parentId: "folder", + sortOrder: 3000, + type: "file", + }), + ]); + + expect(paths.get("doc-a")).toBe("My Workspace/Biology/Cell Notes"); + expect(paths.get("doc-b")).toBe("My Workspace/Biology/Cell Notes (1)"); + expect(paths.get("unsafe")).toBe("My Workspace/Biology/_bad_name_"); + }); + + it("sanitizes empty and unsafe names", () => { + expect(sanitizeExportName("")).toBe("Untitled"); + expect(sanitizeExportName("..")).toBe("Untitled"); + expect(sanitizeExportName('Chapter: 1 / "Draft"')).toBe("Chapter_ 1 _ _Draft_"); + }); + + it("represents empty folders in the zip", () => { + const zip = createZipArchive([{ path: "Workspace/" }, { path: "Workspace/Empty/" }]); + const text = new TextDecoder().decode(zip); + + expect(text).toContain("Workspace/Empty/"); + expect(zip[0]).toBe(0x50); + expect(zip[1]).toBe(0x4b); + }); + + it("adds a notice entry when a file source is missing", async () => { + const page = createPage({ + items: [ + createItem({ + id: "missing-file", + name: "Lecture.pdf", + sortOrder: 1000, + type: "file", + }), + ], + revision: 1, + }); + const entries = await buildWorkspaceExportEntries( + createEnv(), + { + getFileSource: async () => { + throw new Error("Workspace file source object is missing."); + }, + } as never, + page, + ); + const notice = entries.find((entry) => entry.path.endsWith(".missing.txt")); + + expect(notice).toMatchObject({ path: "My Workspace/Lecture.pdf.missing.txt" }); + expect(new TextDecoder().decode(notice?.data as Uint8Array)).toContain( + 'The file "Lecture.pdf" could not be included in this export.', + ); + }); + + it("removes generated CSS when extracting fallback PDF text", () => { + const text = extractTextFromHtml(` + + +New document 1 + + + +

test

+ +`); + + expect(text).toBe("test"); + }); +}); + +function createPage(input: { items: WorkspaceItemSummary[]; revision: number }): WorkspacePage { + return { + itemFacts: [], + items: input.items, + revision: input.revision, + workspace: createWorkspace(), + }; +} + +function createWorkspace(): WorkspaceSummary { + return { + archivedAt: null, + color: null, + createdAt: "2026-07-28T00:00:00.000Z", + description: null, + icon: null, + id: "workspace", + lastOpenedAt: null, + membershipRole: "owner", + name: "My Workspace", + updatedAt: "2026-07-28T00:00:00.000Z", + }; +} + +function createEnv() { + return { + WORKSPACE_KERNEL_FILES: { + get: async () => null, + }, + } as never; +} + +function createItem(input: { + id: string; + name: string; + parentId?: string | null; + sortOrder: number; + type: WorkspaceItemSummary["type"]; +}): WorkspaceItemSummary { + return { + color: null, + createdAt: "2026-07-28T00:00:00.000Z", + deletedAt: null, + id: input.id, + meta: input.type, + metadataJson: {}, + name: input.name, + parentId: input.parentId ?? null, + sortOrder: input.sortOrder, + title: input.name, + type: input.type, + updatedAt: "2026-07-28T00:00:00.000Z", + workspaceId: "workspace", + }; +} diff --git a/src/features/workspaces/export/workspace-export.ts b/src/features/workspaces/export/workspace-export.ts new file mode 100644 index 00000000..7d23f669 --- /dev/null +++ b/src/features/workspaces/export/workspace-export.ts @@ -0,0 +1,477 @@ +import type { WorkspaceItemSummary, WorkspacePage } from "#/features/workspaces/contracts"; +import { serializeTiptapDocumentToMarkdown } from "#/features/workspaces/documents/document-markdown"; +import { parseTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { applyWorkspaceEventToPage } from "#/features/workspaces/model/workspace-page"; +import type { WorkspaceRealtimeEvent } from "#/features/workspaces/realtime/messages"; +import { createStreamingMultipartFile } from "#/lib/http/streaming-multipart"; +import { requireSizedResponseBody } from "#/lib/http/sized-response-body"; +import { createZipArchive, type ZipEntryInput } from "./workspace-zip"; + +export interface WorkspaceExportInput { + env: Cloudflare.Env; + kernel: WorkspaceKernelClient; + page: WorkspacePage; + userId: string; +} + +const gotenbergChromiumHtmlPath = "/forms/chromium/convert/html"; +const gotenbergChromiumHtmlFileName = "index.html"; +const pdfConverterPoolSize = 2; +const textEncoder = new TextEncoder(); + +interface WorkspacePdfConverter { + fetch(request: Request): Promise; + startAndWaitForPorts(options: { + cancellationOptions: { portReadyTimeoutMS: number }; + }): unknown; +} + +export async function exportWorkspaceToZip(input: WorkspaceExportInput) { + const page = reconstructWorkspaceState({ + eventsAfterSnapshot: [], + snapshot: input.page, + }); + const entries = await buildWorkspaceExportEntries(input.env, input.kernel, page); + const archive = createZipArchive(entries); + const fileName = `${sanitizeExportName(page.workspace.name)}.zip`; + + return { + body: archive, + fileName, + }; +} + +export function reconstructWorkspaceState(input: { + eventsAfterSnapshot: WorkspaceRealtimeEvent[]; + snapshot: WorkspacePage; +}): WorkspacePage { + return [...input.eventsAfterSnapshot] + .sort((left, right) => left.revision - right.revision) + .reduce(applyWorkspaceEventToPage, input.snapshot); +} + +export async function buildWorkspaceExportEntries( + env: Cloudflare.Env, + kernel: WorkspaceKernelClient, + page: WorkspacePage, +): Promise { + const workspaceFolderName = sanitizeExportName(page.workspace.name); + const pathIndex = buildWorkspaceExportPathIndex(workspaceFolderName, page.items); + const entries: ZipEntryInput[] = [{ path: `${workspaceFolderName}/` }]; + const usedZipPaths = new Set([`${workspaceFolderName}/`.toLocaleLowerCase()]); + + for (const item of page.items) { + const zipPath = pathIndex.get(item.id); + if (!zipPath) { + continue; + } + + if (item.type === "folder") { + entries.push({ + modifiedAt: new Date(item.updatedAt), + path: reserveUniqueZipPath(`${zipPath}/`, usedZipPaths), + }); + continue; + } + + if (item.type === "document") { + entries.push({ + data: await renderWorkspaceDocumentPdf(env, kernel, item), + modifiedAt: new Date(item.updatedAt), + path: reserveUniqueZipPath(`${stripExportExtension(zipPath)}.pdf`, usedZipPaths), + }); + continue; + } + + if (item.type === "file") { + try { + const source = await kernel.getFileSource({ itemId: item.id }); + const object = await env.WORKSPACE_KERNEL_FILES.get(source.objectKey); + + if (!object) { + throw new Error("Workspace file object was not found."); + } + + entries.push({ + data: await object.arrayBuffer(), + modifiedAt: new Date(item.updatedAt), + path: reserveUniqueZipPath( + replacePathBaseName(zipPath, sanitizeExportName(source.fileName)), + usedZipPaths, + ), + }); + } catch (error) { + void recordWorkspaceExportFailure({ + error, + event: "workspace_export_file", + fields: { + item_id: item.id, + workspace_id: item.workspaceId, + }, + }); + entries.push({ + data: createMissingWorkspaceFileNotice(item.name), + modifiedAt: new Date(item.updatedAt), + path: reserveUniqueZipPath(`${zipPath}.missing.txt`, usedZipPaths), + }); + } + } + } + + return entries; +} + +function createMissingWorkspaceFileNotice(fileName: string) { + return textEncoder.encode( + [ + `The file "${fileName}" could not be included in this export.`, + "", + "Its stored file data is missing from the workspace file storage.", + ].join("\n"), + ); +} + +export function buildWorkspaceExportPathIndex( + workspaceFolderName: string, + items: WorkspaceItemSummary[], +) { + const childrenByParentId = new Map(); + const pathsByItemId = new Map(); + + for (const item of items) { + const children = childrenByParentId.get(item.parentId) ?? []; + children.push(item); + childrenByParentId.set(item.parentId, children); + } + + const visit = (parentId: string | null, parentPath: string) => { + const usedNames = new Set(); + const children = [...(childrenByParentId.get(parentId) ?? [])].sort(compareExportItems); + + for (const item of children) { + const name = createUniqueExportName(sanitizeExportName(item.name), usedNames); + const path = `${parentPath}/${name}`; + pathsByItemId.set(item.id, path); + + if (item.type === "folder") { + visit(item.id, path); + } + } + }; + + visit(null, workspaceFolderName); + return pathsByItemId; +} + +export function sanitizeExportName(name: string | null | undefined) { + const sanitized = (name ?? "") + .replace(/[<>:"/\\|?*]/g, "_") + .split("") + .map((character) => (character.charCodeAt(0) < 32 ? "_" : character)) + .join("") + .replace(/^\.+/g, "") + .replace(/\.+$/g, "") + .replace(/\s+/g, " ") + .trim(); + + return sanitized || "Untitled"; +} + +export function createUniqueExportName(name: string, usedNames: Set) { + let candidate = name; + let index = 1; + + while (usedNames.has(candidate.toLocaleLowerCase())) { + candidate = `${name} (${index})`; + index += 1; + } + + usedNames.add(candidate.toLocaleLowerCase()); + return candidate; +} + +async function renderWorkspaceDocumentPdf( + env: Cloudflare.Env, + kernel: WorkspaceKernelClient, + item: WorkspaceItemSummary, +) { + try { + const checkpoint = await kernel.readDocumentCheckpoint({ itemId: item.id }); + const document = parseTiptapDocumentJson(checkpoint.content); + const markdown = serializeTiptapDocumentToMarkdown(document); + const html = await createDocumentExportHtml(item.name, markdown); + + return await renderHtmlToPdf(env, { + fileName: gotenbergChromiumHtmlFileName, + html, + title: item.name, + }); + } catch (error) { + void recordWorkspaceExportFailure({ + error, + event: "workspace_export_document", + fields: { + item_id: item.id, + workspace_id: item.workspaceId, + }, + }); + throw new Error("Unable to render a workspace document for export."); + } +} + +async function recordWorkspaceExportFailure(input: { + error: unknown; + event: string; + fields: Record; +}) { + const { recordOperationalFailure } = + await import("#/integrations/observability/operational-events"); + + recordOperationalFailure(input); +} + +async function renderHtmlToPdf( + env: Cloudflare.Env, + input: { + fileName: string; + html: string; + title: string; + }, +): Promise { + const converter = await getWorkspacePdfConverter(env); + + if (!converter) { + return createFallbackTextPdf(extractTextFromHtml(input.html)); + } + + const convertedPdf = await renderHtmlWithWorkspacePdfConverter(converter, input).catch((error) => { + void recordWorkspaceExportFailure({ + error, + event: "workspace_export_pdf_converter", + fields: { + renderer: "office_pdf_converter", + }, + }); + return null; + }); + + return convertedPdf ?? createFallbackTextPdf(extractTextFromHtml(input.html)); +} + +async function getWorkspacePdfConverter(env: Cloudflare.Env) { + try { + const { getRandom } = await import("@cloudflare/containers"); + return (await getRandom( + env.OFFICE_PDF_CONVERTER, + pdfConverterPoolSize, + )) as WorkspacePdfConverter | null; + } catch { + return null; + } +} + +async function renderHtmlWithWorkspacePdfConverter( + converter: WorkspacePdfConverter, + input: { + fileName: string; + html: string; + }, +): Promise { + const htmlBytes = textEncoder.encode(input.html); + const multipart = createStreamingMultipartFile({ + body: new Blob([htmlBytes]).stream(), + contentType: "text/html; charset=utf-8", + fileName: input.fileName, + formFieldName: "files", + sizeBytes: htmlBytes.byteLength, + }); + + await Promise.resolve( + converter.startAndWaitForPorts({ + cancellationOptions: { + portReadyTimeoutMS: 60_000, + }, + }), + ); + + const [response] = await Promise.all([ + converter.fetch( + new Request(`http://office-pdf-converter${gotenbergChromiumHtmlPath}`, { + body: multipart.body, + duplex: "half", + headers: { "content-type": multipart.contentType }, + method: "POST", + } as RequestInit & { duplex: "half" }), + ), + multipart.done, + ]); + + if (!response.ok) { + throw new Error(`HTML to PDF conversion failed with status ${response.status}.`); + } + + const sizedBody = requireSizedResponseBody( + response, + () => new Error("HTML to PDF conversion returned an empty PDF."), + ); + + return new Uint8Array(await new Response(sizedBody.body).arrayBuffer()); +} + +async function createDocumentExportHtml(title: string, markdown: string) { + const body = await renderMarkdownToHtml(markdown); + + return ` + + + +${escapeHtml(title)} + + + +
+${body} +
+ +`; +} + +async function renderMarkdownToHtml(markdown: string) { + const [{ unified }, remarkParse, remarkGfm, remarkRehype, rehypeStringify] = await Promise.all([ + import("unified"), + import("remark-parse"), + import("remark-gfm"), + import("remark-rehype"), + import("rehype-stringify"), + ]); + const file = await unified() + .use(remarkParse.default) + .use(remarkGfm.default) + .use(remarkRehype.default) + .use(rehypeStringify.default) + .process(markdown); + + return String(file); +} + +function createFallbackTextPdf(text: string) { + const lines = text.split(/\r?\n/).flatMap((line) => wrapPdfLine(line, 88)); + const content = `BT +/F1 11 Tf +50 742 Td +14 TL +${lines.map((line) => `(${escapePdfText(line)}) Tj T*`).join("\n")} +ET`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + ]; + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + + for (const [index, object] of objects.entries()) { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + + return textEncoder.encode(pdf); +} + +function replacePathBaseName(path: string, baseName: string) { + const slashIndex = path.lastIndexOf("/"); + return slashIndex === -1 ? baseName : `${path.slice(0, slashIndex + 1)}${baseName}`; +} + +function reserveUniqueZipPath(path: string, usedZipPaths: Set) { + const isDirectory = path.endsWith("/"); + const trimmedPath = isDirectory ? path.slice(0, -1) : path; + const slashIndex = trimmedPath.lastIndexOf("/"); + const parentPath = slashIndex === -1 ? "" : trimmedPath.slice(0, slashIndex + 1); + const baseName = slashIndex === -1 ? trimmedPath : trimmedPath.slice(slashIndex + 1); + const extensionIndex = isDirectory ? -1 : baseName.lastIndexOf("."); + const stem = extensionIndex > 0 ? baseName.slice(0, extensionIndex) : baseName; + const extension = extensionIndex > 0 ? baseName.slice(extensionIndex) : ""; + let candidate = path; + let index = 1; + + while (usedZipPaths.has(candidate.toLocaleLowerCase())) { + candidate = `${parentPath}${stem} (${index})${extension}${isDirectory ? "/" : ""}`; + index += 1; + } + + usedZipPaths.add(candidate.toLocaleLowerCase()); + return candidate; +} + +function stripExportExtension(path: string) { + const slashIndex = path.lastIndexOf("/"); + const dotIndex = path.lastIndexOf("."); + + return dotIndex > slashIndex + 1 ? path.slice(0, dotIndex) : path; +} + +function compareExportItems(left: WorkspaceItemSummary, right: WorkspaceItemSummary) { + return left.sortOrder - right.sortOrder || left.name.localeCompare(right.name); +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function extractTextFromHtml(html: string) { + return html + .replace(/]*>[\s\S]*?<\/head>/gi, "\n") + .replace(/]*>[\s\S]*?<\/style>/gi, "\n") + .replace(/]*>[\s\S]*?<\/script>/gi, "\n") + .replace(/]*>[\s\S]*?<\/noscript>/gi, "\n") + .replace(/<[^>]+>/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function escapePdfText(value: string) { + return Array.from(value.replace(/[\\()]/g, "\\$&")) + .filter((character) => { + const code = character.charCodeAt(0); + return code === 9 || code === 10 || code === 13 || (code >= 32 && code <= 126); + }) + .join(""); +} + +function wrapPdfLine(line: string, width: number) { + if (!line) { + return [""]; + } + + const chunks: string[] = []; + for (let index = 0; index < line.length; index += width) { + chunks.push(line.slice(index, index + width)); + } + + return chunks; +} diff --git a/src/features/workspaces/export/workspace-zip.ts b/src/features/workspaces/export/workspace-zip.ts new file mode 100644 index 00000000..49994924 --- /dev/null +++ b/src/features/workspaces/export/workspace-zip.ts @@ -0,0 +1,189 @@ +export interface ZipEntryInput { + data?: ArrayBuffer | Uint8Array | string; + modifiedAt?: Date; + path: string; +} + +interface PreparedZipEntry { + crc32: number; + data: Uint8Array; + localHeaderOffset: number; + modifiedAt: Date; + path: string; +} + +const textEncoder = new TextEncoder(); +const crcTable = createCrc32Table(); + +export function createZipArchive(entries: ZipEntryInput[]): Uint8Array { + const preparedEntries: PreparedZipEntry[] = []; + const chunks: Uint8Array[] = []; + let offset = 0; + + for (const entry of entries) { + const path = normalizeZipEntryPath(entry.path); + if (!path) { + continue; + } + + const data = path.endsWith("/") ? new Uint8Array() : toBytes(entry.data ?? ""); + const prepared: PreparedZipEntry = { + crc32: crc32(data), + data, + localHeaderOffset: offset, + modifiedAt: entry.modifiedAt ?? new Date(), + path, + }; + const localHeader = createLocalFileHeader(prepared); + chunks.push(localHeader, data); + offset += localHeader.byteLength + data.byteLength; + preparedEntries.push(prepared); + } + + const centralDirectoryOffset = offset; + const centralDirectory = preparedEntries.map(createCentralDirectoryHeader); + for (const chunk of centralDirectory) { + chunks.push(chunk); + offset += chunk.byteLength; + } + + const centralDirectorySize = offset - centralDirectoryOffset; + chunks.push( + createEndOfCentralDirectory({ + centralDirectoryOffset, + centralDirectorySize, + entryCount: preparedEntries.length, + }), + ); + + return concatBytes(chunks); +} + +function createLocalFileHeader(entry: PreparedZipEntry) { + const name = textEncoder.encode(entry.path); + const output = new Uint8Array(30 + name.byteLength); + const view = new DataView(output.buffer); + const dosTime = getDosDateTime(entry.modifiedAt); + + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0x0800, true); + view.setUint16(8, 0, true); + view.setUint16(10, dosTime.time, true); + view.setUint16(12, dosTime.date, true); + view.setUint32(14, entry.crc32, true); + view.setUint32(18, entry.data.byteLength, true); + view.setUint32(22, entry.data.byteLength, true); + view.setUint16(26, name.byteLength, true); + output.set(name, 30); + + return output; +} + +function createCentralDirectoryHeader(entry: PreparedZipEntry) { + const name = textEncoder.encode(entry.path); + const output = new Uint8Array(46 + name.byteLength); + const view = new DataView(output.buffer); + const dosTime = getDosDateTime(entry.modifiedAt); + const isDirectory = entry.path.endsWith("/"); + + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, 0x0800, true); + view.setUint16(10, 0, true); + view.setUint16(12, dosTime.time, true); + view.setUint16(14, dosTime.date, true); + view.setUint32(16, entry.crc32, true); + view.setUint32(20, entry.data.byteLength, true); + view.setUint32(24, entry.data.byteLength, true); + view.setUint16(28, name.byteLength, true); + view.setUint32(38, isDirectory ? 0x10 : 0, true); + view.setUint32(42, entry.localHeaderOffset, true); + output.set(name, 46); + + return output; +} + +function createEndOfCentralDirectory(input: { + centralDirectoryOffset: number; + centralDirectorySize: number; + entryCount: number; +}) { + const output = new Uint8Array(22); + const view = new DataView(output.buffer); + + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, input.entryCount, true); + view.setUint16(10, input.entryCount, true); + view.setUint32(12, input.centralDirectorySize, true); + view.setUint32(16, input.centralDirectoryOffset, true); + + return output; +} + +function toBytes(data: ArrayBuffer | Uint8Array | string) { + if (typeof data === "string") { + return textEncoder.encode(data); + } + + return data instanceof Uint8Array ? data : new Uint8Array(data); +} + +function concatBytes(chunks: Uint8Array[]) { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const output = new Uint8Array(total); + let offset = 0; + + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + + return output; +} + +function normalizeZipEntryPath(path: string) { + const isDirectory = path.endsWith("/"); + const normalized = path + .split("/") + .filter((segment) => segment && segment !== "." && segment !== "..") + .join("/"); + + return normalized && isDirectory ? `${normalized}/` : normalized; +} + +function getDosDateTime(date: Date) { + const year = Math.max(1980, date.getFullYear()); + + return { + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + }; +} + +function crc32(data: Uint8Array) { + let crc = 0xffffffff; + + for (const byte of data) { + crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]!; + } + + return (crc ^ 0xffffffff) >>> 0; +} + +function createCrc32Table() { + const table: number[] = []; + + for (let index = 0; index < 256; index += 1) { + let crc = index; + + for (let bit = 0; bit < 8; bit += 1) { + crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + + table[index] = crc >>> 0; + } + + return table; +} diff --git a/src/features/workspaces/upload/workspace-file-direct-upload.ts b/src/features/workspaces/upload/workspace-file-direct-upload.ts index fd252a9a..568bfe6b 100644 --- a/src/features/workspaces/upload/workspace-file-direct-upload.ts +++ b/src/features/workspaces/upload/workspace-file-direct-upload.ts @@ -30,7 +30,10 @@ export type WorkspaceDirectUploadClaims = z.infer; export async function createWorkspaceDirectUploadSession( env: Cloudflare.Env, - input: Omit, + input: Omit & { + allowLocalDevUploadProxy?: boolean; + localUploadOrigin?: string; + }, ) { const claims: WorkspaceDirectUploadClaims = { ...input, @@ -38,13 +41,18 @@ export async function createWorkspaceDirectUploadSession( itemId: crypto.randomUUID(), version: uploadTokenVersion, }; - const uploadUrl = await createPresignedUploadUrl(env, { + const completionToken = await signUploadClaims(getUploadTokenSecret(env, input), claims); + const uploadUrl = await createUploadUrl(env, { + allowLocalDevUploadProxy: input.allowLocalDevUploadProxy, + completionToken, contentType: claims.contentType, + localUploadOrigin: input.localUploadOrigin, objectKey: getWorkspaceDirectUploadObjectKey(claims), + workspaceId: claims.workspaceId, }); return { - completionToken: await signUploadClaims(env.WORKSPACE_UPLOAD_TOKEN_SECRET, claims), + completionToken, uploadUrl, }; } @@ -62,6 +70,7 @@ export function getWorkspaceDirectUploadObjectKey(input: { export async function verifyWorkspaceDirectUploadToken( env: Cloudflare.Env, token: string, + options: { allowLocalDevUploadProxy?: boolean } = {}, ): Promise { const [encodedPayload, encodedSignature, extra] = token.split("."); @@ -69,7 +78,7 @@ export async function verifyWorkspaceDirectUploadToken( throw new Error("Upload completion token is invalid."); } - const key = await createSigningKey(env.WORKSPACE_UPLOAD_TOKEN_SECRET, ["verify"]); + const key = await createSigningKey(getUploadTokenSecret(env, options), ["verify"]); const valid = await crypto.subtle.verify( "HMAC", key, @@ -102,10 +111,27 @@ export async function claimWorkspaceDirectUploadCompletion( return claim ? objectKey : null; } -async function createPresignedUploadUrl( +async function createUploadUrl( env: Cloudflare.Env, - input: { contentType: string; objectKey: string }, + input: { + allowLocalDevUploadProxy?: boolean; + completionToken: string; + contentType: string; + localUploadOrigin?: string; + objectKey: string; + workspaceId: string; + }, ) { + if (input.allowLocalDevUploadProxy && input.localUploadOrigin) { + const url = new URL( + `/api/v1/workspaces/${encodeURIComponent(input.workspaceId)}/file-upload`, + input.localUploadOrigin, + ); + url.searchParams.set("action", "direct"); + url.searchParams.set("completionToken", input.completionToken); + return url.toString(); + } + const client = new AwsClient({ accessKeyId: env.R2_ACCESS_KEY_ID, region: "auto", @@ -129,6 +155,19 @@ async function createPresignedUploadUrl( return signed.url; } +function getUploadTokenSecret( + env: Cloudflare.Env, + options: { allowLocalDevUploadProxy?: boolean } = {}, +) { + if (env.WORKSPACE_UPLOAD_TOKEN_SECRET) { + return env.WORKSPACE_UPLOAD_TOKEN_SECRET; + } + if (options.allowLocalDevUploadProxy) { + return "thinkex-local-dev-upload-token-secret-only"; + } + throw new Error("WORKSPACE_UPLOAD_TOKEN_SECRET is required."); +} + async function signUploadClaims(secret: string, claims: WorkspaceDirectUploadClaims) { const payload = encodeBase64Url(encoder.encode(JSON.stringify(claims))); const key = await createSigningKey(secret, ["sign"]); diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.ts b/src/features/workspaces/upload/workspace-file-upload-storage.ts index 66294788..56ef0976 100644 --- a/src/features/workspaces/upload/workspace-file-upload-storage.ts +++ b/src/features/workspaces/upload/workspace-file-upload-storage.ts @@ -13,6 +13,7 @@ import { type WorkspaceUploadConversion, workspaceFileUploadLimits, } from "#/features/workspaces/model/workspace-file"; +import { recordOperationalFailure } from "#/integrations/observability/operational-events"; import { putFixedLengthR2Object } from "#/lib/r2"; export interface StoredWorkspaceFileUpload { @@ -110,6 +111,21 @@ async function storeWorkspaceFileUploadPreview( body: object.body, contentType: upload.contentType, sizeBytes: object.size, + }).catch(async (error) => { + recordWorkspaceUploadPreviewFallback({ + error, + fileName: upload.fileName, + }); + const fallbackObject = await input.env.WORKSPACE_KERNEL_FILES.get(upload.objectKey); + + if (!fallbackObject) { + throw error; + } + + return { + body: fallbackObject.body, + sizeBytes: fallbackObject.size, + }; }); const stored = await putFixedLengthR2Object( input.env.WORKSPACE_KERNEL_FILES, @@ -129,6 +145,16 @@ async function storeWorkspaceFileUploadPreview( }; } +function recordWorkspaceUploadPreviewFallback(input: { error: unknown; fileName: string }) { + recordOperationalFailure({ + error: input.error, + event: "workspace_file_upload_preview_fallback", + fields: { + file_name: input.fileName, + }, + }); +} + function adoptCanonicalWorkspaceFileUpload( input: FinalizeWorkspaceFileUploadStorageInput, ): PreparedWorkspaceFileUpload { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d8ff641c..e7a45298 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as ApiPosthogSurveyFeedbackRouteImport } from './routes/api/posth import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ProtectedWorkspacesWorkspaceIdRouteImport } from './routes/_protected/workspaces.$workspaceId' import { Route as ApiV1WorkspacesWorkspaceIdFileUploadRouteImport } from './routes/api/v1/workspaces.$workspaceId.file-upload' +import { Route as ApiV1WorkspacesWorkspaceIdExportRouteImport } from './routes/api/v1/workspaces.$workspaceId.export' import { Route as ApiV1WorkspacesWorkspaceIdFilesItemIdPreviewRouteImport } from './routes/api/v1/workspaces.$workspaceId.files.$itemId.preview' import { Route as ApiV1WorkspacesWorkspaceIdFilesItemIdContentRouteImport } from './routes/api/v1/workspaces.$workspaceId.files.$itemId.content' import { Route as ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteImport } from './routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments' @@ -148,6 +149,12 @@ const ApiV1WorkspacesWorkspaceIdFileUploadRoute = path: '/$workspaceId/file-upload', getParentRoute: () => ApiV1WorkspacesRoute, } as any) +const ApiV1WorkspacesWorkspaceIdExportRoute = + ApiV1WorkspacesWorkspaceIdExportRouteImport.update({ + id: '/$workspaceId/export', + path: '/$workspaceId/export', + getParentRoute: () => ApiV1WorkspacesRoute, + } as any) const ApiV1WorkspacesWorkspaceIdFilesItemIdPreviewRoute = ApiV1WorkspacesWorkspaceIdFilesItemIdPreviewRouteImport.update({ id: '/$workspaceId/files/$itemId/preview', @@ -197,6 +204,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -223,6 +231,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -252,6 +261,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -281,6 +291,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -307,6 +318,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -335,6 +347,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -516,6 +529,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1WorkspacesWorkspaceIdFileUploadRouteImport parentRoute: typeof ApiV1WorkspacesRoute } + '/api/v1/workspaces/$workspaceId/export': { + id: '/api/v1/workspaces/$workspaceId/export' + path: '/$workspaceId/export' + fullPath: '/api/v1/workspaces/$workspaceId/export' + preLoaderRoute: typeof ApiV1WorkspacesWorkspaceIdExportRouteImport + parentRoute: typeof ApiV1WorkspacesRoute + } '/api/v1/workspaces/$workspaceId/files/$itemId/preview': { id: '/api/v1/workspaces/$workspaceId/files/$itemId/preview' path: '/$workspaceId/files/$itemId/preview' @@ -593,6 +613,7 @@ const ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren = ) interface ApiV1WorkspacesRouteChildren { + ApiV1WorkspacesWorkspaceIdExportRoute: typeof ApiV1WorkspacesWorkspaceIdExportRoute ApiV1WorkspacesWorkspaceIdFileUploadRoute: typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRoute: typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute: typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -600,6 +621,7 @@ interface ApiV1WorkspacesRouteChildren { } const ApiV1WorkspacesRouteChildren: ApiV1WorkspacesRouteChildren = { + ApiV1WorkspacesWorkspaceIdExportRoute: ApiV1WorkspacesWorkspaceIdExportRoute, ApiV1WorkspacesWorkspaceIdFileUploadRoute: ApiV1WorkspacesWorkspaceIdFileUploadRoute, ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRoute: diff --git a/src/routes/api/v1/workspaces.$workspaceId.export.ts b/src/routes/api/v1/workspaces.$workspaceId.export.ts new file mode 100644 index 00000000..10cd3e9e --- /dev/null +++ b/src/routes/api/v1/workspaces.$workspaceId.export.ts @@ -0,0 +1,155 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { and, eq, isNull } from "drizzle-orm"; + +import { workspaceMembers, workspaces } from "#/db/schema"; +import { createDbContext } from "#/db/server"; +import { exportWorkspaceToZip } from "#/features/workspaces/export/workspace-export"; +import { + getWorkspaceKernelFromEnv, + type WorkspaceKernelClient, +} from "#/features/workspaces/kernel/workspace-kernel-access"; +import { mapWorkspaceDetailRow } from "#/features/workspaces/server/mappers"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; +import { apiError, apiFailure, getRequestId } from "#/lib/api/http"; +import { getSessionFromRequest } from "#/lib/auth-queries.server"; + +async function handleWorkspaceExport(request: Request, workspaceId: string) { + const requestId = getRequestId(request); + + try { + const session = await getSessionFromRequest(request); + + if (!session) { + return apiError( + requestId, + 401, + "UNAUTHORIZED", + "You must be signed in to export workspaces.", + ); + } + + const { kernel, page } = await getExportWorkspacePage({ + workspaceId, + userId: session.user.id, + }); + const archive = await exportWorkspaceToZip({ + env, + kernel, + page, + userId: session.user.id, + }); + + return new Response(toResponseBody(archive.body), { + headers: { + "cache-control": "no-store", + "content-disposition": `attachment; filename="${sanitizeHeaderFileName(archive.fileName)}"`, + "content-length": String(archive.body.byteLength), + "content-type": "application/zip", + "x-request-id": requestId, + }, + }); + } catch (error) { + if (error instanceof WorkspaceForbiddenError) { + return apiError( + requestId, + 403, + "FORBIDDEN", + "You do not have permission to export this workspace.", + ); + } + + if (error instanceof WorkspaceNotFoundError) { + return apiError(requestId, 404, "WORKSPACE_NOT_FOUND", "Workspace not found."); + } + + return apiFailure({ + cause: error, + code: "WORKSPACE_EXPORT_FAILED", + fields: { workspace_id: workspaceId }, + message: "Unable to export this workspace right now.", + request, + requestId, + status: 500, + }); + } +} + +async function getExportWorkspacePage(input: { workspaceId: string; userId: string }): Promise<{ + kernel: WorkspaceKernelClient; + page: Parameters[0]["page"]; +}> { + const dbContext = await createDbContext(); + + try { + const [workspaceRow] = await dbContext.db + .select({ + lastOpenedAt: workspaceMembers.lastOpenedAt, + membershipRole: workspaceMembers.role, + workspace: workspaces, + }) + .from(workspaceMembers) + .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id)) + .where( + and( + eq(workspaceMembers.workspaceId, input.workspaceId), + eq(workspaceMembers.userId, input.userId), + isNull(workspaces.archivedAt), + ), + ) + .limit(1); + + if (!workspaceRow) { + throw new WorkspaceNotFoundError(); + } + + const workspace = mapWorkspaceDetailRow( + { + ...workspaceRow.workspace, + lastOpenedAt: workspaceRow.lastOpenedAt, + }, + workspaceRow.membershipRole, + ); + const kernel = await getWorkspaceKernelFromEnv(env, input.workspaceId); + const page = await kernel.getPage(); + + return { + kernel, + page: { + workspace, + items: page.items, + itemFacts: page.itemFacts, + revision: page.revision, + }, + }; + } finally { + await dbContext.dispose(); + } +} + +class WorkspaceNotFoundError extends Error { + constructor() { + super("Workspace not found."); + this.name = "WorkspaceNotFoundError"; + } +} + +function sanitizeHeaderFileName(fileName: string) { + return fileName.replace(/["\r\n\\]/g, "_"); +} + +function toResponseBody(bytes: Uint8Array) { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +export const Route = createFileRoute("/api/v1/workspaces/$workspaceId/export")({ + server: { + handlers: { + GET: ({ params, request }) => handleWorkspaceExport(request, params.workspaceId), + }, + }, +}); + +export { handleWorkspaceExport }; diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts index 439b1d8d..b3eb3a47 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts @@ -40,6 +40,7 @@ import { import { apiError, apiJson, getRequestId } from "#/lib/api/http"; import { getSessionFromRequest } from "#/lib/auth-queries.server"; import { recordOperationalFailure } from "#/integrations/observability/operational-events"; +import { putFixedLengthR2Object } from "#/lib/r2"; const uploadIntentSchema = z.object({ clientMutationId: z.string().min(1), @@ -64,6 +65,16 @@ async function handleWorkspaceUploadPost(request: Request, workspaceId: string) return apiError(getRequestId(request), 400, "INVALID_UPLOAD", "Unknown upload action."); } +async function handleWorkspaceUploadPut(request: Request, workspaceId: string) { + const action = new URL(request.url).searchParams.get("action"); + + if (action !== "direct") { + return apiError(getRequestId(request), 400, "INVALID_UPLOAD", "Unknown upload action."); + } + + return storeLocalDevDirectUpload(request, workspaceId); +} + async function initiateWorkspaceFileUpload(request: Request, workspaceId: string) { const requestId = getRequestId(request); @@ -87,6 +98,8 @@ async function initiateWorkspaceFileUpload(request: Request, workspaceId: string const session = await createWorkspaceDirectUploadSession(env, { ...input, + allowLocalDevUploadProxy: isLocalDevRequest(request), + localUploadOrigin: new URL(request.url).origin, target: resolveWorkspaceDirectUploadTarget({ contentType: input.contentType, fileName: input.fileName, @@ -101,6 +114,54 @@ async function initiateWorkspaceFileUpload(request: Request, workspaceId: string } } +async function storeLocalDevDirectUpload(request: Request, workspaceId: string) { + const requestId = getRequestId(request); + + try { + if (!isLocalDevRequest(request)) { + return apiError(requestId, 404, "NOT_FOUND", "Upload endpoint not found."); + } + + const completionToken = new URL(request.url).searchParams.get("completionToken"); + + if (!completionToken) { + throw invalidUpload("Upload completion token is missing."); + } + + const claims = await verifyWorkspaceDirectUploadToken(env, completionToken, { + allowLocalDevUploadProxy: true, + }); + + if (claims.workspaceId !== workspaceId) { + throw invalidUpload("Upload completion token does not belong to this workspace."); + } + if (!request.body) { + throw invalidUpload("Upload request is empty."); + } + + const stored = await putFixedLengthR2Object( + env.WORKSPACE_KERNEL_FILES, + getWorkspaceDirectUploadObjectKey(claims), + { + body: request.body, + sizeBytes: claims.fileSize, + }, + { httpMetadata: { contentType: claims.contentType } }, + ); + + if (!stored || stored.size !== claims.fileSize) { + throw invalidUpload("Uploaded file size does not match the selected file."); + } + + return new Response(null, { + status: 204, + headers: { "x-request-id": requestId }, + }); + } catch (error) { + return workspaceUploadErrorResponse(requestId, error); + } +} + async function completeWorkspaceFileUpload(request: Request, workspaceId: string) { const requestId = getRequestId(request); return observeWorkspaceFileIntake({ @@ -319,7 +380,9 @@ async function readUploadClaims(request: Request): Promise handleWorkspaceUploadPost(request, params.workspaceId), + PUT: ({ params, request }) => handleWorkspaceUploadPut(request, params.workspaceId), }, }, });