mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+28
-33
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
+21
-45
@@ -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) {
|
||||
|
||||
+72
-52
@@ -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)
|
||||
|
||||
+22
-20
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user