Files
sim/apps/sim/app/api/files/upload/route.ts
Vikhyath Mondreti be3cdcf981 Merge pull request #3179 from simstudioai/improvement/file-download-timeouts
improvement(timeouts): files/base64 should use max timeouts + auth centralization
2026-02-10 15:57:06 -08:00

342 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
createErrorResponse,
createOptionsResponse,
InvalidRequestError,
} from '@/app/api/files/utils'
const ALLOWED_EXTENSIONS = new Set([
// Documents
'pdf',
'doc',
'docx',
'txt',
'md',
'csv',
'xlsx',
'xls',
'json',
'yaml',
'yml',
// Images
'png',
'jpg',
'jpeg',
'gif',
// Audio
'mp3',
'm4a',
'wav',
'webm',
'ogg',
'flac',
'aac',
'opus',
// Video
'mp4',
'mov',
'avi',
'mkv',
])
function validateFileExtension(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase()
if (!extension) return false
return ALLOWED_EXTENSIONS.has(extension)
}
export const dynamic = 'force-dynamic'
const logger = createLogger('FilesUploadAPI')
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const files = formData.getAll('file') as File[]
if (!files || files.length === 0) {
throw new InvalidRequestError('No files provided')
}
const workflowId = formData.get('workflowId') as string | null
const executionId = formData.get('executionId') as string | null
const workspaceId = formData.get('workspaceId') as string | null
const contextParam = formData.get('context') as string | null
// Context must be explicitly provided
if (!contextParam) {
throw new InvalidRequestError(
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, or profile-pictures)'
)
}
const context = contextParam as StorageContext
const storageService = await import('@/lib/uploads/core/storage-service')
const usingCloudStorage = storageService.hasCloudStorage()
logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)
const uploadResults = []
for (const file of files) {
const originalName = file.name
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
throw new InvalidRequestError(
`File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Handle execution context
if (context === 'execution') {
if (!workflowId || !executionId) {
throw new InvalidRequestError(
'Execution context requires workflowId and executionId parameters'
)
}
const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
const userFile = await uploadExecutionFile(
{
workspaceId: workspaceId || '',
workflowId,
executionId,
},
buffer,
originalName,
file.type,
session.user.id
)
uploadResults.push(userFile)
continue
}
// Handle knowledge-base context
if (context === 'knowledge-base') {
// Validate file type for knowledge base
const validationError = validateFileType(originalName, file.type)
if (validationError) {
throw new InvalidRequestError(validationError.message)
}
if (workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (permission === null) {
return NextResponse.json(
{ error: 'Insufficient permissions for workspace' },
{ status: 403 }
)
}
}
logger.info(`Uploading knowledge-base file: ${originalName}`)
const timestamp = Date.now()
const safeFileName = sanitizeFileName(originalName)
const storageKey = `kb/${timestamp}-${safeFileName}`
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: 'knowledge-base',
userId: session.user.id,
}
if (workspaceId) {
metadata.workspaceId = workspaceId
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type,
context: 'knowledge-base',
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage
? `${fileInfo.path}?context=knowledge-base`
: fileInfo.path
const uploadResult = {
fileName: originalName,
presignedUrl: '', // Not used for server-side uploads
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type,
},
directUploadSupported: false,
}
logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`)
uploadResults.push(uploadResult)
continue
}
// Handle workspace context
if (context === 'workspace') {
if (!workspaceId) {
throw new InvalidRequestError('Workspace context requires workspaceId parameter')
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json(
{ error: 'Write or Admin access required for workspace uploads' },
{ status: 403 }
)
}
try {
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
originalName,
file.type || 'application/octet-stream'
)
uploadResults.push(userFile)
continue
} catch (workspaceError) {
const errorMessage =
workspaceError instanceof Error ? workspaceError.message : 'Upload failed'
const isDuplicate = errorMessage.includes('already exists')
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') ||
errorMessage.includes('storage limit')
logger.warn(`Workspace file upload failed: ${errorMessage}`)
let statusCode = 500
if (isDuplicate) statusCode = 409
else if (isStorageLimitError) statusCode = 413
return NextResponse.json(
{
success: false,
error: errorMessage,
isDuplicate,
},
{ status: statusCode }
)
}
}
// Handle image-only contexts (copilot, chat, profile-pictures)
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (!isImageFileType(file.type)) {
throw new InvalidRequestError(
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
)
}
if (context === 'chat' && workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (permission === null) {
return NextResponse.json(
{ error: 'Insufficient permissions for workspace' },
{ status: 403 }
)
}
}
logger.info(`Uploading ${context} file: ${originalName}`)
const timestamp = Date.now()
const safeFileName = sanitizeFileName(originalName)
const storageKey = `${context}/${timestamp}-${safeFileName}`
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: context,
userId: session.user.id,
}
if (workspaceId && context === 'chat') {
metadata.workspaceId = workspaceId
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type,
context,
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path
const uploadResult = {
fileName: originalName,
presignedUrl: '', // Not used for server-side uploads
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type,
},
directUploadSupported: false,
}
logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)
uploadResults.push(uploadResult)
continue
}
// Unknown context
throw new InvalidRequestError(
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, or profile-pictures`
)
}
if (uploadResults.length === 1) {
return NextResponse.json(uploadResults[0])
}
return NextResponse.json({ files: uploadResults })
} catch (error) {
logger.error('Error in file upload:', error)
return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
}
}
export async function OPTIONS() {
return createOptionsResponse()
}