From c4192f5c1f2dd6c1edd62f87482eaccfc46bdbde Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 7 May 2026 18:52:24 -0700 Subject: [PATCH 1/2] 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/2] 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,