Skip to content

Commit 06ecb6e

Browse files
committed
Merge remote-tracking branch 'origin/staging' into realtime-rooms
2 parents f9f43c3 + ec3156f commit 06ecb6e

13 files changed

Lines changed: 525 additions & 90 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)