Skip to content

Commit b1d4d63

Browse files
fix(uploads): preserve attachment storage semantics
1 parent 6fb1776 commit b1d4d63

8 files changed

Lines changed: 195 additions & 23 deletions

File tree

apps/sim/app/api/files/uploads/route.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
5454

5555
vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: vi.fn() }))
5656

57+
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
5758
import { POST as completeUpload } from '@/app/api/files/uploads/[uploadId]/complete/route'
5859
import { POST as createUpload } from '@/app/api/files/uploads/route'
5960

@@ -142,6 +143,62 @@ describe('/api/files/uploads', () => {
142143
expect(body.data.session).not.toHaveProperty('transfer')
143144
})
144145

146+
it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => {
147+
mockCreateUploadSession.mockResolvedValue({
148+
...session({
149+
workspaceId: 'workspace-1',
150+
purpose: 'mothership_attachment',
151+
method: 'multipart',
152+
storageContext: 'mothership',
153+
storageKey: 'mothership/workspace-1/archive.zip',
154+
fileName: 'archive.zip',
155+
contentType: 'application/zip',
156+
fileSize: MAX_WORKSPACE_FILE_SIZE,
157+
}),
158+
transfer: { method: 'multipart', partSize: 8 * 1024 * 1024, partCount: 640 },
159+
})
160+
const request = new NextRequest('http://localhost/api/files/uploads', {
161+
method: 'POST',
162+
headers: { 'Content-Type': 'application/json' },
163+
body: JSON.stringify({
164+
purpose: 'mothership_attachment',
165+
workspaceId: 'workspace-1',
166+
name: 'archive.zip',
167+
contentType: 'application/zip',
168+
size: MAX_WORKSPACE_FILE_SIZE,
169+
}),
170+
})
171+
172+
const response = await createUpload(request)
173+
174+
expect(response.status).toBe(201)
175+
expect(mockCreateUploadSession).toHaveBeenCalledWith(
176+
expect.objectContaining({
177+
purpose: 'mothership_attachment',
178+
fileSize: MAX_WORKSPACE_FILE_SIZE,
179+
})
180+
)
181+
})
182+
183+
it('rejects mothership attachments above the 5 GiB direct-to-storage limit', async () => {
184+
const request = new NextRequest('http://localhost/api/files/uploads', {
185+
method: 'POST',
186+
headers: { 'Content-Type': 'application/json' },
187+
body: JSON.stringify({
188+
purpose: 'mothership_attachment',
189+
workspaceId: 'workspace-1',
190+
name: 'archive.zip',
191+
contentType: 'application/zip',
192+
size: MAX_WORKSPACE_FILE_SIZE + 1,
193+
}),
194+
})
195+
196+
const response = await createUpload(request)
197+
198+
expect(response.status).toBe(400)
199+
expect(mockCreateUploadSession).not.toHaveBeenCalled()
200+
})
201+
145202
it('reauthorizes a terminal request and returns only the terminal-safe session', async () => {
146203
const logoSession = session({
147204
workspaceId: 'workspace-1',

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock('@/lib/uploads/client/session-upload', () => ({
1616
uploadInternalFileSession: mockUploadInternalFileSession,
1717
}))
1818

19-
import { MULTI_FILE_UPLOAD_MAX_FILE_BYTES } from '@/lib/uploads/client/admission'
19+
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
2020
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
2121

2222
interface HookHarness {
@@ -73,23 +73,46 @@ describe('useFileAttachments admission', () => {
7373

7474
it('rejects aggregate bytes before previews, placeholders, or sessions are allocated', async () => {
7575
const { result, unmount } = renderFileAttachmentsHook()
76-
const files = asFileList(
77-
Array.from({ length: 6 }, (_, index) =>
78-
sizedFile(`image-${index}.png`, MULTI_FILE_UPLOAD_MAX_FILE_BYTES)
79-
)
80-
)
76+
const files = asFileList([
77+
...Array.from({ length: 5 }, (_, index) =>
78+
sizedFile(`large-image-${index}.png`, MAX_WORKSPACE_FILE_SIZE)
79+
),
80+
sizedFile('extra-image.png', 1),
81+
])
8182

8283
await act(async () => {
8384
await result().processFiles(files)
8485
})
8586

8687
expect(mockToastError).toHaveBeenCalledWith("Couldn't add files", {
87-
description: 'Select files totaling 500 MiB or less.',
88+
description: 'Select files totaling 25 GiB or less.',
8889
})
8990
expect(createObjectUrl).not.toHaveBeenCalled()
9091
expect(mockUploadInternalFileSession).not.toHaveBeenCalled()
9192
expect(result().attachedFiles).toEqual([])
9293

9394
unmount()
9495
})
96+
97+
it('starts a mothership session for a file above the old FormData limit', async () => {
98+
mockUploadInternalFileSession.mockResolvedValue({
99+
path: '/api/files/serve/s3/mothership%2Flarge-image.png?context=mothership',
100+
key: 'mothership/large-image.png',
101+
})
102+
const { result, unmount } = renderFileAttachmentsHook()
103+
const file = sizedFile('large-image.png', 101 * 1024 * 1024)
104+
105+
await act(async () => {
106+
await result().processFiles(asFileList([file]))
107+
})
108+
109+
expect(mockUploadInternalFileSession).toHaveBeenCalledWith(
110+
expect.objectContaining({ purpose: 'mothership_attachment', file })
111+
)
112+
expect(result().attachedFiles).toEqual([
113+
expect.objectContaining({ name: file.name, uploading: false }),
114+
])
115+
116+
unmount()
117+
})
95118
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { generateId } from '@sim/utils/id'
88
import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission'
99
import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency'
1010
import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload'
11+
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
1112
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
1213

1314
const logger = createLogger('useFileAttachments')
@@ -135,7 +136,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
135136

136137
if (fileList.length === 0) return
137138
try {
138-
assertMultiFileUploadAdmission(fileList, { existingFiles: attachedFilesRef.current })
139+
assertMultiFileUploadAdmission(fileList, {
140+
existingFiles: attachedFilesRef.current,
141+
maxFileBytes: MAX_WORKSPACE_FILE_SIZE,
142+
})
139143
} catch (error) {
140144
toast.error("Couldn't add files", { description: toError(error).message })
141145
return

apps/sim/lib/api/contracts/upload-sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const createInternalFileUploadBodySchema = z.discriminatedUnion('purpose'
5757
.object({
5858
purpose: z.literal('mothership_attachment'),
5959
...internalFileUploadBaseShape,
60-
size: z.number().int().min(1).max(MAX_WORKSPACE_FORMDATA_FILE_SIZE),
60+
size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE),
6161
workspaceId: workspaceIdSchema,
6262
})
6363
.strict(),

apps/sim/lib/uploads/client/admission.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,24 @@ describe('multi-file upload admission', () => {
5454
})
5555
)
5656
})
57+
58+
it('supports a larger direct-to-storage limit without weakening aggregate admission', () => {
59+
expect(() =>
60+
assertMultiFileUploadAdmission([{ name: 'archive.zip', size: 1024 }], {
61+
maxFileBytes: 1024,
62+
maxTotalBytes: 2048,
63+
})
64+
).not.toThrow()
65+
66+
expect(() =>
67+
assertMultiFileUploadAdmission(files(3, 1024), {
68+
maxFileBytes: 1024,
69+
maxTotalBytes: 2048,
70+
})
71+
).toThrow(
72+
expect.objectContaining<Partial<MultiFileUploadAdmissionError>>({
73+
code: 'UPLOAD_TOTAL_SIZE_EXCEEDED',
74+
})
75+
)
76+
})
5777
})

apps/sim/lib/uploads/client/admission.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
} from '@/lib/uploads/shared/types'
55

66
export const MULTI_FILE_UPLOAD_MAX_FILES = 20
7+
const MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS = 5
78
export const MULTI_FILE_UPLOAD_MAX_FILE_BYTES = Math.min(
89
MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE,
910
MAX_WORKSPACE_FORMDATA_FILE_SIZE
1011
)
11-
export const MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES = 5 * MULTI_FILE_UPLOAD_MAX_FILE_BYTES
12+
export const MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES =
13+
MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * MULTI_FILE_UPLOAD_MAX_FILE_BYTES
1214

1315
export type MultiFileUploadAdmissionErrorCode =
1416
| 'UPLOAD_FILE_COUNT_EXCEEDED'
@@ -32,18 +34,29 @@ interface UploadAdmissionFile {
3234

3335
interface MultiFileUploadAdmissionOptions {
3436
existingFiles?: ArrayLike<UploadAdmissionFile>
37+
maxFileBytes?: number
38+
maxTotalBytes?: number
3539
}
3640

3741
/**
3842
* Bounds one user upload action before previews, UI rows, or upload sessions are allocated.
39-
* Both consumers accept 100 MiB files, so five maximum-sized files form the 500 MiB aggregate
40-
* budget while the count cap still bounds actions containing many small files.
43+
* Knowledge uploads use the shared 100 MiB / 500 MiB defaults. Direct-to-storage consumers may
44+
* provide their larger server-side limit while retaining the same count and aggregate bounds.
4145
*/
4246
export function assertMultiFileUploadAdmission(
4347
files: ArrayLike<UploadAdmissionFile>,
4448
options: MultiFileUploadAdmissionOptions = {}
4549
): void {
4650
const existingFiles = options.existingFiles
51+
const maxFileBytes = options.maxFileBytes ?? MULTI_FILE_UPLOAD_MAX_FILE_BYTES
52+
const maxTotalBytes =
53+
options.maxTotalBytes ?? MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * maxFileBytes
54+
if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) {
55+
throw new Error('Invalid per-file upload limit')
56+
}
57+
if (!Number.isSafeInteger(maxTotalBytes) || maxTotalBytes < maxFileBytes) {
58+
throw new Error('Invalid aggregate upload limit')
59+
}
4760
const existingCount = existingFiles?.length ?? 0
4861
const totalCount = existingCount + files.length
4962
if (totalCount > MULTI_FILE_UPLOAD_MAX_FILES) {
@@ -61,21 +74,29 @@ export function assertMultiFileUploadAdmission(
6174
if (!file || !Number.isSafeInteger(file.size) || file.size < 0) {
6275
throw new Error('Invalid file size in upload selection')
6376
}
64-
if (file.size > MULTI_FILE_UPLOAD_MAX_FILE_BYTES) {
77+
if (file.size > maxFileBytes) {
6578
const label = file.name ? `"${file.name}"` : 'A selected file'
6679
throw new MultiFileUploadAdmissionError(
67-
`${label} is too large. Each file must be 100 MiB or smaller.`,
80+
`${label} is too large. Each file must be ${formatBinaryBytes(maxFileBytes)} or smaller.`,
6881
'UPLOAD_FILE_SIZE_EXCEEDED'
6982
)
7083
}
7184
totalBytes += file.size
7285
}
7386
}
7487

75-
if (totalBytes > MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES) {
88+
if (totalBytes > maxTotalBytes) {
7689
throw new MultiFileUploadAdmissionError(
77-
'Select files totaling 500 MiB or less.',
90+
`Select files totaling ${formatBinaryBytes(maxTotalBytes)} or less.`,
7891
'UPLOAD_TOTAL_SIZE_EXCEEDED'
7992
)
8093
}
8194
}
95+
96+
function formatBinaryBytes(bytes: number): string {
97+
const gibibyte = 1024 ** 3
98+
if (bytes % gibibyte === 0) return `${bytes / gibibyte} GiB`
99+
const mebibyte = 1024 ** 2
100+
if (bytes % mebibyte === 0) return `${bytes / mebibyte} MiB`
101+
return `${bytes} bytes`
102+
}

apps/sim/lib/uploads/upload-session/service.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({
5050
uploadStorageProvider: vi.fn(() => 's3'),
5151
}))
5252

53+
import {
54+
MAX_WORKSPACE_FILE_SIZE,
55+
MAX_WORKSPACE_FORMDATA_FILE_SIZE,
56+
} from '@/lib/uploads/shared/types'
5357
import {
5458
completeUploadSession,
5559
createUploadSession,
@@ -122,7 +126,11 @@ describe('upload sessions', () => {
122126
})
123127
})
124128

125-
it('quota-gates execution attachments while exempting transient mothership attachments', async () => {
129+
it('quota-gates durable files while exempting retention-scoped attachments', async () => {
130+
await createWorkspaceUpload(1024)
131+
expect(mockResolveBillingContext).toHaveBeenCalledOnce()
132+
expect(mockCheckStorageQuota).toHaveBeenCalledOnce()
133+
126134
await createUploadSession({
127135
id: 'execution-upload',
128136
workspaceId: WORKSPACE_ID,
@@ -150,6 +158,49 @@ describe('upload sessions', () => {
150158
expect(mockCheckStorageQuota).toHaveBeenCalledOnce()
151159
})
152160

161+
it('preserves the 5 GiB mothership limit while bounding execution attachments at 100 MiB', async () => {
162+
await expect(
163+
createUploadSession({
164+
id: 'mothership-upload',
165+
workspaceId: WORKSPACE_ID,
166+
userId: 'user-1',
167+
purpose: 'mothership_attachment',
168+
fileName: 'archive.zip',
169+
contentType: 'application/zip',
170+
fileSize: MAX_WORKSPACE_FILE_SIZE,
171+
})
172+
).resolves.toMatchObject({
173+
method: 'multipart',
174+
transfer: { method: 'multipart', partCount: 640 },
175+
})
176+
177+
await expect(
178+
createUploadSession({
179+
id: 'oversized-mothership-upload',
180+
workspaceId: WORKSPACE_ID,
181+
userId: 'user-1',
182+
purpose: 'mothership_attachment',
183+
fileName: 'archive.zip',
184+
contentType: 'application/zip',
185+
fileSize: MAX_WORKSPACE_FILE_SIZE + 1,
186+
})
187+
).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`)
188+
189+
await expect(
190+
createUploadSession({
191+
id: 'execution-upload',
192+
workspaceId: WORKSPACE_ID,
193+
workflowId: 'workflow-1',
194+
executionId: 'execution-1',
195+
userId: 'user-1',
196+
purpose: 'execution_attachment',
197+
fileName: 'result.txt',
198+
contentType: 'text/plain',
199+
fileSize: MAX_WORKSPACE_FORMDATA_FILE_SIZE + 1,
200+
})
201+
).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes`)
202+
})
203+
153204
it('validates PUT completion input independently of finalization', async () => {
154205
const created = await createWorkspaceUpload(1024)
155206

apps/sim/lib/uploads/upload-session/service.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -720,18 +720,14 @@ function maximumFileSize(purpose: UploadSessionPurpose): number {
720720
if (purpose === 'profile_picture' || purpose === 'workspace_logo') {
721721
return UPLOAD_SESSION_ASSET_MAX_BYTES
722722
}
723-
if (purpose === 'mothership_attachment' || purpose === 'execution_attachment') {
723+
if (purpose === 'execution_attachment') {
724724
return MAX_WORKSPACE_FORMDATA_FILE_SIZE
725725
}
726726
return MAX_WORKSPACE_FILE_SIZE
727727
}
728728

729729
function requiresStorageQuota(purpose: UploadSessionPurpose): boolean {
730-
return (
731-
purpose === 'workspace_file' ||
732-
purpose === 'knowledge_document' ||
733-
purpose === 'execution_attachment'
734-
)
730+
return purpose === 'workspace_file' || purpose === 'knowledge_document'
735731
}
736732

737733
function resolveUploadStorage(

0 commit comments

Comments
 (0)