diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 248002656ee..8f1cca412f6 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -11,6 +11,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' @@ -685,7 +686,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) if (denied) return denied - const buffer = await downloadFileFromStorage(userFile, requestId, logger, { + // Generated docs store their generation source, not the rendered binary, so + // the archive must carry the servable bytes instead of the raw source text. + // A still-compiling artifact throws, and the handler's catch turns that into + // the shared 409 via `docNotReadyResponse`. + const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger, { maxBytes: MAX_COMPRESS_FILE_BYTES, }) totalBytes += buffer.length @@ -864,6 +869,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const notReady = docNotReadyResponse(error) if (notReady) return notReady + // A file over its per-file cap is a size rejection, not a fault. Rendered + // documents can cross it even when the stored source was well under. + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ success: false, error: error.message }, { status: 413 }) + } if (error instanceof ShareValidationError) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }) } diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 258e36b9ffb..40ec025932b 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -6,7 +6,11 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + fetchServableWorkspaceFileBuffer, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, @@ -48,7 +52,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - const buffer = await fetchWorkspaceFileBuffer(fileRecord) + // Generated docs store their generation source; serve the rendered artifact. + // Its content type is the rendered one, not the source MIME on the record. + const { buffer, contentType } = await fetchServableWorkspaceFileBuffer(fileRecord) recordAudit({ workspaceId, @@ -76,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo return new Response(new Uint8Array(buffer), { status: 200, headers: { - 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Type': contentType || fileRecord.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, 'Content-Length': String(buffer.length), 'X-File-Id': fileRecord.id, @@ -88,6 +94,11 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo }, }) } catch (error) { + // A generated doc whose artifact is still compiling is retryable, not a fault: + // without this the caller sees a 500 and has no reason to try again. + if (isDocNotReadyError(error)) { + return NextResponse.json({ error: docNotReadyMessage() }, { status: 409 }) + } logger.error(`[${requestId}] Error downloading file:`, error) return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) } diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts new file mode 100644 index 00000000000..db70c84ab09 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts @@ -0,0 +1,282 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { sleep } from '@sim/utils/helpers' +import JSZip from 'jszip' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockVerifyWorkspaceMembership, + mockListWorkspaceFiles, + mockListWorkspaceFileFolders, + mockFetchServableWorkspaceFileBuffer, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockListWorkspaceFileFolders: vi.fn(), + mockFetchServableWorkspaceFileBuffer: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + listWorkspaceFileFolders: mockListWorkspaceFileFolders, + buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => + new Map(folders.map((folder) => [folder.id, folder.name])), + fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, + AuditResourceType: { FILE: 'file' }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { GET } from '@/app/api/workspaces/[id]/files/download/route' + +const WORKSPACE_ID = 'ws-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function workspaceFile(id: string, name: string, folderId: string | null) { + return { + id, + name, + key: `workspace/${WORKSPACE_ID}/${id}`, + path: `/serve/${id}`, + size: 100, + type: 'application/octet-stream', + folderId, + } +} + +function requestFor(query: string) { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?${query}` + ) +} + +describe('workspace files download route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) + mockListWorkspaceFileFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', parentId: null }, + ]) + }) + + it('zips the rendered bytes for a generated doc, not its stored source', async () => { + mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'overview.docx', 'folder-1')]) + // A real .docx is a ZIP; the stored source would be plain JS text. + const rendered = Buffer.from('PKrendered-docx') + mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ + buffer: rendered, + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }) + + const response = await GET(requestFor('fileIds=f1'), context) + + expect(response.status).toBe(200) + expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1) + + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + const entry = zip.file('Reports/overview.docx') + expect(entry).not.toBeNull() + expect(Buffer.from(await entry!.async('uint8array'))).toEqual(rendered) + }) + + it('returns 409 naming the documents whose artifacts are still compiling', async () => { + mockListWorkspaceFiles.mockResolvedValue([ + workspaceFile('f1', 'ready.md', 'folder-1'), + workspaceFile('f2', 'pending.docx', 'folder-1'), + ]) + mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { + if (file.name === 'pending.docx') + throw new DocCompileUserError('Document is still being generated') + return { buffer: Buffer.from('ok'), contentType: 'text/markdown' } + }) + + const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) + + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error).toContain('pending.docx') + expect(body.error).not.toContain('ready.md') + }) + + it('rejects with 400, not 500, when a rendered document blows the byte budget', async () => { + mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'huge.docx', 'folder-1')]) + mockFetchServableWorkspaceFileBuffer.mockRejectedValue( + new PayloadSizeLimitError('servable file download exceeds limit') + ) + + const response = await GET(requestFor('fileIds=f1'), context) + + expect(response.status).toBe(400) + // Names the offending entry rather than blaming the whole selection. + const body = await response.json() + expect(body.error).toContain('huge.docx') + expect(body.error).not.toContain('Selected files total') + }) + + it('blames the entry when its render ceiling exactly equals the remaining budget', async () => { + // Declared at the full budget, so allowance === remaining and the caps tie. + const doc = { ...workspaceFile('f1', 'report.docx', 'folder-1'), size: 250 * 1024 * 1024 } + mockListWorkspaceFiles.mockResolvedValue([doc]) + mockFetchServableWorkspaceFileBuffer.mockRejectedValue( + new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + ) + + const response = await GET(requestFor('fileIds=f1'), context) + + expect(response.status).toBe(400) + // Downloading it on its own is still the way through, so name it. + expect((await response.json()).error).toContain('report.docx') + }) + + it('blames the shared budget, not the entry, when the entry had no smaller cap', async () => { + // .mp4 has no render headroom, so its cap is whatever is left of the budget. + mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'clip.mp4', 'folder-1')]) + mockFetchServableWorkspaceFileBuffer.mockRejectedValue( + new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + ) + + const response = await GET(requestFor('fileIds=f1'), context) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('Selected files total') + expect(body.error).not.toContain('clip.mp4') + }) + + it('lets an uploaded office file larger than the render headroom through', async () => { + const big = { ...workspaceFile('f1', 'deck.pptx', 'folder-1'), size: 80 * 1024 * 1024 } + mockListWorkspaceFiles.mockResolvedValue([big]) + mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ + buffer: Buffer.from('ok'), + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }) + + const response = await GET(requestFor('fileIds=f1'), context) + + expect(response.status).toBe(200) + // Capped at the declared size, not the smaller render headroom. + expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(80 * 1024 * 1024) + }) + + it('caps rendered documents per entry so concurrent reads cannot each claim the budget', async () => { + mockListWorkspaceFiles.mockResolvedValue([ + workspaceFile('f1', 'report.docx', 'folder-1'), + workspaceFile('f2', 'clip.mp4', 'folder-1'), + ]) + mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ + buffer: Buffer.from('ok'), + contentType: 'application/octet-stream', + }) + + await GET(requestFor('fileIds=f1&fileIds=f2'), context) + + const maxBytesFor = (name: string) => + mockFetchServableWorkspaceFileBuffer.mock.calls.find( + (call: [{ name: string }, { maxBytes: number }]) => call[0].name === name + )?.[1].maxBytes + + // Only the source-backed document can render larger than it declares. + expect(maxBytesFor('report.docx')).toBe(50 * 1024 * 1024) + expect(maxBytesFor('clip.mp4')).toBe(250 * 1024 * 1024) + }) + + it('reports an oversized selection as 400 even when the abort cancels other reads', async () => { + const files = Array.from({ length: 40 }, (_, index) => + workspaceFile(`f${index}`, `doc${index}.docx`, 'folder-1') + ) + mockListWorkspaceFiles.mockResolvedValue(files) + mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { + if (file.name === 'doc0.docx') + throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + // Everything else fails the way a cancelled read would. + throw new DOMException('The operation was aborted', 'AbortError') + }) + + const response = await GET( + requestFor(files.map((file) => `fileIds=${file.id}`).join('&')), + context + ) + + // Cancellation noise must not turn the size rejection into a generic 500. + expect(response.status).toBe(400) + }) + + it('keeps the size rejection when a hard failure aborted the read first', async () => { + mockListWorkspaceFiles.mockResolvedValue([ + workspaceFile('f1', 'broken.txt', 'folder-1'), + workspaceFile('f2', 'huge.docx', 'folder-1'), + ]) + mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { + if (file.name === 'broken.txt') throw new Error('storage down') + // Lands after the hard failure has already aborted the shared controller. + await sleep(1) + throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + }) + + const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) + + // "Select fewer files" is actionable; a generic 500 is not. + expect(response.status).toBe(400) + }) + + it('stops issuing reads once one hard-fails instead of draining the selection', async () => { + const files = Array.from({ length: 60 }, (_, index) => + workspaceFile(`f${index}`, `doc${index}.txt`, 'folder-1') + ) + mockListWorkspaceFiles.mockResolvedValue(files) + mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { + if (file.name === 'doc0.txt') throw new Error('storage down') + return { buffer: Buffer.from('ok'), contentType: 'text/plain' } + }) + + const response = await GET( + requestFor(files.map((file) => `fileIds=${file.id}`).join('&')), + context + ) + + expect(response.status).toBe(500) + // Reads already in flight finish, but the queued remainder is never started. + expect(mockFetchServableWorkspaceFileBuffer.mock.calls.length).toBeLessThan(files.length) + }) + + it('surfaces a storage failure as a 500 even when another document is pending', async () => { + mockListWorkspaceFiles.mockResolvedValue([ + workspaceFile('f1', 'pending.docx', 'folder-1'), + workspaceFile('f2', 'broken.txt', 'folder-1'), + ]) + mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { + if (file.name === 'pending.docx') + throw new DocCompileUserError('Document is still being generated') + throw new Error('storage down') + }) + + const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) + + // A 409 would tell the client to retry something that can never succeed. + expect(response.status).toBe(500) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 577f0e7b2dc..b1c5b92cb11 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -5,21 +5,49 @@ import { type NextRequest, NextResponse } from 'next/server' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { buildWorkspaceFileFolderPathMap, - fetchWorkspaceFileBuffer, + fetchServableWorkspaceFileBuffer, listWorkspaceFileFolders, listWorkspaceFiles, } from '@/lib/uploads/contexts/workspace' -import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { formatFileSize, isRenderableDocumentName } from '@/lib/uploads/utils/file-utils' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkspaceFilesDownloadAPI') const MAX_ZIP_DOWNLOAD_FILES = 100 const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 +/** + * Headroom a document may render to beyond what it declared. Office extensions + * cover both ordinary uploads — which serve exactly their declared size — and + * source-backed generated docs, and the two are indistinguishable before the bytes + * are read. So the allowance is the larger of the declared size and this ceiling: + * an uploaded 80 MiB deck still downloads, while a 6 KiB generator source cannot + * quietly render into hundreds of megabytes. + */ +const RENDERED_DOCUMENT_HEADROOM_BYTES = 50 * 1024 * 1024 +/** + * Fan-out for files that serve exactly their declared size. Their total is already + * bounded by the pre-download check, so concurrency cannot push residency past it. + * Renderable documents are read one at a time instead — see below. + */ +const ZIP_MATERIALIZE_CONCURRENCY = 5 + +function overLimitResponse(bytes: number, qualifier = ''): NextResponse { + return NextResponse.json( + { + error: `Selected files total ${formatFileSize(bytes)}${qualifier}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, + }, + { status: 400 } + ) +} function collectDescendantFolderIds( selectedFolderIds: string[], @@ -82,17 +110,136 @@ export const GET = withRouteHandler( ) } - const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) - if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) { + const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { + return overLimitResponse(declaredBytes) + } + + // Generated docs (docx/pptx/pdf/xlsx) store their generation source, not the + // rendered binary, so bytes are resolved through the servable reader — a raw + // read ships source text under a `.docx` name. + // + // Only those documents can exceed the size they declared, so only they can push + // residency past the budget the pre-download check already enforced. They are + // therefore read one at a time, which keeps the overshoot to a single entry; + // everything else stays parallel because its bytes are exactly its declared size. + // Once the budget is blown or a read hard-fails, the abort flag stops reads that + // have not started yet. + const controller = new AbortController() + let renderedBytes = 0 + + interface DownloadOutcome { + buffer: Buffer | null + pendingName: string | null + /** Set only when an entry's own allowance bound it, never the shared budget. */ + overLimitEntry: { name: string; allowance: number } | null + overLimit: boolean + error: unknown + } + const skipped: DownloadOutcome = { + buffer: null, + pendingName: null, + overLimitEntry: null, + overLimit: false, + error: null, + } + + const readEntry = async ( + file: WorkspaceFileRecord, + renderable: boolean + ): Promise => { + if (controller.signal.aborted) return skipped + const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - renderedBytes) + // Only renderable documents carry a cap of their own; everything else is + // bounded solely by what is left of the shared budget. + const allowance = renderable + ? Math.max(file.size, RENDERED_DOCUMENT_HEADROOM_BYTES) + : remaining + try { + const { buffer } = await fetchServableWorkspaceFileBuffer(file, { + maxBytes: Math.min(remaining, allowance), + signal: controller.signal, + }) + renderedBytes += buffer.length + const overLimit = renderedBytes > MAX_ZIP_DOWNLOAD_BYTES + if (overLimit) controller.abort() + return { ...skipped, buffer, overLimit } + } catch (error) { + // Recorded even when another worker already aborted: a size rejection + // describes this file, so losing it to someone else's cancellation would + // downgrade an actionable 400 into an opaque 500. + if (error instanceof PayloadSizeLimitError) { + controller.abort() + // Attributed to this entry when its own cap was the binding one — ties + // included, since the entry's ceiling still made it unshippable and + // downloading it alone is the way through. Otherwise the budget ran out. + return { + ...skipped, + overLimit: true, + overLimitEntry: + renderable && allowance <= remaining ? { name: file.name, allowance } : null, + } + } + // Any other error from an already-aborted read is a consequence of the + // cancellation, not a cause. Checked before this worker aborts anything so + // the worker that actually failed still records its own error. + if (controller.signal.aborted) return skipped + // A pending artifact is worth reporting in full, so keep resolving the + // rest of the selection; anything else dooms the request. + const pending = isDocNotReadyError(error) + if (!pending) controller.abort() + return { ...skipped, pendingName: pending ? file.name : null, error } + } + } + + const outcomes = new Array(filesToZip.length) + const indices = filesToZip.map((_, index) => index) + await Promise.all([ + mapWithConcurrency( + indices.filter((index) => isRenderableDocumentName(filesToZip[index].name)), + 1, + async (index) => { + outcomes[index] = await readEntry(filesToZip[index], true) + } + ), + mapWithConcurrency( + indices.filter((index) => !isRenderableDocumentName(filesToZip[index].name)), + ZIP_MATERIALIZE_CONCURRENCY, + async (index) => { + outcomes[index] = await readEntry(filesToZip[index], false) + } + ), + ]) + const downloads = outcomes + + // Size first: the request cannot succeed at any size-adjacent retry, and a + // descriptive 400 beats an opaque 500 raised by whatever the abort cancelled. + const overLimitEntry = downloads.find((result) => result.overLimitEntry)?.overLimitEntry + if (overLimitEntry) { + // Naming the entry that blew its own allowance, and quoting the allowance that + // actually applied: an aggregate message here would tell the user to select + // fewer files when the selection was fine. return NextResponse.json( { - error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, + error: `"${overLimitEntry.name}" is too large to include in a zip. Entries are capped at ${formatFileSize(overLimitEntry.allowance)}; download it on its own instead.`, }, { status: 400 } ) } - const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file))) + if (downloads.some((result) => result.overLimit)) { + return overLimitResponse(renderedBytes, ' once documents are rendered') + } + + // A hard failure outranks a pending artifact: waiting cannot fix it, so a 409 + // would send the client into a retry loop that never succeeds. + const failure = downloads.find((result) => result.error && !result.pendingName) + if (failure?.error) throw failure.error + + const pendingNames = downloads.flatMap((result) => result.pendingName ?? []) + if (pendingNames.length > 0) { + return NextResponse.json({ error: docNotReadyMessage(pendingNames) }, { status: 409 }) + } // Entry paths stay workspace-root-relative so a mixed selection of folders and // loose files keeps the layout the user sees in the files list. @@ -103,10 +250,11 @@ export const GET = withRouteHandler( })) ) + // Indexed off `downloads` so entries stay aligned with `filesToZip` by construction. const zip = new JSZip() - for (const [index, buffer] of buffers.entries()) { - zip.file(entryPaths[index], buffer) - } + downloads.forEach((result, index) => { + if (result.buffer) zip.file(entryPaths[index], result.buffer) + }) const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) @@ -116,7 +264,7 @@ export const GET = withRouteHandler( action: AuditAction.FILE_DOWNLOADED, resourceType: AuditResourceType.FILE, description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, - metadata: { fileCount: filesToZip.length, totalBytes }, + metadata: { fileCount: filesToZip.length, totalBytes: renderedBytes }, request, }) captureServerEvent( @@ -126,13 +274,18 @@ export const GET = withRouteHandler( { groups: { workspace: workspaceId } } ) - return new NextResponse(new Uint8Array(zipBuffer), { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': 'attachment; filename="workspace-files.zip"', - 'Cache-Control': 'no-store', - }, - }) + return new NextResponse( + // View, not copy — a 250 MB archive would otherwise cost 500 MB. Node buffers + // are never backed by a SharedArrayBuffer, so the narrowing is sound. + new Uint8Array(zipBuffer.buffer as ArrayBuffer, zipBuffer.byteOffset, zipBuffer.byteLength), + { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="workspace-files.zip"', + 'Cache-Control': 'no-store', + }, + } + ) } catch (error) { logger.error('Failed to download workspace file selection:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index c6bcecde52d..2f85a03f24b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -27,7 +27,7 @@ import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' import { captureEvent } from '@/lib/posthog/client' -import { triggerFileDownload } from '@/lib/uploads/client/download' +import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { @@ -951,6 +951,7 @@ export function Files() { }) } catch (err) { logger.error('Failed to download file:', err) + toast.error(toError(err).message) } }, [workspaceId] @@ -1071,7 +1072,7 @@ export function Files() { setShowDeleteConfirm(true) }, [selectedFileIds, selectedFolderIds, files, folders]) - const handleBulkDownload = useCallback(() => { + const handleBulkDownload = useCallback(async () => { const selectedFiles = files.filter((file) => selectedFileIds.includes(file.id)) if (selectedFiles.length === 1 && selectedFolderIds.length === 0) { handleDownload(selectedFiles[0]) @@ -1088,7 +1089,14 @@ export function Files() { is_bulk: true, file_count: selectedFileIds.length + selectedFolderIds.length, }) - window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}` + try { + await triggerArchiveDownload( + `/api/workspaces/${workspaceId}/files/download?${query.toString()}` + ) + } catch (err) { + logger.error('Failed to download selection:', err) + toast.error(toError(err).message) + } }, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId]) const fileDetailBreadcrumbs = useMemo(() => { @@ -1285,8 +1293,14 @@ export function Files() { return } if (item.kind === 'folder') { - window.location.href = `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(item.folder.id)}` + const folderId = item.folder.id closeContextMenu() + triggerArchiveDownload( + `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(folderId)}` + ).catch((err) => { + logger.error('Failed to download folder:', err) + toast.error(toError(err).message) + }) return } handleDownload(item.file) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 9cbdd88d263..ed69a2ffdb2 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -1,5 +1,35 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +/** Hand a fetched blob to the browser as a file save, then release the object URL. */ +function saveBlob(blob: Blob, fileName: string): void { + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = fileName + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(objectUrl) +} + +function fileNameFromDisposition(response: Response, fallback: string): string { + return response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? fallback +} + +/** + * Read the server's error copy off a failed download so the caller can surface it. + * These routes answer with `{ error }` and the message is written for the user — + * which document is still compiling, which entry is too large. + */ +async function downloadErrorMessage(response: Response, fallback: string): Promise { + try { + const body = await response.json() + return typeof body?.error === 'string' && body.error ? body.error : fallback + } catch { + return fallback + } +} + export async function triggerFileDownload(record: WorkspaceFileRecord): Promise { const isMarkdown = record.type === 'text/markdown' || @@ -10,17 +40,26 @@ export async function triggerFileDownload(record: WorkspaceFileRecord): Promise< ? `/api/files/export/${encodeURIComponent(record.id)}` : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + // boundary-raw-fetch: binary download read as a blob, not a JSON contract response const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`) + if (!response.ok) { + throw new Error(await downloadErrorMessage(response, `Failed to download "${record.name}"`)) + } - const blob = await response.blob() - const objectUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = objectUrl - a.download = - response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(objectUrl) + saveBlob(await response.blob(), fileNameFromDisposition(response, record.name)) +} + +/** + * Download a multi-file selection as a zip. Fetched rather than navigated to, so a + * rejection — a document still compiling, an entry too large — surfaces as an error + * the caller can show in place instead of replacing the page with raw JSON. + */ +export async function triggerArchiveDownload(url: string): Promise { + // boundary-raw-fetch: binary zip download read as a blob, not a JSON contract response + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) { + throw new Error(await downloadErrorMessage(response, 'Failed to download the selected files')) + } + + saveBlob(await response.blob(), fileNameFromDisposition(response, 'workspace-files.zip')) } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 6967a7d313f..5b490c8168e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -21,6 +21,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' +import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { getServePathPrefix } from '@/lib/uploads' @@ -968,7 +969,42 @@ export async function getWorkspaceFile( } /** - * Download workspace file content + * Download the bytes a user should actually receive for a workspace file. + * + * Generated docs (docx/pptx/pdf/xlsx) store their GENERATION SOURCE as the primary + * file, so {@link fetchWorkspaceFileBuffer} hands back JavaScript/Python text under + * a `.docx` name. This resolves the rendered artifact instead, and is what every + * download/attachment surface should call. Reach for the raw reader only when the + * source itself is wanted (style extraction, compile checks, the copilot VFS). + * + * Throws `DocCompileUserError` when a generated doc's artifact is still compiling — + * callers surface a retryable 409 via `docNotReadyResponse` rather than shipping source. + */ +export async function fetchServableWorkspaceFileBuffer( + fileRecord: WorkspaceFileRecord, + options: { maxBytes?: number; signal?: AbortSignal } = {} +): Promise<{ buffer: Buffer; contentType: string }> { + const { downloadServableFileFromStorage } = await import('@/lib/uploads/utils/file-utils.server') + + return downloadServableFileFromStorage( + { + id: fileRecord.id, + name: fileRecord.name, + url: fileRecord.url ?? fileRecord.path, + size: fileRecord.size, + type: fileRecord.type, + key: fileRecord.key, + context: fileRecord.storageContext ?? 'workspace', + }, + generateRequestId(), + logger, + options + ) +} + +/** + * Download raw workspace file content. For generated docs this is the GENERATION + * SOURCE, not the rendered document — see {@link fetchServableWorkspaceFileBuffer}. */ export async function fetchWorkspaceFileBuffer( fileRecord: WorkspaceFileRecord, diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index cc230df8108..97013439e77 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -20,6 +20,7 @@ import { getMimeTypeFromExtension, inferContextFromKey, isInternalFileUrl, + isRenderableDocumentName, processSingleFileToUserFile, type RawFileInput, resolveTrustedFileContext, @@ -375,8 +376,8 @@ export async function downloadServableFileFromStorage( // Cheap pre-filter so only generated-doc candidates pay for the heavier resolver // import below. - const ext = getFileExtension(userFile.name) - if (ext !== 'pdf' && ext !== 'docx' && ext !== 'pptx' && ext !== 'xlsx') { + if (!isRenderableDocumentName(userFile.name)) { + const ext = getFileExtension(userFile.name) return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) } } diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 7837617a0cc..442d52d324f 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -210,6 +210,18 @@ export function getFileExtension(filename: string): string { return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' } +/** + * Extensions whose stored bytes may be a generation source that renders to a larger + * binary. Everything else stores exactly what it serves, so its declared size is + * an accurate byte budget. + */ +const RENDERABLE_DOCUMENT_EXTENSIONS = new Set(['pdf', 'docx', 'pptx', 'xlsx']) + +/** True when `fileName` may be backed by a generation source rather than final bytes. */ +export function isRenderableDocumentName(fileName: string): boolean { + return RENDERABLE_DOCUMENT_EXTENSIONS.has(getFileExtension(fileName)) +} + const ARCHIVE_EXTENSIONS = new Set(SUPPORTED_ARCHIVE_EXTENSIONS) /** diff --git a/apps/sim/lib/uploads/utils/servable-file-response.ts b/apps/sim/lib/uploads/utils/servable-file-response.ts index 1e7d1a6124b..63ffb0223f0 100644 --- a/apps/sim/lib/uploads/utils/servable-file-response.ts +++ b/apps/sim/lib/uploads/utils/servable-file-response.ts @@ -1,6 +1,23 @@ import { NextResponse } from 'next/server' import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile' +/** True when `error` means a generated document's artifact is still compiling. */ +export function isDocNotReadyError(error: unknown): error is DocCompileUserError { + return error instanceof DocCompileUserError +} + +/** + * Message for a still-compiling generated document. Batch callers pass the names + * they resolved so the copy says which documents to wait on. + */ +export function docNotReadyMessage(fileNames?: string[]): string { + if (!fileNames || fileNames.length === 0) { + return 'A document is still being generated. Wait for it to finish, then try again.' + } + const subject = fileNames.length === 1 ? 'A document is' : `${fileNames.length} documents are` + return `${subject} still being generated: ${fileNames.join(', ')}. Wait for them to finish, then try again.` +} + /** * Canonical retryable response for an attachment/upload whose generated-document * artifact is still compiling. Returns the 409 when `error` is a @@ -8,16 +25,13 @@ import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compil * otherwise `null` so the caller falls through to its own error handling. Shared * by every tool route that downloads workspace files so the status, body shape, * and user-facing copy stay identical instead of being re-typed per route. + * + * Routes whose error envelope differs, or that resolved a batch and want the pending + * files named, build the 409 themselves from {@link docNotReadyMessage}. */ export function docNotReadyResponse(error: unknown): NextResponse | null { - if (error instanceof DocCompileUserError) { - return NextResponse.json( - { - success: false, - error: 'A document is still being generated. Wait for it to finish, then try again.', - }, - { status: 409 } - ) + if (isDocNotReadyError(error)) { + return NextResponse.json({ success: false, error: docNotReadyMessage() }, { status: 409 }) } return null }