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 f6c22bc4a5d..c8fb824b3c9 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -7,14 +7,25 @@ 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' 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', + 'workspace-logos', + 'execution', +] as const class PresignedUrlError extends Error { constructor( @@ -116,6 +127,101 @@ 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 !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for mothership uploads' }, + { status: 403 } + ) + } + + const fileValidationError = validateFileType(fileName, contentType) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateWorkspaceFileKey(workspaceId, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'mothership', + userId: sessionUserId, + customKey, + 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 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) + } + + 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()) { + throw new ValidationError( + 'workspaceId query parameter is required for workspace-logos uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required for workspace logo uploads' }, + { 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..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,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import type { StorageContext } from '@/lib/uploads/shared/types' +import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' const logger = createLogger('ProfilePictureUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -10,7 +11,7 @@ interface UseProfilePictureUploadProps { onUpload?: (url: string | null) => void onError?: (error: string) => void currentImage?: string | null - context?: StorageContext + context?: 'profile-pictures' | 'workspace-logos' workspaceId?: string } @@ -64,33 +65,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 { path } = await uploadViaApiFallback(file, context, workspaceId) + logger.info(`${context} uploaded successfully via API fallback: ${path}`) + return path + } + throw error } }, [context, workspaceId] 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..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,8 @@ 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' const logger = createLogger('useFileAttachments') @@ -115,6 +117,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 +140,38 @@ 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) - } - - // 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}` - ) + 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') { + 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 + } } - 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/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..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 { 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' @@ -165,6 +170,9 @@ export function FileUpload({ refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) + const uploadFileMutation = useUploadWorkspaceFile() + const queryClient = useQueryClient() + const value = isPreview ? previewValue : storeValue /** @@ -310,58 +318,25 @@ 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, + skipInvalidation: 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) } } @@ -377,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/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index e71041afd05..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 @@ -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,84 @@ 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', + workflowId: activeWorkflowId, + executionId, + 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/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..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,5 +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' const logger = createLogger('WorkspaceLogoUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -60,30 +62,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 { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId) + logger.info(`Workspace logo uploaded via API fallback: ${path}`) + return path + } + 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/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 5e4364c5af6..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() }) }, @@ -331,9 +333,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, + }) + } }, }) } diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 0dd14fcc536..ff2e9bcde5f 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -316,7 +316,15 @@ 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', + 'workspace-logos', + 'execution', +] as const export const uploadTypeSchema = z.enum(validUploadTypes) 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 } +} diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index 07322b071f4..7afaf1ae34d 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -303,7 +303,15 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { interface MultipartUploadOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' + context: + | 'workspace' + | 'knowledge-base' + | 'mothership' + | 'profile-pictures' + | 'workspace-logos' + | 'execution' + workflowId?: string + executionId?: string signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void } @@ -321,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', { @@ -333,6 +341,8 @@ const uploadViaMultipart = async ( fileSize: file.size, workspaceId, context, + ...(workflowId ? { workflowId } : {}), + ...(executionId ? { executionId } : {}), }), signal, }) @@ -517,11 +527,21 @@ const uploadViaMultipart = async ( export interface RunUploadStrategyOptions { file: File workspaceId: string - context: 'workspace' | 'knowledge-base' + 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. */ 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 } @@ -537,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) { @@ -550,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",