Skip to content

Commit 11563cf

Browse files
authored
improvement(uploads): migrate remaining FormData uploads to presigned PUT (#4509)
* fix(uploads): switch mothership uploads to presigned PUT pattern * fix(uploads): drop unreachable size guard in mothership presigned branch * improvement(uploads): migrate profile-picture and workspace-logo uploads to presigned PUT * improvement(uploads): migrate workflow file-upload sub-block to presigned PUT * improvement(uploads): migrate execution-trigger file uploads to presigned PUT * fix(uploads): tighten permission checks and fix multipart customKey for mothership/execution * fix(uploads): require workspace write+ for execution presigned, admin-only for workspace-logos, suppress doubled error toast * fix(uploads): skip per-file invalidation in batch + extract shared API fallback - Add skipInvalidation flag to useUploadWorkspaceFile; file-upload sub-block now invalidates once after the batch instead of per file - Extract uploadViaApiFallback to lib/uploads/client/api-fallback.ts (DRY across 3 hooks)
1 parent 6a927c9 commit 11563cf

12 files changed

Lines changed: 392 additions & 188 deletions

File tree

apps/sim/app/api/files/multipart/route.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
158158
{ status: 413 }
159159
)
160160
}
161+
} else if (context === 'mothership') {
162+
const { generateWorkspaceFileKey } = await import(
163+
'@/lib/uploads/contexts/workspace/workspace-file-manager'
164+
)
165+
customKey = generateWorkspaceFileKey(workspaceId, fileName)
166+
} else if (context === 'execution') {
167+
const workflowId = (data as { workflowId?: unknown }).workflowId
168+
const executionId = (data as { executionId?: unknown }).executionId
169+
if (typeof workflowId !== 'string' || !workflowId.trim()) {
170+
return NextResponse.json(
171+
{ error: 'workflowId is required for execution uploads' },
172+
{ status: 400 }
173+
)
174+
}
175+
if (typeof executionId !== 'string' || !executionId.trim()) {
176+
return NextResponse.json(
177+
{ error: 'executionId is required for execution uploads' },
178+
{ status: 400 }
179+
)
180+
}
181+
const { generateExecutionFileKey } = await import(
182+
'@/lib/uploads/contexts/execution/utils'
183+
)
184+
customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
161185
}
162186

163187
let uploadId: string

apps/sim/app/api/files/presigned/route.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,25 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { CopilotFiles } from '@/lib/uploads'
88
import type { StorageContext } from '@/lib/uploads/config'
99
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
10+
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
11+
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1012
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
1113
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
1214
import { validateFileType } from '@/lib/uploads/utils/validation'
15+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1316
import { createErrorResponse } from '@/app/api/files/utils'
1417

1518
const logger = createLogger('PresignedUploadAPI')
1619

17-
const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const
20+
const VALID_UPLOAD_TYPES = [
21+
'knowledge-base',
22+
'chat',
23+
'copilot',
24+
'profile-pictures',
25+
'mothership',
26+
'workspace-logos',
27+
'execution',
28+
] as const
1829

1930
class PresignedUrlError extends Error {
2031
constructor(
@@ -116,6 +127,101 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
116127
error instanceof Error ? error.message : 'Copilot validation failed'
117128
)
118129
}
130+
} else if (uploadType === 'mothership') {
131+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
132+
if (!workspaceId?.trim()) {
133+
throw new ValidationError('workspaceId query parameter is required for mothership uploads')
134+
}
135+
136+
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
137+
if (permission !== 'write' && permission !== 'admin') {
138+
return NextResponse.json(
139+
{ error: 'Write or Admin access required for mothership uploads' },
140+
{ status: 403 }
141+
)
142+
}
143+
144+
const fileValidationError = validateFileType(fileName, contentType)
145+
if (fileValidationError) {
146+
throw new ValidationError(fileValidationError.message)
147+
}
148+
149+
const customKey = generateWorkspaceFileKey(workspaceId, fileName)
150+
presignedUrlResponse = await generatePresignedUploadUrl({
151+
fileName,
152+
contentType,
153+
fileSize,
154+
context: 'mothership',
155+
userId: sessionUserId,
156+
customKey,
157+
expirationSeconds: 3600,
158+
metadata: { workspaceId },
159+
})
160+
} else if (uploadType === 'execution') {
161+
const workflowId = request.nextUrl.searchParams.get('workflowId')
162+
const executionId = request.nextUrl.searchParams.get('executionId')
163+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
164+
if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) {
165+
throw new ValidationError(
166+
'workflowId, executionId, and workspaceId query parameters are required for execution uploads'
167+
)
168+
}
169+
170+
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
171+
if (permission !== 'write' && permission !== 'admin') {
172+
return NextResponse.json(
173+
{ error: 'Write or Admin access required for execution uploads' },
174+
{ status: 403 }
175+
)
176+
}
177+
178+
const fileValidationError = validateFileType(fileName, contentType)
179+
if (fileValidationError) {
180+
throw new ValidationError(fileValidationError.message)
181+
}
182+
183+
const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
184+
presignedUrlResponse = await generatePresignedUploadUrl({
185+
fileName,
186+
contentType,
187+
fileSize,
188+
context: 'execution',
189+
userId: sessionUserId,
190+
customKey,
191+
expirationSeconds: 3600,
192+
metadata: { workspaceId, workflowId, executionId },
193+
})
194+
} else if (uploadType === 'workspace-logos') {
195+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
196+
if (!workspaceId?.trim()) {
197+
throw new ValidationError(
198+
'workspaceId query parameter is required for workspace-logos uploads'
199+
)
200+
}
201+
202+
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
203+
if (permission !== 'admin') {
204+
return NextResponse.json(
205+
{ error: 'Admin access required for workspace logo uploads' },
206+
{ status: 403 }
207+
)
208+
}
209+
210+
if (!isImageFileType(contentType)) {
211+
throw new ValidationError(
212+
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads'
213+
)
214+
}
215+
216+
presignedUrlResponse = await generatePresignedUploadUrl({
217+
fileName,
218+
contentType,
219+
fileSize,
220+
context: 'workspace-logos',
221+
userId: sessionUserId,
222+
expirationSeconds: 3600,
223+
metadata: { workspaceId },
224+
})
119225
} else {
120226
if (uploadType === 'profile-pictures') {
121227
if (!sessionUserId?.trim()) {

apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3-
import type { StorageContext } from '@/lib/uploads/shared/types'
3+
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
4+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
45

56
const logger = createLogger('ProfilePictureUpload')
67
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@@ -10,7 +11,7 @@ interface UseProfilePictureUploadProps {
1011
onUpload?: (url: string | null) => void
1112
onError?: (error: string) => void
1213
currentImage?: string | null
13-
context?: StorageContext
14+
context?: 'profile-pictures' | 'workspace-logos'
1415
workspaceId?: string
1516
}
1617

@@ -64,33 +65,27 @@ export function useProfilePictureUpload({
6465

6566
const uploadFileToServer = useCallback(
6667
async (file: File): Promise<string> => {
67-
try {
68-
const formData = new FormData()
69-
formData.append('file', file)
70-
formData.append('context', context)
71-
if (workspaceId) {
72-
formData.append('workspaceId', workspaceId)
73-
}
68+
const presignedEndpoint =
69+
context === 'workspace-logos' && workspaceId
70+
? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}`
71+
: `/api/files/presigned?type=${context}`
7472

75-
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
76-
const response = await fetch('/api/files/upload', {
77-
method: 'POST',
78-
body: formData,
73+
try {
74+
const result = await runUploadStrategy({
75+
file,
76+
workspaceId: workspaceId ?? '',
77+
context,
78+
presignedEndpoint,
7979
})
80-
81-
if (!response.ok) {
82-
const errorData = await response.json().catch(() => ({ message: response.statusText }))
83-
throw new Error(
84-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
85-
)
86-
}
87-
88-
const data = await response.json()
89-
const publicUrl = data.fileInfo?.path || data.path || data.url
90-
logger.info(`Profile picture uploaded successfully via server upload: ${publicUrl}`)
91-
return publicUrl
80+
logger.info(`${context} uploaded successfully: ${result.path}`)
81+
return result.path
9282
} catch (error) {
93-
throw new Error(error instanceof Error ? error.message : 'Failed to upload profile picture')
83+
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
84+
const { path } = await uploadViaApiFallback(file, context, workspaceId)
85+
logger.info(`${context} uploaded successfully via API fallback: ${path}`)
86+
return path
87+
}
88+
throw error
9489
}
9590
},
9691
[context, workspaceId]

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

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
77
import { toast } from '@/components/emcn'
8+
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
9+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
810
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
911

1012
const logger = createLogger('useFileAttachments')
@@ -115,6 +117,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
115117
logger.error('User ID not available for file upload')
116118
return
117119
}
120+
if (!workspaceId) {
121+
logger.error('workspaceId required for mothership uploads')
122+
return
123+
}
118124

119125
const files = Array.from(fileList)
120126
if (files.length === 0) return
@@ -134,49 +140,38 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
134140

135141
setAttachedFiles((prev) => [...prev, ...placeholders])
136142

143+
const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}`
144+
137145
await Promise.all(
138146
files.map(async (file, i) => {
139147
const placeholder = placeholders[i]
140148
try {
141-
const formData = new FormData()
142-
formData.append('file', file)
143-
formData.append('context', 'mothership')
144-
if (workspaceId) {
145-
formData.append('workspaceId', workspaceId)
146-
}
147-
148-
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
149-
const uploadResponse = await fetch('/api/files/upload', {
150-
method: 'POST',
151-
body: formData,
152-
})
153-
154-
if (!uploadResponse.ok) {
155-
const errorData = await uploadResponse.json().catch(() => ({
156-
message: `Upload failed: ${uploadResponse.status}`,
157-
}))
158-
throw new Error(
159-
errorData.message ||
160-
errorData.error ||
161-
`Failed to upload file: ${uploadResponse.status}`
162-
)
149+
let result: { path: string; key: string }
150+
try {
151+
result = await runUploadStrategy({
152+
file,
153+
workspaceId,
154+
context: 'mothership',
155+
presignedEndpoint,
156+
})
157+
} catch (error) {
158+
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
159+
const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId)
160+
if (!fallback.key) {
161+
throw new Error('Invalid upload response: missing key')
162+
}
163+
result = { path: fallback.path, key: fallback.key }
164+
} else {
165+
throw error
166+
}
163167
}
164168

165-
const uploadData = await uploadResponse.json()
166-
167-
logger.info(
168-
`File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}`
169-
)
169+
logger.info(`File uploaded successfully: ${result.path}`)
170170

171171
setAttachedFiles((prev) =>
172172
prev.map((f) =>
173173
f.id === placeholder.id
174-
? {
175-
...f,
176-
path: uploadData.fileInfo?.path || uploadData.path || uploadData.url,
177-
key: uploadData.fileInfo?.key || uploadData.key,
178-
uploading: false,
179-
}
174+
? { ...f, path: result.path, key: result.key, uploading: false }
180175
: f
181176
)
182177
)

0 commit comments

Comments
 (0)