improvement(uploads): migrate remaining FormData uploads to presigned PUT (#4509)

* fix(uploads): switch mothership uploads to presigned PUT pattern

* fix(uploads): drop unreachable size guard in mothership presigned branch

* improvement(uploads): migrate profile-picture and workspace-logo uploads to presigned PUT

* improvement(uploads): migrate workflow file-upload sub-block to presigned PUT

* improvement(uploads): migrate execution-trigger file uploads to presigned PUT

* fix(uploads): tighten permission checks and fix multipart customKey for mothership/execution

* fix(uploads): require workspace write+ for execution presigned, admin-only for workspace-logos, suppress doubled error toast

* fix(uploads): skip per-file invalidation in batch + extract shared API fallback

- Add skipInvalidation flag to useUploadWorkspaceFile; file-upload sub-block now invalidates once after the batch instead of per file
- Extract uploadViaApiFallback to lib/uploads/client/api-fallback.ts (DRY across 3 hooks)
This commit is contained in:
Waleed
2026-05-07 20:18:00 -07:00
committed by GitHub
parent 6a927c9cd3
commit 11563cfbc2
12 changed files with 391 additions and 187 deletions
+24
View File
@@ -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
+107 -1
View File
@@ -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()) {
@@ -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<string> => {
const presignedEndpoint =
context === 'workspace-logos' && workspaceId
? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}`
: `/api/files/presigned?type=${context}`
try {
const formData = new FormData()
formData.append('file', file)
formData.append('context', context)
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 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]
@@ -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)
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
}
}
// 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
)
)
@@ -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) {
@@ -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,
key: result.key,
uploadedAt: result.uploadedAt,
expiresAt: result.expiresAt,
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,
})
} 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)
// 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))
// 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)
@@ -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<string> => {
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,
})
const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}`
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}`
)
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(
+8 -4
View File
@@ -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,
})
}
},
})
}
@@ -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)
@@ -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 }
}
+36 -5
View File
@@ -303,7 +303,15 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
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<UploadStrategyResult> => {
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,
})
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",