Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, {
Comment thread
waleedlatif1 marked this conversation as resolved.
maxBytes: MAX_COMPRESS_FILE_BYTES,
})
totalBytes += buffer.length
Expand Down Expand Up @@ -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 })
}
Expand Down
17 changes: 14 additions & 3 deletions apps/sim/app/api/v1/files/[fileId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 })
}
Expand Down
282 changes: 282 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/download/route.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading