From bc5b7378659a5b1695325dd233a88f1c479fcc6a Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 29 Oct 2025 19:09:44 -0700 Subject: [PATCH 01/11] feat(files): added file manager table, enforce permissions for viewing files --- apps/sim/app/api/__test-utils__/utils.ts | 107 +- apps/sim/app/api/files/authorization.ts | 663 ++ apps/sim/app/api/files/delete/route.ts | 103 +- apps/sim/app/api/files/download/route.ts | 79 +- .../execution/[executionId]/[fileId]/route.ts | 58 + apps/sim/app/api/files/multipart/route.ts | 32 +- apps/sim/app/api/files/parse/route.test.ts | 41 +- apps/sim/app/api/files/parse/route.ts | 189 +- .../app/api/files/presigned/batch/route.ts | 4 +- apps/sim/app/api/files/presigned/route.ts | 4 +- .../api/files/serve/[...path]/route.test.ts | 75 +- .../app/api/files/serve/[...path]/route.ts | 84 +- apps/sim/app/api/files/upload/route.ts | 165 +- apps/sim/app/api/files/utils.ts | 18 +- .../api/tools/discord/send-message/route.ts | 6 +- apps/sim/app/api/tools/gmail/draft/route.ts | 6 +- apps/sim/app/api/tools/gmail/send/route.ts | 6 +- .../api/tools/google_drive/upload/route.ts | 6 +- .../microsoft_teams/write_channel/route.ts | 6 +- .../tools/microsoft_teams/write_chat/route.ts | 6 +- apps/sim/app/api/tools/mistral/parse/route.ts | 58 +- .../app/api/tools/onedrive/upload/route.ts | 6 +- apps/sim/app/api/tools/outlook/draft/route.ts | 6 +- apps/sim/app/api/tools/outlook/send/route.ts | 6 +- apps/sim/app/api/tools/s3/put-object/route.ts | 6 +- .../app/api/tools/sharepoint/upload/route.ts | 6 +- .../app/api/tools/slack/send-message/route.ts | 6 +- .../api/tools/telegram/send-document/route.ts | 6 +- .../sim/app/api/tools/vision/analyze/route.ts | 6 +- .../[id]/files/[fileId]/download/route.ts | 20 +- .../files/[fileId]/view/file-viewer.tsx | 27 + .../files/[fileId]/view/page.tsx | 37 + .../components/upload-modal/upload-modal.tsx | 40 +- .../components/create-modal/create-modal.tsx | 56 +- .../knowledge/hooks/use-knowledge-upload.ts | 39 +- .../components/file-display.tsx | 2 +- .../components/user-input/user-input.tsx | 51 +- .../hooks/use-profile-picture-upload.ts | 49 +- .../components/file-uploads/file-uploads.tsx | 21 +- .../knowledge/documents/document-processor.ts | 37 +- .../{core/config-resolver.ts => config.ts} | 141 +- .../contexts/copilot/copilot-file-manager.ts | 16 +- .../execution/execution-file-manager.ts | 27 +- .../workspace/workspace-file-manager.ts | 138 +- apps/sim/lib/uploads/core/setup.server.ts | 2 +- apps/sim/lib/uploads/core/setup.ts | 106 - apps/sim/lib/uploads/core/storage-client.ts | 160 +- apps/sim/lib/uploads/core/storage-service.ts | 238 +- apps/sim/lib/uploads/index.ts | 18 +- .../{blob-client.test.ts => client.test.ts} | 14 +- .../blob/{blob-client.ts => client.ts} | 205 +- apps/sim/lib/uploads/providers/blob/index.ts | 11 +- apps/sim/lib/uploads/providers/blob/types.ts | 36 + .../s3/{s3-client.test.ts => client.test.ts} | 28 +- .../providers/s3/{s3-client.ts => client.ts} | 153 +- apps/sim/lib/uploads/providers/s3/index.ts | 11 +- apps/sim/lib/uploads/providers/s3/types.ts | 33 + apps/sim/lib/uploads/server/metadata.ts | 199 + apps/sim/lib/uploads/shared/types.ts | 61 + apps/sim/lib/uploads/utils/file-processing.ts | 138 - .../lib/uploads/utils/file-utils.server.ts | 97 + apps/sim/lib/uploads/utils/file-utils.ts | 243 +- apps/sim/lib/uploads/utils/index.ts | 1 - .../db/migrations/0102_eminent_amphibian.sql | 19 + .../db/migrations/meta/0102_snapshot.json | 7250 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 23 + 67 files changed, 10088 insertions(+), 1430 deletions(-) create mode 100644 apps/sim/app/api/files/authorization.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx rename apps/sim/lib/uploads/{core/config-resolver.ts => config.ts} (55%) delete mode 100644 apps/sim/lib/uploads/core/setup.ts rename apps/sim/lib/uploads/providers/blob/{blob-client.test.ts => client.test.ts} (96%) rename apps/sim/lib/uploads/providers/blob/{blob-client.ts => client.ts} (77%) create mode 100644 apps/sim/lib/uploads/providers/blob/types.ts rename apps/sim/lib/uploads/providers/s3/{s3-client.test.ts => client.test.ts} (97%) rename apps/sim/lib/uploads/providers/s3/{s3-client.ts => client.ts} (68%) create mode 100644 apps/sim/lib/uploads/providers/s3/types.ts create mode 100644 apps/sim/lib/uploads/server/metadata.ts create mode 100644 apps/sim/lib/uploads/shared/types.ts delete mode 100644 apps/sim/lib/uploads/utils/file-processing.ts create mode 100644 apps/sim/lib/uploads/utils/file-utils.server.ts create mode 100644 packages/db/migrations/0102_eminent_amphibian.sql create mode 100644 packages/db/migrations/meta/0102_snapshot.json diff --git a/apps/sim/app/api/__test-utils__/utils.ts b/apps/sim/app/api/__test-utils__/utils.ts index 36e95d18a0..43d8ba2c73 100644 --- a/apps/sim/app/api/__test-utils__/utils.ts +++ b/apps/sim/app/api/__test-utils__/utils.ts @@ -911,49 +911,44 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions = }, })) - vi.doMock('@/lib/uploads/core/setup', () => ({ + vi.doMock('@/lib/uploads/config', () => ({ USE_S3_STORAGE: provider === 's3', USE_BLOB_STORAGE: provider === 'blob', USE_LOCAL_STORAGE: provider === 'local', getStorageProvider: vi.fn().mockReturnValue(provider), + S3_CONFIG: { + bucket: 'test-s3-bucket', + region: 'us-east-1', + }, + S3_KB_CONFIG: { + bucket: 'test-s3-kb-bucket', + region: 'us-east-1', + }, + S3_CHAT_CONFIG: { + bucket: 'test-s3-chat-bucket', + region: 'us-east-1', + }, + BLOB_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-container', + }, + BLOB_KB_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-kb-container', + }, + BLOB_CHAT_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-chat-container', + }, })) if (provider === 's3') { - vi.doMock('@/lib/uploads/s3/s3-client', () => ({ + vi.doMock('@/lib/uploads/providers/s3/client', () => ({ getS3Client: vi.fn().mockReturnValue({}), - sanitizeFilenameForMetadata: vi.fn((filename) => filename), - })) - - vi.doMock('@/lib/uploads/setup', () => ({ - S3_CONFIG: { - bucket: 'test-s3-bucket', - region: 'us-east-1', - }, - S3_KB_CONFIG: { - bucket: 'test-s3-kb-bucket', - region: 'us-east-1', - }, - S3_CHAT_CONFIG: { - bucket: 'test-s3-chat-bucket', - region: 'us-east-1', - }, - BLOB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-container', - }, - BLOB_KB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-kb-container', - }, - BLOB_CHAT_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-chat-container', - }, })) - vi.doMock('@aws-sdk/client-s3', () => ({ PutObjectCommand: vi.fn(), })) @@ -983,29 +978,9 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions = }), } - vi.doMock('@/lib/uploads/blob/blob-client', () => ({ + vi.doMock('@/lib/uploads/providers/blob/client', () => ({ getBlobServiceClient: vi.fn().mockReturnValue(mockBlobServiceClient), - sanitizeFilenameForMetadata: vi.fn((filename) => filename), - })) - - vi.doMock('@/lib/uploads/setup', () => ({ - BLOB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-container', - }, - BLOB_KB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-kb-container', - }, - BLOB_CHAT_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-chat-container', - }, })) - vi.doMock('@azure/storage-blob', () => ({ BlobSASPermissions: { parse: vi.fn(() => 'w'), @@ -1355,6 +1330,25 @@ export function setupFileApiMocks( authMocks.setUnauthenticated() } + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + verifyKBFileAccess: vi.fn().mockResolvedValue(true), + verifyCopilotFileAccess: vi.fn().mockResolvedValue(true), + lookupWorkspaceFileByKey: vi.fn().mockResolvedValue({ + workspaceId: 'test-workspace-id', + uploadedBy: 'test-user-id', + }), + })) + mockFileSystem({ writeFileSuccess: true, readFileContent: 'test content', @@ -1510,11 +1504,10 @@ export function mockUploadUtils( isUsingCloudStorage: vi.fn().mockReturnValue(isCloudStorage), })) - vi.doMock('@/lib/uploads/setup', () => ({ + vi.doMock('@/lib/uploads/config', () => ({ UPLOAD_DIR: '/test/uploads', USE_S3_STORAGE: isCloudStorage, USE_BLOB_STORAGE: false, - ensureUploadsDirectory: vi.fn().mockResolvedValue(true), S3_CONFIG: { bucket: 'test-bucket', region: 'test-region', diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts new file mode 100644 index 0000000000..a5df21d8ab --- /dev/null +++ b/apps/sim/app/api/files/authorization.ts @@ -0,0 +1,663 @@ +import { db } from '@sim/db' +import { document, workspaceFile } from '@sim/db/schema' +import { eq, like, or } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { getFileMetadata } from '@/lib/uploads' +import type { StorageContext } from '@/lib/uploads/config' +import { + BLOB_CHAT_CONFIG, + BLOB_KB_CONFIG, + S3_CHAT_CONFIG, + S3_KB_CONFIG, +} from '@/lib/uploads/config' +import type { StorageConfig } from '@/lib/uploads/core/storage-client' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' + +const logger = createLogger('FileAuthorization') + +/** + * Authorization result structure + */ +export interface AuthorizationResult { + granted: boolean + reason: string + workspaceId?: string +} + +/** + * Lookup workspace file by storage key from database + * @param key Storage key to lookup + * @returns Workspace file info or null if not found + */ +export async function lookupWorkspaceFileByKey( + key: string +): Promise<{ workspaceId: string; uploadedBy: string } | null> { + try { + // Priority 1: Check new workspaceFiles table + const fileRecord = await getFileMetadataByKey(key, 'workspace') + + if (fileRecord) { + return { + workspaceId: fileRecord.workspaceId || '', + uploadedBy: fileRecord.userId, + } + } + + // Priority 2: Check legacy workspace_file table (for backward compatibility during migration) + try { + const [legacyFile] = await db + .select({ + workspaceId: workspaceFile.workspaceId, + uploadedBy: workspaceFile.uploadedBy, + }) + .from(workspaceFile) + .where(eq(workspaceFile.key, key)) + .limit(1) + + if (legacyFile) { + return { + workspaceId: legacyFile.workspaceId, + uploadedBy: legacyFile.uploadedBy, + } + } + } catch (legacyError) { + // Ignore errors when checking legacy table (it may not exist after migration) + logger.debug('Legacy workspace_file table check failed (may not exist):', legacyError) + } + + return null + } catch (error) { + logger.error('Error looking up workspace file by key:', { key, error }) + return null + } +} + +/** + * Extract workspace ID from workspace file key pattern + * Pattern: {workspaceId}/{timestamp}-{random}-{filename} + */ +function extractWorkspaceIdFromKey(key: string): string | null { + // Use inferContextFromKey to check if it's a workspace file + const inferredContext = inferContextFromKey(key) + if (inferredContext !== 'workspace') { + return null + } + + const parts = key.split('/') + const workspaceId = parts[0] + if (workspaceId && /^[a-f0-9-]{36}$/.test(workspaceId)) { + return workspaceId + } + + return null +} + +/** + * Verify file access based on file path patterns and metadata + * @param cloudKey The file key/path (e.g., "workspace_id/workflow_id/execution_id/filename" or "kb/filename") + * @param userId The authenticated user ID + * @param bucketType Optional bucket type (e.g., 'copilot', 'execution-files') + * @param customConfig Optional custom storage configuration + * @param context Optional explicit storage context + * @param isLocal Optional flag indicating if this is local storage + * @returns Promise True if user has access, false otherwise + */ +export async function verifyFileAccess( + cloudKey: string, + userId: string, + bucketType?: string | null, + customConfig?: StorageConfig, + context?: StorageContext, + isLocal?: boolean +): Promise { + try { + // Infer context from key if not explicitly provided + const inferredContext = context || inferContextFromKey(cloudKey) + + // 1. Workspace files: Check database first (most reliable for both local and cloud) + if (inferredContext === 'workspace') { + return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal) + } + + // 2. Execution files: workspace_id/workflow_id/execution_id/filename + if (inferredContext === 'execution' || isExecutionFile(cloudKey, bucketType)) { + return await verifyExecutionFileAccess(cloudKey, userId, customConfig) + } + + // 3. Copilot files: Check database first, then metadata, then path pattern (legacy) + if (inferredContext === 'copilot' || bucketType === 'copilot') { + return await verifyCopilotFileAccess(cloudKey, userId, customConfig) + } + + // 4. KB files: kb/filename + if (inferredContext === 'knowledge-base') { + return await verifyKBFileAccess(cloudKey, userId, customConfig) + } + + // 5. Chat files: chat/filename + if (inferredContext === 'chat') { + return await verifyChatFileAccess(cloudKey, userId, customConfig) + } + + // 6. Regular uploads: UUID-filename or timestamp-filename + // Check metadata for userId/workspaceId, or database for workspace files + return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal) + } catch (error) { + logger.error('Error verifying file access:', { cloudKey, userId, error }) + // Deny access on error to be safe + return false + } +} + +/** + * Verify access to workspace files + * Priority: Database lookup > Metadata > Deny + */ +async function verifyWorkspaceFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig, + isLocal?: boolean +): Promise { + try { + // Priority 1: Check database (most reliable, works for both local and cloud) + const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey) + if (workspaceFileRecord) { + const permission = await getUserEntityPermissions( + userId, + 'workspace', + workspaceFileRecord.workspaceId + ) + if (permission !== null) { + logger.debug('Workspace file access granted (database lookup)', { + userId, + workspaceId: workspaceFileRecord.workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for file', { + userId, + workspaceId: workspaceFileRecord.workspaceId, + cloudKey, + }) + return false + } + + // Priority 2: Check metadata (works for both local and cloud files) + const config: StorageConfig = customConfig || {} + const metadata = await getFileMetadata(cloudKey, config) + const workspaceId = metadata.workspaceId + + if (workspaceId) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== null) { + logger.debug('Workspace file access granted (metadata)', { + userId, + workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for file (metadata)', { + userId, + workspaceId, + cloudKey, + }) + return false + } + + // No authorization source available - deny access + logger.warn('Workspace file missing authorization metadata', { cloudKey, userId }) + return false + } catch (error) { + logger.error('Error verifying workspace file access', { cloudKey, userId, error }) + return false + } +} + +/** + * Check if file is an execution file based on path pattern + * Execution files have format: workspace_id/workflow_id/execution_id/filename + */ +function isExecutionFile(cloudKey: string, bucketType?: string | null): boolean { + // Execution files are stored in execution-files bucket or have the pattern + if (bucketType === 'execution-files' || bucketType === 'execution') { + return true + } + + // Use inferContextFromKey to check if it's an execution file + return inferContextFromKey(cloudKey) === 'execution' +} + +/** + * Verify access to execution files + * Execution files: workspace_id/workflow_id/execution_id/filename + */ +async function verifyExecutionFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig +): Promise { + const parts = cloudKey.split('/') + if (parts.length < 3) { + logger.warn('Invalid execution file path format', { cloudKey }) + return false + } + + const workspaceId = parts[0] + if (!workspaceId) { + logger.warn('Could not extract workspaceId from execution file path', { cloudKey }) + return false + } + + // Verify user has workspace access + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission === null) { + logger.warn('User does not have workspace access for execution file', { + userId, + workspaceId, + cloudKey, + }) + return false + } + + logger.debug('Execution file access granted', { userId, workspaceId, cloudKey }) + return true +} + +/** + * Verify access to copilot files + * Priority: Database lookup > Metadata > Path pattern (legacy) + */ +async function verifyCopilotFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig +): Promise { + try { + // Priority 1: Check workspaceFiles table (new system) + const fileRecord = await getFileMetadataByKey(cloudKey, 'copilot') + + if (fileRecord) { + // Verify userId matches authenticated user + if (fileRecord.userId === userId) { + logger.debug('Copilot file access granted (workspaceFiles table)', { + userId, + cloudKey, + }) + return true + } + logger.warn('User does not own copilot file', { + userId, + fileUserId: fileRecord.userId, + cloudKey, + }) + return false + } + + // Priority 2: Check metadata (for files not yet in database) + const config: StorageConfig = customConfig || {} + const metadata = await getFileMetadata(cloudKey, config) + const fileUserId = metadata.userId + + if (fileUserId) { + if (fileUserId === userId) { + logger.debug('Copilot file access granted (metadata)', { userId, cloudKey }) + return true + } + logger.warn('User does not own copilot file (metadata)', { + userId, + fileUserId, + cloudKey, + }) + return false + } + + // Priority 3: Legacy path pattern check (userId/filename format) + // This handles old copilot files that may have been stored with userId prefix + const parts = cloudKey.split('/') + if (parts.length >= 2) { + const fileUserId = parts[0] + if (fileUserId && fileUserId === userId) { + logger.debug('Copilot file access granted (path pattern)', { userId, cloudKey }) + return true + } + logger.warn('User does not own copilot file (path pattern)', { + userId, + fileUserId, + cloudKey, + }) + return false + } + + // No authorization source available - deny access + logger.warn('Copilot file missing authorization metadata', { cloudKey, userId }) + return false + } catch (error) { + logger.error('Error verifying copilot file access', { cloudKey, userId, error }) + return false + } +} + +/** + * Verify access to KB files + * KB files: kb/filename + */ +async function verifyKBFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig +): Promise { + try { + // Priority 1: Check workspaceFiles table (new system) + const fileRecord = await getFileMetadataByKey(cloudKey, 'knowledge-base') + + if (fileRecord?.workspaceId) { + const permission = await getUserEntityPermissions(userId, 'workspace', fileRecord.workspaceId) + if (permission !== null) { + logger.debug('KB file access granted (workspaceFiles table)', { + userId, + workspaceId: fileRecord.workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for KB file', { + userId, + workspaceId: fileRecord.workspaceId, + cloudKey, + }) + return false + } + + // Priority 2: Check document table via fileUrl (legacy knowledge base files) + try { + // Try to find document with matching fileUrl + const documents = await db + .select({ + knowledgeBaseId: document.knowledgeBaseId, + }) + .from(document) + .where( + or( + like(document.fileUrl, `%${cloudKey}%`), + like(document.fileUrl, `%${encodeURIComponent(cloudKey)}%`) + ) + ) + .limit(10) // Limit to avoid scanning too many + + // Check each document's knowledge base for workspace access + for (const doc of documents) { + const { knowledgeBase } = await import('@sim/db/schema') + const [kb] = await db + .select({ + workspaceId: knowledgeBase.workspaceId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, doc.knowledgeBaseId)) + .limit(1) + + if (kb?.workspaceId) { + const permission = await getUserEntityPermissions(userId, 'workspace', kb.workspaceId) + if (permission !== null) { + logger.debug('KB file access granted (document table lookup)', { + userId, + workspaceId: kb.workspaceId, + cloudKey, + }) + return true + } + } + } + } catch (docError) { + logger.debug('Document table lookup failed:', docError) + } + + // Priority 3: Check cloud storage metadata + const config: StorageConfig = customConfig || (await getKBStorageConfig()) + const metadata = await getFileMetadata(cloudKey, config) + const workspaceId = metadata.workspaceId + + if (workspaceId) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== null) { + logger.debug('KB file access granted (cloud metadata)', { + userId, + workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for KB file', { + userId, + workspaceId, + cloudKey, + }) + return false + } + + logger.warn('KB file missing workspaceId in all sources', { cloudKey, userId }) + return false + } catch (error) { + logger.error('Error verifying KB file access', { cloudKey, userId, error }) + return false + } +} + +/** + * Verify access to chat files + * Chat files: chat/filename + */ +async function verifyChatFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig +): Promise { + try { + // Determine storage config for chat files + const config: StorageConfig = customConfig || (await getChatStorageConfig()) + + // Retrieve metadata + const metadata = await getFileMetadata(cloudKey, config) + const workspaceId = metadata.workspaceId + + if (!workspaceId) { + logger.warn('Chat file missing workspaceId in metadata', { cloudKey, userId }) + // Deny access if metadata unavailable + return false + } + + // Verify user has workspace access + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission === null) { + logger.warn('User does not have workspace access for chat file', { + userId, + workspaceId, + cloudKey, + }) + return false + } + + logger.debug('Chat file access granted', { userId, workspaceId, cloudKey }) + return true + } catch (error) { + logger.error('Error verifying chat file access', { cloudKey, userId, error }) + return false + } +} + +/** + * Verify access to regular uploads + * Regular uploads: UUID-filename or timestamp-filename + * Priority: Database lookup (for workspace files) > Metadata > Deny + */ +async function verifyRegularFileAccess( + cloudKey: string, + userId: string, + customConfig?: StorageConfig, + isLocal?: boolean +): Promise { + try { + // Priority 1: Check if this might be a workspace file (check database) + // This handles legacy files that might not have metadata + const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey) + if (workspaceFileRecord) { + const permission = await getUserEntityPermissions( + userId, + 'workspace', + workspaceFileRecord.workspaceId + ) + if (permission !== null) { + logger.debug('Regular file access granted (workspace file from database)', { + userId, + workspaceId: workspaceFileRecord.workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for file', { + userId, + workspaceId: workspaceFileRecord.workspaceId, + cloudKey, + }) + return false + } + + // Priority 2: Check metadata (works for both local and cloud files) + const config: StorageConfig = customConfig || {} + const metadata = await getFileMetadata(cloudKey, config) + const fileUserId = metadata.userId + const workspaceId = metadata.workspaceId + + // If file has userId, verify ownership + if (fileUserId) { + if (fileUserId === userId) { + logger.debug('Regular file access granted (userId match)', { userId, cloudKey }) + return true + } + logger.warn('User does not own file', { userId, fileUserId, cloudKey }) + return false + } + + // If file has workspaceId, verify workspace membership + if (workspaceId) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== null) { + logger.debug('Regular file access granted (workspace membership)', { + userId, + workspaceId, + cloudKey, + }) + return true + } + logger.warn('User does not have workspace access for file', { + userId, + workspaceId, + cloudKey, + }) + return false + } + + // No ownership info available - deny access for security + logger.warn('File missing ownership metadata', { cloudKey, userId }) + return false + } catch (error) { + logger.error('Error verifying regular file access', { cloudKey, userId, error }) + return false + } +} + +/** + * Unified authorization function that returns structured result + */ +export async function authorizeFileAccess( + key: string, + userId: string, + context?: StorageContext, + storageConfig?: StorageConfig, + isLocal?: boolean +): Promise { + const granted = await verifyFileAccess(key, userId, null, storageConfig, context, isLocal) + + if (granted) { + // Try to determine workspaceId for logging + let workspaceId: string | undefined + const inferredContext = context || inferContextFromKey(key) + + if (inferredContext === 'workspace') { + const record = await lookupWorkspaceFileByKey(key) + workspaceId = record?.workspaceId + } else { + const extracted = extractWorkspaceIdFromKey(key) + if (extracted) { + workspaceId = extracted + } + } + + return { + granted: true, + reason: 'Access granted', + workspaceId, + } + } + + return { + granted: false, + reason: 'Access denied - insufficient permissions or file not found', + } +} + +/** + * Get KB storage configuration based on current storage provider + */ +async function getKBStorageConfig(): Promise { + const { USE_S3_STORAGE, USE_BLOB_STORAGE } = await import('@/lib/uploads/config') + + if (USE_BLOB_STORAGE) { + return { + containerName: BLOB_KB_CONFIG.containerName, + accountName: BLOB_KB_CONFIG.accountName, + accountKey: BLOB_KB_CONFIG.accountKey, + connectionString: BLOB_KB_CONFIG.connectionString, + } + } + + if (USE_S3_STORAGE) { + return { + bucket: S3_KB_CONFIG.bucket, + region: S3_KB_CONFIG.region, + } + } + + // Fallback to default config + return {} +} + +/** + * Get chat storage configuration based on current storage provider + */ +async function getChatStorageConfig(): Promise { + const { USE_S3_STORAGE, USE_BLOB_STORAGE } = await import('@/lib/uploads/config') + + if (USE_BLOB_STORAGE) { + return { + containerName: BLOB_CHAT_CONFIG.containerName, + accountName: BLOB_CHAT_CONFIG.accountName, + accountKey: BLOB_CHAT_CONFIG.accountKey, + connectionString: BLOB_CHAT_CONFIG.connectionString, + } + } + + if (USE_S3_STORAGE) { + return { + bucket: S3_CHAT_CONFIG.bucket, + region: S3_CHAT_CONFIG.region, + } + } + + // Fallback to default config + return {} +} diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 0a12113b60..9190330c93 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -1,14 +1,17 @@ import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import type { StorageContext } from '@/lib/uploads/core/config-resolver' -import { deleteFile } from '@/lib/uploads/core/storage-service' +import type { StorageContext } from '@/lib/uploads/config' +import { deleteFile, hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, createOptionsResponse, createSuccessResponse, - extractBlobKey, extractFilename, - extractS3Key, + FileNotFoundError, InvalidRequestError, isBlobPath, isCloudPath, @@ -24,20 +27,44 @@ const logger = createLogger('FilesDeleteAPI') */ export async function POST(request: NextRequest) { try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized file delete request', { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId const requestData = await request.json() const { filePath, context } = requestData - logger.info('File delete request received:', { filePath, context }) + logger.info('File delete request received:', { filePath, context, userId }) if (!filePath) { throw new InvalidRequestError('No file path provided') } try { - const key = extractStorageKey(filePath) + const key = extractStorageKeyFromPath(filePath) const storageContext: StorageContext = context || inferContextFromKey(key) + const hasAccess = await verifyFileAccess( + key, + userId, + null, + undefined, + storageContext, + !hasCloudStorage() // isLocal + ) + + if (!hasAccess) { + logger.warn('Unauthorized file delete attempt', { userId, key, context: storageContext }) + throw new FileNotFoundError(`File not found: ${key}`) + } + logger.info(`Deleting file with key: ${key}, context: ${storageContext}`) await deleteFile({ @@ -53,6 +80,11 @@ export async function POST(request: NextRequest) { }) } catch (error) { logger.error('Error deleting file:', error) + + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + return createErrorResponse( error instanceof Error ? error : new Error('Failed to delete file') ) @@ -64,71 +96,20 @@ export async function POST(request: NextRequest) { } /** - * Extract storage key from file path (works for S3, Blob, and local paths) + * Extract storage key from file path */ -function extractStorageKey(filePath: string): string { - if (isS3Path(filePath)) { - return extractS3Key(filePath) - } - - if (isBlobPath(filePath)) { - return extractBlobKey(filePath) - } - - // Handle "/api/files/serve/" paths - if (filePath.startsWith('/api/files/serve/')) { - const pathWithoutQuery = filePath.split('?')[0] - return decodeURIComponent(pathWithoutQuery.substring('/api/files/serve/'.length)) +function extractStorageKeyFromPath(filePath: string): string { + if (isS3Path(filePath) || isBlobPath(filePath) || filePath.startsWith('/api/files/serve/')) { + return extractStorageKey(filePath) } - // For local files, extract filename if (!isCloudPath(filePath)) { return extractFilename(filePath) } - // As a last resort, assume the incoming string is already a raw key return filePath } -/** - * Infer storage context from file key structure - * - * Key patterns: - * - KB: kb/{uuid}-{filename} - * - Workspace: {workspaceId}/{timestamp}-{random}-{filename} - * - Execution: {workspaceId}/{workflowId}/{executionId}/{filename} - * - Copilot: {timestamp}-{random}-{filename} (ambiguous - prefer explicit context) - * - Chat: Uses execution context (same pattern as execution files) - * - General: {timestamp}-{random}-{filename} (fallback for ambiguous patterns) - */ -function inferContextFromKey(key: string): StorageContext { - // KB files always start with 'kb/' prefix - if (key.startsWith('kb/')) { - return 'knowledge-base' - } - - // Execution files: three or more UUID segments (workspace/workflow/execution/...) - // Pattern: {uuid}/{uuid}/{uuid}/{filename} - const segments = key.split('/') - if (segments.length >= 4 && segments[0].match(/^[a-f0-9-]{36}$/)) { - return 'execution' - } - - // Workspace files: UUID-like ID followed by timestamp pattern - // Pattern: {uuid}/{timestamp}-{random}-{filename} - if (key.match(/^[a-f0-9-]{36}\/\d+-[a-z0-9]+-/)) { - return 'workspace' - } - - // Copilot/General files: timestamp-random-filename (no path segments) - // Pattern: {timestamp}-{random}-{filename} - if (key.match(/^\d+-[a-z0-9]+-/)) { - return 'general' - } - - return 'general' -} - /** * Handle CORS preflight requests */ diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 516edf8d2b..e8516176f6 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -1,9 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import type { StorageContext } from '@/lib/uploads/core/config-resolver' -import { generatePresignedDownloadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { getBaseUrl } from '@/lib/urls/utils' -import { createErrorResponse } from '@/app/api/files/utils' +import type { StorageContext } from '@/lib/uploads/config' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { verifyFileAccess } from '@/app/api/files/authorization' +import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' const logger = createLogger('FileDownload') @@ -11,6 +12,16 @@ export const dynamic = 'force-dynamic' export async function POST(request: NextRequest) { try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized download URL request', { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId const body = await request.json() const { key, name, isExecutionFile, context } = body @@ -27,41 +38,37 @@ export async function POST(request: NextRequest) { logger.info(`Using execution context for file: ${key}`) } - if (hasCloudStorage()) { - try { - const downloadUrl = await generatePresignedDownloadUrl( - key, - storageContext, - 5 * 60 // 5 minutes - ) - - logger.info(`Generated download URL for ${storageContext} file: ${key}`) - - return NextResponse.json({ - downloadUrl, - expiresIn: 300, // 5 minutes in seconds - fileName: name || key.split('/').pop() || 'download', - }) - } catch (error) { - logger.error(`Failed to generate presigned URL for ${key}:`, error) - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate download URL'), - 500 - ) - } - } else { - const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` - - logger.info(`Using local storage path for file: ${key}`) - - return NextResponse.json({ - downloadUrl, - expiresIn: null, - fileName: name || key.split('/').pop() || 'download', - }) + const hasAccess = await verifyFileAccess( + key, + userId, + isExecutionFile ? 'execution' : null, + undefined, + storageContext, + !hasCloudStorage() + ) + + if (!hasAccess) { + logger.warn('Unauthorized download URL request', { userId, key, context: storageContext }) + throw new FileNotFoundError(`File not found: ${key}`) } + + const { getBaseUrl } = await import('@/lib/urls/utils') + const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` + + logger.info(`Generated download URL for ${storageContext} file: ${key}`) + + return NextResponse.json({ + downloadUrl, + expiresIn: null, + fileName: name || key.split('/').pop() || 'download', + }) } catch (error) { logger.error('Error in file download endpoint:', error) + + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + return createErrorResponse( error instanceof Error ? error : new Error('Internal server error'), 500 diff --git a/apps/sim/app/api/files/execution/[executionId]/[fileId]/route.ts b/apps/sim/app/api/files/execution/[executionId]/[fileId]/route.ts index 7e057ee7ec..49dee1c46a 100644 --- a/apps/sim/app/api/files/execution/[executionId]/[fileId]/route.ts +++ b/apps/sim/app/api/files/execution/[executionId]/[fileId]/route.ts @@ -1,5 +1,10 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateExecutionFileDownloadUrl, getExecutionFiles, @@ -17,6 +22,16 @@ export async function GET( { params }: { params: Promise<{ executionId: string; fileId: string }> } ) { try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized execution file download request', { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId const { executionId, fileId } = await params if (!executionId || !fileId) { @@ -25,6 +40,49 @@ export async function GET( logger.info(`Generating download URL for file ${fileId} in execution ${executionId}`) + const [executionLog] = await db + .select({ + workflowId: workflowExecutionLogs.workflowId, + }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (!executionLog) { + return NextResponse.json({ error: 'Execution not found' }, { status: 404 }) + } + + const [workflowData] = await db + .select({ + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, executionLog.workflowId)) + .limit(1) + + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (!workflowData.workspaceId) { + logger.warn('Workflow missing workspaceId', { + workflowId: executionLog.workflowId, + executionId, + }) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const permission = await getUserEntityPermissions(userId, 'workspace', workflowData.workspaceId) + if (permission === null) { + logger.warn('User does not have workspace access for execution file', { + userId, + workspaceId: workflowData.workspaceId, + executionId, + fileId, + }) + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + const executionFiles = await getExecutionFiles(executionId) if (executionFiles.length === 0) { diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index c69e2ba5f6..ee8c36547a 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { const config = getStorageConfig(context) if (storageProvider === 's3') { - const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/s3-client') + const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') const result = await initiateS3MultipartUpload({ fileName, @@ -68,9 +68,7 @@ export async function POST(request: NextRequest) { }) } if (storageProvider === 'blob') { - const { initiateMultipartUpload } = await import( - '@/lib/uploads/providers/blob/blob-client' - ) + const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') const result = await initiateMultipartUpload({ fileName, @@ -107,16 +105,16 @@ export async function POST(request: NextRequest) { const config = getStorageConfig(context) if (storageProvider === 's3') { - const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/s3-client') + const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') const presignedUrls = await getS3MultipartPartUrls(key, uploadId, partNumbers) return NextResponse.json({ presignedUrls }) } if (storageProvider === 'blob') { - const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/blob-client') + const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') - const presignedUrls = await getMultipartPartUrls(key, uploadId, partNumbers, { + const presignedUrls = await getMultipartPartUrls(key, partNumbers, { containerName: config.containerName!, accountName: config.accountName!, accountKey: config.accountKey, @@ -145,7 +143,7 @@ export async function POST(request: NextRequest) { if (storageProvider === 's3') { const { completeS3MultipartUpload } = await import( - '@/lib/uploads/providers/s3/s3-client' + '@/lib/uploads/providers/s3/client' ) const parts = upload.parts // S3 format: { ETag, PartNumber } @@ -160,11 +158,11 @@ export async function POST(request: NextRequest) { } if (storageProvider === 'blob') { const { completeMultipartUpload } = await import( - '@/lib/uploads/providers/blob/blob-client' + '@/lib/uploads/providers/blob/client' ) const parts = upload.parts // Azure format: { blockId, partNumber } - const result = await completeMultipartUpload(key, uploadId, parts, { + const result = await completeMultipartUpload(key, parts, { containerName: config.containerName!, accountName: config.accountName!, accountKey: config.accountKey, @@ -190,7 +188,7 @@ export async function POST(request: NextRequest) { const { uploadId, key, parts } = data if (storageProvider === 's3') { - const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/s3-client') + const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') const result = await completeS3MultipartUpload(key, uploadId, parts) @@ -204,11 +202,9 @@ export async function POST(request: NextRequest) { }) } if (storageProvider === 'blob') { - const { completeMultipartUpload } = await import( - '@/lib/uploads/providers/blob/blob-client' - ) + const { completeMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - const result = await completeMultipartUpload(key, uploadId, parts, { + const result = await completeMultipartUpload(key, parts, { containerName: config.containerName!, accountName: config.accountName!, accountKey: config.accountKey, @@ -238,15 +234,15 @@ export async function POST(request: NextRequest) { const config = getStorageConfig(context as StorageContext) if (storageProvider === 's3') { - const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/s3-client') + const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') await abortS3MultipartUpload(key, uploadId) logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`) } else if (storageProvider === 'blob') { - const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/blob-client') + const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - await abortMultipartUpload(key, uploadId, { + await abortMultipartUpload(key, { containerName: config.containerName!, accountName: config.accountName!, accountKey: config.accountKey, diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index bfac0d5982..fa0793648d 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -7,7 +7,6 @@ import { NextRequest } from 'next/server' */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils' -import { POST } from '@/app/api/files/parse/route' const mockJoin = vi.fn((...args: string[]): string => { if (args[0] === '/test/uploads') { @@ -18,7 +17,11 @@ const mockJoin = vi.fn((...args: string[]): string => { describe('File Parse API Route', () => { beforeEach(() => { - vi.resetAllMocks() + vi.resetModules() + + setupFileApiMocks({ + authenticated: true, + }) vi.doMock('@/lib/file-parsers', () => ({ isSupportedFileType: vi.fn().mockReturnValue(true), @@ -50,8 +53,6 @@ describe('File Parse API Route', () => { }) it('should handle missing file path', async () => { - setupFileApiMocks() - const req = createMockRequest('POST', {}) const { POST } = await import('@/app/api/files/parse/route') @@ -66,6 +67,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', + authenticated: true, }) const req = createMockRequest('POST', { @@ -91,6 +93,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', + authenticated: true, }) const req = createMockRequest('POST', { @@ -114,6 +117,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', + authenticated: true, }) const req = createMockRequest('POST', { @@ -135,6 +139,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', + authenticated: true, }) const req = createMockRequest('POST', { @@ -159,6 +164,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', + authenticated: true, }) const req = createMockRequest('POST', { @@ -183,6 +189,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', + authenticated: true, }) const downloadFileMock = vi.fn().mockRejectedValue(new Error('Access denied')) @@ -211,6 +218,7 @@ describe('File Parse API Route', () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', + authenticated: true, }) vi.doMock('fs/promises', () => ({ @@ -236,7 +244,10 @@ describe('File Parse API Route', () => { describe('Files Parse API - Path Traversal Security', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetModules() + setupFileApiMocks({ + authenticated: true, + }) }) describe('Path Traversal Prevention', () => { @@ -257,11 +268,14 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() expect(result.success).toBe(false) - expect(result.error).toMatch(/Access denied|Invalid path|Path outside allowed directory/) + expect(result.error).toMatch( + /Access denied|Invalid path|Path outside allowed directory|Unauthorized/ + ) } }) @@ -280,11 +294,12 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() expect(result.success).toBe(false) - expect(result.error).toMatch(/Access denied|Invalid path/) + expect(result.error).toMatch(/Access denied|Invalid path|Unauthorized/) } }) @@ -305,11 +320,12 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() expect(result.success).toBe(false) - expect(result.error).toMatch(/Access denied|Path outside allowed directory/) + expect(result.error).toMatch(/Access denied|Path outside allowed directory|Unauthorized/) } }) @@ -328,6 +344,7 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -354,11 +371,14 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() expect(result.success).toBe(false) - expect(result.error).toMatch(/Access denied|Invalid path|Path outside allowed directory/) + expect(result.error).toMatch( + /Access denied|Invalid path|Path outside allowed directory|Unauthorized/ + ) } }) @@ -377,6 +397,7 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -394,6 +415,7 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -407,6 +429,7 @@ describe('Files Parse API - Path Traversal Security', () => { body: JSON.stringify({}), }) + const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index eb4e293bbd..67a84fadd6 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -4,31 +4,22 @@ import fsPromises, { readFile } from 'fs/promises' import path from 'path' import binaryExtensionsList from 'binary-extensions' import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' import { validateExternalUrl } from '@/lib/security/input-validation' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' -import { extractStorageKey } from '@/lib/uploads/utils/file-utils' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' import '@/lib/uploads/core/setup.server' export const dynamic = 'force-dynamic' const logger = createLogger('FilesParseAPI') -/** - * Infer storage context from file key pattern - */ -function inferContextFromKey(key: string): StorageContext { - if (key.startsWith('kb/')) return 'knowledge-base' - - const segments = key.split('/') - if (segments.length >= 4 && segments[0].match(/^[a-f0-9-]{36}$/)) return 'execution' - if (key.match(/^[a-f0-9-]{36}\/\d+-[a-z0-9]+-/)) return 'workspace' - - return 'general' -} - const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds @@ -37,6 +28,7 @@ interface ParseResult { content?: string error?: string filePath: string + originalName?: string // Original filename from database (for workspace files) metadata?: { fileType: string size: number @@ -82,6 +74,16 @@ export async function POST(request: NextRequest) { const startTime = Date.now() try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized file parse request', { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId const requestData = await request.json() const { filePath, fileType, workspaceId } = requestData @@ -89,7 +91,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 }) } - logger.info('File parse request received:', { filePath, fileType, workspaceId }) + logger.info('File parse request received:', { filePath, fileType, workspaceId, userId }) if (Array.isArray(filePath)) { const results = [] @@ -103,17 +105,18 @@ export async function POST(request: NextRequest) { continue } - const result = await parseFileSingle(path, fileType, workspaceId) + const result = await parseFileSingle(path, fileType, workspaceId, userId) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime } if (result.success) { + const displayName = result.originalName || result.filePath.split('/').pop() || 'unknown' results.push({ success: true, output: { content: result.content, - name: result.filePath.split('/').pop() || 'unknown', + name: displayName, fileType: result.metadata?.fileType || 'application/octet-stream', size: result.metadata?.size || 0, binary: false, @@ -131,21 +134,22 @@ export async function POST(request: NextRequest) { }) } - const result = await parseFileSingle(filePath, fileType, workspaceId) + const result = await parseFileSingle(filePath, fileType, workspaceId, userId) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime } if (result.success) { + const displayName = result.originalName || result.filePath.split('/').pop() || 'unknown' return NextResponse.json({ success: true, output: { content: result.content, - name: result.filePath.split('/').pop() || 'unknown', + name: displayName, fileType: result.metadata?.fileType || 'application/octet-stream', size: result.metadata?.size || 0, - binary: false, // We only return text content + binary: false, }, }) } @@ -169,8 +173,9 @@ export async function POST(request: NextRequest) { */ async function parseFileSingle( filePath: string, - fileType?: string, - workspaceId?: string + fileType: string, + workspaceId: string, + userId: string ): Promise { logger.info('Parsing file:', filePath) @@ -192,18 +197,18 @@ async function parseFileSingle( } if (filePath.includes('/api/files/serve/')) { - return handleCloudFile(filePath, fileType) + return handleCloudFile(filePath, fileType, undefined, userId) } if (filePath.startsWith('http://') || filePath.startsWith('https://')) { - return handleExternalUrl(filePath, fileType, workspaceId) + return handleExternalUrl(filePath, fileType, workspaceId, userId) } if (isUsingCloudStorage()) { - return handleCloudFile(filePath, fileType) + return handleCloudFile(filePath, fileType, undefined, userId) } - return handleLocalFile(filePath, fileType) + return handleLocalFile(filePath, fileType, userId) } /** @@ -239,8 +244,9 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string */ async function handleExternalUrl( url: string, - fileType?: string, - workspaceId?: string + fileType: string, + workspaceId: string, + userId: string ): Promise { try { logger.info('Fetching external URL:', url) @@ -267,7 +273,7 @@ async function handleExternalUrl( BLOB_EXECUTION_FILES_CONFIG, USE_S3_STORAGE, USE_BLOB_STORAGE, - } = await import('@/lib/uploads/core/setup') + } = await import('@/lib/uploads/config') let isExecutionFile = false try { @@ -291,6 +297,20 @@ async function handleExternalUrl( const shouldCheckWorkspace = workspaceId && !isExecutionFile if (shouldCheckWorkspace) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission === null) { + logger.warn('User does not have workspace access for file parse', { + userId, + workspaceId, + filename, + }) + return { + success: false, + error: 'File not found', + filePath: url, + } + } + const { fileExistsInWorkspace, listWorkspaceFiles } = await import( '@/lib/uploads/contexts/workspace' ) @@ -303,7 +323,7 @@ async function handleExternalUrl( if (existingFile) { const storageFilePath = `/api/files/serve/${existingFile.key}` - return handleCloudFile(storageFilePath, fileType, 'workspace') + return handleCloudFile(storageFilePath, fileType, 'workspace', userId) } } } @@ -330,13 +350,18 @@ async function handleExternalUrl( if (shouldCheckWorkspace) { try { - const { getSession } = await import('@/lib/auth') - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') - - const session = await getSession() - if (session?.user?.id) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn('User does not have write permission for workspace file save', { + userId, + workspaceId, + filename, + permission, + }) + } else { + const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') const mimeType = response.headers.get('content-type') || getMimeType(extension) - await uploadWorkspaceFile(workspaceId, session.user.id, buffer, filename, mimeType) + await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType) logger.info(`Saved URL file to workspace storage: ${filename}`) } } catch (saveError) { @@ -370,8 +395,9 @@ async function handleExternalUrl( */ async function handleCloudFile( filePath: string, - fileType?: string, - explicitContext?: string + fileType: string, + explicitContext: string | undefined, + userId: string ): Promise { try { const cloudKey = extractStorageKey(filePath) @@ -379,24 +405,69 @@ async function handleCloudFile( logger.info('Extracted cloud key:', cloudKey) const context = (explicitContext as StorageContext) || inferContextFromKey(cloudKey) + + const hasAccess = await verifyFileAccess( + cloudKey, + userId, + null, + undefined, + context, + false // isLocal + ) + + if (!hasAccess) { + logger.warn('Unauthorized cloud file parse attempt', { userId, key: cloudKey, context }) + return { + success: false, + error: 'File not found', + filePath, + } + } + + let originalFilename: string | undefined + if (context === 'workspace') { + try { + const fileRecord = await getFileMetadataByKey(cloudKey, 'workspace') + + if (fileRecord) { + originalFilename = fileRecord.originalName + logger.debug(`Found original filename for workspace file: ${originalFilename}`) + } + } catch (dbError) { + logger.debug(`Failed to lookup original filename for ${cloudKey}:`, dbError) + } + } + const fileBuffer = await StorageService.downloadFile({ key: cloudKey, context }) logger.info( `Downloaded file from ${context} storage (${explicitContext ? 'explicit' : 'inferred'}): ${cloudKey}, size: ${fileBuffer.length} bytes` ) - const filename = cloudKey.split('/').pop() || cloudKey + const filename = originalFilename || cloudKey.split('/').pop() || cloudKey const extension = path.extname(filename).toLowerCase().substring(1) + let parseResult: ParseResult if (extension === 'pdf') { - return await handlePdfBuffer(fileBuffer, filename, fileType, filePath) - } - if (extension === 'csv') { - return await handleCsvBuffer(fileBuffer, filename, fileType, filePath) + parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, filePath) + } else if (extension === 'csv') { + parseResult = await handleCsvBuffer(fileBuffer, filename, fileType, filePath) + } else if (isSupportedFileType(extension)) { + parseResult = await handleGenericTextBuffer( + fileBuffer, + filename, + extension, + fileType, + filePath + ) + } else { + parseResult = handleGenericBuffer(fileBuffer, filename, extension, fileType) } - if (isSupportedFileType(extension)) { - return await handleGenericTextBuffer(fileBuffer, filename, extension, fileType, filePath) + + if (originalFilename) { + parseResult.originalName = originalFilename } - return handleGenericBuffer(fileBuffer, filename, extension, fileType) + + return parseResult } catch (error) { logger.error(`Error handling cloud file ${filePath}:`, error) @@ -416,9 +487,33 @@ async function handleCloudFile( /** * Handle local file */ -async function handleLocalFile(filePath: string, fileType?: string): Promise { +async function handleLocalFile( + filePath: string, + fileType: string, + userId: string +): Promise { try { const filename = filePath.split('/').pop() || filePath + + const context = inferContextFromKey(filename) + const hasAccess = await verifyFileAccess( + filename, + userId, + null, + undefined, + context, + true // isLocal + ) + + if (!hasAccess) { + logger.warn('Unauthorized local file parse attempt', { userId, filename }) + return { + success: false, + error: 'File not found', + filePath, + } + } + const fullPath = path.join(UPLOAD_DIR_SERVER, filename) logger.info('Processing local file:', fullPath) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index a40ce7f46b..8d5956b685 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -1,8 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import type { StorageContext } from '@/lib/uploads/core/config-resolver' -import { USE_BLOB_STORAGE } from '@/lib/uploads/core/setup' +import type { StorageContext } from '@/lib/uploads/config' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { generateBatchPresignedUploadUrls, hasCloudStorage, diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 8b81283094..e38fa309cf 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -2,8 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { CopilotFiles } from '@/lib/uploads' -import type { StorageContext } from '@/lib/uploads/core/config-resolver' -import { USE_BLOB_STORAGE } from '@/lib/uploads/core/setup' +import type { StorageContext } from '@/lib/uploads/config' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { validateFileType } from '@/lib/uploads/utils/validation' import { createErrorResponse } from '@/app/api/files/utils' diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 5e7a64ce64..786ff9d4a3 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -16,6 +16,19 @@ describe('File Serve API Route', () => { withUploadUtils: true, }) + // Mock checkHybridAuth + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: true, + userId: 'test-user-id', + }), + })) + + // Mock verifyFileAccess + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + })) + vi.doMock('fs', () => ({ existsSync: vi.fn().mockReturnValue(true), })) @@ -45,8 +58,7 @@ describe('File Serve API Route', () => { getContentType: vi.fn().mockReturnValue('text/plain'), isS3Path: vi.fn().mockReturnValue(false), isBlobPath: vi.fn().mockReturnValue(false), - extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()), + extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'), })) @@ -99,12 +111,22 @@ describe('File Serve API Route', () => { getContentType: vi.fn().mockReturnValue('text/plain'), isS3Path: vi.fn().mockReturnValue(false), isBlobPath: vi.fn().mockReturnValue(false), - extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()), + extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), findLocalFile: vi.fn().mockReturnValue('/test/uploads/nested/path/file.txt'), })) + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: true, + userId: 'test-user-id', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + })) + const req = new NextRequest('http://localhost:3000/api/files/serve/nested/path/file.txt') const params = { path: ['nested', 'path', 'file.txt'] } const { GET } = await import('@/app/api/files/serve/[...path]/route') @@ -142,6 +164,17 @@ describe('File Serve API Route', () => { USE_BLOB_STORAGE: false, })) + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: true, + userId: 'test-user-id', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + })) + vi.doMock('@/app/api/files/utils', () => ({ FileNotFoundError: class FileNotFoundError extends Error { constructor(message: string) { @@ -167,8 +200,7 @@ describe('File Serve API Route', () => { getContentType: vi.fn().mockReturnValue('image/png'), isS3Path: vi.fn().mockReturnValue(false), isBlobPath: vi.fn().mockReturnValue(false), - extractS3Key: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractBlobKey: vi.fn().mockImplementation((path) => path.split('/').pop()), + extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'), })) @@ -197,6 +229,17 @@ describe('File Serve API Route', () => { readFile: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')), })) + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: true, + userId: 'test-user-id', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(false), // File not found = no access + })) + vi.doMock('@/app/api/files/utils', () => ({ FileNotFoundError: class FileNotFoundError extends Error { constructor(message: string) { @@ -214,8 +257,7 @@ describe('File Serve API Route', () => { getContentType: vi.fn().mockReturnValue('text/plain'), isS3Path: vi.fn().mockReturnValue(false), isBlobPath: vi.fn().mockReturnValue(false), - extractS3Key: vi.fn(), - extractBlobKey: vi.fn(), + extractStorageKey: vi.fn(), extractFilename: vi.fn(), findLocalFile: vi.fn().mockReturnValue(null), })) @@ -246,7 +288,24 @@ describe('File Serve API Route', () => { for (const test of contentTypeTests) { it(`should serve ${test.ext} file with correct content type`, async () => { + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: true, + userId: 'test-user-id', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + })) + vi.doMock('@/app/api/files/utils', () => ({ + FileNotFoundError: class FileNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'FileNotFoundError' + } + }, getContentType: () => test.contentType, findLocalFile: () => `/test/uploads/file.${test.ext}`, createFileResponse: (obj: { buffer: Buffer; contentType: string; filename: string }) => diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 3ed643e06b..b4d00c66ff 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -4,8 +4,10 @@ import { NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' -import type { StorageContext } from '@/lib/uploads/core/config-resolver' +import type { StorageContext } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, createFileResponse, @@ -31,8 +33,11 @@ export async function GET( const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - logger.warn('Unauthorized file access attempt', { path, error: authResult.error }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized file access attempt', { + path, + error: authResult.error || 'Missing userId', + }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -47,7 +52,7 @@ export async function GET( const legacyBucketType = request.nextUrl.searchParams.get('bucket') if (isUsingCloudStorage() || isCloudPath) { - return await handleCloudProxy(cloudKey, contextParam, legacyBucketType, userId) + return await handleCloudProxy(cloudKey, userId, contextParam, legacyBucketType) } return await handleLocalFile(fullPath, userId) @@ -62,8 +67,26 @@ export async function GET( } } -async function handleLocalFile(filename: string, userId?: string): Promise { +async function handleLocalFile(filename: string, userId: string): Promise { try { + const contextParam: StorageContext | undefined = inferContextFromKey(filename) as + | StorageContext + | undefined + + const hasAccess = await verifyFileAccess( + filename, + userId, + null, + undefined, + contextParam, + true // isLocal = true + ) + + if (!hasAccess) { + logger.warn('Unauthorized local file access attempt', { userId, filename }) + throw new FileNotFoundError(`File not found: ${filename}`) + } + const filePath = findLocalFile(filename) if (!filePath) { @@ -86,44 +109,11 @@ async function handleLocalFile(filename: string, userId?: string): Promise= 4 && segments[0].match(/^[a-f0-9-]{36}$/)) { - return 'execution' - } - - // Copilot files: timestamp-random-filename (no path segments) - // Pattern: {timestamp}-{random}-{filename} - // NOTE: This is ambiguous with other contexts - prefer explicit context parameter - if (key.match(/^\d+-[a-z0-9]+-/)) { - // Could be copilot, general, or chat - default to general - return 'general' - } - - return 'general' -} - async function handleCloudProxy( cloudKey: string, + userId: string, contextParam?: string | null, - legacyBucketType?: string | null, - userId?: string + legacyBucketType?: string | null ): Promise { try { let context: StorageContext @@ -139,6 +129,20 @@ async function handleCloudProxy( logger.info(`Inferred context: ${context} from key pattern: ${cloudKey}`) } + const hasAccess = await verifyFileAccess( + cloudKey, + userId, + legacyBucketType || null, + undefined, + context, + false // isLocal = false + ) + + if (!hasAccess) { + logger.warn('Unauthorized cloud file access attempt', { userId, key: cloudKey, context }) + throw new FileNotFoundError(`File not found: ${cloudKey}`) + } + let fileBuffer: Buffer if (context === 'copilot') { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 9c7aefda3b..c00eb092ab 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -2,6 +2,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import '@/lib/uploads/core/setup.server' import { getSession } from '@/lib/auth' +import { getUserEntityPermissions } from '@/lib/permissions/utils' +import type { StorageContext } from '@/lib/uploads/config' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { validateFileType } from '@/lib/uploads/utils/validation' import { createErrorResponse, createOptionsResponse, @@ -57,6 +61,12 @@ export async function POST(request: NextRequest) { 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 + + // Determine context: explicit > workspace > execution > general + const context: StorageContext = + (contextParam as StorageContext) || + (workspaceId ? 'workspace' : workflowId && executionId ? 'execution' : 'general') const storageService = await import('@/lib/uploads/core/storage-service') const usingCloudStorage = storageService.hasCloudStorage() @@ -68,6 +78,8 @@ export async function POST(request: NextRequest) { ) } else if (workspaceId) { logger.info(`Uploading files for workspace-scoped storage: workspace=${workspaceId}`) + } else if (contextParam) { + logger.info(`Uploading files for ${contextParam} context`) } const uploadResults = [] @@ -96,15 +108,83 @@ export async function POST(request: NextRequest) { }, buffer, originalName, - file.type + file.type, + false, // isAsync + session.user.id // userId available from session ) uploadResults.push(userFile) continue } - // Priority 2: Workspace-scoped storage (persistent, no expiry) - if (workspaceId) { + // Priority 2: Knowledge-base files (must check BEFORE workspace to avoid duplicate file check) + 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 metadata: Record = { + 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: originalName, + contentType: file.type, + context: 'knowledge-base', + 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 + } + + // Priority 3: Workspace-scoped storage (persistent, no expiry) + // Only if context is NOT explicitly set to something else + if (workspaceId && !contextParam) { try { const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') const userFile = await uploadWorkspaceFile( @@ -118,7 +198,6 @@ export async function POST(request: NextRequest) { uploadResults.push(userFile) continue } catch (workspaceError) { - // Check error type const errorMessage = workspaceError instanceof Error ? workspaceError.message : 'Upload failed' const isDuplicate = errorMessage.includes('already exists') @@ -128,7 +207,6 @@ export async function POST(request: NextRequest) { logger.warn(`Workspace file upload failed: ${errorMessage}`) - // Determine appropriate status code let statusCode = 500 if (isDuplicate) statusCode = 409 else if (isStorageLimitError) statusCode = 413 @@ -144,15 +222,90 @@ export async function POST(request: NextRequest) { } } + // Priority 4: Context-specific uploads (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 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: originalName, + contentType: file.type, + context, + 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 + } + + // Priority 5: General uploads (fallback) try { logger.info(`Uploading file (general context): ${originalName}`) - const storageService = await import('@/lib/uploads/core/storage-service') + const metadata: Record = { + originalName: originalName, + uploadedAt: new Date().toISOString(), + purpose: 'general', + userId: session.user.id, + } + + if (workspaceId) { + metadata.workspaceId = workspaceId + } + const fileInfo = await storageService.uploadFile({ file: buffer, fileName: originalName, contentType: file.type, context: 'general', + metadata, }) let downloadUrl: string | undefined diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index bb36a6fb79..eb73df982c 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs' import { join, resolve, sep } from 'path' import { NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' -import { UPLOAD_DIR } from '@/lib/uploads/core/setup' +import { UPLOAD_DIR } from '@/lib/uploads/config' const logger = createLogger('FilesUtils') @@ -102,22 +102,6 @@ export function isCloudPath(path: string): boolean { return isS3Path(path) || isBlobPath(path) } -export function extractStorageKey(path: string, storageType: 's3' | 'blob'): string { - const prefix = `/api/files/serve/${storageType}/` - if (path.includes(prefix)) { - return decodeURIComponent(path.split(prefix)[1]) - } - return path -} - -export function extractS3Key(path: string): string { - return extractStorageKey(path, 's3') -} - -export function extractBlobKey(path: string): string { - return extractStorageKey(path, 'blob') -} - export function extractFilename(path: string): string { let filename: string diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 945e932f93..5751134b04 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index 7f80642118..1612fb647a 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' import { base64UrlEncode, buildMimeMessage } from '@/tools/gmail/utils' diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 40987cc647..5ed867eab2 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' import { base64UrlEncode, buildMimeMessage } from '@/tools/gmail/utils' diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index a63b619332..ffc31e780d 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-processing' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' import { GOOGLE_WORKSPACE_MIME_TYPES, diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index e0672bbe1a..3b8484c99d 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index fccaef44c3..ea0daae22c 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index aa45d82344..e9b58b14e9 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -2,28 +2,16 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { type StorageContext, StorageService } from '@/lib/uploads' -import { extractStorageKey } from '@/lib/uploads/utils/file-utils' +import { StorageService } from '@/lib/uploads' +import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { getBaseUrl } from '@/lib/urls/utils' import { generateRequestId } from '@/lib/utils' +import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' const logger = createLogger('MistralParseAPI') -/** - * Infer storage context from file key pattern - */ -function inferContextFromKey(key: string): StorageContext { - if (key.startsWith('kb/')) return 'knowledge-base' - - const segments = key.split('/') - if (segments.length >= 4 && segments[0].match(/^[a-f0-9-]{36}$/)) return 'execution' - if (key.match(/^[a-f0-9-]{36}\/\d+-[a-z0-9]+-/)) return 'workspace' - - return 'general' -} - const MistralParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), filePath: z.string().min(1, 'File path is required'), @@ -38,38 +26,64 @@ export async function POST(request: NextRequest) { const requestId = generateRequestId() try { + // Strict authentication check - userId is required const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - logger.warn(`[${requestId}] Unauthorized Mistral parse attempt: ${authResult.error}`) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Mistral parse attempt`, { + error: authResult.error || 'Missing userId', + }) return NextResponse.json( { success: false, - error: authResult.error || 'Authentication required', + error: authResult.error || 'Unauthorized', }, { status: 401 } ) } + const userId = authResult.userId const body = await request.json() const validatedData = MistralParseSchema.parse(body) logger.info(`[${requestId}] Mistral parse request`, { filePath: validatedData.filePath, isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + userId, }) let fileUrl = validatedData.filePath - // Check if it's an internal workspace file path if (validatedData.filePath?.includes('/api/files/serve/')) { try { const storageKey = extractStorageKey(validatedData.filePath) - // Infer context from key pattern const context = inferContextFromKey(storageKey) - // Generate 5-minute presigned URL for external API access + const hasAccess = await verifyFileAccess( + storageKey, + userId, + null, + undefined, + context, + false // isLocal + ) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) logger.info(`[${requestId}] Generated presigned URL for ${context} file`) } catch (error) { @@ -83,12 +97,10 @@ export async function POST(request: NextRequest) { ) } } else if (validatedData.filePath?.startsWith('/')) { - // Convert relative path to absolute URL const baseUrl = getBaseUrl() fileUrl = `${baseUrl}${validatedData.filePath}` } - // Call Mistral API with the resolved URL const mistralBody: any = { model: 'mistral-ocr-latest', document: { diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 1c6d1ccc39..f1276e3ad4 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -3,10 +3,8 @@ import * as XLSX from 'xlsx' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-processing' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 8a58e14b36..2814d23ac9 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index e6104df925..0c7a60885f 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index 9ea1980044..ad03c937c3 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -3,10 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-processing' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 57b9a38481..da9409098f 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 0eeead68c1..b5b65cb9e9 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index a556a7aa25..fc01b6a165 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processFilesToUserFiles, -} from '@/lib/uploads/utils/file-processing' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' import { convertMarkdownToHTML } from '@/tools/telegram/utils' diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index b807ddd3f9..e05d3f4146 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -2,10 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - downloadFileFromStorage, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-processing' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index 8c14e15cb9..59601f9e1e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,7 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { StorageService } from '@/lib/uploads' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { generateRequestId } from '@/lib/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -12,8 +11,8 @@ const logger = createLogger('WorkspaceFileDownloadAPI') /** * POST /api/workspaces/[id]/files/[fileId]/download - * Generate presigned download URL (requires read permission) - * Reuses execution file helper pattern for 5-minute presigned URLs + * Return authenticated file serve URL (requires read permission) + * Uses /api/files/serve endpoint which enforces authentication and context */ export async function POST( request: NextRequest, @@ -28,7 +27,6 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check workspace permissions (requires read) const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) if (!userPermission) { logger.warn( @@ -42,20 +40,18 @@ export async function POST( return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - // Generate 5-minute presigned URL using unified storage service - const downloadUrl = await StorageService.generatePresignedDownloadUrl( - fileRecord.key, - 'workspace', - 5 * 60 // 5 minutes - ) + const { getBaseUrl } = await import('@/lib/urls/utils') + const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` + const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}/view` logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) return NextResponse.json({ success: true, - downloadUrl, + downloadUrl: serveUrl, + viewerUrl: viewerUrl, fileName: fileRecord.name, - expiresIn: 300, // 5 minutes + expiresIn: null, }) } catch (error) { logger.error(`[${requestId}] Error generating download URL:`, error) diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx new file mode 100644 index 0000000000..778217a961 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx @@ -0,0 +1,27 @@ +'use client' + +import { createLogger } from '@/lib/logs/console/logger' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' + +const logger = createLogger('FileViewer') + +interface FileViewerProps { + file: WorkspaceFileRecord +} + +export function FileViewer({ file }: FileViewerProps) { + const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace` + + return ( +
+