Skip to content
Merged
24 changes: 24 additions & 0 deletions apps/sim/app/api/files/multipart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 107 additions & 1 deletion apps/sim/app/api/files/presigned/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 },
})
Comment thread
waleedlatif1 marked this conversation as resolved.
} 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 }
)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -64,33 +65,27 @@ export function useProfilePictureUpload({

const uploadFileToServer = useCallback(
async (file: File): Promise<string> => {
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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
)
)
Expand Down
Loading
Loading