diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 6ae6a10ed5..7c4893dc44 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -17,10 +17,14 @@ const { mockIsUsingCloudStorage, mockGetStorageProvider, mockValidateFileType, + mockValidateAttachmentFileType, mockGenerateCopilotUploadUrl, mockIsImageFileType, mockGetStorageProviderUploads, mockIsUsingCloudStorageUploads, + mockGetUserEntityPermissions, + mockGenerateWorkspaceFileKey, + mockGenerateExecutionFileKey, } = vi.hoisted(() => ({ mockVerifyFileAccess: vi.fn().mockResolvedValue(true), mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), @@ -30,6 +34,7 @@ const { mockIsUsingCloudStorage: vi.fn(), mockGetStorageProvider: vi.fn(), mockValidateFileType: vi.fn().mockReturnValue(null), + mockValidateAttachmentFileType: vi.fn().mockReturnValue(null), mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({ url: 'https://example.com/presigned-url', key: 'copilot/test-key.txt', @@ -37,6 +42,14 @@ const { mockIsImageFileType: vi.fn().mockReturnValue(true), mockGetStorageProviderUploads: vi.fn(), mockIsUsingCloudStorageUploads: vi.fn(), + mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'), + mockGenerateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` + ), + mockGenerateExecutionFileKey: vi.fn( + (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) => + `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}` + ), })) vi.mock('@/app/api/files/authorization', () => ({ @@ -61,6 +74,19 @@ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, + validateAttachmentFileType: mockValidateAttachmentFileType, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, +})) + +vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ + generateExecutionFileKey: mockGenerateExecutionFileKey, })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ @@ -139,6 +165,8 @@ function setupFileApiMocks( ) mockValidateFileType.mockReturnValue(null) + mockValidateAttachmentFileType.mockReturnValue(null) + mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetStorageProviderUploads.mockReturnValue( storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' @@ -518,6 +546,167 @@ describe('/api/files/presigned', () => { }) }) + describe('mothership uploads', () => { + it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'screenshot.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png') + expect(mockValidateFileType).not.toHaveBeenCalled() + }) + + it('rejects unsupported types when validator returns an error', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockValidateAttachmentFileType.mockReturnValue({ + code: 'UNSUPPORTED_FILE_TYPE', + message: 'Unsupported file type: exe.', + supportedTypes: [], + }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'virus.exe', + contentType: 'application/octet-stream', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + expect(response.status).toBe(400) + expect(data.code).toBe('VALIDATION_ERROR') + expect(data.error).toContain('exe') + }) + + it('returns 403 when user lacks workspace write permission', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(403) + }) + }) + + describe('execution uploads', () => { + it('uses validateAttachmentFileType — accepts video', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'output.mp4', + contentType: 'video/mp4', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4') + expect(mockValidateFileType).not.toHaveBeenCalled() + }) + + it('rejects when validator returns an error', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockValidateAttachmentFileType.mockReturnValue({ + code: 'UNSUPPORTED_FILE_TYPE', + message: 'Unsupported file type: bin.', + supportedTypes: [], + }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'blob.bin', + contentType: 'application/octet-stream', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + expect(response.status).toBe(400) + expect(data.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when missing workflowId/executionId', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'output.mp4', + contentType: 'video/mp4', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(400) + }) + }) + + describe('knowledge-base uploads', () => { + it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=knowledge-base', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf') + expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() + }) + }) + describe('OPTIONS', () => { it('should handle CORS preflight requests', async () => { const response = await OPTIONS() diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index c8fb824b3c..7c4bb01ec6 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -11,7 +11,7 @@ import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils 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 { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse } from '@/app/api/files/utils' @@ -141,7 +141,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const fileValidationError = validateFileType(fileName, contentType) + const fileValidationError = validateAttachmentFileType(fileName) if (fileValidationError) { throw new ValidationError(fileValidationError.message) } @@ -175,7 +175,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const fileValidationError = validateFileType(fileName, contentType) + const fileValidationError = validateAttachmentFileType(fileName) if (fileValidationError) { throw new ValidationError(fileValidationError.message) } diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 424935f594..828cf83aa0 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -15,11 +15,8 @@ import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' import { - SUPPORTED_AUDIO_EXTENSIONS, - SUPPORTED_CODE_EXTENSIONS, - SUPPORTED_DOCUMENT_EXTENSIONS, + SUPPORTED_ATTACHMENT_EXTENSIONS, SUPPORTED_IMAGE_EXTENSIONS, - SUPPORTED_VIDEO_EXTENSIONS, validateFileType, } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -29,13 +26,7 @@ import { InvalidRequestError, } from '@/app/api/files/utils' -const ALLOWED_EXTENSIONS = new Set([ - ...SUPPORTED_DOCUMENT_EXTENSIONS, - ...SUPPORTED_CODE_EXTENSIONS, - ...SUPPORTED_IMAGE_EXTENSIONS, - ...SUPPORTED_AUDIO_EXTENSIONS, - ...SUPPORTED_VIDEO_EXTENSIONS, -]) +const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) function validateFileExtension(filename: string): boolean { const extension = filename.split('.').pop()?.toLowerCase() diff --git a/apps/sim/lib/uploads/utils/validation.test.ts b/apps/sim/lib/uploads/utils/validation.test.ts new file mode 100644 index 0000000000..f5db99cbd0 --- /dev/null +++ b/apps/sim/lib/uploads/utils/validation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + SUPPORTED_ATTACHMENT_EXTENSIONS, + validateAttachmentFileType, +} from '@/lib/uploads/utils/validation' + +describe('validateAttachmentFileType', () => { + it('accepts image files (png, jpg, gif, webp, svg)', () => { + expect(validateAttachmentFileType('screenshot.png')).toBeNull() + expect(validateAttachmentFileType('photo.jpg')).toBeNull() + expect(validateAttachmentFileType('photo.JPEG')).toBeNull() + expect(validateAttachmentFileType('animation.gif')).toBeNull() + expect(validateAttachmentFileType('image.webp')).toBeNull() + expect(validateAttachmentFileType('icon.svg')).toBeNull() + }) + + it('accepts video files (mp4, mov, webm)', () => { + expect(validateAttachmentFileType('clip.mp4')).toBeNull() + expect(validateAttachmentFileType('clip.mov')).toBeNull() + expect(validateAttachmentFileType('clip.webm')).toBeNull() + }) + + it('accepts audio files (mp3, wav, m4a)', () => { + expect(validateAttachmentFileType('voice.mp3')).toBeNull() + expect(validateAttachmentFileType('voice.wav')).toBeNull() + expect(validateAttachmentFileType('voice.m4a')).toBeNull() + }) + + it('accepts document files (pdf, docx, csv, md)', () => { + expect(validateAttachmentFileType('report.pdf')).toBeNull() + expect(validateAttachmentFileType('letter.docx')).toBeNull() + expect(validateAttachmentFileType('data.csv')).toBeNull() + expect(validateAttachmentFileType('notes.md')).toBeNull() + }) + + it('accepts code files (ts, py, sh, json)', () => { + expect(validateAttachmentFileType('app.ts')).toBeNull() + expect(validateAttachmentFileType('main.py')).toBeNull() + expect(validateAttachmentFileType('script.sh')).toBeNull() + expect(validateAttachmentFileType('config.json')).toBeNull() + }) + + it('rejects executables and unknown extensions', () => { + expect(validateAttachmentFileType('virus.exe')?.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(validateAttachmentFileType('installer.msi')?.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(validateAttachmentFileType('archive.dmg')?.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(validateAttachmentFileType('binary.bin')?.code).toBe('UNSUPPORTED_FILE_TYPE') + }) + + it('rejects files with no extension', () => { + const result = validateAttachmentFileType('README') + expect(result?.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(result?.message).toContain('README') + }) + + it('rejects files with non-alphanumeric extensions', () => { + expect(validateAttachmentFileType('odd.<>')?.code).toBe('UNSUPPORTED_FILE_TYPE') + expect(validateAttachmentFileType('foo. ')?.code).toBe('UNSUPPORTED_FILE_TYPE') + }) + + it('does not contain duplicate extensions (e.g. webm)', () => { + const seen = new Set() + for (const ext of SUPPORTED_ATTACHMENT_EXTENSIONS) { + expect(seen.has(ext)).toBe(false) + seen.add(ext) + } + }) + + it('returns supportedTypes list in error', () => { + const result = validateAttachmentFileType('foo.exe') + expect(result?.supportedTypes).toEqual(expect.arrayContaining(['png', 'pdf', 'mp4', 'mp3'])) + }) +}) diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index af0a5581fb..4f46d67516 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -219,6 +219,37 @@ export interface FileValidationError { supportedTypes: string[] } +export const SUPPORTED_ATTACHMENT_EXTENSIONS = Array.from( + new Set([ + ...SUPPORTED_DOCUMENT_EXTENSIONS, + ...SUPPORTED_CODE_EXTENSIONS, + ...SUPPORTED_IMAGE_EXTENSIONS, + ...SUPPORTED_AUDIO_EXTENSIONS, + ...SUPPORTED_VIDEO_EXTENSIONS, + ]) +) as readonly string[] + +/** + * Validate that a file's extension is allowed as a chat/mothership attachment. + * + * Permits documents, code, images, audio, and video — anything users would + * reasonably attach to a chat message. Rejects executables and unknown types. + */ +export function validateAttachmentFileType(fileName: string): FileValidationError | null { + const raw = extractExtension(fileName) + const extension = isAlphanumericExtension(raw) ? raw : '' + + if (!SUPPORTED_ATTACHMENT_EXTENSIONS.includes(extension)) { + return { + code: 'UNSUPPORTED_FILE_TYPE', + message: `Unsupported file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported types include documents, code, images, audio, and video.`, + supportedTypes: [...SUPPORTED_ATTACHMENT_EXTENSIONS], + } + } + + return null +} + /** * Validate if a file type is supported for document processing */