Skip to content

Commit ec3156f

Browse files
authored
fix(files): count the document body against the export limit (#6006)
* fix(files): count the document body against the export limit The 250 MB export cap measured only the embedded assets' declared sizes. The markdown body was downloaded with no limit and never counted, so a large document with modest attachments cleared the check and still produced a zip well over the stated limit, materialized whole in memory. The body is the largest single entry in most bundles, so excluding it left the limit unenforced against the item most able to exceed it. It is now capped on read and counted alongside its assets, and the message names both. Follow-up to #5995, which introduced the asset caps without extending them to the body. * test(v1): cover the file download's rendered-bytes behavior The route had no tests, and #5995 changed what it serves: rendered bytes, the resolved content type rather than the record's source MIME, Content-Length from the rendered length, and a retryable 409 while an artifact compiles. One test pins the filename/content-type relationship. A review flagged the download as naming a rendered file with a source extension, but the renderer picks its output format from the file name — getE2BDocFormat and COMPILABLE_FORMATS both key on it — so a .docx renders to a docx and the two cannot disagree. The test makes that argument executable rather than a comment. * fix(files): report an oversized document body as a size rejection Capping the body read meant an oversized document threw PayloadSizeLimitError, which nothing caught, so the caller got a generic 500 — hiding the very limit message the cap was added to produce. It now returns the same 400 as the bundle check, naming the export limit. * chore(files): drop the unreachable export asset-count cap extractEmbeddedFileRefs stops collecting at MAX_EMBEDDED_IMAGES, so the list this route receives is already bounded before it arrives and the count check could never fire. Its test only passed because it mocked the extractor, so it asserted a branch production cannot reach. The byte ceilings are the real bound and stay. A comment records where the count is actually enforced, so the next reader does not add a second one.
1 parent 3df01e7 commit ec3156f

3 files changed

Lines changed: 202 additions & 37 deletions

File tree

apps/sim/app/api/files/export/[id]/route.test.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { createMockRequest } from '@sim/testing'
55
import JSZip from 'jszip'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
78

89
const {
910
mockCheckAuth,
@@ -79,19 +80,6 @@ describe('markdown export bundling', () => {
7980
mockExtractEmbeddedImageIds.mockReturnValue([])
8081
})
8182

82-
it('rejects a document embedding more assets than an export may bundle', async () => {
83-
mockExtractEmbeddedImageIds.mockReturnValue(
84-
Array.from({ length: 501 }, (_, index) => `img-${index}`)
85-
)
86-
87-
const response = await GET(request(), context)
88-
89-
expect(response.status).toBe(400)
90-
expect((await response.json()).error).toContain('501')
91-
// Rejected on the embed count alone: nothing was resolved or downloaded.
92-
expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1)
93-
})
94-
9583
it('rejects on declared asset bytes before downloading any of them', async () => {
9684
mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
9785
mockGetFileMetadataById.mockImplementation(async (id: string) =>
@@ -116,6 +104,39 @@ describe('markdown export bundling', () => {
116104
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
117105
})
118106

107+
it('counts the document body against the export limit, not just its assets', async () => {
108+
// Assets alone sit under the cap; the body is what carries the bundle over it.
109+
mockExtractEmbeddedImageIds.mockReturnValue(['a'])
110+
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
111+
112+
const response = await GET(request(), context)
113+
114+
expect(response.status).toBe(400)
115+
expect((await response.json()).error).toContain('document and its embedded files')
116+
})
117+
118+
it('caps the document body read rather than loading it unbounded', async () => {
119+
mockExtractEmbeddedImageIds.mockReturnValue([])
120+
121+
await GET(request(), context)
122+
123+
const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md'))
124+
expect(bodyCall?.[0].maxBytes).toBe(250 * MB)
125+
})
126+
127+
it('reports an oversized body as a size rejection, not a server error', async () => {
128+
mockExtractEmbeddedImageIds.mockReturnValue([])
129+
mockDownloadFile.mockRejectedValue(
130+
new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 })
131+
)
132+
133+
const response = await GET(request(), context)
134+
135+
// The cap exists to produce a clear limit message; a 500 would hide it.
136+
expect(response.status).toBe(400)
137+
expect((await response.json()).error).toContain('export limit')
138+
})
139+
119140
it('caps each asset download rather than trusting its declared size', async () => {
120141
mockExtractEmbeddedImageIds.mockReturnValue(['a'])
121142

apps/sim/app/api/files/export/[id]/route.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server'
1010
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1111
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
1212
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
13+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1415
import { captureServerEvent } from '@/lib/posthog/server'
1516
import type { StorageContext } from '@/lib/uploads/config'
@@ -23,16 +24,14 @@ import { encodeFilenameForHeader } from '@/app/api/files/utils'
2324
const logger = createLogger('FilesExportAPI')
2425

2526
/**
26-
* Bundling caps. The embed list comes from scanning the document body, so its length
27-
* and the bytes behind it are whatever the author put there — without these the export
28-
* would materialize an unbounded number of unbounded assets in one request.
27+
* Byte ceilings for a bundled export. The bytes behind an embed list are whatever the
28+
* author put there, so without these the export would materialize unbounded assets in
29+
* one request. They match the bulk-download route, so the two export surfaces reject at
30+
* the same size.
2931
*
30-
* The byte ceilings are the real bound and match the bulk-download route, so the two
31-
* export surfaces reject at the same size. The count is only a guard on the metadata
32-
* lookups that precede the byte check, so it sits far above any hand-authored document
33-
* rather than at a number a screenshot-heavy doc could plausibly reach.
32+
* There is deliberately no count cap here: `extractEmbeddedFileRefs` already stops at
33+
* `MAX_EMBEDDED_IMAGES`, so the list this route receives is bounded before it arrives.
3434
*/
35-
const MAX_EXPORT_ASSETS = 500
3635
const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024
3736
const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024
3837

@@ -128,10 +127,26 @@ export const GET = withRouteHandler(
128127
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
129128
}
130129

131-
const mdBuffer = await downloadFile({
132-
key: record.key,
133-
context: record.context as StorageContext,
134-
})
130+
// Capped like everything else in the bundle: the document body is usually the
131+
// largest single entry, so leaving it unbounded left the export limit unenforced
132+
// against the one item most able to exceed it. A body that alone exceeds the limit
133+
// is a size rejection, so it reports as one rather than as a server error.
134+
let mdBuffer: Buffer
135+
try {
136+
mdBuffer = await downloadFile({
137+
key: record.key,
138+
context: record.context as StorageContext,
139+
maxBytes: MAX_EXPORT_TOTAL_BYTES,
140+
})
141+
} catch (error) {
142+
if (!isPayloadSizeLimitError(error)) throw error
143+
return NextResponse.json(
144+
{
145+
error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
146+
},
147+
{ status: 400 }
148+
)
149+
}
135150
let mdContent = mdBuffer.toString('utf-8')
136151

137152
const imageIds = extractEmbeddedImageIds(mdContent)
@@ -152,15 +167,6 @@ export const GET = withRouteHandler(
152167
})
153168
}
154169

155-
if (imageIds.length > MAX_EXPORT_ASSETS) {
156-
return NextResponse.json(
157-
{
158-
error: `This document embeds ${imageIds.length} files, more than the ${MAX_EXPORT_ASSETS} an export can bundle.`,
159-
},
160-
{ status: 400 }
161-
)
162-
}
163-
164170
// Metadata first: declared sizes bound the download before a byte is read, and the
165171
// authorization check costs nothing to run here.
166172
const assetTargets = (
@@ -180,11 +186,14 @@ export const GET = withRouteHandler(
180186
})
181187
).filter((target): target is NonNullable<typeof target> => target !== null)
182188

183-
const declaredAssetBytes = assetTargets.reduce((sum, target) => sum + target.record.size, 0)
184-
if (declaredAssetBytes > MAX_EXPORT_TOTAL_BYTES) {
189+
// The body counts against the same budget as its assets — the zip holds both, so a
190+
// limit that measured only the attachments would not describe the archive produced.
191+
const bundleBytes =
192+
mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0)
193+
if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) {
185194
return NextResponse.json(
186195
{
187-
error: `Embedded files total ${formatFileSize(declaredAssetBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
196+
error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
188197
},
189198
{ status: 400 }
190199
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockCheckRateLimit,
9+
mockValidateWorkspaceAccess,
10+
mockGetWorkspaceFile,
11+
mockFetchServableWorkspaceFileBuffer,
12+
} = vi.hoisted(() => ({
13+
mockCheckRateLimit: vi.fn(),
14+
mockValidateWorkspaceAccess: vi.fn(),
15+
mockGetWorkspaceFile: vi.fn(),
16+
mockFetchServableWorkspaceFileBuffer: vi.fn(),
17+
}))
18+
19+
vi.mock('@/app/api/v1/middleware', () => ({
20+
checkRateLimit: mockCheckRateLimit,
21+
createRateLimitResponse: () => new Response('rate limited', { status: 429 }),
22+
validateWorkspaceAccess: mockValidateWorkspaceAccess,
23+
}))
24+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
25+
getWorkspaceFile: mockGetWorkspaceFile,
26+
fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
27+
}))
28+
vi.mock('@/lib/workspace-files/orchestration', () => ({
29+
performDeleteWorkspaceFileItems: vi.fn(),
30+
}))
31+
vi.mock('@sim/audit', () => ({
32+
recordAudit: vi.fn(),
33+
AuditAction: { FILE_DOWNLOADED: 'file.downloaded', FILE_DELETED: 'file.deleted' },
34+
AuditResourceType: { FILE: 'file' },
35+
}))
36+
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
37+
38+
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
39+
import { GET } from '@/app/api/v1/files/[fileId]/route'
40+
41+
const WORKSPACE_ID = 'ws-1'
42+
const FILE_ID = 'file-1'
43+
const context = { params: Promise.resolve({ fileId: FILE_ID }) }
44+
45+
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
46+
47+
function request() {
48+
return createMockRequest(
49+
'GET',
50+
undefined,
51+
{},
52+
`http://localhost:3000/api/v1/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`
53+
)
54+
}
55+
56+
/** A generated document: the name carries the target extension, the type the source. */
57+
function generatedDocument(name = 'report.docx') {
58+
return {
59+
id: FILE_ID,
60+
workspaceId: WORKSPACE_ID,
61+
name,
62+
key: `workspace/${WORKSPACE_ID}/${FILE_ID}`,
63+
path: `/serve/${FILE_ID}`,
64+
size: 6_242,
65+
type: 'text/x-docxjs',
66+
uploadedBy: 'user-1',
67+
uploadedAt: new Date('2026-01-01'),
68+
updatedAt: new Date('2026-01-01'),
69+
}
70+
}
71+
72+
describe('v1 file download', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
76+
mockValidateWorkspaceAccess.mockResolvedValue(null)
77+
mockGetWorkspaceFile.mockResolvedValue(generatedDocument())
78+
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
79+
buffer: Buffer.from('PKrendered'),
80+
contentType: DOCX_MIME,
81+
})
82+
})
83+
84+
it('serves the rendered bytes and the rendered content type', async () => {
85+
const response = await GET(request(), context)
86+
87+
expect(response.status).toBe(200)
88+
// Not the record's `text/x-docxjs`, which describes the stored source.
89+
expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
90+
expect(Buffer.from(await response.arrayBuffer()).toString()).toContain('rendered')
91+
})
92+
93+
it('names the download with an extension matching the served content type', async () => {
94+
// The renderer picks its output format from the file name, so the two cannot
95+
// disagree: a `.docx` renders to a docx. This pins that invariant.
96+
const response = await GET(request(), context)
97+
98+
const disposition = response.headers.get('Content-Disposition') ?? ''
99+
expect(disposition).toContain('report.docx')
100+
expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
101+
})
102+
103+
it('reports Content-Length from the rendered bytes, not the declared source size', async () => {
104+
const rendered = Buffer.alloc(50_000)
105+
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
106+
buffer: rendered,
107+
contentType: DOCX_MIME,
108+
})
109+
110+
const response = await GET(request(), context)
111+
112+
expect(response.headers.get('Content-Length')).toBe(String(rendered.length))
113+
})
114+
115+
it('returns a retryable 409 while the artifact is still compiling', async () => {
116+
mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
117+
new DocCompileUserError('Document is still being generated')
118+
)
119+
120+
const response = await GET(request(), context)
121+
122+
// A 500 would give the caller no reason to try again.
123+
expect(response.status).toBe(409)
124+
expect((await response.json()).error).toContain('still being generated')
125+
})
126+
127+
it('404s a file that does not exist', async () => {
128+
mockGetWorkspaceFile.mockResolvedValue(null)
129+
130+
const response = await GET(request(), context)
131+
132+
expect(response.status).toBe(404)
133+
expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
134+
})
135+
})

0 commit comments

Comments
 (0)