diff --git a/apps/sim/app/api/files/presigned/batch/route.test.ts b/apps/sim/app/api/files/presigned/batch/route.test.ts new file mode 100644 index 00000000000..988fae9cce4 --- /dev/null +++ b/apps/sim/app/api/files/presigned/batch/route.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for the batch presigned upload API route + * + * @vitest-environment node + */ + +import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockValidateFileType, + mockGetUserEntityPermissions, + mockRecordKnowledgeBaseFileOwnershipMany, +} = vi.hoisted(() => ({ + mockValidateFileType: vi.fn().mockReturnValue(null), + mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'), + mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/uploads/config', () => ({ + getServeStoragePrefix: () => 's3', +})) + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +vi.mock('@/lib/uploads/utils/validation', () => ({ + validateFileType: mockValidateFileType, + SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany, +})) + +import { POST } from '@/app/api/files/presigned/batch/route' + +const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1' + +const buildRequest = (query: string, files?: unknown) => + new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, { + method: 'POST', + body: JSON.stringify({ + files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], + }), + }) + +describe('/api/files/presigned/batch', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockValidateFileType.mockReturnValue(null) + mockGetUserEntityPermissions.mockResolvedValue('write') + mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) + storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation( + async (files: Array<{ fileName: string }>, context: string) => + files.map((file) => ({ + url: `https://example.com/${context}/${file.fileName}`, + key: `${context}/${file.fileName}`, + })) + ) + }) + + it('returns 401 when the caller has no session', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await POST(buildRequest(KB_QUERY)) + + expect(response.status).toBe(401) + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) + + it.each([ + 'workspace-logos', + 'profile-pictures', + 'execution', + 'mothership', + 'chat', + 'copilot', + 'workspace', + ])('refuses to presign the %s context', async (type) => { + const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`)) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Invalid type parameter') + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) + + it('returns 400 when type is missing', async () => { + const response = await POST(buildRequest('workspaceId=ws-1')) + + expect(response.status).toBe(400) + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) + + it('returns 400 when workspaceId is missing', async () => { + const response = await POST(buildRequest('type=knowledge-base')) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('workspaceId') + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) + + it.each([['read'], [null]])( + 'returns 403 when the caller has %s access to the workspace', + async (permission) => { + mockGetUserEntityPermissions.mockResolvedValue(permission) + + const response = await POST(buildRequest(KB_QUERY)) + + expect(response.status).toBe(403) + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + } + ) + + it('authorizes the workspace before returning the local-storage fallback', async () => { + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) + mockGetUserEntityPermissions.mockResolvedValue('read') + + const response = await POST(buildRequest(KB_QUERY)) + + expect(response.status).toBe(403) + }) + + it('rejects unsupported file types before minting any URL', async () => { + mockValidateFileType.mockReturnValue({ + code: 'UNSUPPORTED_FILE_TYPE', + message: 'Unsupported file type: html.', + supportedTypes: ['pdf'], + }) + + const response = await POST( + buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }]) + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) + + it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => { + const response = await POST(buildRequest(KB_QUERY)) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith( + [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], + 'knowledge-base', + 'user-1', + 3600 + ) + expect(data.files).toHaveLength(1) + expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf') + expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base') + expect(data.directUploadSupported).toBe(true) + expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([ + { + key: 'knowledge-base/doc.pdf', + userId: 'user-1', + workspaceId: 'ws-1', + originalName: 'doc.pdf', + contentType: 'application/pdf', + size: 1024, + }, + ]) + }) + + it('returns the fallback response when cloud storage is not configured', async () => { + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) + + const response = await POST(buildRequest(KB_QUERY)) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.directUploadSupported).toBe(false) + expect(data.files[0].presignedUrl).toBe('') + expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index 85dfb85ec08..226fdc9ed87 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -2,12 +2,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { batchPresignedUploadBodyContract, - uploadTypeSchema, + batchPresignedUploadTypeSchema, + batchPresignedUploadTypes, } 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 type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { generateBatchPresignedUploadUrls, @@ -20,8 +20,13 @@ import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('BatchPresignedUploadAPI') -const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const - +/** + * Mints presigned upload URLs for knowledge-base ingest, the only context this + * endpoint can authorize. Every request must name a workspace the caller has + * write access to; other storage contexts are rejected rather than presigned, + * because a presigned PUT is a write grant into a bucket served from a trusted + * origin. + */ export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -52,59 +57,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 }) } - const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam) + const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam) if (!uploadTypeResult.success) { return NextResponse.json( - { error: `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` }, + { + error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`, + }, { status: 400 } ) } - const uploadType = uploadTypeResult.data as StorageContext - + const uploadType = uploadTypeResult.data const sessionUserId = session.user.id - let knowledgeBaseWorkspaceId: string | null = null - if (uploadType === 'knowledge-base') { - for (const file of files) { - const fileValidationError = validateFileType(file.fileName, file.contentType) - if (fileValidationError) { - return NextResponse.json( - { - error: fileValidationError.message, - code: fileValidationError.code, - supportedTypes: fileValidationError.supportedTypes, - }, - { status: 400 } - ) - } - } - - knowledgeBaseWorkspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!knowledgeBaseWorkspaceId?.trim()) { + for (const file of files) { + const fileValidationError = validateFileType(file.fileName, file.contentType) + if (fileValidationError) { return NextResponse.json( - { error: 'workspaceId query parameter is required for knowledge-base uploads' }, + { + error: fileValidationError.message, + code: fileValidationError.code, + supportedTypes: fileValidationError.supportedTypes, + }, { status: 400 } ) } + } - const permission = await getUserEntityPermissions( - sessionUserId, - 'workspace', - knowledgeBaseWorkspaceId + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + return NextResponse.json( + { error: 'workspaceId query parameter is required for knowledge-base uploads' }, + { status: 400 } ) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } } - if (uploadType === 'copilot' && !sessionUserId?.trim()) { + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( - { error: 'Authenticated user session is required for copilot uploads' }, - { status: 400 } + { error: 'Write or Admin access required for knowledge-base uploads' }, + { status: 403 } ) } @@ -149,19 +141,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)` ) - if (uploadType === 'knowledge-base' && knowledgeBaseWorkspaceId) { - const ownerWorkspaceId = knowledgeBaseWorkspaceId - await recordKnowledgeBaseFileOwnershipMany( - presignedUrls.map((urlResponse, index) => ({ - key: urlResponse.key, - userId: sessionUserId, - workspaceId: ownerWorkspaceId, - originalName: files[index].fileName, - contentType: files[index].contentType, - size: files[index].fileSize, - })) - ) - } + await recordKnowledgeBaseFileOwnershipMany( + presignedUrls.map((urlResponse, index) => ({ + key: urlResponse.key, + userId: sessionUserId, + workspaceId, + originalName: files[index].fileName, + contentType: files[index].contentType, + size: files[index].fileSize, + })) + ) const storagePrefix = getServeStoragePrefix() diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index c3e756620ca..3674ac70b76 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -220,14 +220,17 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -235,11 +238,11 @@ describe('/api/files/presigned', () => { expect(response.status).toBe(200) expect(data.directUploadSupported).toBe(false) expect(data.presignedUrl).toBe('') - expect(data.fileName).toBe('test.txt') + expect(data.fileName).toBe('avatar.png') expect(data.fileInfo).toBeDefined() - expect(data.fileInfo.name).toBe('test.txt') + expect(data.fileInfo.name).toBe('avatar.png') expect(data.fileInfo.size).toBe(1024) - expect(data.fileInfo.type).toBe('text/plain') + expect(data.fileInfo.type).toBe('image/png') }) it('should return error when fileName is missing', async () => { @@ -339,14 +342,17 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test document.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'test avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -354,11 +360,11 @@ describe('/api/files/presigned', () => { expect(response.status).toBe(200) expect(data.presignedUrl).toBe('https://example.com/presigned-url') expect(data.fileInfo).toMatchObject({ - path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=chat$/), - key: expect.stringMatching(/.*test.document\.txt$/), - name: 'test document.txt', + path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/), + key: expect.stringMatching(/.*test.avatar\.png$/), + name: 'test avatar.png', size: 1024, - type: 'text/plain', + type: 'image/png', }) expect(data.directUploadSupported).toBe(true) }) @@ -389,27 +395,30 @@ describe('/api/files/presigned', () => { expect(data.directUploadSupported).toBe(true) }) - it('should generate chat S3 presigned URL with chat prefix and direct path', async () => { + it('should generate profile-pictures S3 presigned URL with its prefix and direct path', async () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', }) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'chat-logo.png', - contentType: 'image/png', - fileSize: 4096, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) const response = await POST(request) const data = await response.json() expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=chat$/) + expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) + expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/) expect(data.presignedUrl).toBeTruthy() expect(data.directUploadSupported).toBe(true) }) @@ -420,14 +429,17 @@ describe('/api/files/presigned', () => { storageProvider: 'blob', }) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test document.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'test avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -436,35 +448,38 @@ describe('/api/files/presigned', () => { expect(data.presignedUrl).toBeTruthy() expect(typeof data.presignedUrl).toBe('string') expect(data.fileInfo).toMatchObject({ - key: expect.stringMatching(/.*test.document\.txt$/), - name: 'test document.txt', + key: expect.stringMatching(/.*test.avatar\.png$/), + name: 'test avatar.png', size: 1024, - type: 'text/plain', + type: 'image/png', }) expect(data.directUploadSupported).toBe(true) }) - it('should generate chat Azure Blob presigned URL with chat prefix and direct path', async () => { + it('should generate profile-pictures Azure Blob presigned URL with its prefix and direct path', async () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 'blob', }) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'chat-logo.png', - contentType: 'image/png', - fileSize: 4096, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) const response = await POST(request) const data = await response.json() expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=chat$/) + expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) + expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=profile-pictures$/) expect(data.presignedUrl).toBeTruthy() expect(data.directUploadSupported).toBe(true) }) @@ -479,14 +494,17 @@ describe('/api/files/presigned', () => { new Error('Unknown storage provider: unknown') ) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -506,14 +524,17 @@ describe('/api/files/presigned', () => { new Error('S3 service unavailable') ) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -533,14 +554,17 @@ describe('/api/files/presigned', () => { new Error('Azure service unavailable') ) - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 1024, - }), - }) + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=profile-pictures', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 1024, + }), + } + ) const response = await POST(request) const data = await response.json() @@ -568,6 +592,26 @@ describe('/api/files/presigned', () => { expect(data.error).toBe('Invalid JSON in request body') // Updated error message expect(data.code).toBe('VALIDATION_ERROR') }) + + it('rejects the unauthorizable chat context without minting a URL', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { + method: 'POST', + body: JSON.stringify({ + fileName: 'poc.html', + contentType: 'text/html', + fileSize: 41, + }), + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Invalid type parameter') + expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() + }) }) describe('mothership uploads', () => { diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 0104f78fcc5..49bec3aab16 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -1,12 +1,15 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { presignedUploadBodyContract, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer' +import { + presignedUploadBodyContract, + presignedUploadTypeSchema, + presignedUploadTypes, +} 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 { CopilotFiles } from '@/lib/uploads' -import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' @@ -20,16 +23,6 @@ import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('PresignedUploadAPI') -const VALID_UPLOAD_TYPES = [ - 'knowledge-base', - 'chat', - 'copilot', - 'profile-pictures', - 'mothership', - 'workspace-logos', - 'execution', -] as const - class PresignedUrlError extends Error { constructor( message: string, @@ -76,14 +69,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new ValidationError('type query parameter is required') } - const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam) + const uploadTypeResult = presignedUploadTypeSchema.safeParse(uploadTypeParam) if (!uploadTypeResult.success) { throw new ValidationError( - `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` + `Invalid type parameter. Must be one of: ${presignedUploadTypes.join(', ')}` ) } - const uploadType = uploadTypeResult.data as StorageContext + const uploadType = uploadTypeResult.data if (uploadType === 'knowledge-base') { const fileValidationError = validateFileType(fileName, contentType) @@ -290,17 +283,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: fileSize, }) } else { - if (uploadType === 'profile-pictures') { - if (!sessionUserId?.trim()) { - throw new ValidationError( - 'Authenticated user session is required for profile picture uploads' - ) - } - if (!isImageFileType(contentType)) { - throw new ValidationError( - 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads' - ) - } + if (!isImageFileType(contentType)) { + throw new ValidationError( + 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads' + ) } presignedUrlResponse = await generatePresignedUploadUrl({ diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 7f49d9e797e..b5ad82d362c 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -338,8 +338,35 @@ export const validUploadTypes = [ export const uploadTypeSchema = z.enum(validUploadTypes) +/** + * Storage contexts a client may mint a single presigned upload URL for. Each one + * has a per-context authorization predicate in `/api/files/presigned`; a context + * that cannot be authorized must not be listed here. `chat` is deliberately + * absent — it has no owning entity to authorize against and no client that mints + * one (chat assets go through the server-proxied `/api/files/upload`). + */ +export const presignedUploadTypes = [ + 'knowledge-base', + 'copilot', + 'profile-pictures', + 'mothership', + 'workspace-logos', + 'execution', +] as const + +export const presignedUploadTypeSchema = z.enum(presignedUploadTypes) + +/** + * Storage contexts `/api/files/presigned/batch` serves. Batching exists only for + * knowledge-base ingest; no other context has a batch client, and the batch + * endpoint carries no authorization predicate for one. + */ +export const batchPresignedUploadTypes = ['knowledge-base'] as const + +export const batchPresignedUploadTypeSchema = z.enum(batchPresignedUploadTypes) + export const presignedUploadQuerySchema = z.object({ - type: uploadTypeSchema, + type: presignedUploadTypeSchema, }) export const presignedUrlBodySchema = z