import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { abortMultipartUploadContract, type CompleteMultipartBody, completeMultipartUploadContract, getMultipartPartUrlsContract, initiateMultipartUploadContract, multipartActionSchema, } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getStorageConfig, getStorageProvider, isUsingCloudStorage, type StorageContext, } from '@/lib/uploads' import { deleteFile } from '@/lib/uploads/core/storage-service' import { signUploadToken, type UploadTokenPayload, verifyUploadToken, } from '@/lib/uploads/core/upload-token' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MultipartUploadAPI') /** * Contexts the multipart endpoint accepts. The quota-exempt public-asset * contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the * system-internal `logs` context are deliberately excluded: their uploads are * small images capped far below the multipart threshold and routed through the * presigned endpoint, so they have no large-file flow here. Accepting them would * only expose a path that bypasses the per-user storage quota, since every * context in this set is quota-enforced below. */ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'knowledge-base', 'chat', 'copilot', 'mothership', 'execution', 'workspace', ]) /** * Unified part identity sent by the client when completing a multipart upload. * `etag` is required for S3 (CompleteMultipartUpload). For Azure the server * derives the block id from `partNumber` via {@link deriveBlobBlockId}. */ interface ClientCompletedPart { partNumber: number etag?: string } const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] => Array.isArray(value) && value.every( (p) => p !== null && typeof p === 'object' && typeof (p as ClientCompletedPart).partNumber === 'number' && ((p as ClientCompletedPart).etag === undefined || typeof (p as ClientCompletedPart).etag === 'string') ) const buildS3CustomConfig = (config: StorageConfig) => config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined const buildBlobCustomConfig = (config: StorageConfig) => ({ containerName: config.containerName!, accountName: config.accountName!, accountKey: config.accountKey, connectionString: config.connectionString, }) const verifyTokenForUser = (token: string | undefined, userId: string) => { if (!token || typeof token !== 'string') { return null } const result = verifyUploadToken(token) if (!result.valid || result.payload.userId !== userId) { return null } return result.payload } /** * Record a trusted storage-key -> workspace ownership binding for completed * knowledge-base uploads. KB file authorization resolves the owning workspace * from this binding, so every KB object must have one. No-op for other contexts. */ const recordKnowledgeBaseOwnership = async ( payload: UploadTokenPayload, key: string ): Promise => { if (payload.context !== 'knowledge-base' || !payload.workspaceId) { return } await recordKnowledgeBaseFileOwnership({ key, userId: payload.userId, workspaceId: payload.workspaceId, originalName: payload.fileName ?? key.split('/').pop() ?? key, contentType: payload.contentType ?? 'application/octet-stream', size: typeof payload.fileSize === 'number' ? payload.fileSize : 0, }) } export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = session.user.id const actionParam = request.nextUrl.searchParams.get('action') const actionResult = multipartActionSchema.safeParse(actionParam) const action = actionResult.success ? actionResult.data : null if (!isUsingCloudStorage()) { return NextResponse.json( { error: 'Multipart upload is only available with cloud storage (S3 or Azure Blob)' }, { status: 400 } ) } const storageProvider = getStorageProvider() switch (action) { case 'initiate': { const parsed = await parseRequest( initiateMultipartUploadContract, request, {}, { validationErrorResponse: (error) => NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), } ) if (!parsed.success) return parsed.response const data = parsed.data.body const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data if (!workspaceId || typeof workspaceId !== 'string') { return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) { return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 }) } const storageContext = context as StorageContext const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } const config = getStorageConfig(storageContext) if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) { const { checkStorageQuota } = await import('@/lib/billing/storage') const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0) if (!quotaCheck.allowed) { return NextResponse.json( { error: quotaCheck.error || 'Storage limit exceeded' }, { status: 413 } ) } } let customKey: string | undefined if (context === 'workspace' || context === 'mothership') { const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types') if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) { return NextResponse.json( { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, { status: 413 } ) } 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 let key: string if (storageProvider === 's3') { const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') const result = await initiateS3MultipartUpload({ fileName, contentType, fileSize, customConfig: buildS3CustomConfig(config), customKey, purpose: context, }) uploadId = result.uploadId key = result.key } else if (storageProvider === 'blob') { const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') const result = await initiateMultipartUpload({ fileName, contentType, fileSize, customConfig: buildBlobCustomConfig(config), customKey, }) uploadId = result.uploadId key = result.key } else { return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, { status: 400 } ) } const uploadToken = signUploadToken({ uploadId, key, userId, workspaceId, context: storageContext, fileName, contentType, ...(typeof fileSize === 'number' ? { fileSize } : {}), }) logger.info( `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}` ) return NextResponse.json({ uploadId, key, uploadToken }) } case 'get-part-urls': { const parsed = await parseRequest( getMultipartPartUrlsContract, request, {}, { validationErrorResponse: (error) => NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), } ) if (!parsed.success) return parsed.response const data = parsed.data.body const { partNumbers } = data const tokenPayload = verifyTokenForUser(data.uploadToken, userId) if (!tokenPayload) { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) } const { uploadId, key, context } = tokenPayload const config = getStorageConfig(context) if (storageProvider === 's3') { const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') const presignedUrls = await getS3MultipartPartUrls( key, uploadId, partNumbers, buildS3CustomConfig(config) ) return NextResponse.json({ presignedUrls }) } if (storageProvider === 'blob') { const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') const presignedUrls = await getMultipartPartUrls( key, partNumbers, buildBlobCustomConfig(config) ) return NextResponse.json({ presignedUrls }) } return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, { status: 400 } ) } case 'complete': { const parsed = await parseRequest( completeMultipartUploadContract, request, {}, { validationErrorResponse: (error) => NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), } ) if (!parsed.success) return parsed.response const data: CompleteMultipartBody = parsed.data.body const s3Module = storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null const blobModule = storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => { const { uploadId, key, context } = payload const config = getStorageConfig(context) let completed: { location: string; path: string; key: string } if (storageProvider === 's3' && s3Module) { const { completeS3MultipartUpload } = s3Module const s3Parts = parts.map((p) => { if (!p.etag) { throw new Error(`Missing etag for S3 part ${p.partNumber}`) } return { ETag: p.etag, PartNumber: p.partNumber } }) completed = await completeS3MultipartUpload( key, uploadId, s3Parts, buildS3CustomConfig(config) ) } else if (storageProvider === 'blob' && blobModule) { const { completeMultipartUpload, deriveBlobBlockId } = blobModule const blobParts = parts.map((p) => ({ partNumber: p.partNumber, blockId: deriveBlobBlockId(p.partNumber), })) completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config)) } else { throw new Error(`Unsupported storage provider: ${storageProvider}`) } try { await recordKnowledgeBaseOwnership(payload, completed.key) } catch (error) { // The object is committed, but without an ownership binding a KB file // is unreadable and undeletable via the KB paths. Remove the orphan // best-effort and surface a retryable error so the client re-uploads. if (payload.context === 'knowledge-base') { await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {}) } throw error } return { success: true as const, location: completed.location, path: completed.path, key: completed.key, } } if ('uploads' in data && Array.isArray(data.uploads)) { const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = [] for (const upload of data.uploads) { const payload = verifyTokenForUser(upload.uploadToken, userId) if (!payload) { return NextResponse.json( { error: 'Invalid or expired upload token' }, { status: 403 } ) } if (!isClientCompletedParts(upload.parts)) { return NextResponse.json( { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, { status: 400 } ) } verified.push({ payload, parts: upload.parts }) } const results = await Promise.all( verified.map(({ payload, parts }) => completeOne(payload, parts)) ) logger.info(`Completed ${verified.length} multipart uploads`) return NextResponse.json({ results }) } const single = data const tokenPayload = verifyTokenForUser(single.uploadToken, userId) if (!tokenPayload) { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) } if (!isClientCompletedParts(single.parts)) { return NextResponse.json( { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, { status: 400 } ) } const result = await completeOne(tokenPayload, single.parts) logger.info( `Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})` ) return NextResponse.json(result) } case 'abort': { const parsed = await parseRequest( abortMultipartUploadContract, request, {}, { validationErrorResponse: (error) => NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), } ) if (!parsed.success) return parsed.response const data = parsed.data.body const tokenPayload = verifyTokenForUser(data.uploadToken, userId) if (!tokenPayload) { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) } const { uploadId, key, context } = tokenPayload const config = getStorageConfig(context) if (storageProvider === 's3') { const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config)) logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`) } else if (storageProvider === 'blob') { const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') await abortMultipartUpload(key, buildBlobCustomConfig(config)) logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`) } else { return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, { status: 400 } ) } return NextResponse.json({ success: true }) } default: return NextResponse.json( { error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' }, { status: 400 } ) } } catch (error) { logger.error('Multipart upload error:', error) return NextResponse.json( { error: getErrorMessage(error, 'Multipart upload failed') }, { status: 500 } ) } })