import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { uploadFilesFormFieldsSchema, uploadFilesFormFilesSchema, } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_ATTACHMENT_EXTENSIONS, SUPPORTED_IMAGE_EXTENSIONS, validateFileType, } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) 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 const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const formData = await readFormDataWithLimit(request, { maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, label: 'multipart upload body', }) const rawFiles = formData.getAll('file') const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles) if (!filesResult.success) { throw new InvalidRequestError('No files provided') } const files = filesResult.data const totalFileSize = files.reduce((total, file) => total + file.size, 0) assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files') const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({ workflowId: formData.get('workflowId'), executionId: formData.get('executionId'), workspaceId: formData.get('workspaceId'), context: formData.get('context'), }) if (!formFieldsResult.success) { throw new InvalidRequestError( getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data') ) } const formFields = formFieldsResult.data const { workflowId, executionId, workspaceId, context: contextParam } = formFields // Context must be explicitly provided if (!contextParam) { throw new InvalidRequestError( 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)' ) } 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`) // Execution context requires a workspace write/admin permission check. Resolve it once per // request (not per file) since workspaceId is invariant across all files in the upload. let executionUploadContext: | { workspaceId: string; workflowId: string; executionId: string } | undefined if (context === 'execution') { if (!workflowId || !executionId || !workspaceId) { throw new InvalidRequestError( 'Execution context requires workflowId, executionId, and workspaceId parameters' ) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( { error: 'Write or Admin access required for execution uploads' }, { status: 403 } ) } executionUploadContext = { workspaceId, workflowId, executionId } } // Mothership context requires the same workspace write/admin permission check, plus a // storage quota check. Resolve both once per request (not per file) since workspaceId is // invariant across all files in the upload and quota must account for the full batch size, // not just one file. let mothershipWorkspaceId: string | undefined if (context === 'mothership') { if (!workspaceId) { throw new InvalidRequestError('Mothership context requires workspaceId parameter') } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( { error: 'Write or Admin access required for mothership uploads' }, { status: 403 } ) } const { checkStorageQuota } = await import('@/lib/billing/storage') const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize) if (!quotaCheck.allowed) { return NextResponse.json( { error: quotaCheck.error || 'Storage limit exceeded' }, { status: 413 } ) } mothershipWorkspaceId = workspaceId } const uploadResults = [] for (const file of files) { const originalName = file.name || 'untitled.md' 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 buffer = await readFileToBufferWithLimit(file, { maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE, label: 'uploaded file', }) // Handle execution context if (context === 'execution' && executionUploadContext) { const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const userFile = await uploadExecutionFile( executionUploadContext, 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) { throw new InvalidRequestError('workspaceId is required for knowledge-base uploads') } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( { error: 'Write or Admin access required for knowledge-base uploads' }, { status: 403 } ) } logger.info(`Uploading knowledge-base file: ${originalName}`) const storageKey = generateKnowledgeBaseFileKey(originalName) const metadata: Record = { originalName: originalName, uploadedAt: new Date().toISOString(), purpose: 'knowledge-base', userId: session.user.id, 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 = getErrorMessage(workspaceError, '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 mothership context (chat-scoped uploads to workspace S3) if (context === 'mothership' && mothershipWorkspaceId) { logger.info(`Uploading mothership file: ${originalName}`) const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName) const metadata: Record = { originalName: originalName, uploadedAt: new Date().toISOString(), purpose: 'mothership', userId: session.user.id, workspaceId: mothershipWorkspaceId, } const fileInfo = await storageService.uploadFile({ file: buffer, fileName: storageKey, contentType: file.type || 'application/octet-stream', context: 'mothership', preserveKey: true, customKey: storageKey, metadata, }) const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path uploadResults.push({ fileName: originalName, presignedUrl: '', fileInfo: { path: finalPath, key: fileInfo.key, name: originalName, size: buffer.length, type: file.type || 'application/octet-stream', }, directUploadSupported: false, }) logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`) continue } if ( context === 'copilot' || context === 'chat' || context === 'profile-pictures' || context === 'workspace-logos' ) { if (context !== 'copilot') { const mimeType = file.type const isGenericMime = !mimeType || mimeType === 'application/octet-stream' const extension = originalName.split('.').pop()?.toLowerCase() ?? '' const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes( extension ) const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType) if (!isImage) { throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`) } } if (context === 'workspace-logos') { if (!workspaceId) { throw new InvalidRequestError('workspace-logos context requires workspaceId parameter') } const permission = await getUserEntityPermissions( session.user.id, 'workspace', workspaceId ) if (permission !== 'admin') { return NextResponse.json( { error: 'Admin access required for workspace logo uploads' }, { status: 403 } ) } } 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 resolvedContentType = resolveFileType({ type: file.type, name: originalName }) const timestamp = Date.now() const safeFileName = sanitizeFileName(originalName) const storageKey = `${context}/${timestamp}-${safeFileName}` const metadata: Record = { 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: resolvedContentType, 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: resolvedContentType, }, directUploadSupported: false, } logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`) if (context === 'workspace-logos' && workspaceId) { recordAudit({ workspaceId, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, action: AuditAction.FILE_UPLOADED, resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, description: `Uploaded workspace logo "${originalName}"`, metadata: { fileName: originalName, fileKey: fileInfo.key, fileSize: buffer.length, fileType: resolvedContentType, }, request, }) captureServerEvent(session.user.id, 'workspace_logo_uploaded', { workspace_id: workspaceId, file_name: originalName, file_size: buffer.length, }) } uploadResults.push(uploadResult) continue } // Unknown context throw new InvalidRequestError( `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos` ) } if (uploadResults.length === 1) { return NextResponse.json(uploadResults[0]) } return NextResponse.json({ files: uploadResults }) } catch (error) { logger.error('Error in file upload:', error) if (isPayloadSizeLimitError(error)) { return NextResponse.json( { error: 'PayloadSizeLimitError', message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`, }, { status: 413 } ) } return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) } })