From c4192f5c1f2dd6c1edd62f87482eaccfc46bdbde Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 18:52:24 -0700 Subject: [PATCH 1/8] fix(uploads): switch mothership uploads to presigned PUT pattern --- apps/sim/app/api/files/presigned/route.ts | 48 +++++++++- .../user-input/hooks/use-file-attachments.ts | 94 ++++++++++++------- .../sim/lib/api/contracts/storage-transfer.ts | 8 +- apps/sim/lib/uploads/client/direct-upload.ts | 4 +- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index f6c22bc4a5d..7794275d076 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -7,14 +7,23 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('PresignedUploadAPI') -const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const +const VALID_UPLOAD_TYPES = [ + 'knowledge-base', + 'chat', + 'copilot', + 'profile-pictures', + 'mothership', +] as const class PresignedUrlError extends Error { constructor( @@ -116,6 +125,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => { error instanceof Error ? error.message : 'Copilot validation failed' ) } + } else if (uploadType === 'mothership') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError('workspaceId query parameter is required for mothership uploads') + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission === null) { + return NextResponse.json( + { error: 'Insufficient permissions for workspace' }, + { status: 403 } + ) + } + + const fileValidationError = validateFileType(fileName, contentType) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + if (fileSize > MAX_WORKSPACE_FILE_SIZE) { + return NextResponse.json( + { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, + { status: 413 } + ) + } + + const customKey = generateWorkspaceFileKey(workspaceId, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'mothership', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) } else { if (uploadType === 'profile-pictures') { if (!sessionUserId?.trim()) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 9c09054d5ba..61f93a41378 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { toast } from '@/components/emcn' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import { resolveFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('useFileAttachments') @@ -51,6 +52,44 @@ interface UseFileAttachmentsProps { isLoading?: boolean } +/** + * Server-proxied fallback used only when cloud storage isn't configured (local dev). + * Production always takes the presigned PUT path. + */ +async function uploadViaApiFallback( + file: File, + workspaceId: string +): Promise<{ path: string; key: string }> { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'mothership') + formData.append('workspaceId', workspaceId) + + // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson + const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) + } + const data = (await response.json()) as { + fileInfo?: { path?: string; key?: string } + path?: string + key?: string + url?: string + } + const path = data.fileInfo?.path ?? data.path ?? data.url + const key = data.fileInfo?.key ?? data.key + if (!path || !key) { + throw new Error('Invalid upload response: missing path or key') + } + return { path, key } +} + /** * Custom hook to manage file attachments including upload, drag/drop, and preview * Handles S3 presigned URL uploads and preview URL generation @@ -115,6 +154,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { logger.error('User ID not available for file upload') return } + if (!workspaceId) { + logger.error('workspaceId required for mothership uploads') + return + } const files = Array.from(fileList) if (files.length === 0) return @@ -134,49 +177,34 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { setAttachedFiles((prev) => [...prev, ...placeholders]) + const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}` + await Promise.all( files.map(async (file, i) => { const placeholder = placeholders[i] try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'mothership') - if (workspaceId) { - formData.append('workspaceId', workspaceId) + let result: { path: string; key: string } + try { + result = await runUploadStrategy({ + file, + workspaceId, + context: 'mothership', + presignedEndpoint, + }) + } catch (error) { + if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { + result = await uploadViaApiFallback(file, workspaceId) + } else { + throw error + } } - // boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies - const uploadResponse = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }) - - if (!uploadResponse.ok) { - const errorData = await uploadResponse.json().catch(() => ({ - message: `Upload failed: ${uploadResponse.status}`, - })) - throw new Error( - errorData.message || - errorData.error || - `Failed to upload file: ${uploadResponse.status}` - ) - } - - const uploadData = await uploadResponse.json() - - logger.info( - `File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}` - ) + logger.info(`File uploaded successfully: ${result.path}`) setAttachedFiles((prev) => prev.map((f) => f.id === placeholder.id - ? { - ...f, - path: uploadData.fileInfo?.path || uploadData.path || uploadData.url, - key: uploadData.fileInfo?.key || uploadData.key, - uploading: false, - } + ? { ...f, path: result.path, key: result.key, uploading: false } : f ) ) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 0dd14fcc536..49d28fe33fb 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -316,7 +316,13 @@ export const fileDeleteBodySchema = z .passthrough() const MAX_FILE_SIZE = 100 * 1024 * 1024 -export const validUploadTypes = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const +export const validUploadTypes = [ + 'knowledge-base', + 'chat', + 'copilot', + 'profile-pictures', + 'mothership', +] as const export const uploadTypeSchema = z.enum(validUploadTypes) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index 07322b071f4..9813e383873 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -303,7 +303,7 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { interface MultipartUploadOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' + context: 'workspace' | 'knowledge-base' | 'mothership' signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -517,7 +517,7 @@ const uploadViaMultipart = async ( export interface RunUploadStrategyOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' + context: 'workspace' | 'knowledge-base' | 'mothership' /** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */ presignedEndpoint?: string /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ From ebf97a2852b30e97e82f537674f2809de4ba30a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:00:01 -0700 Subject: [PATCH 2/8] fix(uploads): drop unreachable size guard in mothership presigned branch --- apps/sim/app/api/files/presigned/route.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 7794275d076..c662a872932 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -9,7 +9,6 @@ import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateFileType } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -144,13 +143,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new ValidationError(fileValidationError.message) } - if (fileSize > MAX_WORKSPACE_FILE_SIZE) { - return NextResponse.json( - { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, - { status: 413 } - ) - } - const customKey = generateWorkspaceFileKey(workspaceId, fileName) presignedUrlResponse = await generatePresignedUploadUrl({ fileName, From dba6e2f47bbc5a65e4cdd604d772075fc7fc8772 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:26:22 -0700 Subject: [PATCH 3/8] improvement(uploads): migrate profile-picture and workspace-logo uploads to presigned PUT --- apps/sim/app/api/files/presigned/route.ts | 32 +++++++ .../hooks/use-profile-picture-upload.ts | 84 +++++++++++++------ .../hooks/use-workspace-logo-upload.ts | 72 +++++++++++----- .../sim/lib/api/contracts/storage-transfer.ts | 1 + apps/sim/lib/uploads/client/direct-upload.ts | 4 +- 5 files changed, 145 insertions(+), 48 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index c662a872932..5789a395bbf 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -22,6 +22,7 @@ const VALID_UPLOAD_TYPES = [ 'copilot', 'profile-pictures', 'mothership', + 'workspace-logos', ] as const class PresignedUrlError extends Error { @@ -154,6 +155,37 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expirationSeconds: 3600, metadata: { workspaceId }, }) + } else if (uploadType === 'workspace-logos') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError( + 'workspaceId query parameter is required for workspace-logos uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission === null) { + return NextResponse.json( + { error: 'Insufficient permissions for workspace' }, + { status: 403 } + ) + } + + if (!isImageFileType(contentType)) { + throw new ValidationError( + 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads' + ) + } + + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'workspace-logos', + userId: sessionUserId, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) } else { if (uploadType === 'profile-pictures') { if (!sessionUserId?.trim()) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 3c910a128bb..57d992399f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import type { StorageContext } from '@/lib/uploads/shared/types' const logger = createLogger('ProfilePictureUpload') @@ -10,10 +11,49 @@ interface UseProfilePictureUploadProps { onUpload?: (url: string | null) => void onError?: (error: string) => void currentImage?: string | null - context?: StorageContext + context?: 'profile-pictures' | 'workspace-logos' workspaceId?: string } +/** + * Server-proxied fallback used only when cloud storage isn't configured (local dev). + * Production always takes the presigned PUT path. + */ +async function uploadViaApiFallback( + file: File, + context: StorageContext, + workspaceId?: string +): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('context', context) + if (workspaceId) { + formData.append('workspaceId', workspaceId) + } + + // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson + const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) + } + const data = (await response.json()) as { + fileInfo?: { path?: string } + path?: string + url?: string + } + const publicUrl = data.fileInfo?.path ?? data.path ?? data.url + if (!publicUrl) { + throw new Error('Invalid upload response: missing path') + } + return publicUrl +} + /** * Hook for handling profile picture upload functionality. * Manages file validation, preview generation, and server upload. @@ -64,33 +104,27 @@ export function useProfilePictureUpload({ const uploadFileToServer = useCallback( async (file: File): Promise => { - try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', context) - if (workspaceId) { - formData.append('workspaceId', workspaceId) - } + const presignedEndpoint = + context === 'workspace-logos' && workspaceId + ? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}` + : `/api/files/presigned?type=${context}` - // boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, + try { + const result = await runUploadStrategy({ + file, + workspaceId: workspaceId ?? '', + context, + presignedEndpoint, }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: response.statusText })) - throw new Error( - errorData.message || errorData.error || `Failed to upload file: ${response.status}` - ) - } - - const data = await response.json() - const publicUrl = data.fileInfo?.path || data.path || data.url - logger.info(`Profile picture uploaded successfully via server upload: ${publicUrl}`) - return publicUrl + logger.info(`${context} uploaded successfully: ${result.path}`) + return result.path } catch (error) { - throw new Error(error instanceof Error ? error.message : 'Failed to upload profile picture') + if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { + const publicUrl = await uploadViaApiFallback(file, context, workspaceId) + logger.info(`${context} uploaded successfully via API fallback: ${publicUrl}`) + return publicUrl + } + throw error } }, [context, workspaceId] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts index 1fe74412935..74031f237a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -1,10 +1,40 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' const logger = createLogger('WorkspaceLogoUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp'] +async function uploadViaApiFallback(file: File, workspaceId: string): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'workspace-logos') + formData.append('workspaceId', workspaceId) + + // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson + const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) + } + const data = (await response.json()) as { + fileInfo?: { path?: string } + path?: string + url?: string + } + const publicUrl = data.fileInfo?.path ?? data.path ?? data.url + if (!publicUrl) { + throw new Error('Invalid upload response: missing path') + } + return publicUrl +} + interface UseWorkspaceLogoUploadProps { workspaceId?: string currentLogoUrl?: string | null @@ -60,30 +90,30 @@ export function useWorkspaceLogoUpload({ }, []) const uploadFileToServer = useCallback(async (file: File): Promise => { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'workspace-logos') - if (workspaceIdRef.current) { - formData.append('workspaceId', workspaceIdRef.current) + const targetWorkspaceId = workspaceIdRef.current + if (!targetWorkspaceId) { + throw new Error('workspaceId is required for workspace logo upload') } - // boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: response.statusText })) - throw new Error( - errorData.message || errorData.error || `Failed to upload file: ${response.status}` - ) + const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}` + + try { + const result = await runUploadStrategy({ + file, + workspaceId: targetWorkspaceId, + context: 'workspace-logos', + presignedEndpoint, + }) + logger.info(`Workspace logo uploaded successfully: ${result.path}`) + return result.path + } catch (error) { + if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { + const publicUrl = await uploadViaApiFallback(file, targetWorkspaceId) + logger.info(`Workspace logo uploaded via API fallback: ${publicUrl}`) + return publicUrl + } + throw error } - - const data = await response.json() - const publicUrl = data.fileInfo?.path || data.path || data.url - logger.info(`Workspace logo uploaded successfully: ${publicUrl}`) - return publicUrl }, []) const processFile = useCallback( diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 49d28fe33fb..8ed3db64a3a 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -322,6 +322,7 @@ export const validUploadTypes = [ 'copilot', 'profile-pictures', 'mothership', + 'workspace-logos', ] as const export const uploadTypeSchema = z.enum(validUploadTypes) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index 9813e383873..c92d0e31f15 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -303,7 +303,7 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { interface MultipartUploadOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' | 'mothership' + context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos' signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -517,7 +517,7 @@ const uploadViaMultipart = async ( export interface RunUploadStrategyOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' | 'mothership' + context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos' /** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */ presignedEndpoint?: string /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ From 4cb6d65d06a5b199e32b097c93b14ef9d7ee79ca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:27:32 -0700 Subject: [PATCH 4/8] improvement(uploads): migrate workflow file-upload sub-block to presigned PUT --- .../components/file-upload/file-upload.tsx | 58 +++++-------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 4faee74069e..a7206249a06 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -12,7 +12,7 @@ import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer' import { cn } from '@/lib/core/utils/cn' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useUploadWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -165,6 +165,8 @@ export function FileUpload({ refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) + const uploadFileMutation = useUploadWorkspaceFile() + const value = isPreview ? previewValue : storeValue /** @@ -310,58 +312,24 @@ export function FileUpload({ for (const file of validFiles) { try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'workspace') - - if (workspaceId) { - formData.append('workspaceId', workspaceId) - } - - // boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, + const data = await uploadFileMutation.mutateAsync({ + workspaceId, + file, + skipToast: true, }) - const data = await response.json() - - if (!response.ok) { - const errorMessage = - data.message || data.error || `Failed to upload file: ${response.status}` - uploadErrors.push(`${file.name}: ${errorMessage}`) - - setUploadError(errorMessage) - - if (data.isDuplicate || response.status === 409) { - setTimeout(() => setUploadError(null), 5000) - } - continue - } - - if (data.success === false) { - const errorMessage = data.error || 'Upload failed' - uploadErrors.push(`${file.name}: ${errorMessage}`) - - setUploadError(errorMessage) - - if (data.isDuplicate) { - setTimeout(() => setUploadError(null), 5000) - } - continue - } - uploadedFiles.push({ - name: file.name, - path: data.file?.url || data.url, // Workspace: data.file.url, Non-workspace: data.url - key: data.file?.key || data.key, // Storage key for proper file access - size: file.size, - type: file.type, + name: data.file.name, + path: data.file.url, + key: data.file.key, + size: data.file.size, + type: data.file.type, }) } catch (error) { logger.error(`Error uploading ${file.name}:`, error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' uploadErrors.push(`${file.name}: ${errorMessage}`) + setUploadError(errorMessage) } } From 3ec86ce91b2fab56f7c8efd17d503e322455b4ee Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:30:25 -0700 Subject: [PATCH 5/8] improvement(uploads): migrate execution-trigger file uploads to presigned PUT --- apps/sim/app/api/files/presigned/route.ts | 28 ++++ .../hooks/use-workflow-execution.ts | 122 ++++++++++-------- .../sim/lib/api/contracts/storage-transfer.ts | 1 + apps/sim/lib/uploads/client/direct-upload.ts | 16 ++- 4 files changed, 113 insertions(+), 54 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 5789a395bbf..f5201525484 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -23,6 +24,7 @@ const VALID_UPLOAD_TYPES = [ 'profile-pictures', 'mothership', 'workspace-logos', + 'execution', ] as const class PresignedUrlError extends Error { @@ -155,6 +157,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expirationSeconds: 3600, metadata: { workspaceId }, }) + } else if (uploadType === 'execution') { + const workflowId = request.nextUrl.searchParams.get('workflowId') + const executionId = request.nextUrl.searchParams.get('executionId') + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) { + throw new ValidationError( + 'workflowId, executionId, and workspaceId query parameters are required for execution uploads' + ) + } + + const fileValidationError = validateFileType(fileName, contentType) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'execution', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId, workflowId, executionId }, + }) } else if (uploadType === 'workspace-logos') { const workspaceId = request.nextUrl.searchParams.get('workspaceId') if (!workspaceId?.trim()) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index e71041afd05..4a0a8298883 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -10,6 +10,7 @@ import { requestJson } from '@/lib/api/client/request' import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events' import { extractTriggerMockPayload, @@ -505,65 +506,82 @@ export function useWorkflowExecution() { typeof (value as any).onUploadError === 'function' if (workflowInput.files && Array.isArray(workflowInput.files)) { try { + const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(activeWorkflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}` for (const fileData of workflowInput.files) { - // Create FormData for upload - const formData = new FormData() - formData.append('file', fileData.file) - formData.append('context', 'execution') - formData.append('workflowId', activeWorkflowId) - formData.append('executionId', executionId) - formData.append('workspaceId', workspaceId) - - // boundary-raw-fetch: multipart/form-data file upload, requestJson only supports JSON bodies - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }) - - if (response.ok) { - const uploadResult = await response.json() - // Convert upload result to clean UserFile format - const processUploadResult = (result: any) => ({ - id: - result.id || - `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - name: result.name, - url: result.url, - size: result.size, - type: result.type, + try { + const result = await runUploadStrategy({ + file: fileData.file, + workspaceId, + context: 'execution', + presignedEndpoint, + }) + uploadedFiles.push({ + id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + name: fileData.file.name, + url: result.path, + size: fileData.file.size, + type: fileData.file.type, key: result.key, - uploadedAt: result.uploadedAt, - expiresAt: result.expiresAt, }) - - // The API returns the file directly for single uploads - // or { files: [...] } for multiple uploads - if (uploadResult.files && Array.isArray(uploadResult.files)) { - uploadedFiles.push(...uploadResult.files.map(processUploadResult)) - } else if (uploadResult.path || uploadResult.url) { - // Single file upload - the result IS the file object - uploadedFiles.push(processUploadResult(uploadResult)) + } catch (uploadError) { + if ( + uploadError instanceof DirectUploadError && + uploadError.code === 'FALLBACK_REQUIRED' + ) { + const formData = new FormData() + formData.append('file', fileData.file) + formData.append('context', 'execution') + formData.append('workflowId', activeWorkflowId) + formData.append('executionId', executionId) + formData.append('workspaceId', workspaceId) + + // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson + const response = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }) + if (!response.ok) { + const errorData = await response.json().catch(() => null) + const reason = + errorData?.message || errorData?.error || `${response.status}` + const message = `Failed to upload ${fileData.name}: ${reason}` + logger.error(message) + if (isUploadErrorCapable(workflowInput)) { + try { + workflowInput.onUploadError(message) + } catch {} + } + continue + } + const uploadResult = await response.json() + const processUploadResult = (r: any) => ({ + id: + r.id || + `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + name: r.name, + url: r.url, + size: r.size, + type: r.type, + key: r.key, + uploadedAt: r.uploadedAt, + expiresAt: r.expiresAt, + }) + if (uploadResult.files && Array.isArray(uploadResult.files)) { + uploadedFiles.push(...uploadResult.files.map(processUploadResult)) + } else if (uploadResult.path || uploadResult.url) { + uploadedFiles.push(processUploadResult(uploadResult)) + } } else { - logger.error('Unexpected upload response format:', uploadResult) - } - } else { - const cloned = response.clone() - const errorData = await response.json().catch(() => null) - const reason = - errorData?.message || - errorData?.error || - (await cloned.text().catch(() => '')) || - `${response.status}` - const message = `Failed to upload ${fileData.name}: ${reason}` - logger.error(message) - if (isUploadErrorCapable(workflowInput)) { - try { - workflowInput.onUploadError(message) - } catch {} + const message = `Failed to upload ${fileData.name}: ${toError(uploadError).message}` + logger.error(message) + if (isUploadErrorCapable(workflowInput)) { + try { + workflowInput.onUploadError(message) + } catch {} + } } } } - // Update workflow input with uploaded files workflowInput.files = uploadedFiles } catch (error) { logger.error('Error uploading files:', error) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 8ed3db64a3a..ff2e9bcde5f 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -323,6 +323,7 @@ export const validUploadTypes = [ 'profile-pictures', 'mothership', 'workspace-logos', + 'execution', ] as const export const uploadTypeSchema = z.enum(validUploadTypes) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index c92d0e31f15..f58b9d5ab30 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -303,7 +303,13 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { interface MultipartUploadOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos' + context: + | 'workspace' + | 'knowledge-base' + | 'mothership' + | 'profile-pictures' + | 'workspace-logos' + | 'execution' signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -517,7 +523,13 @@ const uploadViaMultipart = async ( export interface RunUploadStrategyOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos' + context: + | 'workspace' + | 'knowledge-base' + | 'mothership' + | 'profile-pictures' + | 'workspace-logos' + | 'execution' /** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */ presignedEndpoint?: string /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ From 56b90ef70e1b65b3117cfe2ccd72b40e02fa7796 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:36:53 -0700 Subject: [PATCH 6/8] fix(uploads): tighten permission checks and fix multipart customKey for mothership/execution --- apps/sim/app/api/files/multipart/route.ts | 24 ++++++++++++++++++ apps/sim/app/api/files/presigned/route.ts | 8 +++--- .../hooks/use-workflow-execution.ts | 2 ++ apps/sim/lib/uploads/client/direct-upload.ts | 25 ++++++++++++++++--- bun.lock | 1 + 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 80213d54cbb..e61cbd543a8 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -158,6 +158,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } + } else if (context === 'mothership') { + const { generateWorkspaceFileKey } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-manager' + ) + customKey = generateWorkspaceFileKey(workspaceId, fileName) + } else if (context === 'execution') { + const workflowId = (data as { workflowId?: unknown }).workflowId + const executionId = (data as { executionId?: unknown }).executionId + if (typeof workflowId !== 'string' || !workflowId.trim()) { + return NextResponse.json( + { error: 'workflowId is required for execution uploads' }, + { status: 400 } + ) + } + if (typeof executionId !== 'string' || !executionId.trim()) { + return NextResponse.json( + { error: 'executionId is required for execution uploads' }, + { status: 400 } + ) + } + const { generateExecutionFileKey } = await import( + '@/lib/uploads/contexts/execution/utils' + ) + customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) } let uploadId: string diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index f5201525484..786316132c3 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -134,9 +134,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission === null) { + if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( - { error: 'Insufficient permissions for workspace' }, + { error: 'Write or Admin access required for mothership uploads' }, { status: 403 } ) } @@ -192,9 +192,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission === null) { + if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( - { error: 'Insufficient permissions for workspace' }, + { error: 'Write or Admin access required for workspace logo uploads' }, { status: 403 } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 4a0a8298883..5f5ae679b8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -513,6 +513,8 @@ export function useWorkflowExecution() { file: fileData.file, workspaceId, context: 'execution', + workflowId: activeWorkflowId, + executionId, presignedEndpoint, }) uploadedFiles.push({ diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index f58b9d5ab30..7afaf1ae34d 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -310,6 +310,8 @@ interface MultipartUploadOptions { | 'profile-pictures' | 'workspace-logos' | 'execution' + workflowId?: string + executionId?: string signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -327,7 +329,7 @@ interface PartUrl { const uploadViaMultipart = async ( opts: MultipartUploadOptions ): Promise<{ key: string; path: string }> => { - const { file, workspaceId, context, signal, onProgress } = opts + const { file, workspaceId, context, workflowId, executionId, signal, onProgress } = opts // boundary-raw-fetch: multipart upload control plane uses action query strings; client lifecycle (initiate/get-part-urls/complete/abort) is sequenced manually and not modeled by a single contract const initiateResponse = await fetch('/api/files/multipart?action=initiate', { @@ -339,6 +341,8 @@ const uploadViaMultipart = async ( fileSize: file.size, workspaceId, context, + ...(workflowId ? { workflowId } : {}), + ...(executionId ? { executionId } : {}), }), signal, }) @@ -534,6 +538,10 @@ export interface RunUploadStrategyOptions { presignedEndpoint?: string /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ presignedOverride?: PresignedUploadInfo + /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ + workflowId?: string + /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ + executionId?: string signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -549,8 +557,17 @@ export interface RunUploadStrategyOptions { export const runUploadStrategy = async ( opts: RunUploadStrategyOptions ): Promise => { - const { file, presignedEndpoint, presignedOverride, workspaceId, context, signal, onProgress } = - opts + const { + file, + presignedEndpoint, + presignedOverride, + workspaceId, + context, + workflowId, + executionId, + signal, + onProgress, + } = opts const contentType = getFileContentType(file) if (presignedOverride && !presignedOverride.directUploadSupported) { @@ -562,6 +579,8 @@ export const runUploadStrategy = async ( file, workspaceId, context, + workflowId, + executionId, signal, onProgress, }) diff --git a/bun.lock b/bun.lock index 3c658d61d32..9c5e1dedc68 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", From 8e5e67b8b567124396485c24a4d09ac7ccd8ca39 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 19:41:26 -0700 Subject: [PATCH 7/8] fix(uploads): require workspace write+ for execution presigned, admin-only for workspace-logos, suppress doubled error toast --- apps/sim/app/api/files/presigned/route.ts | 12 ++++++++++-- apps/sim/hooks/queries/workspace-files.ts | 8 +++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 786316132c3..c8fb824b3c9 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -167,6 +167,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for execution uploads' }, + { status: 403 } + ) + } + const fileValidationError = validateFileType(fileName, contentType) if (fileValidationError) { throw new ValidationError(fileValidationError.message) @@ -192,9 +200,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { + if (permission !== 'admin') { return NextResponse.json( - { error: 'Write or Admin access required for workspace logo uploads' }, + { error: 'Admin access required for workspace logo uploads' }, { status: 403 } ) } diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 5e4364c5af6..90bd76c19ed 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -331,9 +331,11 @@ export function useUploadWorkspaceFile() { }, onError: (error, variables) => { logger.error('Failed to upload file:', error) - toast.error(`Failed to upload "${variables.file.name}": ${error.message}`, { - duration: 5000, - }) + if (!variables.skipToast) { + toast.error(`Failed to upload "${variables.file.name}": ${error.message}`, { + duration: 5000, + }) + } }, }) } From 139544a4413b1a7b474a9f973ee17974073d63b1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 20:05:53 -0700 Subject: [PATCH 8/8] 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) --- .../hooks/use-profile-picture-upload.ts | 47 ++----------------- .../user-input/hooks/use-file-attachments.ts | 45 +++--------------- .../components/file-upload/file-upload.tsx | 10 +++- .../hooks/use-workspace-logo-upload.ts | 36 ++------------ apps/sim/hooks/queries/workspace-files.ts | 4 +- apps/sim/lib/uploads/client/api-fallback.ts | 42 +++++++++++++++++ 6 files changed, 68 insertions(+), 116 deletions(-) create mode 100644 apps/sim/lib/uploads/client/api-fallback.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 57d992399f2..e0143fffab7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' -import type { StorageContext } from '@/lib/uploads/shared/types' const logger = createLogger('ProfilePictureUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -15,45 +15,6 @@ interface UseProfilePictureUploadProps { workspaceId?: string } -/** - * Server-proxied fallback used only when cloud storage isn't configured (local dev). - * Production always takes the presigned PUT path. - */ -async function uploadViaApiFallback( - file: File, - context: StorageContext, - workspaceId?: string -): Promise { - const formData = new FormData() - formData.append('file', file) - formData.append('context', context) - if (workspaceId) { - formData.append('workspaceId', workspaceId) - } - - // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson - const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - message?: string - error?: string - } - throw new Error( - errorData.message || errorData.error || `Failed to upload file: ${response.status}` - ) - } - const data = (await response.json()) as { - fileInfo?: { path?: string } - path?: string - url?: string - } - const publicUrl = data.fileInfo?.path ?? data.path ?? data.url - if (!publicUrl) { - throw new Error('Invalid upload response: missing path') - } - return publicUrl -} - /** * Hook for handling profile picture upload functionality. * Manages file validation, preview generation, and server upload. @@ -120,9 +81,9 @@ export function useProfilePictureUpload({ return result.path } catch (error) { if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const publicUrl = await uploadViaApiFallback(file, context, workspaceId) - logger.info(`${context} uploaded successfully via API fallback: ${publicUrl}`) - return publicUrl + const { path } = await uploadViaApiFallback(file, context, workspaceId) + logger.info(`${context} uploaded successfully via API fallback: ${path}`) + return path } throw error } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 61f93a41378..57d808c1f03 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { toast } from '@/components/emcn' +import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import { resolveFileType } from '@/lib/uploads/utils/file-utils' @@ -52,44 +53,6 @@ interface UseFileAttachmentsProps { isLoading?: boolean } -/** - * Server-proxied fallback used only when cloud storage isn't configured (local dev). - * Production always takes the presigned PUT path. - */ -async function uploadViaApiFallback( - file: File, - workspaceId: string -): Promise<{ path: string; key: string }> { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'mothership') - formData.append('workspaceId', workspaceId) - - // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson - const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - message?: string - error?: string - } - throw new Error( - errorData.message || errorData.error || `Failed to upload file: ${response.status}` - ) - } - const data = (await response.json()) as { - fileInfo?: { path?: string; key?: string } - path?: string - key?: string - url?: string - } - const path = data.fileInfo?.path ?? data.path ?? data.url - const key = data.fileInfo?.key ?? data.key - if (!path || !key) { - throw new Error('Invalid upload response: missing path or key') - } - return { path, key } -} - /** * Custom hook to manage file attachments including upload, drag/drop, and preview * Handles S3 presigned URL uploads and preview URL generation @@ -193,7 +156,11 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { }) } catch (error) { if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - result = await uploadViaApiFallback(file, workspaceId) + const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId) + if (!fallback.key) { + throw new Error('Invalid upload response: missing key') + } + result = { path: fallback.path, key: fallback.key } } else { throw error } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index a7206249a06..e8dc74c8b19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Combobox } from '@/components/emcn/components' @@ -12,7 +13,11 @@ import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer' import { cn } from '@/lib/core/utils/cn' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { useUploadWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { + useUploadWorkspaceFile, + useWorkspaceFiles, + workspaceFilesKeys, +} from '@/hooks/queries/workspace-files' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -166,6 +171,7 @@ export function FileUpload({ } = useWorkspaceFiles(isPreview ? '' : workspaceId) const uploadFileMutation = useUploadWorkspaceFile() + const queryClient = useQueryClient() const value = isPreview ? previewValue : storeValue @@ -316,6 +322,7 @@ export function FileUpload({ workspaceId, file, skipToast: true, + skipInvalidation: true, }) uploadedFiles.push({ @@ -345,6 +352,7 @@ export function FileUpload({ if (workspaceId) { void refetchWorkspaceFiles() + void queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) } if (uploadedFiles.length === 1) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts index 74031f237a5..23d41f3ac27 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -1,40 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' const logger = createLogger('WorkspaceLogoUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp'] -async function uploadViaApiFallback(file: File, workspaceId: string): Promise { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'workspace-logos') - formData.append('workspaceId', workspaceId) - - // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson - const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - message?: string - error?: string - } - throw new Error( - errorData.message || errorData.error || `Failed to upload file: ${response.status}` - ) - } - const data = (await response.json()) as { - fileInfo?: { path?: string } - path?: string - url?: string - } - const publicUrl = data.fileInfo?.path ?? data.path ?? data.url - if (!publicUrl) { - throw new Error('Invalid upload response: missing path') - } - return publicUrl -} - interface UseWorkspaceLogoUploadProps { workspaceId?: string currentLogoUrl?: string | null @@ -108,9 +80,9 @@ export function useWorkspaceLogoUpload({ return result.path } catch (error) { if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const publicUrl = await uploadViaApiFallback(file, targetWorkspaceId) - logger.info(`Workspace logo uploaded via API fallback: ${publicUrl}`) - return publicUrl + const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId) + logger.info(`Workspace logo uploaded via API fallback: ${path}`) + return path } throw error } diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 90bd76c19ed..8321996b219 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -206,6 +206,7 @@ interface UploadFileParams { onProgress?: (event: UploadProgressEvent) => void signal?: AbortSignal skipToast?: boolean + skipInvalidation?: boolean } interface UploadFileResponse { @@ -320,7 +321,8 @@ export function useUploadWorkspaceFile() { return useMutation({ mutationFn: ({ workspaceId, file, onProgress, signal }: UploadFileParams) => uploadWorkspaceFile(workspaceId, file, onProgress, signal), - onSettled: () => { + onSettled: (_data, _error, variables) => { + if (variables.skipInvalidation) return queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, diff --git a/apps/sim/lib/uploads/client/api-fallback.ts b/apps/sim/lib/uploads/client/api-fallback.ts new file mode 100644 index 00000000000..67b90fbc20c --- /dev/null +++ b/apps/sim/lib/uploads/client/api-fallback.ts @@ -0,0 +1,42 @@ +import type { StorageContext } from '@/lib/uploads/shared/types' + +/** + * Server-proxied fallback used only when cloud storage isn't configured (local dev). + * Production always takes the presigned PUT path. + */ +export async function uploadViaApiFallback( + file: File, + context: StorageContext, + workspaceId?: string +): Promise<{ path: string; key?: string }> { + const formData = new FormData() + formData.append('file', file) + formData.append('context', context) + if (workspaceId) { + formData.append('workspaceId', workspaceId) + } + + // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson + const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) + } + const data = (await response.json()) as { + fileInfo?: { path?: string; key?: string } + path?: string + key?: string + url?: string + } + const path = data.fileInfo?.path ?? data.path ?? data.url + const key = data.fileInfo?.key ?? data.key + if (!path) { + throw new Error('Invalid upload response: missing path') + } + return { path, key } +}