Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 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,22 @@ 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 { 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(
Expand Down Expand Up @@ -116,6 +124,36 @@ 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 }
)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Comment thread
waleedlatif1 marked this conversation as resolved.

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 },
})
Comment thread
waleedlatif1 marked this conversation as resolved.
} else {
if (uploadType === 'profile-pictures') {
if (!sessionUserId?.trim()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)
)
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/api/contracts/storage-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/uploads/client/direct-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
interface MultipartUploadOptions {
file: File
workspaceId: string
context: 'workspace' | 'knowledge-base'
context: 'workspace' | 'knowledge-base' | 'mothership'
signal?: AbortSignal
onProgress?: (event: UploadProgressEvent) => void
}
Expand Down Expand Up @@ -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. */
Expand Down
Loading