chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for KB file authorization (`verifyKBFileAccess` via `verifyFileAccess`).
|
||||
*
|
||||
* These lock in the security-critical contract: access is granted only when a
|
||||
* trusted ownership binding names a workspace the caller can access AND an active
|
||||
* document still references the exact key. A planted `document.fileUrl` (the
|
||||
* reported vulnerability) can never grant access because ownership comes from the
|
||||
* binding, not the document.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted(
|
||||
() => ({
|
||||
mockGetFileMetadataByKey: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockGetFileMetadata: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
getFileMetadata: mockGetFileMetadata,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/config', () => ({
|
||||
BLOB_CHAT_CONFIG: {},
|
||||
S3_CHAT_CONFIG: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/server/metadata', () => ({
|
||||
getFileMetadataByKey: mockGetFileMetadataByKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
inferContextFromKey: vi.fn(() => 'knowledge-base'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/constants', () => ({
|
||||
isUuid: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization'
|
||||
|
||||
const CLOUD_KEY = 'kb/1780162789495-secret.txt'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function grantAccess(cloudKey: string) {
|
||||
return verifyFileAccess(cloudKey, USER_ID, undefined, 'knowledge-base')
|
||||
}
|
||||
|
||||
describe('verifyKBFileAccess (binding-only)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default liveness query result: one active document references the exact storage key.
|
||||
dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }])
|
||||
})
|
||||
|
||||
it('grants access when the binding owner workspace is accessible and an active document references the key', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(true)
|
||||
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1')
|
||||
})
|
||||
|
||||
it('denies when the caller lacks permission on the owner workspace (cross-tenant)', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('denies when there is no ownership binding (planted or un-backfilled key)', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue(null)
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
// Authorization never consults workspace permissions without a binding.
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies when the binding is soft-deleted', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: new Date() })
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies when the binding has no workspace owner', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: null, deletedAt: null })
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies when no active document references the key (archived/soft-deleted KB liveness)', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the binding lookup throws', async () => {
|
||||
mockGetFileMetadataByKey.mockRejectedValue(new Error('db down'))
|
||||
|
||||
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyKBFileWriteAccess (binding-only delete authorization)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('grants delete when the caller has write on the owner workspace', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('grants delete when the caller is admin on the owner workspace', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('denies delete when the caller has only read on the owner workspace', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('denies delete when there is no binding (no fallback)', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue(null)
|
||||
|
||||
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the binding lookup throws', async () => {
|
||||
mockGetFileMetadataByKey.mockRejectedValue(new Error('db down'))
|
||||
|
||||
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('public-context access (profile-pictures / og-images / workspace-logos)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function read(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
|
||||
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false)
|
||||
}
|
||||
|
||||
function write(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
|
||||
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false, { requireWrite: true })
|
||||
}
|
||||
|
||||
it('grants public reads without any ownership check', async () => {
|
||||
await expect(read('og-images/banner.png', 'og-images')).resolves.toBe(true)
|
||||
await expect(read('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
|
||||
await expect(read('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
expect(mockGetFileMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies a cross-tenant delete that names a workspace key under a public context', async () => {
|
||||
await expect(write('workspace/victim-ws/123-report.pdf', 'og-images')).resolves.toBe(false)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('grants a profile-picture delete only to the owning user', async () => {
|
||||
mockGetFileMetadata.mockResolvedValue({ userId: USER_ID })
|
||||
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('denies a profile-picture delete for a non-owner', async () => {
|
||||
mockGetFileMetadata.mockResolvedValue({ userId: 'other-user' })
|
||||
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('grants a workspace-logo delete to write/admin on the owning workspace', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1' })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
|
||||
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1')
|
||||
})
|
||||
|
||||
it('denies a workspace-logo delete for a non-member of the owning workspace', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws' })
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('denies a workspace-logo delete when no ownership binding exists', async () => {
|
||||
mockGetFileMetadataByKey.mockResolvedValue(null)
|
||||
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
|
||||
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,803 @@
|
||||
import { db } from '@sim/db'
|
||||
import { document, knowledgeBase, workspaceFile } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { permissionSatisfies } from '@sim/platform-authz/workspace'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getFileMetadata } from '@/lib/uploads'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { BLOB_CHAT_CONFIG, S3_CHAT_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'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { isUuid } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('FileAuthorization')
|
||||
|
||||
/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
|
||||
export class FileAccessDeniedError extends Error {
|
||||
constructor() {
|
||||
super('File not found')
|
||||
this.name = 'FileAccessDeniedError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthorizationResult {
|
||||
granted: boolean
|
||||
reason: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
type WorkspacePermission = 'read' | 'write' | 'admin'
|
||||
|
||||
/**
|
||||
* Whether a resolved workspace permission satisfies a file operation. Read and
|
||||
* download paths accept any membership; destructive operations (`requireWrite`)
|
||||
* require write or admin, matching the permission needed to create the file.
|
||||
*/
|
||||
function workspacePermissionSatisfies(
|
||||
permission: WorkspacePermission | null,
|
||||
requireWrite: boolean
|
||||
): boolean {
|
||||
return permissionSatisfies(permission, requireWrite ? 'write' : 'read')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup workspace file by storage key from database
|
||||
* @param key Storage key to lookup
|
||||
* @returns Workspace file info or null if not found
|
||||
*/
|
||||
async function lookupWorkspaceFileByKey(
|
||||
key: string,
|
||||
options?: { includeDeleted?: boolean }
|
||||
): Promise<{ workspaceId: string; uploadedBy: string } | null> {
|
||||
try {
|
||||
const { includeDeleted = false } = options ?? {}
|
||||
// Priority 1: Check new workspaceFiles table
|
||||
const fileRecord = await getFileMetadataByKey(key, 'workspace', { includeDeleted })
|
||||
|
||||
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(
|
||||
includeDeleted
|
||||
? eq(workspaceFile.key, key)
|
||||
: and(eq(workspaceFile.key, key), isNull(workspaceFile.deletedAt))
|
||||
)
|
||||
.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 {
|
||||
const inferredContext = inferContextFromKey(key)
|
||||
if (inferredContext !== 'workspace') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the proper parsing utility from workspace context module
|
||||
const parts = key.split('/')
|
||||
const workspaceId = parts[0]
|
||||
|
||||
if (workspaceId && isUuid(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 customConfig Optional custom storage configuration
|
||||
* @param context Optional explicit storage context
|
||||
* @param isLocal Optional flag indicating if this is local storage
|
||||
* @returns Promise<boolean> True if user has access, false otherwise
|
||||
*/
|
||||
export async function verifyFileAccess(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
customConfig?: StorageConfig,
|
||||
context?: StorageContext | 'general',
|
||||
isLocal?: boolean,
|
||||
options?: { requireWrite?: boolean }
|
||||
): Promise<boolean> {
|
||||
const requireWrite = options?.requireWrite ?? false
|
||||
try {
|
||||
if (context === 'general') {
|
||||
return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite)
|
||||
}
|
||||
|
||||
// Infer context from key if not explicitly provided
|
||||
const inferredContext = context || inferContextFromKey(cloudKey)
|
||||
|
||||
// 0. Public contexts: profile pictures, OG images, and workspace logos are world-readable, so reads short-circuit; writes require proof of ownership
|
||||
if (
|
||||
inferredContext === 'profile-pictures' ||
|
||||
inferredContext === 'og-images' ||
|
||||
inferredContext === 'workspace-logos'
|
||||
) {
|
||||
if (requireWrite) {
|
||||
return await verifyPublicAssetWriteAccess(cloudKey, userId, inferredContext, customConfig)
|
||||
}
|
||||
logger.info('Public file access allowed', { cloudKey, context: inferredContext })
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
|
||||
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite)
|
||||
}
|
||||
|
||||
// 2. Execution files: workspace_id/workflow_id/execution_id/filename
|
||||
if (inferredContext === 'execution') {
|
||||
return await verifyExecutionFileAccess(cloudKey, userId, customConfig, requireWrite)
|
||||
}
|
||||
|
||||
// 3. Copilot files: Check database first, then metadata, then path pattern (legacy)
|
||||
if (inferredContext === '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, requireWrite)
|
||||
}
|
||||
|
||||
// 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, requireWrite)
|
||||
} 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,
|
||||
requireWrite = false
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const anyWorkspaceFileRecord = await getFileMetadataByKey(cloudKey, 'workspace', {
|
||||
includeDeleted: true,
|
||||
})
|
||||
if (anyWorkspaceFileRecord?.deletedAt) {
|
||||
logger.warn('Workspace file access denied for archived file', {
|
||||
userId,
|
||||
cloudKey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 (workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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 (workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a destructive operation (delete) on a "public" asset context:
|
||||
* `profile-pictures`, `workspace-logos`, or `og-images`. These contexts are
|
||||
* world-readable, so {@link verifyFileAccess} short-circuits reads — but a write
|
||||
* must prove ownership of the user/workspace the object belongs to and never
|
||||
* short-circuit to `true`.
|
||||
*
|
||||
* - `workspace-logos` carry a trusted `workspace_files` binding written at upload
|
||||
* time; require write/admin on the owning workspace.
|
||||
* - `profile-pictures` are owned by a single user, recorded in the storage
|
||||
* object's `userId` metadata at upload time; require an exact owner match.
|
||||
* - `og-images` are platform/blog assets with no per-user or per-workspace owner
|
||||
* and no user-facing delete path; always deny.
|
||||
*/
|
||||
async function verifyPublicAssetWriteAccess(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
context: 'profile-pictures' | 'og-images' | 'workspace-logos',
|
||||
customConfig?: StorageConfig
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (context === 'workspace-logos') {
|
||||
const binding = await getFileMetadataByKey(cloudKey, 'workspace-logos')
|
||||
if (!binding?.workspaceId) {
|
||||
logger.warn('workspace-logos delete denied: no ownership binding', { userId, cloudKey })
|
||||
return false
|
||||
}
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId)
|
||||
if (!workspacePermissionSatisfies(permission, true)) {
|
||||
logger.warn('workspace-logos delete denied: write/admin required on owner workspace', {
|
||||
userId,
|
||||
workspaceId: binding.workspaceId,
|
||||
cloudKey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (context === 'profile-pictures') {
|
||||
const config: StorageConfig = customConfig || {}
|
||||
const metadata = await getFileMetadata(cloudKey, config)
|
||||
if (metadata.userId && metadata.userId === userId) {
|
||||
return true
|
||||
}
|
||||
// Fail closed when the owner cannot be established. Distinguish a missing
|
||||
// owner record (no `userId` metadata — e.g. an object predating owner
|
||||
// tagging) from a genuine ownership mismatch so the denial is diagnosable.
|
||||
if (!metadata.userId) {
|
||||
logger.warn(
|
||||
'profile-pictures delete denied: file has no owner metadata to verify against',
|
||||
{
|
||||
userId,
|
||||
cloudKey,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
logger.warn('profile-pictures delete denied: caller does not own the file', {
|
||||
userId,
|
||||
fileUserId: metadata.userId,
|
||||
cloudKey,
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
logger.warn('og-images delete denied: no user-facing delete path', { userId, cloudKey })
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error verifying public asset write access', { cloudKey, userId, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify access to execution files
|
||||
* Modern format: execution/workspace_id/workflow_id/execution_id/filename
|
||||
* Legacy format: workspace_id/workflow_id/execution_id/filename
|
||||
*/
|
||||
async function verifyExecutionFileAccess(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
customConfig?: StorageConfig,
|
||||
requireWrite = false
|
||||
): Promise<boolean> {
|
||||
const parts = cloudKey.split('/')
|
||||
|
||||
// Determine if this is modern prefixed or legacy format
|
||||
let workspaceId: string
|
||||
if (parts[0] === 'execution') {
|
||||
// Modern format: execution/workspaceId/workflowId/executionId/filename
|
||||
if (parts.length < 5) {
|
||||
logger.warn('Invalid execution file path format (modern)', { cloudKey })
|
||||
return false
|
||||
}
|
||||
workspaceId = parts[1]
|
||||
} else {
|
||||
// Legacy format: workspaceId/workflowId/executionId/filename
|
||||
if (parts.length < 4) {
|
||||
logger.warn('Invalid execution file path format (legacy)', { cloudKey })
|
||||
return false
|
||||
}
|
||||
workspaceId = parts[0]
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn('Could not extract workspaceId from execution file path', { cloudKey })
|
||||
return false
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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<boolean> {
|
||||
try {
|
||||
// Priority 1: Check workspaceFiles table (new system)
|
||||
const fileRecord = await getFileMetadataByKey(cloudKey, 'copilot')
|
||||
|
||||
if (fileRecord) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an active KB document (non-archived/excluded/deleted, in a
|
||||
* non-deleted KB) in the owning workspace references exactly `cloudKey`, matched
|
||||
* on the document's persisted canonical `storageKey`. This is an exact, indexed
|
||||
* lookup — no URL parsing or wildcard matching at read time. It is a lifecycle
|
||||
* signal only: it reflects whether the file is still part of a live KB, not who
|
||||
* owns it (ownership comes from the binding).
|
||||
*/
|
||||
async function hasActiveKbDocumentForKey(cloudKey: string, workspaceId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBase.workspaceId, workspaceId),
|
||||
eq(document.storageKey, cloudKey),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt),
|
||||
isNull(knowledgeBase.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify access to KB files (`kb/<key>`).
|
||||
*
|
||||
* Authorization is determined entirely by clear state:
|
||||
* 1. Ownership — the trusted `workspace_files` binding (exact key) names the
|
||||
* owning workspace; the caller must have permission on it. Ownership is
|
||||
* never inferred from an attacker-authorable `document.fileUrl`.
|
||||
* 2. Liveness — an active document must still reference the exact key, so the
|
||||
* retained bytes of an archived document or soft-deleted KB are not
|
||||
* downloadable (the liveness document is not an authorization signal).
|
||||
*
|
||||
* A missing binding denies (the ownership backfill populates bindings for
|
||||
* pre-existing objects before this path is deployed).
|
||||
*/
|
||||
async function verifyKBFileAccess(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
customConfig?: StorageConfig
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base', {
|
||||
includeDeleted: true,
|
||||
})
|
||||
|
||||
if (!binding) {
|
||||
logger.warn('KB file access denied: no ownership binding', { userId, cloudKey })
|
||||
return false
|
||||
}
|
||||
if (binding.deletedAt) {
|
||||
logger.warn('KB file access denied for deleted file binding', { userId, cloudKey })
|
||||
return false
|
||||
}
|
||||
if (!binding.workspaceId) {
|
||||
logger.warn('KB file binding missing workspace owner', { userId, cloudKey })
|
||||
return false
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId)
|
||||
if (permission === null) {
|
||||
logger.warn('User does not have workspace access for KB file', {
|
||||
userId,
|
||||
workspaceId: binding.workspaceId,
|
||||
cloudKey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId))) {
|
||||
logger.warn('KB file access denied: no active document references the file', {
|
||||
userId,
|
||||
cloudKey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
logger.debug('KB file access granted (ownership binding)', {
|
||||
userId,
|
||||
workspaceId: binding.workspaceId,
|
||||
cloudKey,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error verifying KB file access', { cloudKey, userId, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a destructive operation (delete) on a KB file.
|
||||
*
|
||||
* Binding-only: resolves the owning workspace from the trusted ownership binding
|
||||
* and requires write/admin permission. Never uses the transitional read fallback,
|
||||
* so a not-yet-bound key cannot be deleted cross-tenant.
|
||||
*/
|
||||
export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base')
|
||||
if (!binding?.workspaceId) {
|
||||
logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey })
|
||||
return false
|
||||
}
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
logger.warn('KB file delete denied: write/admin required on owner workspace', {
|
||||
userId,
|
||||
workspaceId: binding.workspaceId,
|
||||
cloudKey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error verifying KB file write access', { cloudKey, userId, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify access to chat files
|
||||
* Chat files: chat/filename
|
||||
*/
|
||||
async function verifyChatFileAccess(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
customConfig?: StorageConfig,
|
||||
requireWrite = false
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const config: StorageConfig = customConfig || (await getChatStorageConfig())
|
||||
|
||||
const metadata = await getFileMetadata(cloudKey, config)
|
||||
const workspaceId = metadata.workspaceId
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn('Chat file missing workspaceId in metadata', { cloudKey, userId })
|
||||
return false
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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,
|
||||
requireWrite = false
|
||||
): Promise<boolean> {
|
||||
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 (workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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 (workspacePermissionSatisfies(permission, requireWrite)) {
|
||||
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
|
||||
*/
|
||||
async function authorizeFileAccess(
|
||||
key: string,
|
||||
userId: string,
|
||||
context?: StorageContext,
|
||||
storageConfig?: StorageConfig,
|
||||
isLocal?: boolean
|
||||
): Promise<AuthorizationResult> {
|
||||
const granted = await verifyFileAccess(key, userId, storageConfig, context, isLocal)
|
||||
|
||||
if (granted) {
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper for tool routes that download user files from storage.
|
||||
*
|
||||
* Validates that `key` is a non-empty string, that `userId` is present, and
|
||||
* that the authenticated user owns the file. Returns a 404 `NextResponse` on
|
||||
* any failure so callers can `return` it immediately; returns `null` when
|
||||
* access is granted.
|
||||
*/
|
||||
export async function assertToolFileAccess(
|
||||
key: unknown,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
routeLogger: ReturnType<typeof createLogger>
|
||||
): Promise<NextResponse | null> {
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
|
||||
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
const hasAccess = await verifyFileAccess(key, userId)
|
||||
if (!hasAccess) {
|
||||
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })
|
||||
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat storage configuration based on current storage provider
|
||||
*/
|
||||
async function getChatStorageConfig(): Promise<StorageConfig> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
hybridAuthMockFns,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockVerifyFileAccess = vi.fn()
|
||||
const mockVerifyWorkspaceFileAccess = vi.fn()
|
||||
const mockGetStorageProvider = vi.fn()
|
||||
const mockIsUsingCloudStorage = vi.fn()
|
||||
|
||||
return {
|
||||
mockVerifyFileAccess,
|
||||
mockVerifyWorkspaceFileAccess,
|
||||
mockGetStorageProvider,
|
||||
mockIsUsingCloudStorage,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })),
|
||||
lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })),
|
||||
gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })),
|
||||
lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })),
|
||||
ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })),
|
||||
asc: vi.fn((field: unknown) => ({ field, type: 'asc' })),
|
||||
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
|
||||
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
|
||||
isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })),
|
||||
inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })),
|
||||
notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })),
|
||||
like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })),
|
||||
ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })),
|
||||
count: vi.fn((field: unknown) => ({ field, type: 'count' })),
|
||||
sum: vi.fn((field: unknown) => ({ field, type: 'sum' })),
|
||||
avg: vi.fn((field: unknown) => ({ field, type: 'avg' })),
|
||||
min: vi.fn((field: unknown) => ({ field, type: 'min' })),
|
||||
max: vi.fn((field: unknown) => ({ field, type: 'max' })),
|
||||
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn(() => 'test-uuid'),
|
||||
generateShortId: vi.fn(() => 'mock-short-id'),
|
||||
isValidUuid: vi.fn((v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mocks.mockVerifyFileAccess,
|
||||
verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
getStorageProvider: mocks.mockGetStorageProvider,
|
||||
isUsingCloudStorage: mocks.mockIsUsingCloudStorage,
|
||||
StorageService: {
|
||||
uploadFile: storageServiceMockFns.mockUploadFile,
|
||||
downloadFile: storageServiceMockFns.mockDownloadFile,
|
||||
deleteFile: storageServiceMockFns.mockDeleteFile,
|
||||
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
|
||||
},
|
||||
uploadFile: storageServiceMockFns.mockUploadFile,
|
||||
downloadFile: storageServiceMockFns.mockDownloadFile,
|
||||
deleteFile: storageServiceMockFns.mockDeleteFile,
|
||||
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/server/metadata', () => ({
|
||||
deleteFileMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/setup.server', () => ({}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true }),
|
||||
}))
|
||||
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { POST } from '@/app/api/files/delete/route'
|
||||
|
||||
describe('File Delete API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
})
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } })
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'test-user-id',
|
||||
error: undefined,
|
||||
})
|
||||
mocks.mockVerifyFileAccess.mockResolvedValue(true)
|
||||
mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
|
||||
storageServiceMockFns.mockDeleteFile.mockResolvedValue(undefined)
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mocks.mockGetStorageProvider.mockReturnValue('s3')
|
||||
mocks.mockIsUsingCloudStorage.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('should handle local file deletion successfully', async () => {
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
|
||||
mocks.mockGetStorageProvider.mockReturnValue('local')
|
||||
mocks.mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/workspace/test-workspace-id/test-file.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('message')
|
||||
expect(['File deleted successfully', "File not found, but that's okay"]).toContain(data.message)
|
||||
})
|
||||
|
||||
it('should handle file not found gracefully', async () => {
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
|
||||
mocks.mockGetStorageProvider.mockReturnValue('local')
|
||||
mocks.mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/workspace/test-workspace-id/nonexistent.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('message')
|
||||
})
|
||||
|
||||
it('should handle S3 file deletion successfully', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/workspace/test-workspace-id/1234567890-test-file.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('message', 'File deleted successfully')
|
||||
|
||||
expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({
|
||||
key: 'workspace/test-workspace-id/1234567890-test-file.txt',
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle Azure Blob file deletion successfully', async () => {
|
||||
mocks.mockGetStorageProvider.mockReturnValue('blob')
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/workspace/test-workspace-id/1234567890-test-document.pdf',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('message', 'File deleted successfully')
|
||||
|
||||
expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({
|
||||
key: 'workspace/test-workspace-id/1234567890-test-document.pdf',
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing file path', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'InvalidRequestError')
|
||||
expect(data).toHaveProperty('message', 'No file path provided')
|
||||
})
|
||||
|
||||
it('rejects a client context that disagrees with the key prefix', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/s3/workspace/victim-ws/1234-report.pdf',
|
||||
context: 'og-images',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'InvalidRequestError')
|
||||
expect(mocks.mockVerifyFileAccess).not.toHaveBeenCalled()
|
||||
expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { deleteFile, hasCloudStorage } from '@/lib/uploads/core/storage-service'
|
||||
import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization'
|
||||
import {
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
extractFilename,
|
||||
FileNotFoundError,
|
||||
InvalidRequestError,
|
||||
} from '@/app/api/files/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('FilesDeleteAPI')
|
||||
|
||||
/**
|
||||
* Main API route handler for file deletion
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(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 parsed = await parseRequest(
|
||||
fileDeleteContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(
|
||||
new InvalidRequestError(getValidationErrorMessage(error, 'Invalid request data'))
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { filePath, context } = parsed.data.body
|
||||
|
||||
logger.info('File delete request received:', { filePath, context, userId })
|
||||
|
||||
if (!filePath) {
|
||||
throw new InvalidRequestError('No file path provided')
|
||||
}
|
||||
|
||||
try {
|
||||
const key = extractStorageKeyFromPath(filePath)
|
||||
|
||||
// Derive context from the trusted key prefix, never the client-supplied value; a supplied context must agree with the key.
|
||||
const storageContext: StorageContext = inferContextFromKey(key)
|
||||
if (context && context !== storageContext) {
|
||||
logger.warn('File delete context mismatch', { key, context, inferred: storageContext })
|
||||
throw new InvalidRequestError(`Provided context "${context}" does not match the file key`)
|
||||
}
|
||||
|
||||
// Deletes require write/admin on the owning workspace; KB deletes are binding-only with no read fallback.
|
||||
const hasAccess =
|
||||
storageContext === 'knowledge-base'
|
||||
? await verifyKBFileWriteAccess(key, userId)
|
||||
: await verifyFileAccess(key, userId, undefined, storageContext, !hasCloudStorage(), {
|
||||
requireWrite: true,
|
||||
})
|
||||
|
||||
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({
|
||||
key,
|
||||
context: storageContext,
|
||||
})
|
||||
await deleteFileMetadata(key)
|
||||
|
||||
logger.info(`File successfully deleted: ${key}`)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
message: 'File deleted successfully',
|
||||
})
|
||||
} 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')
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing request:', error)
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('Invalid request'))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Extract storage key from file path
|
||||
*/
|
||||
function extractStorageKeyFromPath(filePath: string): string {
|
||||
if (filePath.startsWith('/api/files/serve/')) {
|
||||
return extractStorageKey(filePath)
|
||||
}
|
||||
|
||||
return extractFilename(filePath)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { hasCloudStorage } from '@/lib/uploads/core/storage-service'
|
||||
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('FileDownload')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(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 parsed = await parseRequest(
|
||||
fileDownloadContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(
|
||||
new Error(getValidationErrorMessage(error, 'Invalid request data')),
|
||||
400
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { key, name, url } = parsed.data.body
|
||||
|
||||
if (!key) {
|
||||
return createErrorResponse(new Error('File key is required'), 400)
|
||||
}
|
||||
|
||||
if (key.startsWith('url/')) {
|
||||
if (!url) {
|
||||
return createErrorResponse(new Error('URL is required for URL-type files'), 400)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
downloadUrl: url,
|
||||
expiresIn: null,
|
||||
fileName: name || key.split('/').pop() || 'download',
|
||||
})
|
||||
}
|
||||
|
||||
// Derive context from the trusted key prefix, mirroring the serve route this URL
|
||||
// delegates to, which re-derives context from the key and ignores any client-supplied value.
|
||||
const storageContext = inferContextFromKey(key)
|
||||
|
||||
const hasAccess = await verifyFileAccess(
|
||||
key,
|
||||
userId,
|
||||
undefined, // customConfig
|
||||
storageContext, // context
|
||||
!hasCloudStorage() // isLocal
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Unauthorized download URL request', { userId, key, context: storageContext })
|
||||
throw new FileNotFoundError(`File not found: ${key}`)
|
||||
}
|
||||
|
||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
||||
const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}`
|
||||
|
||||
logger.info(`Generated download URL for ${storageContext} file: ${key}`)
|
||||
|
||||
const downloadName = name || key.split('/').pop() || 'download'
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceName: downloadName,
|
||||
description: `Downloaded file "${downloadName}"`,
|
||||
metadata: { key, fileName: downloadName, context: storageContext },
|
||||
request,
|
||||
})
|
||||
captureServerEvent(userId, 'file_downloaded', {
|
||||
is_bulk: false,
|
||||
file_count: 1,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
downloadUrl,
|
||||
expiresIn: null,
|
||||
fileName: downloadName,
|
||||
})
|
||||
} 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
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import path from 'node:path'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import JSZip from 'jszip'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import { downloadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import { encodeFilenameForHeader } from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('FilesExportAPI')
|
||||
|
||||
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
|
||||
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
|
||||
|
||||
function isMarkdown(originalName: string, contentType: string): boolean {
|
||||
if (MARKDOWN_MIME_TYPES.has(contentType)) return true
|
||||
const ext = originalName.split('.').pop()?.toLowerCase() ?? ''
|
||||
return MARKDOWN_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
function safeFilename(name: string): string {
|
||||
return path
|
||||
.basename(name)
|
||||
.replace(/["\\]/g, '_')
|
||||
.replace(/[\r\n\t]/g, '')
|
||||
}
|
||||
|
||||
function deduplicatedFilename(preferred: string, existing: Set<string>, imageId: string): string {
|
||||
if (!existing.has(preferred)) return preferred
|
||||
const ext = path.extname(preferred)
|
||||
const base = path.basename(preferred, ext)
|
||||
const short = `${base}_${imageId.slice(0, 8)}${ext}`
|
||||
if (!existing.has(short)) return short
|
||||
return `${base}_${imageId}${ext}`
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const parsed = await parseRequest(fileExportContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const record = await getFileMetadataById(id)
|
||||
if (!record) {
|
||||
logger.warn('File not found by ID', { id })
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const hasAccess = await verifyFileAccess(record.key, userId)
|
||||
if (!hasAccess) {
|
||||
logger.warn('Unauthorized file export attempt', { id, userId })
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the egress only at a real success exit (serve redirect, plain
|
||||
* markdown, or bundled zip) so a mid-export failure never logs a download
|
||||
* that never happened.
|
||||
*/
|
||||
const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => {
|
||||
recordAudit({
|
||||
workspaceId: record.workspaceId ?? null,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: record.id,
|
||||
resourceName: record.originalName,
|
||||
description: `Exported file "${record.originalName}"`,
|
||||
metadata: {
|
||||
fileId: record.id,
|
||||
fileName: record.originalName,
|
||||
bytes: record.size,
|
||||
format,
|
||||
assetCount,
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'file_downloaded',
|
||||
{
|
||||
...(record.workspaceId ? { workspace_id: record.workspaceId } : {}),
|
||||
is_bulk: assetCount > 0,
|
||||
file_count: 1 + assetCount,
|
||||
},
|
||||
record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMarkdown(record.originalName, record.contentType)) {
|
||||
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
|
||||
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
|
||||
auditExport('file', 0)
|
||||
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
|
||||
}
|
||||
|
||||
const mdBuffer = await downloadFile({
|
||||
key: record.key,
|
||||
context: record.context as StorageContext,
|
||||
})
|
||||
let mdContent = mdBuffer.toString('utf-8')
|
||||
|
||||
const imageIds = extractEmbeddedImageIds(mdContent)
|
||||
|
||||
logger.info('Exporting markdown', { id, imageCount: imageIds.length })
|
||||
|
||||
if (imageIds.length === 0) {
|
||||
const mdName = safeFilename(record.originalName)
|
||||
const mdBytes = Buffer.from(mdContent, 'utf-8')
|
||||
auditExport('markdown', 0)
|
||||
return new NextResponse(new Uint8Array(mdBytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`,
|
||||
'Content-Length': String(mdBytes.length),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const fetchResults = await Promise.allSettled(
|
||||
imageIds.map(async (imageId) => {
|
||||
const imgRecord = await getFileMetadataById(imageId)
|
||||
if (!imgRecord) return null
|
||||
const imgHasAccess = await verifyFileAccess(imgRecord.key, userId)
|
||||
if (!imgHasAccess) return null
|
||||
const imgBuffer = await downloadFile({
|
||||
key: imgRecord.key,
|
||||
context: imgRecord.context as StorageContext,
|
||||
})
|
||||
return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer }
|
||||
})
|
||||
)
|
||||
|
||||
const assetMap = new Map<string, { filename: string; buffer: Buffer }>()
|
||||
const usedFilenames = new Set<string>()
|
||||
|
||||
for (let i = 0; i < fetchResults.length; i++) {
|
||||
const result = fetchResults[i]
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn('Failed to fetch asset for export', {
|
||||
imageId: imageIds[i],
|
||||
error: toError(result.reason).message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!result.value) continue
|
||||
const { imageId, originalName, buffer } = result.value
|
||||
const preferred = safeFilename(originalName)
|
||||
const filename = deduplicatedFilename(preferred, usedFilenames, imageId)
|
||||
usedFilenames.add(filename)
|
||||
assetMap.set(imageId, { filename, buffer })
|
||||
}
|
||||
|
||||
for (const [imageId, asset] of assetMap) {
|
||||
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const replacement = `./assets/${asset.filename}`
|
||||
// Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app
|
||||
// `/workspace/<ws>/files/<id>` path — so a bundled asset never leaves a broken link in the export.
|
||||
mdContent = mdContent
|
||||
.replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement)
|
||||
.replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement)
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file(safeFilename(record.originalName), mdContent)
|
||||
const assetsFolder = zip.folder('assets')!
|
||||
for (const { filename, buffer } of assetMap.values()) {
|
||||
assetsFolder.file(filename, buffer)
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
||||
const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`)
|
||||
|
||||
auditExport('zip', assetMap.size)
|
||||
return new NextResponse(new Uint8Array(zipBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`,
|
||||
'Content-Length': String(zipBuffer.length),
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockIsUsingCloudStorage,
|
||||
mockGetStorageProvider,
|
||||
mockGetStorageConfig,
|
||||
mockCompleteS3MultipartUpload,
|
||||
mockCompleteBlobMultipartUpload,
|
||||
mockDeriveBlobBlockId,
|
||||
mockVerifyUploadToken,
|
||||
mockSignUploadToken,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsUsingCloudStorage: vi.fn(),
|
||||
mockGetStorageProvider: vi.fn(),
|
||||
mockGetStorageConfig: vi.fn(),
|
||||
mockCompleteS3MultipartUpload: vi.fn(),
|
||||
mockCompleteBlobMultipartUpload: vi.fn(),
|
||||
mockDeriveBlobBlockId: vi.fn(),
|
||||
mockVerifyUploadToken: vi.fn(),
|
||||
mockSignUploadToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
isUsingCloudStorage: mockIsUsingCloudStorage,
|
||||
getStorageProvider: mockGetStorageProvider,
|
||||
getStorageConfig: mockGetStorageConfig,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/upload-token', () => ({
|
||||
signUploadToken: mockSignUploadToken,
|
||||
verifyUploadToken: mockVerifyUploadToken,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/providers/s3/client', () => ({
|
||||
completeS3MultipartUpload: mockCompleteS3MultipartUpload,
|
||||
initiateS3MultipartUpload: mockInitiateS3MultipartUpload,
|
||||
getS3MultipartPartUrls: vi.fn(),
|
||||
abortS3MultipartUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/providers/blob/client', () => ({
|
||||
completeMultipartUpload: mockCompleteBlobMultipartUpload,
|
||||
deriveBlobBlockId: mockDeriveBlobBlockId,
|
||||
initiateMultipartUpload: vi.fn(),
|
||||
getMultipartPartUrls: vi.fn(),
|
||||
abortMultipartUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
|
||||
mockCheckStorageQuota: vi.fn(),
|
||||
mockInitiateS3MultipartUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
checkStorageQuota: mockCheckStorageQuota,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/files/multipart/route'
|
||||
|
||||
const tokenPayload = {
|
||||
uploadId: 'upload-1',
|
||||
key: 'workspace/ws-1/123-abc-file.bin',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
context: 'workspace' as const,
|
||||
}
|
||||
|
||||
const makeRequest = (action: string, body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/files/multipart?action=${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
describe('POST /api/files/multipart action=complete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockIsUsingCloudStorage.mockReturnValue(true)
|
||||
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
|
||||
mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload })
|
||||
mockSignUploadToken.mockReturnValue('signed-token')
|
||||
mockCompleteS3MultipartUpload.mockResolvedValue({
|
||||
location: 'loc',
|
||||
path: '/api/files/serve/...',
|
||||
key: tokenPayload.key,
|
||||
})
|
||||
mockCompleteBlobMultipartUpload.mockResolvedValue({
|
||||
location: 'loc',
|
||||
path: '/api/files/serve/...',
|
||||
key: tokenPayload.key,
|
||||
})
|
||||
mockDeriveBlobBlockId.mockImplementation(
|
||||
(n: number) => `block-${n.toString().padStart(6, '0')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects parts without partNumber', async () => {
|
||||
mockGetStorageProvider.mockReturnValue('s3')
|
||||
const res = await POST(
|
||||
makeRequest('complete', {
|
||||
uploadToken: 'tok',
|
||||
parts: [{ etag: 'abc' }],
|
||||
})
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('S3 path requires etag and forwards { ETag, PartNumber }', async () => {
|
||||
mockGetStorageProvider.mockReturnValue('s3')
|
||||
|
||||
const missingEtag = await POST(
|
||||
makeRequest('complete', {
|
||||
uploadToken: 'tok',
|
||||
parts: [{ partNumber: 1 }],
|
||||
})
|
||||
)
|
||||
expect(missingEtag.status).toBe(500)
|
||||
|
||||
mockCompleteS3MultipartUpload.mockClear()
|
||||
|
||||
const ok = await POST(
|
||||
makeRequest('complete', {
|
||||
uploadToken: 'tok',
|
||||
parts: [
|
||||
{ partNumber: 1, etag: 'aaa' },
|
||||
{ partNumber: 2, etag: 'bbb' },
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(ok.status).toBe(200)
|
||||
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith(
|
||||
tokenPayload.key,
|
||||
tokenPayload.uploadId,
|
||||
[
|
||||
{ ETag: 'aaa', PartNumber: 1 },
|
||||
{ ETag: 'bbb', PartNumber: 2 },
|
||||
],
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('Blob path derives blockId from partNumber and ignores etag', async () => {
|
||||
mockGetStorageProvider.mockReturnValue('blob')
|
||||
mockGetStorageConfig.mockReturnValue({
|
||||
containerName: 'c',
|
||||
accountName: 'a',
|
||||
accountKey: 'k',
|
||||
})
|
||||
|
||||
const res = await POST(
|
||||
makeRequest('complete', {
|
||||
uploadToken: 'tok',
|
||||
parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }],
|
||||
})
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1)
|
||||
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
|
||||
expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
|
||||
tokenPayload.key,
|
||||
[
|
||||
{ partNumber: 1, blockId: 'block-000001' },
|
||||
{ partNumber: 2, blockId: 'block-000002' },
|
||||
],
|
||||
expect.objectContaining({ containerName: 'c' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 403 when token is invalid', async () => {
|
||||
mockGetStorageProvider.mockReturnValue('s3')
|
||||
mockVerifyUploadToken.mockReturnValueOnce({ valid: false })
|
||||
const res = await POST(
|
||||
makeRequest('complete', {
|
||||
uploadToken: 'bad',
|
||||
parts: [{ partNumber: 1, etag: 'a' }],
|
||||
})
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('batch complete normalizes per upload', async () => {
|
||||
mockGetStorageProvider.mockReturnValue('s3')
|
||||
const res = await POST(
|
||||
makeRequest('complete', {
|
||||
uploads: [
|
||||
{
|
||||
uploadToken: 'tok-a',
|
||||
parts: [{ partNumber: 1, etag: 'aaa' }],
|
||||
},
|
||||
{
|
||||
uploadToken: 'tok-b',
|
||||
parts: [{ partNumber: 1, etag: 'bbb' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/files/multipart action=initiate quota enforcement', () => {
|
||||
const makeInitiateRequest = (body: unknown) =>
|
||||
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockIsUsingCloudStorage.mockReturnValue(true)
|
||||
mockGetStorageProvider.mockReturnValue('s3')
|
||||
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
|
||||
mockSignUploadToken.mockReturnValue('signed-token')
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
|
||||
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
|
||||
})
|
||||
|
||||
it('blocks upload when fileSize: 0 exceeds quota', async () => {
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
|
||||
|
||||
const res = await makeInitiateRequest({
|
||||
fileName: 'file.bin',
|
||||
contentType: 'application/octet-stream',
|
||||
fileSize: 0,
|
||||
workspaceId: 'ws-1',
|
||||
context: 'knowledge-base',
|
||||
})
|
||||
|
||||
const response = await POST(res)
|
||||
expect(response.status).toBe(413)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('Storage limit exceeded')
|
||||
})
|
||||
|
||||
it('allows quota-enforced contexts that pass the quota check', async () => {
|
||||
const res = await makeInitiateRequest({
|
||||
fileName: 'doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 99999,
|
||||
workspaceId: 'ws-1',
|
||||
context: 'knowledge-base',
|
||||
})
|
||||
|
||||
const response = await POST(res)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999)
|
||||
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
|
||||
'rejects quota-exempt context %s — not allowed via the multipart endpoint',
|
||||
async (context) => {
|
||||
const res = await makeInitiateRequest({
|
||||
fileName: 'asset.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 100 * 1024 * 1024 * 1024,
|
||||
workspaceId: 'ws-1',
|
||||
context,
|
||||
})
|
||||
|
||||
const response = await POST(res)
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toMatch(/invalid storage context/i)
|
||||
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
|
||||
expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,481 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
abortMultipartUploadContract,
|
||||
type CompleteMultipartBody,
|
||||
completeMultipartUploadContract,
|
||||
getMultipartPartUrlsContract,
|
||||
initiateMultipartUploadContract,
|
||||
multipartActionSchema,
|
||||
} from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getStorageConfig,
|
||||
getStorageProvider,
|
||||
isUsingCloudStorage,
|
||||
type StorageContext,
|
||||
} from '@/lib/uploads'
|
||||
import { deleteFile } from '@/lib/uploads/core/storage-service'
|
||||
import {
|
||||
signUploadToken,
|
||||
type UploadTokenPayload,
|
||||
verifyUploadToken,
|
||||
} from '@/lib/uploads/core/upload-token'
|
||||
import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
|
||||
import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MultipartUploadAPI')
|
||||
|
||||
/**
|
||||
* Contexts the multipart endpoint accepts. The quota-exempt public-asset
|
||||
* contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the
|
||||
* system-internal `logs` context are deliberately excluded: their uploads are
|
||||
* small images capped far below the multipart threshold and routed through the
|
||||
* presigned endpoint, so they have no large-file flow here. Accepting them would
|
||||
* only expose a path that bypasses the per-user storage quota, since every
|
||||
* context in this set is quota-enforced below.
|
||||
*/
|
||||
const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
|
||||
'knowledge-base',
|
||||
'chat',
|
||||
'copilot',
|
||||
'mothership',
|
||||
'execution',
|
||||
'workspace',
|
||||
])
|
||||
|
||||
/**
|
||||
* Unified part identity sent by the client when completing a multipart upload.
|
||||
* `etag` is required for S3 (CompleteMultipartUpload). For Azure the server
|
||||
* derives the block id from `partNumber` via {@link deriveBlobBlockId}.
|
||||
*/
|
||||
interface ClientCompletedPart {
|
||||
partNumber: number
|
||||
etag?: string
|
||||
}
|
||||
|
||||
const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(p) =>
|
||||
p !== null &&
|
||||
typeof p === 'object' &&
|
||||
typeof (p as ClientCompletedPart).partNumber === 'number' &&
|
||||
((p as ClientCompletedPart).etag === undefined ||
|
||||
typeof (p as ClientCompletedPart).etag === 'string')
|
||||
)
|
||||
|
||||
const buildS3CustomConfig = (config: StorageConfig) =>
|
||||
config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined
|
||||
|
||||
const buildBlobCustomConfig = (config: StorageConfig) => ({
|
||||
containerName: config.containerName!,
|
||||
accountName: config.accountName!,
|
||||
accountKey: config.accountKey,
|
||||
connectionString: config.connectionString,
|
||||
})
|
||||
|
||||
const verifyTokenForUser = (token: string | undefined, userId: string) => {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return null
|
||||
}
|
||||
const result = verifyUploadToken(token)
|
||||
if (!result.valid || result.payload.userId !== userId) {
|
||||
return null
|
||||
}
|
||||
return result.payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a trusted storage-key -> workspace ownership binding for completed
|
||||
* knowledge-base uploads. KB file authorization resolves the owning workspace
|
||||
* from this binding, so every KB object must have one. No-op for other contexts.
|
||||
*/
|
||||
const recordKnowledgeBaseOwnership = async (
|
||||
payload: UploadTokenPayload,
|
||||
key: string
|
||||
): Promise<void> => {
|
||||
if (payload.context !== 'knowledge-base' || !payload.workspaceId) {
|
||||
return
|
||||
}
|
||||
await recordKnowledgeBaseFileOwnership({
|
||||
key,
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
originalName: payload.fileName ?? key.split('/').pop() ?? key,
|
||||
contentType: payload.contentType ?? 'application/octet-stream',
|
||||
size: typeof payload.fileSize === 'number' ? payload.fileSize : 0,
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const actionParam = request.nextUrl.searchParams.get('action')
|
||||
const actionResult = multipartActionSchema.safeParse(actionParam)
|
||||
const action = actionResult.success ? actionResult.data : null
|
||||
|
||||
if (!isUsingCloudStorage()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Multipart upload is only available with cloud storage (S3 or Azure Blob)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const storageProvider = getStorageProvider()
|
||||
|
||||
switch (action) {
|
||||
case 'initiate': {
|
||||
const parsed = await parseRequest(
|
||||
initiateMultipartUploadContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const data = parsed.data.body
|
||||
const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data
|
||||
|
||||
if (!workspaceId || typeof workspaceId !== 'string') {
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) {
|
||||
return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 })
|
||||
}
|
||||
const storageContext = context as StorageContext
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const config = getStorageConfig(storageContext)
|
||||
|
||||
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) {
|
||||
const { checkStorageQuota } = await import('@/lib/billing/storage')
|
||||
const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quotaCheck.error || 'Storage limit exceeded' },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let customKey: string | undefined
|
||||
if (context === 'workspace' || context === 'mothership') {
|
||||
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
|
||||
if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const { generateWorkspaceFileKey } = await import(
|
||||
'@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
)
|
||||
customKey = generateWorkspaceFileKey(workspaceId, fileName)
|
||||
} else if (context === 'execution') {
|
||||
const workflowId = (data as { workflowId?: unknown }).workflowId
|
||||
const executionId = (data as { executionId?: unknown }).executionId
|
||||
if (typeof workflowId !== 'string' || !workflowId.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workflowId is required for execution uploads' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (typeof executionId !== 'string' || !executionId.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'executionId is required for execution uploads' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { generateExecutionFileKey } = await import(
|
||||
'@/lib/uploads/contexts/execution/utils'
|
||||
)
|
||||
customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
|
||||
}
|
||||
|
||||
let uploadId: string
|
||||
let key: string
|
||||
|
||||
if (storageProvider === 's3') {
|
||||
const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
|
||||
const result = await initiateS3MultipartUpload({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
customConfig: buildS3CustomConfig(config),
|
||||
customKey,
|
||||
purpose: context,
|
||||
})
|
||||
uploadId = result.uploadId
|
||||
key = result.key
|
||||
} else if (storageProvider === 'blob') {
|
||||
const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
|
||||
const result = await initiateMultipartUpload({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
customConfig: buildBlobCustomConfig(config),
|
||||
customKey,
|
||||
})
|
||||
uploadId = result.uploadId
|
||||
key = result.key
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported storage provider: ${storageProvider}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uploadToken = signUploadToken({
|
||||
uploadId,
|
||||
key,
|
||||
userId,
|
||||
workspaceId,
|
||||
context: storageContext,
|
||||
fileName,
|
||||
contentType,
|
||||
...(typeof fileSize === 'number' ? { fileSize } : {}),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ uploadId, key, uploadToken })
|
||||
}
|
||||
|
||||
case 'get-part-urls': {
|
||||
const parsed = await parseRequest(
|
||||
getMultipartPartUrlsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const data = parsed.data.body
|
||||
const { partNumbers } = data
|
||||
|
||||
const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
|
||||
if (!tokenPayload) {
|
||||
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { uploadId, key, context } = tokenPayload
|
||||
const config = getStorageConfig(context)
|
||||
|
||||
if (storageProvider === 's3') {
|
||||
const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client')
|
||||
const presignedUrls = await getS3MultipartPartUrls(
|
||||
key,
|
||||
uploadId,
|
||||
partNumbers,
|
||||
buildS3CustomConfig(config)
|
||||
)
|
||||
return NextResponse.json({ presignedUrls })
|
||||
}
|
||||
if (storageProvider === 'blob') {
|
||||
const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client')
|
||||
const presignedUrls = await getMultipartPartUrls(
|
||||
key,
|
||||
partNumbers,
|
||||
buildBlobCustomConfig(config)
|
||||
)
|
||||
return NextResponse.json({ presignedUrls })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported storage provider: ${storageProvider}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
const parsed = await parseRequest(
|
||||
completeMultipartUploadContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const data: CompleteMultipartBody = parsed.data.body
|
||||
|
||||
const s3Module =
|
||||
storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null
|
||||
const blobModule =
|
||||
storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null
|
||||
|
||||
const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => {
|
||||
const { uploadId, key, context } = payload
|
||||
const config = getStorageConfig(context)
|
||||
|
||||
let completed: { location: string; path: string; key: string }
|
||||
if (storageProvider === 's3' && s3Module) {
|
||||
const { completeS3MultipartUpload } = s3Module
|
||||
const s3Parts = parts.map((p) => {
|
||||
if (!p.etag) {
|
||||
throw new Error(`Missing etag for S3 part ${p.partNumber}`)
|
||||
}
|
||||
return { ETag: p.etag, PartNumber: p.partNumber }
|
||||
})
|
||||
completed = await completeS3MultipartUpload(
|
||||
key,
|
||||
uploadId,
|
||||
s3Parts,
|
||||
buildS3CustomConfig(config)
|
||||
)
|
||||
} else if (storageProvider === 'blob' && blobModule) {
|
||||
const { completeMultipartUpload, deriveBlobBlockId } = blobModule
|
||||
const blobParts = parts.map((p) => ({
|
||||
partNumber: p.partNumber,
|
||||
blockId: deriveBlobBlockId(p.partNumber),
|
||||
}))
|
||||
completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config))
|
||||
} else {
|
||||
throw new Error(`Unsupported storage provider: ${storageProvider}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await recordKnowledgeBaseOwnership(payload, completed.key)
|
||||
} catch (error) {
|
||||
// The object is committed, but without an ownership binding a KB file
|
||||
// is unreadable and undeletable via the KB paths. Remove the orphan
|
||||
// best-effort and surface a retryable error so the client re-uploads.
|
||||
if (payload.context === 'knowledge-base') {
|
||||
await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
location: completed.location,
|
||||
path: completed.path,
|
||||
key: completed.key,
|
||||
}
|
||||
}
|
||||
|
||||
if ('uploads' in data && Array.isArray(data.uploads)) {
|
||||
const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = []
|
||||
for (const upload of data.uploads) {
|
||||
const payload = verifyTokenForUser(upload.uploadToken, userId)
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired upload token' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (!isClientCompletedParts(upload.parts)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid parts payload: expected [{ partNumber, etag? }]' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
verified.push({ payload, parts: upload.parts })
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
verified.map(({ payload, parts }) => completeOne(payload, parts))
|
||||
)
|
||||
|
||||
logger.info(`Completed ${verified.length} multipart uploads`)
|
||||
return NextResponse.json({ results })
|
||||
}
|
||||
|
||||
const single = data
|
||||
const tokenPayload = verifyTokenForUser(single.uploadToken, userId)
|
||||
if (!tokenPayload) {
|
||||
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
|
||||
}
|
||||
if (!isClientCompletedParts(single.parts)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid parts payload: expected [{ partNumber, etag? }]' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await completeOne(tokenPayload, single.parts)
|
||||
logger.info(
|
||||
`Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})`
|
||||
)
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
case 'abort': {
|
||||
const parsed = await parseRequest(
|
||||
abortMultipartUploadContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const data = parsed.data.body
|
||||
const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
|
||||
if (!tokenPayload) {
|
||||
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { uploadId, key, context } = tokenPayload
|
||||
const config = getStorageConfig(context)
|
||||
|
||||
if (storageProvider === 's3') {
|
||||
const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
|
||||
await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config))
|
||||
logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`)
|
||||
} else if (storageProvider === 'blob') {
|
||||
const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
|
||||
await abortMultipartUpload(key, buildBlobCustomConfig(config))
|
||||
logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`)
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported storage provider: ${storageProvider}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Multipart upload error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Multipart upload failed') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,938 @@
|
||||
/**
|
||||
* Tests for file parse API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockVerifyFileAccess,
|
||||
mockVerifyWorkspaceFileAccess,
|
||||
mockGetStorageProvider,
|
||||
mockIsUsingCloudStorage,
|
||||
mockIsSupportedFileType,
|
||||
mockParseFile,
|
||||
mockParseBuffer,
|
||||
mockFsAccess,
|
||||
mockFsStat,
|
||||
mockFsReadFile,
|
||||
mockFsWriteFile,
|
||||
mockJoin,
|
||||
actualPath,
|
||||
mockUploadWorkspaceFile,
|
||||
} = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const actualPath = require('path') as typeof import('path')
|
||||
return {
|
||||
mockVerifyFileAccess: vi.fn().mockResolvedValue(true),
|
||||
mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
|
||||
mockGetStorageProvider: vi.fn().mockReturnValue('s3'),
|
||||
mockIsUsingCloudStorage: vi.fn().mockReturnValue(true),
|
||||
mockIsSupportedFileType: vi.fn().mockReturnValue(true),
|
||||
mockParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'parsed content',
|
||||
metadata: { pageCount: 1 },
|
||||
}),
|
||||
mockParseBuffer: vi.fn().mockResolvedValue({
|
||||
content: 'parsed buffer content',
|
||||
metadata: { pageCount: 1 },
|
||||
}),
|
||||
mockFsAccess: vi.fn().mockResolvedValue(undefined),
|
||||
mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true, size: 17 })),
|
||||
mockFsReadFile: vi.fn().mockResolvedValue(Buffer.from('test file content')),
|
||||
mockFsWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockJoin: vi.fn((...args: string[]): string => {
|
||||
if (args[0] === '/test/uploads') {
|
||||
return `/test/uploads/${args[args.length - 1]}`
|
||||
}
|
||||
return actualPath.join(...args)
|
||||
}),
|
||||
actualPath,
|
||||
mockUploadWorkspaceFile: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => ({
|
||||
id: 'wf_test',
|
||||
name: fileName,
|
||||
size: 0,
|
||||
type: 'application/octet-stream',
|
||||
url: `/api/files/serve/${workspaceId}/${fileName}`,
|
||||
key: `${workspaceId}/${fileName}`,
|
||||
context: 'workspace',
|
||||
})
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mockVerifyFileAccess,
|
||||
verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
getStorageProvider: mockGetStorageProvider,
|
||||
isUsingCloudStorage: mockIsUsingCloudStorage,
|
||||
StorageService: storageServiceMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/file-parsers', () => ({
|
||||
isSupportedFileType: mockIsSupportedFileType,
|
||||
parseFile: mockParseFile,
|
||||
parseBuffer: mockParseBuffer,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('path', () => ({
|
||||
default: actualPath,
|
||||
...actualPath,
|
||||
join: mockJoin,
|
||||
basename: actualPath.basename,
|
||||
extname: actualPath.extname,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/setup.server', () => ({}))
|
||||
vi.mock('@/lib/uploads/core/setup.server', () => ({
|
||||
UPLOAD_DIR_SERVER: '/test/uploads',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
|
||||
vi.mock('@/lib/core/utils/logging', () => ({
|
||||
sanitizeUrlForLog: vi.fn((url: string) => url),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/execution', () => ({
|
||||
uploadExecutionFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
uploadWorkspaceFile: mockUploadWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/server/metadata', () => ({
|
||||
getFileMetadataByKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
access: mockFsAccess,
|
||||
stat: mockFsStat,
|
||||
readFile: mockFsReadFile,
|
||||
writeFile: mockFsWriteFile,
|
||||
},
|
||||
access: mockFsAccess,
|
||||
stat: mockFsStat,
|
||||
readFile: mockFsReadFile,
|
||||
writeFile: mockFsWriteFile,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/files/parse/route'
|
||||
|
||||
function setupFileApiMocks(
|
||||
options: {
|
||||
authenticated?: boolean
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
cloudEnabled?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
|
||||
|
||||
if (authenticated) {
|
||||
authMockFns.mockGetSession.mockResolvedValue({
|
||||
user: { id: 'test-user-id', email: 'test@example.com' },
|
||||
})
|
||||
} else {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: authenticated,
|
||||
userId: authenticated ? 'test-user-id' : undefined,
|
||||
error: authenticated ? undefined : 'Unauthorized',
|
||||
})
|
||||
|
||||
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
|
||||
success: authenticated,
|
||||
userId: authenticated ? 'test-user-id' : undefined,
|
||||
error: authenticated ? undefined : 'Unauthorized',
|
||||
})
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: authenticated,
|
||||
userId: authenticated ? 'test-user-id' : undefined,
|
||||
error: authenticated ? undefined : 'Unauthorized',
|
||||
})
|
||||
|
||||
mockGetStorageProvider.mockReturnValue(storageProvider)
|
||||
mockIsUsingCloudStorage.mockReturnValue(cloudEnabled)
|
||||
}
|
||||
|
||||
describe('File Parse API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
setupFileApiMocks({
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue({ canView: true })
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test file content'))
|
||||
mockFsStat.mockResolvedValue({ isFile: () => true, size: 17 })
|
||||
mockFsReadFile.mockResolvedValue(Buffer.from('test file content'))
|
||||
mockIsSupportedFileType.mockReturnValue(true)
|
||||
mockUploadWorkspaceFile.mockClear()
|
||||
mockParseFile.mockResolvedValue({
|
||||
content: 'parsed content',
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
mockParseBuffer.mockResolvedValue({
|
||||
content: 'parsed buffer content',
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should handle missing file path', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'No file path provided')
|
||||
})
|
||||
|
||||
it('should accept and process a local file', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/test-file.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).not.toBeNull()
|
||||
|
||||
if (data.success === true) {
|
||||
expect(data).toHaveProperty('output')
|
||||
} else {
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(typeof data.error).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('should process S3 files', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/s3/test-file.pdf',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
if (data.success === true) {
|
||||
expect(data).toHaveProperty('output')
|
||||
} else {
|
||||
expect(data).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('should keep known binary extensions as binary even when the bytes are valid UTF-8', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
mockIsSupportedFileType.mockReturnValue(false)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('valid utf8 bytes'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/image.png',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.content).toBe('[Binary PNG file - 16 bytes]')
|
||||
})
|
||||
|
||||
it('should parse unknown extensions as text when the bytes look like UTF-8 text', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
mockIsSupportedFileType.mockReturnValue(false)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('plain text content'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/readme.customtext',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.content).toBe('plain text content')
|
||||
})
|
||||
|
||||
it('should handle multiple files', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: ['/api/files/serve/file1.txt', '/api/files/serve/file2.txt'],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success')
|
||||
expect(data).toHaveProperty('results')
|
||||
expect(Array.isArray(data.results)).toBe(true)
|
||||
expect(data.results).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should keep the multi-file download cap independent from the remaining parsed-output cap', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(
|
||||
new Response('file content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('second file content', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-length': String(20 * 1024 * 1024),
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const fourMbContent = 'a'.repeat(4 * 1024 * 1024)
|
||||
mockParseBuffer
|
||||
.mockResolvedValueOnce({
|
||||
content: fourMbContent,
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
content: 'second file',
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.results).toHaveLength(2)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://example.com/file1.txt',
|
||||
'203.0.113.10',
|
||||
expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 })
|
||||
)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://example.com/file2.txt',
|
||||
'203.0.113.10',
|
||||
expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 })
|
||||
)
|
||||
})
|
||||
|
||||
it('should never dedup external URL fetches by path filename — two URLs sharing image.png both download', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(
|
||||
new Response('first image bytes', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('second image bytes — different content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
})
|
||||
)
|
||||
mockIsSupportedFileType.mockReturnValue(false)
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: [
|
||||
'https://files.slack.com/files-pri/T07-FAAA/download/image.png',
|
||||
'https://files.slack.com/files-pri/T07-FBBB/download/image.png',
|
||||
],
|
||||
workspaceId: 'workspace-id',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.results).toHaveLength(2)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://files.slack.com/files-pri/T07-FAAA/download/image.png',
|
||||
'203.0.113.10',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://files.slack.com/files-pri/T07-FBBB/download/image.png',
|
||||
'203.0.113.10',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(2)
|
||||
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop multi-file parsing once the combined parsed output is too large', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('file content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
|
||||
mockParseBuffer.mockResolvedValueOnce({
|
||||
content: 'a'.repeat(5 * 1024 * 1024 + 1),
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('too large')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should include successful multi-file parse results when a later file exceeds the cap', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('file content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
|
||||
mockParseBuffer
|
||||
.mockResolvedValueOnce({
|
||||
content: 'first file',
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
content: 'a'.repeat(5 * 1024 * 1024),
|
||||
metadata: { pageCount: 1 },
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.error).toContain('too large')
|
||||
expect(data.results).toHaveLength(1)
|
||||
expect(data.results[0].output.content).toBe('first file')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should pass custom headers when fetching external URLs', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('private file content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
|
||||
const headers = { Authorization: 'Bearer xoxb-test-token' }
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: 'https://files.slack.com/files-pri/T000-F000/download/report.txt',
|
||||
headers,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
|
||||
'https://files.slack.com/files-pri/T000-F000/download/report.txt',
|
||||
'203.0.113.10',
|
||||
expect.objectContaining({
|
||||
timeout: 30000,
|
||||
headers,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject oversized external downloads before reading the body', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('oversized', {
|
||||
status: 200,
|
||||
headers: { 'content-length': '104857601', 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: 'https://example.com/large.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('too large')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
|
||||
'https://example.com/large.txt',
|
||||
'203.0.113.10',
|
||||
expect.objectContaining({
|
||||
maxResponseBytes: 104857600,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject oversized local files before materializing them', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
authenticated: true,
|
||||
})
|
||||
mockFsStat.mockResolvedValue({ isFile: () => true, size: 104857601 })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: 'workspace/large.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('too large')
|
||||
expect(mockFsReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should process execution file URLs with context query param', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath:
|
||||
'/api/files/serve/s3/6vzIweweXAS1pJ1mMSrr9Flh6paJpHAx/79dac297-5ebb-410b-b135-cc594dfcb361/c36afbb0-af50-42b0-9b23-5dae2d9384e8/Confirmation.pdf?context=execution',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
if (data.success === true) {
|
||||
expect(data).toHaveProperty('output')
|
||||
} else {
|
||||
expect(data).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('should process workspace file URLs with context query param', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath:
|
||||
'/api/files/serve/s3/fa8e96e6-7482-4e3c-a0e8-ea083b28af55-be56ca4f-83c2-4559-a6a4-e25eb4ab8ee2_1761691045516-1ie5q86-Confirmation.pdf?context=workspace',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
if (data.success === true) {
|
||||
expect(data).toHaveProperty('output')
|
||||
} else {
|
||||
expect(data).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle S3 access errors gracefully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('Access denied'))
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: '/api/files/serve/s3/test-file.txt',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data).toBeDefined()
|
||||
expect(typeof data).toBe('object')
|
||||
})
|
||||
|
||||
it('should handle access errors gracefully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
authenticated: true,
|
||||
})
|
||||
|
||||
mockFsAccess.mockRejectedValue(new Error('ENOENT: no such file'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
filePath: 'nonexistent.txt',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success')
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Files Parse API - Path Traversal Security', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupFileApiMocks({
|
||||
authenticated: true,
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue({ canView: true })
|
||||
})
|
||||
|
||||
describe('Path Traversal Prevention', () => {
|
||||
it('should reject path traversal attempts with .. segments', async () => {
|
||||
const maliciousRequests = [
|
||||
'../../../etc/passwd',
|
||||
'/api/files/serve/../../../etc/passwd',
|
||||
'/api/files/serve/../../app.js',
|
||||
'/api/files/serve/../.env',
|
||||
'uploads/../../../etc/hosts',
|
||||
]
|
||||
|
||||
for (const maliciousPath of maliciousRequests) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: maliciousPath,
|
||||
}),
|
||||
})
|
||||
|
||||
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|Unauthorized/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject paths with tilde characters', async () => {
|
||||
const maliciousPaths = [
|
||||
'~/../../etc/passwd',
|
||||
'/api/files/serve/~/secret.txt',
|
||||
'~root/.ssh/id_rsa',
|
||||
]
|
||||
|
||||
for (const maliciousPath of maliciousPaths) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: maliciousPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toMatch(/Access denied|Invalid path|Unauthorized/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject absolute paths outside upload directory', async () => {
|
||||
const maliciousPaths = [
|
||||
'/etc/passwd',
|
||||
'/root/.bashrc',
|
||||
'/app/.env',
|
||||
'/var/log/auth.log',
|
||||
'C:\\Windows\\System32\\drivers\\etc\\hosts',
|
||||
]
|
||||
|
||||
for (const maliciousPath of maliciousPaths) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: maliciousPath,
|
||||
}),
|
||||
})
|
||||
|
||||
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|Unauthorized/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow valid paths within upload directory', async () => {
|
||||
const validPaths = [
|
||||
'/api/files/serve/document.txt',
|
||||
'/api/files/serve/folder/file.pdf',
|
||||
'/api/files/serve/subfolder/image.png',
|
||||
]
|
||||
|
||||
for (const validPath of validPaths) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: validPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
expect(result.error).not.toMatch(
|
||||
/Access denied|Path outside allowed directory|Invalid path/
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should not treat .. inside external URLs as path traversal', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('slack file content', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
)
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
// Slack truncates long titles with a literal ellipsis, so the slug contains `..`
|
||||
const slackUrl =
|
||||
'https://files.slack.com/files-pri/T08-F0B/_other__no_invitation_messages_get_sent_-_sim_on_railway...txt'
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filePath: slackUrl, workspaceId: 'workspace-id' }),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// The URL reaching the pinned fetch proves it passed validation and routed
|
||||
// to external-URL handling rather than being rejected as a local path.
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
|
||||
slackUrl,
|
||||
'203.0.113.10',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should still reject traversal in https URLs that look like internal serve URLs', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '203.0.113.10',
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
|
||||
new Response('should never be fetched', { status: 200 })
|
||||
)
|
||||
|
||||
// Absolute https URL containing `/api/files/serve/` matches isInternalFileUrl and would
|
||||
// route to handleCloudFile — so it must keep traversal protection, not be waved through
|
||||
// as an external URL.
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: 'https://attacker.com/api/files/serve/../../../etc/passwd',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toMatch(/Access denied: path traversal detected/)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle encoded path traversal attempts', async () => {
|
||||
const encodedMaliciousPaths = [
|
||||
'/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd
|
||||
'/api/files/serve/..%2f..%2f..%2fetc%2fpasswd',
|
||||
'/api/files/serve/%2e%2e/%2e%2e/etc/passwd',
|
||||
]
|
||||
|
||||
for (const maliciousPath of encodedMaliciousPaths) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: decodeURIComponent(maliciousPath),
|
||||
}),
|
||||
})
|
||||
|
||||
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|Unauthorized/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle null byte injection attempts', async () => {
|
||||
const nullBytePaths = [
|
||||
'/api/files/serve/file.txt\0../../etc/passwd',
|
||||
'file.txt\0/etc/passwd',
|
||||
'/api/files/serve/document.pdf\0/var/log/auth.log',
|
||||
]
|
||||
|
||||
for (const maliciousPath of nullBytePaths) {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: maliciousPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty file paths', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filePath: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(result.error).toBe('No file path provided')
|
||||
})
|
||||
|
||||
it('should handle missing filePath parameter', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/files/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(result.error).toBe('No file path provided')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
batchPresignedUploadBodyContract,
|
||||
uploadTypeSchema,
|
||||
} from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import {
|
||||
generateBatchPresignedUploadUrls,
|
||||
hasCloudStorage,
|
||||
} from '@/lib/uploads/core/storage-service'
|
||||
import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata'
|
||||
import { validateFileType } from '@/lib/uploads/utils/validation'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { createErrorResponse } from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('BatchPresignedUploadAPI')
|
||||
|
||||
const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
batchPresignedUploadBodyContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ error: getValidationErrorMessage(error, 'Invalid request data') },
|
||||
{ status: 400 }
|
||||
),
|
||||
invalidJsonResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { files } = parsed.data.body
|
||||
|
||||
const uploadTypeParam = request.nextUrl.searchParams.get('type')
|
||||
if (!uploadTypeParam) {
|
||||
return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam)
|
||||
if (!uploadTypeResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uploadType = uploadTypeResult.data as StorageContext
|
||||
|
||||
const sessionUserId = session.user.id
|
||||
|
||||
let knowledgeBaseWorkspaceId: string | null = null
|
||||
if (uploadType === 'knowledge-base') {
|
||||
for (const file of files) {
|
||||
const fileValidationError = validateFileType(file.fileName, file.contentType)
|
||||
if (fileValidationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: fileValidationError.message,
|
||||
code: fileValidationError.code,
|
||||
supportedTypes: fileValidationError.supportedTypes,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
knowledgeBaseWorkspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!knowledgeBaseWorkspaceId?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId query parameter is required for knowledge-base uploads' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(
|
||||
sessionUserId,
|
||||
'workspace',
|
||||
knowledgeBaseWorkspaceId
|
||||
)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for knowledge-base uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadType === 'copilot' && !sessionUserId?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authenticated user session is required for copilot uploads' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCloudStorage()) {
|
||||
logger.info(
|
||||
`Local storage detected - batch presigned URLs not available, client will use API fallback`
|
||||
)
|
||||
return NextResponse.json({
|
||||
files: files.map((file) => ({
|
||||
fileName: file.fileName,
|
||||
presignedUrl: '', // Empty URL signals fallback to API upload
|
||||
fileInfo: {
|
||||
path: '',
|
||||
key: '',
|
||||
name: file.fileName,
|
||||
size: file.fileSize,
|
||||
type: file.contentType,
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})),
|
||||
directUploadSupported: false,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Generating batch ${uploadType} presigned URLs for ${files.length} files`)
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const presignedUrls = await generateBatchPresignedUploadUrls(
|
||||
files.map((file) => ({
|
||||
fileName: file.fileName,
|
||||
contentType: file.contentType,
|
||||
fileSize: file.fileSize,
|
||||
})),
|
||||
uploadType,
|
||||
sessionUserId,
|
||||
3600 // 1 hour
|
||||
)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(
|
||||
`Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)`
|
||||
)
|
||||
|
||||
if (uploadType === 'knowledge-base' && knowledgeBaseWorkspaceId) {
|
||||
const ownerWorkspaceId = knowledgeBaseWorkspaceId
|
||||
await recordKnowledgeBaseFileOwnershipMany(
|
||||
presignedUrls.map((urlResponse, index) => ({
|
||||
key: urlResponse.key,
|
||||
userId: sessionUserId,
|
||||
workspaceId: ownerWorkspaceId,
|
||||
originalName: files[index].fileName,
|
||||
contentType: files[index].contentType,
|
||||
size: files[index].fileSize,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
|
||||
|
||||
return NextResponse.json({
|
||||
files: presignedUrls.map((urlResponse, index) => {
|
||||
const finalPath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(urlResponse.key)}?context=${uploadType}`
|
||||
const file = files[index]
|
||||
|
||||
return {
|
||||
fileName: file.fileName,
|
||||
presignedUrl: urlResponse.url,
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: urlResponse.key,
|
||||
name: file.fileName,
|
||||
size: file.fileSize,
|
||||
type: file.contentType,
|
||||
},
|
||||
uploadHeaders: urlResponse.uploadHeaders,
|
||||
directUploadSupported: true,
|
||||
}
|
||||
}),
|
||||
directUploadSupported: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error generating batch presigned URLs:', error)
|
||||
return createErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to generate batch presigned URLs')
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* Tests for file presigned API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockVerifyFileAccess,
|
||||
mockVerifyWorkspaceFileAccess,
|
||||
mockUseBlobStorage,
|
||||
mockUseS3Storage,
|
||||
mockGetStorageConfig,
|
||||
mockIsUsingCloudStorage,
|
||||
mockGetStorageProvider,
|
||||
mockValidateFileType,
|
||||
mockValidateAttachmentFileType,
|
||||
mockGenerateCopilotUploadUrl,
|
||||
mockIsImageFileType,
|
||||
mockGetStorageProviderUploads,
|
||||
mockIsUsingCloudStorageUploads,
|
||||
mockGetUserEntityPermissions,
|
||||
mockGenerateWorkspaceFileKey,
|
||||
mockGenerateExecutionFileKey,
|
||||
mockInsertFileMetadata,
|
||||
} = vi.hoisted(() => ({
|
||||
mockVerifyFileAccess: vi.fn().mockResolvedValue(true),
|
||||
mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
|
||||
mockUseBlobStorage: { value: false },
|
||||
mockUseS3Storage: { value: true },
|
||||
mockGetStorageConfig: vi.fn(),
|
||||
mockIsUsingCloudStorage: vi.fn(),
|
||||
mockGetStorageProvider: vi.fn(),
|
||||
mockValidateFileType: vi.fn().mockReturnValue(null),
|
||||
mockValidateAttachmentFileType: vi.fn().mockReturnValue(null),
|
||||
mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({
|
||||
url: 'https://example.com/presigned-url',
|
||||
key: 'copilot/test-key.txt',
|
||||
}),
|
||||
mockIsImageFileType: vi.fn().mockReturnValue(true),
|
||||
mockGetStorageProviderUploads: vi.fn(),
|
||||
mockIsUsingCloudStorageUploads: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||
mockGenerateWorkspaceFileKey: vi.fn(
|
||||
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}`
|
||||
),
|
||||
mockGenerateExecutionFileKey: vi.fn(
|
||||
(ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) =>
|
||||
`execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}`
|
||||
),
|
||||
mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mockVerifyFileAccess,
|
||||
verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/config', () => ({
|
||||
get USE_BLOB_STORAGE() {
|
||||
return mockUseBlobStorage.value
|
||||
},
|
||||
get USE_S3_STORAGE() {
|
||||
return mockUseS3Storage.value
|
||||
},
|
||||
UPLOAD_DIR: '/uploads',
|
||||
getStorageConfig: mockGetStorageConfig,
|
||||
isUsingCloudStorage: mockIsUsingCloudStorage,
|
||||
getStorageProvider: mockGetStorageProvider,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/utils/validation', () => ({
|
||||
validateFileType: mockValidateFileType,
|
||||
validateAttachmentFileType: mockValidateAttachmentFileType,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
|
||||
generateExecutionFileKey: mockGenerateExecutionFileKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/server/metadata', () => ({
|
||||
insertFileMetadata: mockInsertFileMetadata,
|
||||
recordKnowledgeBaseFileOwnership: (ownership: Record<string, unknown>) =>
|
||||
mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
isImageFileType: mockIsImageFileType,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
CopilotFiles: {
|
||||
generateCopilotUploadUrl: mockGenerateCopilotUploadUrl,
|
||||
},
|
||||
getStorageProvider: mockGetStorageProviderUploads,
|
||||
isUsingCloudStorage: mockIsUsingCloudStorageUploads,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/files/presigned/route'
|
||||
|
||||
const defaultMockUser = {
|
||||
id: 'test-user-id',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
|
||||
function setupFileApiMocks(
|
||||
options: {
|
||||
authenticated?: boolean
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
cloudEnabled?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
|
||||
|
||||
if (authenticated) {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
|
||||
} else {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
const useBlobStorage = storageProvider === 'blob' && cloudEnabled
|
||||
const useS3Storage = storageProvider === 's3' && cloudEnabled
|
||||
|
||||
mockUseBlobStorage.value = useBlobStorage
|
||||
mockUseS3Storage.value = useS3Storage
|
||||
|
||||
mockGetStorageConfig.mockReturnValue(
|
||||
useBlobStorage
|
||||
? {
|
||||
accountName: 'testaccount',
|
||||
accountKey: 'testkey',
|
||||
connectionString: 'testconnection',
|
||||
containerName: 'testcontainer',
|
||||
}
|
||||
: {
|
||||
bucket: 'test-bucket',
|
||||
region: 'us-east-1',
|
||||
}
|
||||
)
|
||||
mockIsUsingCloudStorage.mockReturnValue(cloudEnabled)
|
||||
mockGetStorageProvider.mockReturnValue(
|
||||
storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
|
||||
)
|
||||
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled)
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockImplementation(
|
||||
async (opts: { fileName: string; context: string; customKey?: string }) => {
|
||||
const timestamp = Date.now()
|
||||
const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const key = opts.customKey ?? `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}`
|
||||
return {
|
||||
url: 'https://example.com/presigned-url',
|
||||
key,
|
||||
}
|
||||
}
|
||||
)
|
||||
storageServiceMockFns.mockGeneratePresignedDownloadUrl.mockResolvedValue(
|
||||
'https://example.com/presigned-url'
|
||||
)
|
||||
|
||||
mockValidateFileType.mockReturnValue(null)
|
||||
mockValidateAttachmentFileType.mockReturnValue(null)
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mockGetStorageProviderUploads.mockReturnValue(
|
||||
storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
|
||||
)
|
||||
mockIsUsingCloudStorageUploads.mockReturnValue(cloudEnabled)
|
||||
}
|
||||
|
||||
describe('/api/files/presigned', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return graceful fallback response when cloud storage is not enabled', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.directUploadSupported).toBe(false)
|
||||
expect(data.presignedUrl).toBe('')
|
||||
expect(data.fileName).toBe('test.txt')
|
||||
expect(data.fileInfo).toBeDefined()
|
||||
expect(data.fileInfo.name).toBe('test.txt')
|
||||
expect(data.fileInfo.size).toBe(1024)
|
||||
expect(data.fileInfo.type).toBe('text/plain')
|
||||
})
|
||||
|
||||
it('should return error when fileName is missing', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('fileName is required and cannot be empty')
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('should return error when contentType is missing', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('contentType is required and cannot be empty')
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('should return error when fileSize is invalid', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('fileSize must be a positive number')
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('should return error when file size exceeds limit', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit)
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'large-file.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: largeFileSize,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toContain('exceeds maximum allowed size')
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('should generate S3 presigned URL successfully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test document.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.presignedUrl).toBe('https://example.com/presigned-url')
|
||||
expect(data.fileInfo).toMatchObject({
|
||||
path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=chat$/),
|
||||
key: expect.stringMatching(/.*test.document\.txt$/),
|
||||
name: 'test document.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
})
|
||||
expect(data.directUploadSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('should generate knowledge-base S3 presigned URL with kb prefix', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'knowledge-doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 2048,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/)
|
||||
expect(data.directUploadSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('should generate chat S3 presigned URL with chat prefix and direct path', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'chat-logo.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
|
||||
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=chat$/)
|
||||
expect(data.presignedUrl).toBeTruthy()
|
||||
expect(data.directUploadSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('should generate Azure Blob presigned URL successfully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 'blob',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test document.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.presignedUrl).toBeTruthy()
|
||||
expect(typeof data.presignedUrl).toBe('string')
|
||||
expect(data.fileInfo).toMatchObject({
|
||||
key: expect.stringMatching(/.*test.document\.txt$/),
|
||||
name: 'test document.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
})
|
||||
expect(data.directUploadSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('should generate chat Azure Blob presigned URL with chat prefix and direct path', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 'blob',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'chat-logo.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
|
||||
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=chat$/)
|
||||
expect(data.presignedUrl).toBeTruthy()
|
||||
expect(data.directUploadSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('should return error for unknown storage provider', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
|
||||
new Error('Unknown storage provider: unknown')
|
||||
)
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.error).toBeTruthy()
|
||||
expect(typeof data.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle S3 errors gracefully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
|
||||
new Error('S3 service unavailable')
|
||||
)
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.error).toBeTruthy()
|
||||
expect(typeof data.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle Azure Blob errors gracefully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 'blob',
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue(
|
||||
new Error('Azure service unavailable')
|
||||
)
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
fileSize: 1024,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.error).toBeTruthy()
|
||||
expect(typeof data.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
|
||||
method: 'POST',
|
||||
body: 'invalid json',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError)
|
||||
expect(data.error).toBe('Invalid JSON in request body') // Updated error message
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mothership uploads', () => {
|
||||
it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'screenshot.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png')
|
||||
expect(mockValidateFileType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unsupported types when validator returns an error', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
mockValidateAttachmentFileType.mockReturnValue({
|
||||
code: 'UNSUPPORTED_FILE_TYPE',
|
||||
message: 'Unsupported file type: exe.',
|
||||
supportedTypes: [],
|
||||
})
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'virus.exe',
|
||||
contentType: 'application/octet-stream',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
expect(data.error).toContain('exe')
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks workspace write permission', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('inserts a workspaceFiles row with context=mothership so previews authorize', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'screenshot.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledWith({
|
||||
key: data.fileInfo.key,
|
||||
userId: 'test-user-id',
|
||||
workspaceId: 'ws-1',
|
||||
context: 'mothership',
|
||||
originalName: 'screenshot.png',
|
||||
contentType: 'image/png',
|
||||
size: 4096,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 500 when insertFileMetadata fails so callers do not get an unauthorizable URL', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
mockInsertFileMetadata.mockRejectedValueOnce(new Error('DB connection lost'))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'screenshot.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution uploads', () => {
|
||||
it('uses validateAttachmentFileType — accepts video', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'output.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4')
|
||||
expect(mockValidateFileType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when validator returns an error', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
mockValidateAttachmentFileType.mockReturnValue({
|
||||
code: 'UNSUPPORTED_FILE_TYPE',
|
||||
message: 'Unsupported file type: bin.',
|
||||
supportedTypes: [],
|
||||
})
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'blob.bin',
|
||||
contentType: 'application/octet-stream',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 400 when missing workflowId/executionId', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'output.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('inserts a workspaceFiles row with context=execution so previews authorize', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'output.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledWith({
|
||||
key: data.fileInfo.key,
|
||||
userId: 'test-user-id',
|
||||
workspaceId: 'ws-1',
|
||||
context: 'execution',
|
||||
originalName: 'output.mp4',
|
||||
contentType: 'video/mp4',
|
||||
size: 4096,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-logos uploads', () => {
|
||||
it('inserts a workspaceFiles row with context=workspace-logos so logos authorize', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=workspace-logos&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1)
|
||||
expect(mockInsertFileMetadata).toHaveBeenCalledWith({
|
||||
key: data.fileInfo.key,
|
||||
userId: 'test-user-id',
|
||||
workspaceId: 'ws-1',
|
||||
context: 'workspace-logos',
|
||||
originalName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
size: 4096,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('knowledge-base uploads', () => {
|
||||
it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf')
|
||||
expect(mockValidateAttachmentFileType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires workspaceId for knowledge-base uploads', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=knowledge-base',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 403 when the user lacks write access to the workspace', async () => {
|
||||
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 4096,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { presignedUploadBodyContract, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { CopilotFiles } from '@/lib/uploads'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
|
||||
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
|
||||
import { insertFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
|
||||
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
|
||||
import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { createErrorResponse } from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('PresignedUploadAPI')
|
||||
|
||||
const VALID_UPLOAD_TYPES = [
|
||||
'knowledge-base',
|
||||
'chat',
|
||||
'copilot',
|
||||
'profile-pictures',
|
||||
'mothership',
|
||||
'workspace-logos',
|
||||
'execution',
|
||||
] as const
|
||||
|
||||
class PresignedUrlError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode = 400
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PresignedUrlError'
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationError extends PresignedUrlError {
|
||||
constructor(message: string) {
|
||||
super(message, 'VALIDATION_ERROR', 400)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
presignedUploadBodyContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
throw new ValidationError(getValidationErrorMessage(error, 'Invalid request data'))
|
||||
},
|
||||
invalidJsonResponse: () => {
|
||||
throw new ValidationError('Invalid JSON in request body')
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { fileName, contentType, fileSize } = parsed.data.body
|
||||
|
||||
const uploadTypeParam = request.nextUrl.searchParams.get('type')
|
||||
if (!uploadTypeParam) {
|
||||
throw new ValidationError('type query parameter is required')
|
||||
}
|
||||
|
||||
const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam)
|
||||
if (!uploadTypeResult.success) {
|
||||
throw new ValidationError(
|
||||
`Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const uploadType = uploadTypeResult.data as StorageContext
|
||||
|
||||
if (uploadType === 'knowledge-base') {
|
||||
const fileValidationError = validateFileType(fileName, contentType)
|
||||
if (fileValidationError) {
|
||||
throw new ValidationError(`${fileValidationError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const sessionUserId = session.user.id
|
||||
|
||||
if (!hasCloudStorage()) {
|
||||
logger.info(
|
||||
`Local storage detected - presigned URL not available for ${fileName}, client will use API fallback`
|
||||
)
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: '', // Empty URL signals fallback to API upload
|
||||
fileInfo: {
|
||||
path: '',
|
||||
key: '',
|
||||
name: fileName,
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Generating ${uploadType} presigned URL for ${fileName}`)
|
||||
|
||||
let presignedUrlResponse
|
||||
|
||||
if (uploadType === 'copilot') {
|
||||
try {
|
||||
presignedUrlResponse = await CopilotFiles.generateCopilotUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
userId: sessionUserId,
|
||||
expirationSeconds: 3600,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new ValidationError(getErrorMessage(error, 'Chat validation failed'))
|
||||
}
|
||||
} else if (uploadType === 'mothership') {
|
||||
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!workspaceId?.trim()) {
|
||||
throw new ValidationError('workspaceId query parameter is required for chat uploads')
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for chat uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileValidationError = validateAttachmentFileType(fileName)
|
||||
if (fileValidationError) {
|
||||
throw new ValidationError(fileValidationError.message)
|
||||
}
|
||||
|
||||
const customKey = generateWorkspaceFileKey(workspaceId, fileName)
|
||||
presignedUrlResponse = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'mothership',
|
||||
userId: sessionUserId,
|
||||
customKey,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId },
|
||||
})
|
||||
|
||||
await insertFileMetadata({
|
||||
key: presignedUrlResponse.key,
|
||||
userId: sessionUserId,
|
||||
workspaceId,
|
||||
context: 'mothership',
|
||||
originalName: fileName,
|
||||
contentType,
|
||||
size: fileSize,
|
||||
})
|
||||
} else if (uploadType === 'execution') {
|
||||
const workflowId = request.nextUrl.searchParams.get('workflowId')
|
||||
const executionId = request.nextUrl.searchParams.get('executionId')
|
||||
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) {
|
||||
throw new ValidationError(
|
||||
'workflowId, executionId, and workspaceId query parameters are required for execution uploads'
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for execution uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileValidationError = validateAttachmentFileType(fileName)
|
||||
if (fileValidationError) {
|
||||
throw new ValidationError(fileValidationError.message)
|
||||
}
|
||||
|
||||
const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
|
||||
presignedUrlResponse = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'execution',
|
||||
userId: sessionUserId,
|
||||
customKey,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId, workflowId, executionId },
|
||||
})
|
||||
|
||||
await insertFileMetadata({
|
||||
key: presignedUrlResponse.key,
|
||||
userId: sessionUserId,
|
||||
workspaceId,
|
||||
context: 'execution',
|
||||
originalName: fileName,
|
||||
contentType,
|
||||
size: fileSize,
|
||||
})
|
||||
} else if (uploadType === 'workspace-logos') {
|
||||
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!workspaceId?.trim()) {
|
||||
throw new ValidationError(
|
||||
'workspaceId query parameter is required for workspace-logos uploads'
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required for workspace logo uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isImageFileType(contentType)) {
|
||||
throw new ValidationError(
|
||||
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads'
|
||||
)
|
||||
}
|
||||
|
||||
presignedUrlResponse = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'workspace-logos',
|
||||
userId: sessionUserId,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId },
|
||||
})
|
||||
|
||||
await insertFileMetadata({
|
||||
key: presignedUrlResponse.key,
|
||||
userId: sessionUserId,
|
||||
workspaceId,
|
||||
context: 'workspace-logos',
|
||||
originalName: fileName,
|
||||
contentType,
|
||||
size: fileSize,
|
||||
})
|
||||
} else if (uploadType === 'knowledge-base') {
|
||||
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
|
||||
if (!workspaceId?.trim()) {
|
||||
throw new ValidationError(
|
||||
'workspaceId query parameter is required for knowledge-base uploads'
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for knowledge-base uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const customKey = generateKnowledgeBaseFileKey(fileName)
|
||||
presignedUrlResponse = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'knowledge-base',
|
||||
userId: sessionUserId,
|
||||
customKey,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId },
|
||||
})
|
||||
|
||||
await recordKnowledgeBaseFileOwnership({
|
||||
key: presignedUrlResponse.key,
|
||||
userId: sessionUserId,
|
||||
workspaceId,
|
||||
originalName: fileName,
|
||||
contentType,
|
||||
size: fileSize,
|
||||
})
|
||||
} else {
|
||||
if (uploadType === 'profile-pictures') {
|
||||
if (!sessionUserId?.trim()) {
|
||||
throw new ValidationError(
|
||||
'Authenticated user session is required for profile picture uploads'
|
||||
)
|
||||
}
|
||||
if (!isImageFileType(contentType)) {
|
||||
throw new ValidationError(
|
||||
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
presignedUrlResponse = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: uploadType,
|
||||
userId: sessionUserId,
|
||||
expirationSeconds: 3600, // 1 hour
|
||||
})
|
||||
}
|
||||
|
||||
const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}`
|
||||
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: presignedUrlResponse.url,
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: presignedUrlResponse.key,
|
||||
name: fileName,
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
},
|
||||
uploadHeaders: presignedUrlResponse.uploadHeaders,
|
||||
directUploadSupported: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error generating presigned URL:', error)
|
||||
|
||||
if (error instanceof PresignedUrlError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
directUploadSupported: false,
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return createErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to generate presigned URL')
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockEnforceRateLimit,
|
||||
mockValidateDeploymentAuth,
|
||||
mockDownloadFile,
|
||||
mockResolveServableDoc,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockEnforceRateLimit: vi.fn(),
|
||||
mockValidateDeploymentAuth: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockResolveServableDoc: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/rate-limit', () => ({
|
||||
enforcePublicFileRateLimit: mockEnforceRateLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment-auth', () => ({
|
||||
validateDeploymentAuth: mockValidateDeploymentAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFile: mockDownloadFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
|
||||
resolveServableDoc: mockResolveServableDoc,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/files/public/[token]/content/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const request = (token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/content`)
|
||||
|
||||
const passwordShare = {
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
|
||||
file: {
|
||||
id: 'wf_1',
|
||||
key: 'workspace/ws/secret-key.pdf',
|
||||
workspaceId: 'ws-1',
|
||||
originalName: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 4,
|
||||
},
|
||||
workspaceName: 'Acme',
|
||||
ownerName: 'Jane',
|
||||
}
|
||||
|
||||
describe('GET /api/files/public/[token]/content', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnforceRateLimit.mockResolvedValue(null)
|
||||
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
|
||||
mockDownloadFile.mockResolvedValue(Buffer.from('data'))
|
||||
mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' })
|
||||
})
|
||||
|
||||
it('returns 401 and never reads storage when a password share is unauthorized', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('auth_required_password')
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the bytes once authorized', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
key: passwordShare.file.key,
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { downloadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PublicFileContentAPI')
|
||||
|
||||
/**
|
||||
* GET /api/files/public/[token]/content
|
||||
* Public, unauthenticated bytes for a shared file. Authorized solely by an active
|
||||
* share token — never by workspace membership. 404 for unknown/inactive/deleted
|
||||
* shares. Disposition (inline vs attachment) is resolved from the file type by
|
||||
* {@link createFileResponse}; the public page's Download button uses `<a download>`.
|
||||
*
|
||||
* Generated office docs are stored as source; {@link resolveServableDoc} swaps in
|
||||
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
|
||||
* pass through untouched. A generated doc whose compiled artifact isn't built yet
|
||||
* returns 409 rather than serving raw source under a binary content type.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const limited = await enforcePublicFileRateLimit(request, 'content')
|
||||
if (limited) return limited
|
||||
|
||||
const parsed = await parseRequest(getPublicFileContentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { file } = resolved
|
||||
const raw = await downloadFile({ key: file.key, context: 'workspace' })
|
||||
|
||||
const servable = file.workspaceId
|
||||
? await resolveServableDoc(file.workspaceId, raw, file.originalName)
|
||||
: ({ kind: 'passthrough' } as const)
|
||||
|
||||
if (servable.kind === 'unavailable') {
|
||||
logger.info('Public shared doc not yet compiled', { token, key: file.key })
|
||||
return NextResponse.json(
|
||||
{ error: 'This document is still being prepared. Please try again shortly.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = servable.kind === 'artifact' ? servable.buffer : raw
|
||||
const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType
|
||||
|
||||
logger.info('Public shared file served', { token, key: file.key, size: buffer.length })
|
||||
|
||||
// Anonymous access: null actor (owner-as-actor would misread as a self-download).
|
||||
recordAudit({
|
||||
workspaceId: file.workspaceId ?? null,
|
||||
actorId: null,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: file.id,
|
||||
resourceName: file.originalName,
|
||||
description: `Public share download of "${file.originalName}"`,
|
||||
metadata: {
|
||||
access: 'public_share',
|
||||
anonymous: true,
|
||||
sharedByUserId: file.userId,
|
||||
fileName: file.originalName,
|
||||
bytes: buffer.length,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
// Revalidate every request: a shared file can be unshared, edited, or deleted,
|
||||
// so the fixed token URL must never serve stale bytes from a long-lived cache.
|
||||
return createFileResponse({
|
||||
buffer,
|
||||
contentType,
|
||||
filename: file.originalName,
|
||||
cacheControl: 'private, no-cache, must-revalidate',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error serving public shared file:', error)
|
||||
if (error instanceof FileNotFoundError) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveShare, mockRateLimit, mockValidateAuth, mockDownloadFile, mockResolveImage } =
|
||||
vi.hoisted(() => ({
|
||||
mockResolveShare: vi.fn(),
|
||||
mockRateLimit: vi.fn(),
|
||||
mockValidateAuth: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockResolveImage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveShare,
|
||||
}))
|
||||
vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit }))
|
||||
vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth }))
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
|
||||
vi.mock('@/lib/uploads/server/inline-image', () => ({
|
||||
resolveWorkspaceInlineImage: mockResolveImage,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/files/public/[token]/inline/route'
|
||||
|
||||
const TOKEN = 'tok_share_123456'
|
||||
const DOC_KEY = 'workspace/ws-1/doc.md'
|
||||
const IMG_KEY = 'workspace/ws-1/photo.png'
|
||||
const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB'
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
|
||||
|
||||
const params = { params: Promise.resolve({ token: TOKEN }) }
|
||||
const req = (q: string) => new NextRequest(`http://localhost/api/files/public/${TOKEN}/inline?${q}`)
|
||||
|
||||
const share = {
|
||||
share: { id: 'sh_1', token: TOKEN, authType: 'public' },
|
||||
file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' },
|
||||
workspaceName: 'Acme',
|
||||
ownerName: 'Jane',
|
||||
}
|
||||
|
||||
/** doc bytes embed the image via the view form; image bytes are a real PNG */
|
||||
function downloadByKey(docContent = ``) {
|
||||
return ({ key }: { key: string }) =>
|
||||
Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG)
|
||||
}
|
||||
|
||||
describe('GET /api/files/public/[token]/inline', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRateLimit.mockResolvedValue(null)
|
||||
mockResolveShare.mockResolvedValue(share)
|
||||
mockValidateAuth.mockResolvedValue({ authorized: true })
|
||||
mockResolveImage.mockResolvedValue({
|
||||
key: IMG_KEY,
|
||||
contentType: 'image/png',
|
||||
filename: 'photo.png',
|
||||
})
|
||||
mockDownloadFile.mockImplementation(downloadByKey())
|
||||
})
|
||||
|
||||
it('serves a same-workspace image referenced by the doc, typed from its bytes', async () => {
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/png')
|
||||
})
|
||||
|
||||
it('serves a key-referenced image', async () => {
|
||||
mockDownloadFile.mockImplementation(
|
||||
downloadByKey(`}?context=workspace)`)
|
||||
)
|
||||
const res = await GET(req(`key=${encodeURIComponent(IMG_KEY)}`), params)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('404s when the reference is not embedded in the shared document', async () => {
|
||||
mockDownloadFile.mockImplementation(downloadByKey('no images here'))
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockResolveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s when the referenced file is not in the document workspace', async () => {
|
||||
mockResolveImage.mockResolvedValue(null)
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('404s when the bytes are not a renderable image', async () => {
|
||||
mockDownloadFile.mockImplementation(({ key }: { key: string }) =>
|
||||
Promise.resolve(
|
||||
key === DOC_KEY
|
||||
? Buffer.from(``, 'utf-8')
|
||||
: Buffer.from('<svg/>', 'utf-8')
|
||||
)
|
||||
)
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('401s and never reads storage when the share is unauthorized', async () => {
|
||||
mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' })
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s for an unknown or inactive token', async () => {
|
||||
mockResolveShare.mockResolvedValue(null)
|
||||
const res = await GET(req(`fileId=${FILE_ID}`), params)
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import {
|
||||
extractEmbeddedImageIds,
|
||||
extractEmbeddedImageKeys,
|
||||
} from '@/lib/copilot/tools/server/files/embedded-image-refs'
|
||||
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { downloadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
|
||||
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
|
||||
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PublicInlineFileAPI')
|
||||
|
||||
/**
|
||||
* GET /api/files/public/[token]/inline?key=<cloudKey>|fileId=<id>
|
||||
*
|
||||
* Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them
|
||||
* instead of broken icons. The share grants the document bytes; this route extends that grant to the
|
||||
* document's referenced images only, behind three gates that together hold the security boundary:
|
||||
*
|
||||
* 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The
|
||||
* token is a capability for the document and its embeds, never an arbitrary workspace file.
|
||||
* 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace
|
||||
* ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author
|
||||
* can write but must never resolve) from loading.
|
||||
* 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type,
|
||||
* and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is
|
||||
* refused rather than rendered inline.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const limited = await enforcePublicFileRateLimit(request, 'content')
|
||||
if (limited) return limited
|
||||
|
||||
const parsed = await parseRequest(getPublicInlineFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const ref = parsed.data.query
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { file: doc } = resolved
|
||||
if (!doc.workspaceId) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
// Referenced-by-doc gate: the share grants exactly the images the document embeds.
|
||||
const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8')
|
||||
const referenced = ref.fileId
|
||||
? extractEmbeddedImageIds(docText).includes(ref.fileId)
|
||||
: extractEmbeddedImageKeys(docText).includes(ref.key as string)
|
||||
if (!referenced) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
// Same-workspace gate: resolve scoped to the document's own workspace.
|
||||
const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref)
|
||||
if (!image) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
// Content-truth gate (`sniff`): render only genuine raster image bytes; audit after.
|
||||
const response = await serveInlineImage(image, { sniff: true })
|
||||
|
||||
// Anonymous access: null actor (owner-as-actor would misread as a self-download).
|
||||
recordAudit({
|
||||
workspaceId: doc.workspaceId,
|
||||
actorId: null,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceName: image.filename,
|
||||
description: `Public share inline image "${image.filename}"`,
|
||||
metadata: {
|
||||
access: 'public_share',
|
||||
anonymous: true,
|
||||
inline: true,
|
||||
sharedByUserId: doc.userId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
if (error instanceof FileNotFoundError) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
logger.error('Error serving public inline image:', error)
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockIsEmailAllowed,
|
||||
mockSetDeploymentAuthCookie,
|
||||
mockGenerateOTP,
|
||||
mockStoreOTP,
|
||||
mockGetOTP,
|
||||
mockDeleteOTP,
|
||||
mockIncrementOTPAttempts,
|
||||
mockDecodeOTPValue,
|
||||
mockRenderOTPEmail,
|
||||
mockSendEmail,
|
||||
mockCheckRateLimitDirect,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockGenerateOTP: vi.fn(),
|
||||
mockStoreOTP: vi.fn(),
|
||||
mockGetOTP: vi.fn(),
|
||||
mockDeleteOTP: vi.fn(),
|
||||
mockIncrementOTPAttempts: vi.fn(),
|
||||
mockDecodeOTPValue: vi.fn(),
|
||||
mockRenderOTPEmail: vi.fn(),
|
||||
mockSendEmail: vi.fn(),
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
isEmailAllowed: mockIsEmailAllowed,
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/otp', () => ({
|
||||
generateOTP: mockGenerateOTP,
|
||||
storeOTP: mockStoreOTP,
|
||||
getOTP: mockGetOTP,
|
||||
deleteOTP: mockDeleteOTP,
|
||||
incrementOTPAttempts: mockIncrementOTPAttempts,
|
||||
decodeOTPValue: mockDecodeOTPValue,
|
||||
MAX_OTP_ATTEMPTS: 5,
|
||||
OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 },
|
||||
OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 },
|
||||
}))
|
||||
vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail }))
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail }))
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST, PUT } from '@/app/api/files/public/[token]/otp/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const post = (email: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const put = (email: string, otp: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/otp`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, otp }),
|
||||
})
|
||||
|
||||
const emailShare = {
|
||||
share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] },
|
||||
file: { originalName: 'report.pdf' },
|
||||
}
|
||||
|
||||
describe('POST /api/files/public/[token]/otp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
|
||||
mockResolveActiveShareByToken.mockResolvedValue(emailShare)
|
||||
mockIsEmailAllowed.mockReturnValue(true)
|
||||
mockGenerateOTP.mockReturnValue('123456')
|
||||
mockRenderOTPEmail.mockResolvedValue('<html/>')
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('sends a code to an allow-listed email', async () => {
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456')
|
||||
expect(mockSendEmail).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an email not on the allow-list with 403', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(false)
|
||||
const res = await POST(post('user@evil.com'), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockStoreOTP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lowercases the email for allow-list matching and OTP storage', async () => {
|
||||
await POST(post('User@ACME.com'), params())
|
||||
expect(mockIsEmailAllowed).toHaveBeenCalledWith('user@acme.com', expect.anything())
|
||||
expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456')
|
||||
})
|
||||
|
||||
it('rejects a non-email share with 400', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...emailShare,
|
||||
share: { ...emailShare.share, authType: 'password' },
|
||||
})
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 429 when the IP rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 })
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/files/public/[token]/otp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveActiveShareByToken.mockResolvedValue(emailShare)
|
||||
mockGetOTP.mockResolvedValue('123456:0')
|
||||
mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 })
|
||||
})
|
||||
|
||||
it('verifies a correct code, sets the cookie, returns authType', async () => {
|
||||
const res = await PUT(put('user@acme.com', '123456'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ authType: 'email' })
|
||||
expect(mockDeleteOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com')
|
||||
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'file',
|
||||
'sh_1',
|
||||
'email',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a wrong code with 400 and increments attempts', async () => {
|
||||
mockIncrementOTPAttempts.mockResolvedValueOnce('incremented')
|
||||
const res = await PUT(put('user@acme.com', '000000'), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockIncrementOTPAttempts).toHaveBeenCalled()
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 429 when attempts are exhausted on a wrong code', async () => {
|
||||
mockIncrementOTPAttempts.mockResolvedValueOnce('locked')
|
||||
const res = await PUT(put('user@acme.com', '000000'), params())
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
|
||||
it('returns 400 when no code was issued', async () => {
|
||||
mockGetOTP.mockResolvedValueOnce(null)
|
||||
const res = await PUT(put('user@acme.com', '123456'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import {
|
||||
requestPublicFileOtpContract,
|
||||
verifyPublicFileOtpContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
generateOTP,
|
||||
getOTP,
|
||||
incrementOTPAttempts,
|
||||
MAX_OTP_ATTEMPTS,
|
||||
OTP_EMAIL_RATE_LIMIT,
|
||||
OTP_IP_RATE_LIMIT,
|
||||
storeOTP,
|
||||
} from '@/lib/core/security/otp'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PublicFileOtpAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SHARE_EMAIL_LABEL = 'a shared file'
|
||||
|
||||
/** Allow-list for an email-gated share, read off the resolved row. */
|
||||
function shareAllowedEmails(allowedEmails: unknown): string[] {
|
||||
return Array.isArray(allowedEmails) ? (allowedEmails as string[]) : []
|
||||
}
|
||||
|
||||
function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse {
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000)))
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]/otp
|
||||
* Sends a 6-digit verification code to an allow-listed email for an email-gated share.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:ip:${ip}`,
|
||||
OTP_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`)
|
||||
return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestPublicFileOtpContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
// Normalize once so allow-list matching, OTP storage, and the verify lookup
|
||||
// all key off the same value (allow-list entries are stored lowercase).
|
||||
const email = normalizeEmail(parsed.data.body.email)
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'email') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use email authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) {
|
||||
return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 })
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:email:${resolved.share.id}:${email}`,
|
||||
OTP_EMAIL_RATE_LIMIT
|
||||
)
|
||||
if (!emailRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`)
|
||||
return rateLimited(emailRateLimit.retryAfterMs, OTP_EMAIL_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
await storeOTP('file', resolved.share.id, email, otp)
|
||||
|
||||
const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL)
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Verification code for ${SHARE_EMAIL_LABEL}`,
|
||||
html: emailHtml,
|
||||
})
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send verification email' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent for share ${resolved.share.id}`)
|
||||
return NextResponse.json({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/files/public/[token]/otp
|
||||
* Verifies the code and, on success, sets the `file_auth_{shareId}` cookie.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(verifyPublicFileOtpContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const { otp } = parsed.data.body
|
||||
const email = normalizeEmail(parsed.data.body.email)
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'email') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use email authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const storedValue = await getOTP('file', resolved.share.id, email)
|
||||
if (!storedValue) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No verification code found, request a new one' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('file', resolved.share.id, email)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many failed attempts. Please request a new code.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many failed attempts. Please request a new code.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteOTP('file', resolved.share.id, email)
|
||||
|
||||
const response = NextResponse.json({ authType: resolved.share.authType })
|
||||
setDeploymentAuthCookie(
|
||||
response,
|
||||
'file',
|
||||
resolved.share.id,
|
||||
resolved.share.authType,
|
||||
resolved.share.password
|
||||
)
|
||||
logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockEnforceRateLimit,
|
||||
mockValidateDeploymentAuth,
|
||||
mockSetDeploymentAuthCookie,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockEnforceRateLimit: vi.fn(),
|
||||
mockValidateDeploymentAuth: vi.fn(),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/rate-limit', () => ({
|
||||
enforcePublicFileRateLimit: mockEnforceRateLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment-auth', () => ({
|
||||
validateDeploymentAuth: mockValidateDeploymentAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
}))
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { GET, POST } from '@/app/api/files/public/[token]/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)
|
||||
const postRequest = (password: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
const publicShare = {
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null },
|
||||
file: {
|
||||
id: 'wf_1',
|
||||
key: 'workspace/ws/secret-key.pdf',
|
||||
workspaceId: 'ws-secret',
|
||||
originalName: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 2048,
|
||||
},
|
||||
workspaceName: 'Acme Workspace',
|
||||
ownerName: 'Jane Doe',
|
||||
}
|
||||
|
||||
const passwordShare = {
|
||||
...publicShare,
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
|
||||
}
|
||||
|
||||
describe('GET /api/files/public/[token]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
|
||||
mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default
|
||||
})
|
||||
|
||||
it('returns 429 when the per-IP rate limit is exceeded', async () => {
|
||||
mockEnforceRateLimit.mockResolvedValueOnce(
|
||||
NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 })
|
||||
)
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(mockResolveActiveShareByToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown or inactive token', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns public-safe metadata without leaking the key or workspace id', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare)
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({
|
||||
token: 'tok_1',
|
||||
name: 'report.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 2048,
|
||||
workspaceName: 'Acme Workspace',
|
||||
ownerName: 'Jane Doe',
|
||||
})
|
||||
expect(JSON.stringify(body)).not.toContain('secret-key')
|
||||
expect(JSON.stringify(body)).not.toContain('ws-secret')
|
||||
})
|
||||
|
||||
it('returns 401 auth_required_password for a password share without a valid cookie', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare)
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('auth_required_password')
|
||||
expect(mockValidateDeploymentAuth).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
passwordShare.share,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
})
|
||||
|
||||
it('serves metadata for a password share once authorized by cookie', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare)
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).name).toBe('report.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/files/public/[token]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
|
||||
})
|
||||
|
||||
it('sets the file_auth cookie and returns the authType on a correct password', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await POST(postRequest('hunter2'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ authType: 'password' })
|
||||
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'file',
|
||||
'sh_1',
|
||||
'password',
|
||||
'enc:secret'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to mint a cookie for a non-password (e.g. public) share', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...passwordShare,
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null },
|
||||
})
|
||||
const res = await POST(postRequest('whatever'), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockValidateDeploymentAuth).not.toHaveBeenCalled()
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 401 Invalid password on mismatch without setting a cookie', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Invalid password',
|
||||
})
|
||||
const res = await POST(postRequest('wrong'), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('Invalid password')
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 429 with Retry-After when password attempts are rate-limited', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: 60_000,
|
||||
})
|
||||
const res = await POST(postRequest('wrong'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('60')
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
|
||||
const res = await POST(postRequest('hunter2'), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
authenticatePublicFileContract,
|
||||
getPublicFileContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
|
||||
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PublicFileMetadataAPI')
|
||||
|
||||
/**
|
||||
* GET /api/files/public/[token]
|
||||
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
|
||||
* inactive, or deleted shares — the existence of a file is never leaked. A
|
||||
* password-protected share returns 401 `auth_required_password` until a valid
|
||||
* `file_auth_{shareId}` cookie is present.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const limited = await enforcePublicFileRateLimit(request, 'metadata')
|
||||
if (limited) return limited
|
||||
|
||||
const parsed = await parseRequest(getPublicFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { file, workspaceName, ownerName } = resolved
|
||||
return NextResponse.json({
|
||||
token,
|
||||
name: file.originalName,
|
||||
type: file.contentType,
|
||||
size: file.size,
|
||||
workspaceName,
|
||||
ownerName,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching public file metadata:', error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to fetch file') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited
|
||||
* via the shared deployment-auth gate; returns 401 (`Invalid password`) on
|
||||
* mismatch and 429 (with `Retry-After`) when throttled.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(authenticatePublicFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const { password } = parsed.data.body
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// This endpoint authenticates password shares only. Refusing other modes
|
||||
// here prevents minting a `file_auth` cookie for a `public` share (which
|
||||
// `validateDeploymentAuth` would otherwise authorize), which could later
|
||||
// satisfy the gate if the share is switched to `email`/`sso`.
|
||||
if (resolved.share.authType !== 'password') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use password authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
{ password },
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
const response = NextResponse.json(
|
||||
{ error: auth.error ?? 'Invalid password' },
|
||||
{ status: auth.status ?? 401 }
|
||||
)
|
||||
if (auth.status === 429 && auth.retryAfterMs !== undefined) {
|
||||
response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000)))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ authType: resolved.share.authType })
|
||||
setDeploymentAuthCookie(
|
||||
response,
|
||||
'file',
|
||||
resolved.share.id,
|
||||
resolved.share.authType,
|
||||
resolved.share.password
|
||||
)
|
||||
logger.info('Public file share password accepted', { token, shareId: resolved.share.id })
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error authenticating public file share:', error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to authenticate') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveActiveShareByToken, mockIsEmailAllowed, mockCheckRateLimitDirect } = vi.hoisted(
|
||||
() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed }))
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/files/public/[token]/sso/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const post = (email: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/sso`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const ssoShare = {
|
||||
share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] },
|
||||
file: { originalName: 'report.pdf' },
|
||||
}
|
||||
|
||||
describe('POST /api/files/public/[token]/sso', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
|
||||
mockResolveActiveShareByToken.mockResolvedValue(ssoShare)
|
||||
})
|
||||
|
||||
it('returns eligible:true for an allow-listed email', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(true)
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ eligible: true })
|
||||
})
|
||||
|
||||
it('returns eligible:false for a non-listed email', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(false)
|
||||
const res = await POST(post('user@evil.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ eligible: false })
|
||||
})
|
||||
|
||||
it('rejects a non-sso share with 400', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...ssoShare,
|
||||
share: { ...ssoShare.share, authType: 'email' },
|
||||
})
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 429 when rate-limited', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 })
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('PublicFileSSOAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 20,
|
||||
refillRate: 20,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]/sso
|
||||
* Reports whether an email is on the allow-list for an SSO-gated share. The actual
|
||||
* authentication is the global Sim session (checked at the page/route gate).
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
response.headers.set(
|
||||
'Retry-After',
|
||||
String(Math.ceil((ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000))
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(publicFileSSOContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const email = normalizeEmail(parsed.data.body.email)
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'sso') {
|
||||
return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowedEmails = Array.isArray(resolved.share.allowedEmails)
|
||||
? (resolved.share.allowedEmails as string[])
|
||||
: []
|
||||
return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { downloadFile } from '@/lib/uploads/core/storage-service'
|
||||
import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image'
|
||||
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
|
||||
import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('InlineImageServe')
|
||||
|
||||
/**
|
||||
* A shared/edited/deleted file must never serve stale bytes from its fixed inline URL, so every inline
|
||||
* image revalidates on each request.
|
||||
*/
|
||||
const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
|
||||
|
||||
/**
|
||||
* Download and respond with an already-workspace-scoped inline image — the single serving tail for both
|
||||
* the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the
|
||||
* served content type is derived from the bytes and non-raster content is refused with 404; otherwise the
|
||||
* stored content type is served, matching the in-app serve route.
|
||||
*/
|
||||
export async function serveInlineImage(
|
||||
image: ResolvedInlineImage,
|
||||
{ sniff }: { sniff: boolean }
|
||||
): Promise<NextResponse> {
|
||||
const buffer = await downloadFile({ key: image.key, context: 'workspace' })
|
||||
|
||||
let contentType = image.contentType
|
||||
if (sniff) {
|
||||
const sniffed = sniffImageContentType(buffer)
|
||||
if (!sniffed) {
|
||||
logger.warn('Embedded reference is not a renderable image', { key: image.key })
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
contentType = sniffed
|
||||
}
|
||||
|
||||
return createFileResponse({
|
||||
buffer,
|
||||
contentType,
|
||||
filename: image.filename,
|
||||
cacheControl: INLINE_CACHE_CONTROL,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Tests for file serve API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockVerifyFileAccess,
|
||||
mockReadFile,
|
||||
mockIsUsingCloudStorage,
|
||||
mockDownloadCopilotFile,
|
||||
mockInferContextFromKey,
|
||||
mockGetContentType,
|
||||
mockFindLocalFile,
|
||||
mockCreateFileResponse,
|
||||
mockCreateErrorResponse,
|
||||
FileNotFoundError,
|
||||
} = vi.hoisted(() => {
|
||||
class FileNotFoundErrorClass extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FileNotFoundError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
mockVerifyFileAccess: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockIsUsingCloudStorage: vi.fn(),
|
||||
mockDownloadCopilotFile: vi.fn(),
|
||||
mockInferContextFromKey: vi.fn(),
|
||||
mockGetContentType: vi.fn(),
|
||||
mockFindLocalFile: vi.fn(),
|
||||
mockCreateFileResponse: vi.fn(),
|
||||
mockCreateErrorResponse: vi.fn(),
|
||||
FileNotFoundError: FileNotFoundErrorClass,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mockVerifyFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
CopilotFiles: {
|
||||
downloadCopilotFile: mockDownloadCopilotFile,
|
||||
},
|
||||
isUsingCloudStorage: mockIsUsingCloudStorage,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
inferContextFromKey: mockInferContextFromKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/setup.server', () => ({}))
|
||||
|
||||
vi.mock('@/lib/execution/sandbox/run-task', () => ({
|
||||
runSandboxTask: vi
|
||||
.fn()
|
||||
.mockImplementation(async (taskId: string) =>
|
||||
taskId === 'pdf-generate' ? Buffer.from('%PDF-compiled') : Buffer.from('PK\x03\x04compiled')
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
parseWorkspaceFileKey: vi.fn().mockReturnValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/utils', () => ({
|
||||
FileNotFoundError,
|
||||
createFileResponse: mockCreateFileResponse,
|
||||
createErrorResponse: mockCreateErrorResponse,
|
||||
getContentType: mockGetContentType,
|
||||
extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
extractFilename: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
findLocalFile: mockFindLocalFile,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/files/serve/[...path]/route'
|
||||
|
||||
describe('File Serve API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockVerifyFileAccess.mockResolvedValue(true)
|
||||
mockReadFile.mockResolvedValue(Buffer.from('test content'))
|
||||
mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mockInferContextFromKey.mockReturnValue('workspace')
|
||||
mockGetContentType.mockReturnValue('text/plain')
|
||||
mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt')
|
||||
mockCreateFileResponse.mockImplementation(
|
||||
(file: { buffer: Buffer; contentType: string; filename: string }) => {
|
||||
return new Response(file.buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType,
|
||||
'Content-Disposition': `inline; filename="${file.filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
mockCreateErrorResponse.mockImplementation((error: Error) => {
|
||||
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
|
||||
status: error.name === 'FileNotFoundError' ? 404 : 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should serve local file successfully', async () => {
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/test-file.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'test-file.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain')
|
||||
const disposition = response.headers.get('Content-Disposition')
|
||||
expect(disposition).toContain('inline')
|
||||
expect(disposition).toContain('filename=')
|
||||
expect(disposition).toContain('test-file.txt')
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle nested paths correctly', async () => {
|
||||
mockFindLocalFile.mockReturnValue('/test/uploads/nested/path/file.txt')
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nested-path-file.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'nested-path-file.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/test/uploads/nested/path/file.txt')
|
||||
})
|
||||
|
||||
it('should serve cloud file by downloading and proxying', async () => {
|
||||
mockIsUsingCloudStorage.mockReturnValue(true)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test cloud file content'))
|
||||
mockGetContentType.mockReturnValue('image/png')
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/1234567890-image.png'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', '1234567890-image.png'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||
|
||||
expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({
|
||||
key: 'workspace/test-workspace-id/1234567890-image.png',
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 when file not found', async () => {
|
||||
mockVerifyFileAccess.mockResolvedValue(false)
|
||||
mockFindLocalFile.mockReturnValue(null)
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nonexistent.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'nonexistent.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
error: 'FileNotFoundError',
|
||||
message: expect.stringContaining('File not found'),
|
||||
})
|
||||
})
|
||||
|
||||
describe('content type detection', () => {
|
||||
const contentTypeTests = [
|
||||
{ ext: 'pdf', contentType: 'application/pdf' },
|
||||
{ ext: 'json', contentType: 'application/json' },
|
||||
{ ext: 'jpg', contentType: 'image/jpeg' },
|
||||
{ ext: 'txt', contentType: 'text/plain' },
|
||||
{ ext: 'unknown', contentType: 'application/octet-stream' },
|
||||
]
|
||||
|
||||
for (const test of contentTypeTests) {
|
||||
it(`should serve ${test.ext} file with correct content type`, async () => {
|
||||
mockGetContentType.mockReturnValue(test.contentType)
|
||||
mockFindLocalFile.mockReturnValue(`/test/uploads/file.${test.ext}`)
|
||||
mockCreateFileResponse.mockImplementation(
|
||||
(obj: { buffer: Buffer; contentType: string; filename: string }) =>
|
||||
new Response(obj.buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': obj.contentType,
|
||||
'Content-Disposition': `inline; filename="${obj.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const req = new NextRequest(
|
||||
`http://localhost:3000/api/files/serve/workspace/test-workspace-id/file.${test.ext}`
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', `file.${test.ext}`] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.headers.get('Content-Type')).toBe(test.contentType)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
DocCompileUserError,
|
||||
resolveServableDocBytes,
|
||||
} from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
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,
|
||||
FileNotFoundError,
|
||||
findLocalFile,
|
||||
getContentType,
|
||||
} from '@/app/api/files/utils'
|
||||
|
||||
const logger = createLogger('FilesServeAPI')
|
||||
|
||||
/**
|
||||
* Resolves the bytes + content type to serve for a stored file via the shared
|
||||
* {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
|
||||
* bypasses resolution and serves the stored source as-is.
|
||||
*/
|
||||
async function compileDocumentIfNeeded(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
workspaceId: string | undefined,
|
||||
raw: boolean,
|
||||
ownerKey: string | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
if (raw) return { buffer, contentType: getContentType(filename) }
|
||||
return resolveServableDocBytes({
|
||||
rawBuffer: buffer,
|
||||
fileName: filename,
|
||||
workspaceId,
|
||||
ownerKey,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
|
||||
|
||||
function stripStorageKeyPrefix(segment: string): string {
|
||||
return STORAGE_KEY_PREFIX_RE.test(segment) ? segment.replace(STORAGE_KEY_PREFIX_RE, '') : segment
|
||||
}
|
||||
|
||||
function getWorkspaceIdForCompile(key: string): string | undefined {
|
||||
return parseWorkspaceFileKey(key) ?? undefined
|
||||
}
|
||||
|
||||
const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
|
||||
const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
|
||||
|
||||
/**
|
||||
* Cache-Control for a served file. A versioned request (`?v=<updatedAt>`) addresses
|
||||
* content-immutable bytes — generated docs are content-addressed and the version
|
||||
* bumps on every edit — so the browser may cache it indefinitely; re-opens and
|
||||
* focus refetches then resolve from cache with no round trip. Unversioned workspace
|
||||
* reads stay revalidated because the same storage key is edited in place.
|
||||
*/
|
||||
function resolveServeCacheControl(
|
||||
versioned: boolean,
|
||||
context: string | undefined
|
||||
): string | undefined {
|
||||
if (versioned) return IMMUTABLE_CACHE_CONTROL
|
||||
return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
|
||||
try {
|
||||
const paramsResult = fileServeParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
throw new FileNotFoundError('No file path provided')
|
||||
}
|
||||
const { path } = paramsResult.data
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
throw new FileNotFoundError('No file path provided')
|
||||
}
|
||||
|
||||
logger.info('File serve request:', { path })
|
||||
|
||||
const fullPath = path.join('/')
|
||||
const isS3Path = path[0] === 's3'
|
||||
const isBlobPath = path[0] === 'blob'
|
||||
const isCloudPath = isS3Path || isBlobPath
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
const isPublicByKeyPrefix =
|
||||
cloudKey.startsWith('profile-pictures/') ||
|
||||
cloudKey.startsWith('og-images/') ||
|
||||
cloudKey.startsWith('workspace-logos/')
|
||||
|
||||
if (isPublicByKeyPrefix) {
|
||||
const context = inferContextFromKey(cloudKey)
|
||||
logger.info(`Serving public ${context}:`, { cloudKey })
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
return await handleCloudProxyPublic(cloudKey, context)
|
||||
}
|
||||
return await handleLocalFilePublic(fullPath)
|
||||
}
|
||||
|
||||
const query = fileServeQuerySchema.parse({
|
||||
raw: request.nextUrl.searchParams.get('raw'),
|
||||
v: request.nextUrl.searchParams.get('v'),
|
||||
})
|
||||
const raw = query.raw === '1'
|
||||
const versioned = query.v != null
|
||||
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn('Unauthorized file access attempt', {
|
||||
path,
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
|
||||
if (isUsingCloudStorage()) {
|
||||
return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal)
|
||||
}
|
||||
|
||||
return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal)
|
||||
} catch (error) {
|
||||
// An in-progress/incomplete doc source fails to compile — this is expected
|
||||
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
|
||||
// alarming error; the client re-fetches once the doc finishes (the serve
|
||||
// URL is busted on the file's updatedAt).
|
||||
if (error instanceof DocCompileUserError) {
|
||||
logger.info('Serve: document still compiling, returning 409', {
|
||||
message: error.message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
|
||||
}
|
||||
|
||||
logger.error('Error serving file:', error)
|
||||
|
||||
if (error instanceof FileNotFoundError) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleLocalFile(
|
||||
filename: string,
|
||||
userId: string,
|
||||
raw: boolean,
|
||||
versioned: boolean,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<NextResponse> {
|
||||
const ownerKey = `user:${userId}`
|
||||
try {
|
||||
const contextParam: StorageContext | undefined = inferContextFromKey(filename) as
|
||||
| StorageContext
|
||||
| undefined
|
||||
|
||||
const hasAccess = await verifyFileAccess(
|
||||
filename,
|
||||
userId,
|
||||
undefined, // customConfig
|
||||
contextParam, // context
|
||||
true // isLocal
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Unauthorized local file access attempt', { userId, filename })
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const filePath = await findLocalFile(filename)
|
||||
|
||||
if (!filePath) {
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const rawBuffer = await readFile(filePath)
|
||||
const segment = filename.split('/').pop() || filename
|
||||
const displayName = stripStorageKeyPrefix(segment)
|
||||
const workspaceId = getWorkspaceIdForCompile(filename)
|
||||
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
|
||||
rawBuffer,
|
||||
displayName,
|
||||
workspaceId,
|
||||
raw,
|
||||
ownerKey,
|
||||
signal
|
||||
)
|
||||
|
||||
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename: displayName,
|
||||
cacheControl: resolveServeCacheControl(versioned, contextParam),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error reading local file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudProxy(
|
||||
cloudKey: string,
|
||||
userId: string,
|
||||
raw = false,
|
||||
versioned = false,
|
||||
signal: AbortSignal | undefined = undefined
|
||||
): Promise<NextResponse> {
|
||||
const ownerKey = `user:${userId}`
|
||||
try {
|
||||
const context = inferContextFromKey(cloudKey)
|
||||
logger.info(`Inferred context: ${context} from key pattern: ${cloudKey}`)
|
||||
|
||||
const hasAccess = await verifyFileAccess(
|
||||
cloudKey,
|
||||
userId,
|
||||
undefined, // customConfig
|
||||
context, // context
|
||||
false // isLocal
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Unauthorized cloud file access attempt', { userId, key: cloudKey, context })
|
||||
throw new FileNotFoundError(`File not found: ${cloudKey}`)
|
||||
}
|
||||
|
||||
let rawBuffer: Buffer
|
||||
|
||||
if (context === 'copilot') {
|
||||
rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
|
||||
} else {
|
||||
rawBuffer = await downloadFile({
|
||||
key: cloudKey,
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
const segment = cloudKey.split('/').pop() || 'download'
|
||||
const displayName = stripStorageKeyPrefix(segment)
|
||||
const workspaceId = getWorkspaceIdForCompile(cloudKey)
|
||||
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
|
||||
rawBuffer,
|
||||
displayName,
|
||||
workspaceId,
|
||||
raw,
|
||||
ownerKey,
|
||||
signal
|
||||
)
|
||||
|
||||
logger.info('Cloud file served', {
|
||||
userId,
|
||||
key: cloudKey,
|
||||
size: fileBuffer.length,
|
||||
context,
|
||||
})
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename: displayName,
|
||||
cacheControl: resolveServeCacheControl(versioned, context),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error downloading from cloud storage:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudProxyPublic(
|
||||
cloudKey: string,
|
||||
context: StorageContext
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
let fileBuffer: Buffer
|
||||
|
||||
if (context === 'copilot') {
|
||||
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
|
||||
} else {
|
||||
fileBuffer = await downloadFile({
|
||||
key: cloudKey,
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
const filename = cloudKey.split('/').pop() || 'download'
|
||||
const contentType = getContentType(filename)
|
||||
|
||||
logger.info('Public cloud file served', {
|
||||
key: cloudKey,
|
||||
size: fileBuffer.length,
|
||||
context,
|
||||
})
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error serving public cloud file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
|
||||
try {
|
||||
const filePath = await findLocalFile(filename)
|
||||
|
||||
if (!filePath) {
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath)
|
||||
const contentType = getContentType(filename)
|
||||
|
||||
logger.info('Public local file served', { filename, size: fileBuffer.length })
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error reading public local file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
/**
|
||||
* Tests for file upload API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
hybridAuthMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockVerifyFileAccess = vi.fn()
|
||||
const mockVerifyWorkspaceFileAccess = vi.fn()
|
||||
const mockVerifyKBFileAccess = vi.fn()
|
||||
const mockVerifyCopilotFileAccess = vi.fn()
|
||||
const mockUploadWorkspaceFile = vi.fn()
|
||||
const mockGetStorageProvider = vi.fn()
|
||||
const mockIsUsingCloudStorage = vi.fn()
|
||||
const mockUploadFile = vi.fn()
|
||||
const mockUploadExecutionFile = vi.fn()
|
||||
const mockCheckStorageQuota = vi.fn()
|
||||
|
||||
return {
|
||||
mockVerifyFileAccess,
|
||||
mockVerifyWorkspaceFileAccess,
|
||||
mockVerifyKBFileAccess,
|
||||
mockVerifyCopilotFileAccess,
|
||||
mockUploadWorkspaceFile,
|
||||
mockGetStorageProvider,
|
||||
mockIsUsingCloudStorage,
|
||||
mockUploadFile,
|
||||
mockUploadExecutionFile,
|
||||
mockCheckStorageQuota,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })),
|
||||
lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })),
|
||||
gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })),
|
||||
lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })),
|
||||
ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })),
|
||||
asc: vi.fn((field: unknown) => ({ field, type: 'asc' })),
|
||||
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
|
||||
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
|
||||
isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })),
|
||||
inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })),
|
||||
notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })),
|
||||
like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })),
|
||||
ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })),
|
||||
count: vi.fn((field: unknown) => ({ field, type: 'count' })),
|
||||
sum: vi.fn((field: unknown) => ({ field, type: 'sum' })),
|
||||
avg: vi.fn((field: unknown) => ({ field, type: 'avg' })),
|
||||
min: vi.fn((field: unknown) => ({ field, type: 'min' })),
|
||||
max: vi.fn((field: unknown) => ({ field, type: 'max' })),
|
||||
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn(() => 'test-uuid'),
|
||||
generateShortId: vi.fn(() => 'mock-short-id'),
|
||||
isValidUuid: vi.fn((v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mocks.mockVerifyFileAccess,
|
||||
verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess,
|
||||
verifyKBFileAccess: mocks.mockVerifyKBFileAccess,
|
||||
verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
uploadWorkspaceFile: mocks.mockUploadWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/execution', () => ({
|
||||
uploadExecutionFile: mocks.mockUploadExecutionFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
getStorageProvider: mocks.mockGetStorageProvider,
|
||||
isUsingCloudStorage: mocks.mockIsUsingCloudStorage,
|
||||
uploadFile: mocks.mockUploadFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/uploads/shared/types')>()
|
||||
return {
|
||||
...actual,
|
||||
MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/uploads/setup.server', () => ({
|
||||
UPLOAD_DIR_SERVER: '/tmp/test-uploads',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
checkStorageQuota: mocks.mockCheckStorageQuota,
|
||||
}))
|
||||
|
||||
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { POST } from '@/app/api/files/upload/route'
|
||||
|
||||
/**
|
||||
* Configure mocks for authenticated file upload tests
|
||||
*/
|
||||
function setupFileApiMocks(
|
||||
options: {
|
||||
authenticated?: boolean
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
cloudEnabled?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
})
|
||||
|
||||
if (authenticated) {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } })
|
||||
} else {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
|
||||
success: authenticated,
|
||||
userId: authenticated ? 'test-user-id' : undefined,
|
||||
error: authenticated ? undefined : 'Unauthorized',
|
||||
})
|
||||
|
||||
mocks.mockVerifyFileAccess.mockResolvedValue(true)
|
||||
mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
|
||||
mocks.mockVerifyKBFileAccess.mockResolvedValue(true)
|
||||
mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true)
|
||||
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mocks.mockUploadWorkspaceFile.mockResolvedValue({
|
||||
id: 'test-file-id',
|
||||
name: 'test.txt',
|
||||
url: '/api/files/serve/workspace/test-workspace-id/test-file.txt',
|
||||
size: 100,
|
||||
type: 'text/plain',
|
||||
key: 'workspace/test-workspace-id/1234567890-test.txt',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
mocks.mockUploadExecutionFile.mockResolvedValue({
|
||||
id: 'test-execution-file-id',
|
||||
name: 'test.txt',
|
||||
url: '/api/files/serve/execution/test-workspace-id/test-file.txt',
|
||||
size: 100,
|
||||
type: 'text/plain',
|
||||
key: 'execution/test-workspace-id/1234567890-test.txt',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
mocks.mockGetStorageProvider.mockReturnValue(storageProvider)
|
||||
mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled)
|
||||
mocks.mockUploadFile.mockResolvedValue({
|
||||
path: '/api/files/serve/test-key.txt',
|
||||
key: 'test-key.txt',
|
||||
name: 'test.txt',
|
||||
size: 100,
|
||||
type: 'text/plain',
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled)
|
||||
storageServiceMockFns.mockUploadFile.mockResolvedValue({
|
||||
key: 'test-key',
|
||||
path: '/test/path',
|
||||
})
|
||||
|
||||
mocks.mockCheckStorageQuota.mockResolvedValue({
|
||||
allowed: true,
|
||||
currentUsage: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
}
|
||||
|
||||
describe('File Upload API Route', () => {
|
||||
const createMockFormData = (files: File[], context = 'workspace'): FormData => {
|
||||
const formData = new FormData()
|
||||
formData.append('context', context)
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
files.forEach((file) => {
|
||||
formData.append('file', file)
|
||||
})
|
||||
return formData
|
||||
}
|
||||
|
||||
const createMockFile = (
|
||||
name = 'test.txt',
|
||||
type = 'text/plain',
|
||||
content = 'test content'
|
||||
): File => {
|
||||
return new File([content], name, { type })
|
||||
}
|
||||
|
||||
const createUploadRequest = (formData: FormData): NextRequest =>
|
||||
new NextRequest('http://localhost:3000/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should upload a file to local storage', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
|
||||
const mockFile = createMockFile()
|
||||
const formData = createMockFormData([mockFile])
|
||||
|
||||
const req = createUploadRequest(formData)
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('url')
|
||||
expect(data.url).toMatch(/\/api\/files\/serve\/.*\.txt$/)
|
||||
expect(data).toHaveProperty('name', 'test.txt')
|
||||
expect(data).toHaveProperty('size')
|
||||
expect(data).toHaveProperty('type', 'text/plain')
|
||||
expect(data).toHaveProperty('key')
|
||||
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept chunked multipart uploads without a content-length header', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
|
||||
const formData = createMockFormData([createMockFile()])
|
||||
const req = new NextRequest('http://localhost:3000/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
expect(req.headers.get('content-length')).toBeNull()
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload a file to S3 when in S3 mode', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
const mockFile = createMockFile()
|
||||
const formData = createMockFormData([mockFile])
|
||||
|
||||
const req = createUploadRequest(formData)
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('url')
|
||||
expect(data.url).toContain('/api/files/serve/')
|
||||
expect(data).toHaveProperty('name', 'test.txt')
|
||||
expect(data).toHaveProperty('size')
|
||||
expect(data).toHaveProperty('type', 'text/plain')
|
||||
expect(data).toHaveProperty('key')
|
||||
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multiple file uploads', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
|
||||
const mockFile1 = createMockFile('file1.txt', 'text/plain')
|
||||
const mockFile2 = createMockFile('file2.txt', 'text/plain')
|
||||
const formData = createMockFormData([mockFile1, mockFile2])
|
||||
|
||||
const req = createUploadRequest(formData)
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200)
|
||||
expect(response.status).toBeLessThan(600)
|
||||
expect(data).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects oversized workspace uploads before materializing file contents', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
|
||||
const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025))
|
||||
const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer')
|
||||
const formData = {
|
||||
getAll: (name: string) => (name === 'file' ? [mockFile] : []),
|
||||
get: (name: string) => {
|
||||
if (name === 'context') return 'workspace'
|
||||
if (name === 'workspaceId') return 'test-workspace-id'
|
||||
return null
|
||||
},
|
||||
} as unknown as FormData
|
||||
|
||||
const req = {
|
||||
formData: async () => formData,
|
||||
} as unknown as NextRequest
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.error).toBe('PayloadSizeLimitError')
|
||||
expect(data.message).toContain('File exceeds the server upload limit')
|
||||
expect(data.message).toContain('Use direct upload for larger workspace files')
|
||||
expect(arrayBufferSpy).not.toHaveBeenCalled()
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing files', async () => {
|
||||
setupFileApiMocks()
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
const req = createUploadRequest(formData)
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'InvalidRequestError')
|
||||
expect(data).toHaveProperty('message', 'No files provided')
|
||||
})
|
||||
|
||||
it('should handle S3 upload errors', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: true,
|
||||
storageProvider: 's3',
|
||||
})
|
||||
|
||||
mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded'))
|
||||
|
||||
const mockFile = createMockFile()
|
||||
const formData = createMockFormData([mockFile])
|
||||
|
||||
const req = createUploadRequest(formData)
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(typeof data.error).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Upload Security Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue({
|
||||
user: { id: 'test-user-id' },
|
||||
})
|
||||
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
|
||||
storageServiceMockFns.mockUploadFile.mockResolvedValue({
|
||||
key: 'test-key',
|
||||
path: '/test/path',
|
||||
})
|
||||
mocks.mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('File Extension Validation', () => {
|
||||
beforeEach(() => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept allowed file types', async () => {
|
||||
const allowedTypes = [
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'txt',
|
||||
'md',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'csv',
|
||||
'xlsx',
|
||||
'xls',
|
||||
]
|
||||
|
||||
for (const ext of allowedTypes) {
|
||||
const formData = new FormData()
|
||||
const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('should accept HTML files (supported document type)', async () => {
|
||||
const formData = new FormData()
|
||||
const htmlContent = '<h1>Hello World</h1>'
|
||||
const file = new File([htmlContent], 'document.html', { type: 'text/html' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should accept SVG files (supported image type)', async () => {
|
||||
const formData = new FormData()
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>'
|
||||
const file = new File([svgContent], 'image.svg', { type: 'image/svg+xml' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should reject unsupported file types', async () => {
|
||||
const formData = new FormData()
|
||||
const content = 'binary data'
|
||||
const file = new File([content], 'archive.exe', { type: 'application/octet-stream' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.message).toContain("File type 'exe' is not allowed")
|
||||
})
|
||||
|
||||
it('should reject files without extensions', async () => {
|
||||
const formData = new FormData()
|
||||
const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.message).toContain("File type 'noextension' is not allowed")
|
||||
})
|
||||
|
||||
it('should handle multiple files with mixed valid/invalid types', async () => {
|
||||
const formData = new FormData()
|
||||
|
||||
const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' })
|
||||
formData.append('file', validFile)
|
||||
|
||||
const invalidFile = new File(['binary content'], 'malicious.exe', {
|
||||
type: 'application/x-msdownload',
|
||||
})
|
||||
formData.append('file', invalidFile)
|
||||
formData.append('context', 'workspace')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.message).toContain("File type 'exe' is not allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Execution Context Permission Gate', () => {
|
||||
const createExecutionFormData = (
|
||||
file: File,
|
||||
workspaceId: string | null = 'test-workspace-id'
|
||||
) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'execution')
|
||||
formData.append('workflowId', 'test-workflow-id')
|
||||
formData.append('executionId', 'test-execution-id')
|
||||
if (workspaceId !== null) formData.append('workspaceId', workspaceId)
|
||||
return formData
|
||||
}
|
||||
|
||||
const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => {
|
||||
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
|
||||
const formData = createExecutionFormData(file, workspaceId)
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return POST(req as unknown as NextRequest)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects execution uploads without workspaceId', async () => {
|
||||
const response = await postExecutionUpload(null)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.message).toContain('workflowId, executionId, and workspaceId')
|
||||
expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects execution uploads for a read-only workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const response = await postExecutionUpload()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Write or Admin access required for execution uploads')
|
||||
expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects execution uploads for a member with no workspace permission', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
const response = await postExecutionUpload()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows execution uploads for a write-permission workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const response = await postExecutionUpload()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'test-workspace-id',
|
||||
workflowId: 'test-workflow-id',
|
||||
executionId: 'test-execution-id',
|
||||
},
|
||||
expect.anything(),
|
||||
'test.pdf',
|
||||
'application/pdf',
|
||||
'test-user-id'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows execution uploads for an admin-permission workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
const response = await postExecutionUpload()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.mockUploadExecutionFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mothership Context Permission Gate', () => {
|
||||
const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => {
|
||||
const formData = new FormData()
|
||||
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'mothership')
|
||||
if (workspaceId !== null) formData.append('workspaceId', workspaceId)
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return POST(req as unknown as NextRequest)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects mothership uploads without workspaceId', async () => {
|
||||
const response = await postMothershipUpload(null)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.message).toContain('workspaceId')
|
||||
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects mothership uploads for a workspace the caller does not belong to', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
const response = await postMothershipUpload()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Write or Admin access required for mothership uploads')
|
||||
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects mothership uploads for a read-only workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const response = await postMothershipUpload()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects mothership uploads over the caller storage quota', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mocks.mockCheckStorageQuota.mockResolvedValue({
|
||||
allowed: false,
|
||||
currentUsage: 100,
|
||||
limit: 100,
|
||||
error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB',
|
||||
})
|
||||
|
||||
const response = await postMothershipUpload()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
const data = await response.json()
|
||||
expect(data.error).toContain('Storage limit exceeded')
|
||||
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows mothership uploads for a write-permission workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const response = await postMothershipUpload()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'workspace',
|
||||
'test-workspace-id'
|
||||
)
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows mothership uploads for an admin-permission workspace member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
const response = await postMothershipUpload()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks quota once against the combined size of a multi-file batch', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const formData = new FormData()
|
||||
const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' })
|
||||
const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' })
|
||||
formData.append('file', fileA)
|
||||
formData.append('file', fileB)
|
||||
formData.append('context', 'mothership')
|
||||
formData.append('workspaceId', 'test-workspace-id')
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30)
|
||||
expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authentication Requirements', () => {
|
||||
it('should reject uploads without authentication', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const formData = new FormData()
|
||||
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
|
||||
formData.append('file', file)
|
||||
|
||||
const req = new Request('http://localhost/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,485 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sanitizeFileName } from '@/executor/constants'
|
||||
import '@/lib/uploads/core/setup.server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import {
|
||||
uploadFilesFormFieldsSchema,
|
||||
uploadFilesFormFilesSchema,
|
||||
} from '@/lib/api/contracts/storage-transfer'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
assertKnownSizeWithinLimit,
|
||||
isPayloadSizeLimitError,
|
||||
MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
readFileToBufferWithLimit,
|
||||
readFormDataWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
|
||||
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
SUPPORTED_ATTACHMENT_EXTENSIONS,
|
||||
SUPPORTED_IMAGE_EXTENSIONS,
|
||||
validateFileType,
|
||||
} from '@/lib/uploads/utils/validation'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)
|
||||
|
||||
function validateFileExtension(filename: string): boolean {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
if (!extension) return false
|
||||
return ALLOWED_EXTENSIONS.has(extension)
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('FilesUploadAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await readFormDataWithLimit(request, {
|
||||
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
label: 'multipart upload body',
|
||||
})
|
||||
|
||||
const rawFiles = formData.getAll('file')
|
||||
const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles)
|
||||
if (!filesResult.success) {
|
||||
throw new InvalidRequestError('No files provided')
|
||||
}
|
||||
const files = filesResult.data
|
||||
const totalFileSize = files.reduce((total, file) => total + file.size, 0)
|
||||
assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files')
|
||||
|
||||
const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({
|
||||
workflowId: formData.get('workflowId'),
|
||||
executionId: formData.get('executionId'),
|
||||
workspaceId: formData.get('workspaceId'),
|
||||
context: formData.get('context'),
|
||||
})
|
||||
if (!formFieldsResult.success) {
|
||||
throw new InvalidRequestError(
|
||||
getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data')
|
||||
)
|
||||
}
|
||||
const formFields = formFieldsResult.data
|
||||
const { workflowId, executionId, workspaceId, context: contextParam } = formFields
|
||||
|
||||
// Context must be explicitly provided
|
||||
if (!contextParam) {
|
||||
throw new InvalidRequestError(
|
||||
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
|
||||
)
|
||||
}
|
||||
|
||||
const context = contextParam as StorageContext
|
||||
|
||||
const storageService = await import('@/lib/uploads/core/storage-service')
|
||||
const usingCloudStorage = storageService.hasCloudStorage()
|
||||
logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)
|
||||
|
||||
// Execution context requires a workspace write/admin permission check. Resolve it once per
|
||||
// request (not per file) since workspaceId is invariant across all files in the upload.
|
||||
let executionUploadContext:
|
||||
| { workspaceId: string; workflowId: string; executionId: string }
|
||||
| undefined
|
||||
if (context === 'execution') {
|
||||
if (!workflowId || !executionId || !workspaceId) {
|
||||
throw new InvalidRequestError(
|
||||
'Execution context requires workflowId, executionId, and workspaceId parameters'
|
||||
)
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for execution uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
executionUploadContext = { workspaceId, workflowId, executionId }
|
||||
}
|
||||
|
||||
// Mothership context requires the same workspace write/admin permission check, plus a
|
||||
// storage quota check. Resolve both once per request (not per file) since workspaceId is
|
||||
// invariant across all files in the upload and quota must account for the full batch size,
|
||||
// not just one file.
|
||||
let mothershipWorkspaceId: string | undefined
|
||||
if (context === 'mothership') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for mothership uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { checkStorageQuota } = await import('@/lib/billing/storage')
|
||||
const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize)
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quotaCheck.error || 'Storage limit exceeded' },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
mothershipWorkspaceId = workspaceId
|
||||
}
|
||||
|
||||
const uploadResults = []
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name || 'untitled.md'
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
throw new InvalidRequestError(
|
||||
`File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await readFileToBufferWithLimit(file, {
|
||||
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE,
|
||||
label: 'uploaded file',
|
||||
})
|
||||
|
||||
// Handle execution context
|
||||
if (context === 'execution' && executionUploadContext) {
|
||||
const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
|
||||
const userFile = await uploadExecutionFile(
|
||||
executionUploadContext,
|
||||
buffer,
|
||||
originalName,
|
||||
file.type,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
uploadResults.push(userFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle knowledge-base context
|
||||
if (context === 'knowledge-base') {
|
||||
// Validate file type for knowledge base
|
||||
const validationError = validateFileType(originalName, file.type)
|
||||
if (validationError) {
|
||||
throw new InvalidRequestError(validationError.message)
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('workspaceId is required for knowledge-base uploads')
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'write' && permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for knowledge-base uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Uploading knowledge-base file: ${originalName}`)
|
||||
|
||||
const storageKey = generateKnowledgeBaseFileKey(originalName)
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'knowledge-base',
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: file.type,
|
||||
context: 'knowledge-base',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage
|
||||
? `${fileInfo.path}?context=knowledge-base`
|
||||
: fileInfo.path
|
||||
|
||||
const uploadResult = {
|
||||
fileName: originalName,
|
||||
presignedUrl: '', // Not used for server-side uploads
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: file.type,
|
||||
},
|
||||
directUploadSupported: false,
|
||||
}
|
||||
|
||||
logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`)
|
||||
uploadResults.push(uploadResult)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle workspace context
|
||||
if (context === 'workspace') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Workspace context requires workspaceId parameter')
|
||||
}
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for workspace uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
session.user.id,
|
||||
buffer,
|
||||
originalName,
|
||||
file.type || 'application/octet-stream'
|
||||
)
|
||||
|
||||
uploadResults.push(userFile)
|
||||
continue
|
||||
} catch (workspaceError) {
|
||||
const errorMessage = getErrorMessage(workspaceError, 'Upload failed')
|
||||
const isDuplicate = errorMessage.includes('already exists')
|
||||
const isStorageLimitError =
|
||||
errorMessage.includes('Storage limit exceeded') ||
|
||||
errorMessage.includes('storage limit')
|
||||
|
||||
logger.warn(`Workspace file upload failed: ${errorMessage}`)
|
||||
|
||||
let statusCode = 500
|
||||
if (isDuplicate) statusCode = 409
|
||||
else if (isStorageLimitError) statusCode = 413
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
isDuplicate,
|
||||
},
|
||||
{ status: statusCode }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mothership context (chat-scoped uploads to workspace S3)
|
||||
if (context === 'mothership' && mothershipWorkspaceId) {
|
||||
logger.info(`Uploading mothership file: ${originalName}`)
|
||||
|
||||
const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName)
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'mothership',
|
||||
userId: session.user.id,
|
||||
workspaceId: mothershipWorkspaceId,
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
context: 'mothership',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
|
||||
|
||||
uploadResults.push({
|
||||
fileName: originalName,
|
||||
presignedUrl: '',
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: file.type || 'application/octet-stream',
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
context === 'copilot' ||
|
||||
context === 'chat' ||
|
||||
context === 'profile-pictures' ||
|
||||
context === 'workspace-logos'
|
||||
) {
|
||||
if (context !== 'copilot') {
|
||||
const mimeType = file.type
|
||||
const isGenericMime = !mimeType || mimeType === 'application/octet-stream'
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() ?? ''
|
||||
const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes(
|
||||
extension
|
||||
)
|
||||
const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType)
|
||||
if (!isImage) {
|
||||
throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`)
|
||||
}
|
||||
}
|
||||
|
||||
if (context === 'workspace-logos') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('workspace-logos context requires workspaceId parameter')
|
||||
}
|
||||
const permission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required for workspace logo uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (context === 'chat' && workspaceId) {
|
||||
const permission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (permission === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Insufficient permissions for workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Uploading ${context} file: ${originalName}`)
|
||||
|
||||
const resolvedContentType = resolveFileType({ type: file.type, name: originalName })
|
||||
|
||||
const timestamp = Date.now()
|
||||
const safeFileName = sanitizeFileName(originalName)
|
||||
const storageKey = `${context}/${timestamp}-${safeFileName}`
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: context,
|
||||
userId: session.user.id,
|
||||
}
|
||||
|
||||
if (workspaceId && context === 'chat') {
|
||||
metadata.workspaceId = workspaceId
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: resolvedContentType,
|
||||
context,
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path
|
||||
|
||||
const uploadResult = {
|
||||
fileName: originalName,
|
||||
presignedUrl: '', // Not used for server-side uploads
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: resolvedContentType,
|
||||
},
|
||||
directUploadSupported: false,
|
||||
}
|
||||
|
||||
logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)
|
||||
|
||||
if (context === 'workspace-logos' && workspaceId) {
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Uploaded workspace logo "${originalName}"`,
|
||||
metadata: {
|
||||
fileName: originalName,
|
||||
fileKey: fileInfo.key,
|
||||
fileSize: buffer.length,
|
||||
fileType: resolvedContentType,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(session.user.id, 'workspace_logo_uploaded', {
|
||||
workspace_id: workspaceId,
|
||||
file_name: originalName,
|
||||
file_size: buffer.length,
|
||||
})
|
||||
}
|
||||
|
||||
uploadResults.push(uploadResult)
|
||||
continue
|
||||
}
|
||||
|
||||
// Unknown context
|
||||
throw new InvalidRequestError(
|
||||
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
|
||||
)
|
||||
}
|
||||
|
||||
if (uploadResults.length === 1) {
|
||||
return NextResponse.json(uploadResults[0])
|
||||
}
|
||||
return NextResponse.json({ files: uploadResults })
|
||||
} catch (error) {
|
||||
logger.error('Error in file upload:', error)
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'PayloadSizeLimitError',
|
||||
message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFileResponse, extractFilename, findLocalFile } from '@/app/api/files/utils'
|
||||
|
||||
describe('extractFilename', () => {
|
||||
describe('legitimate file paths', () => {
|
||||
it('should extract filename from standard serve path', () => {
|
||||
expect(extractFilename('/api/files/serve/test-file.txt')).toBe('test-file.txt')
|
||||
})
|
||||
|
||||
it('should extract filename from serve path with special characters', () => {
|
||||
expect(extractFilename('/api/files/serve/document-with-dashes_and_underscores.pdf')).toBe(
|
||||
'document-with-dashes_and_underscores.pdf'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle simple filename without serve path', () => {
|
||||
expect(extractFilename('simple-file.txt')).toBe('simple-file.txt')
|
||||
})
|
||||
|
||||
it('should extract last segment from nested path', () => {
|
||||
expect(extractFilename('nested/path/file.txt')).toBe('file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud storage paths', () => {
|
||||
it('should preserve S3 path structure', () => {
|
||||
expect(extractFilename('/api/files/serve/s3/1234567890-test-file.txt')).toBe(
|
||||
's3/1234567890-test-file.txt'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve S3 path with nested folders', () => {
|
||||
expect(extractFilename('/api/files/serve/s3/folder/subfolder/document.pdf')).toBe(
|
||||
's3/folder/subfolder/document.pdf'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve Azure Blob path structure', () => {
|
||||
expect(extractFilename('/api/files/serve/blob/1234567890-test-document.pdf')).toBe(
|
||||
'blob/1234567890-test-document.pdf'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve Blob path with nested folders', () => {
|
||||
expect(extractFilename('/api/files/serve/blob/uploads/user-files/report.xlsx')).toBe(
|
||||
'blob/uploads/user-files/report.xlsx'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('security - path traversal prevention', () => {
|
||||
it('should sanitize basic path traversal attempt', () => {
|
||||
expect(extractFilename('/api/files/serve/../config.txt')).toBe('config.txt')
|
||||
})
|
||||
|
||||
it('should sanitize deep path traversal attempt', () => {
|
||||
expect(extractFilename('/api/files/serve/../../../../../etc/passwd')).toBe('etcpasswd')
|
||||
})
|
||||
|
||||
it('should sanitize multiple path traversal patterns', () => {
|
||||
expect(extractFilename('/api/files/serve/../../secret.txt')).toBe('secret.txt')
|
||||
})
|
||||
|
||||
it('should sanitize path traversal with forward slashes', () => {
|
||||
expect(extractFilename('/api/files/serve/../../../system/file')).toBe('systemfile')
|
||||
})
|
||||
|
||||
it('should sanitize mixed path traversal patterns', () => {
|
||||
expect(extractFilename('/api/files/serve/../folder/../file.txt')).toBe('folderfile.txt')
|
||||
})
|
||||
|
||||
it('should remove directory separators from local filenames', () => {
|
||||
expect(extractFilename('/api/files/serve/folder/with/separators.txt')).toBe(
|
||||
'folderwithseparators.txt'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle backslash path separators (Windows style)', () => {
|
||||
expect(extractFilename('/api/files/serve/folder\\file.txt')).toBe('folderfile.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud storage path traversal prevention', () => {
|
||||
it('should sanitize S3 path traversal attempts while preserving structure', () => {
|
||||
expect(extractFilename('/api/files/serve/s3/../config')).toBe('s3/config')
|
||||
})
|
||||
|
||||
it('should sanitize S3 path with nested traversal attempts', () => {
|
||||
expect(extractFilename('/api/files/serve/s3/folder/../sensitive/../file.txt')).toBe(
|
||||
's3/folder/sensitive/file.txt'
|
||||
)
|
||||
})
|
||||
|
||||
it('should sanitize Blob path traversal attempts while preserving structure', () => {
|
||||
expect(extractFilename('/api/files/serve/blob/../system.txt')).toBe('blob/system.txt')
|
||||
})
|
||||
|
||||
it('should remove leading dots from cloud path segments', () => {
|
||||
expect(extractFilename('/api/files/serve/s3/.hidden/../file.txt')).toBe('s3/hidden/file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle filename with dots (but not traversal)', () => {
|
||||
expect(extractFilename('/api/files/serve/file.with.dots.txt')).toBe('file.with.dots.txt')
|
||||
})
|
||||
|
||||
it('should handle filename with multiple extensions', () => {
|
||||
expect(extractFilename('/api/files/serve/archive.tar.gz')).toBe('archive.tar.gz')
|
||||
})
|
||||
|
||||
it('should throw error for empty filename after sanitization', () => {
|
||||
expect(() => extractFilename('/api/files/serve/')).toThrow(
|
||||
'Invalid or empty filename after sanitization'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for filename that becomes empty after path traversal removal', () => {
|
||||
expect(() => extractFilename('/api/files/serve/../..')).toThrow(
|
||||
'Invalid or empty filename after sanitization'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle single character filenames', () => {
|
||||
expect(extractFilename('/api/files/serve/a')).toBe('a')
|
||||
})
|
||||
|
||||
it('should handle numeric filenames', () => {
|
||||
expect(extractFilename('/api/files/serve/123')).toBe('123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should match old behavior for legitimate local files', () => {
|
||||
// These test cases verify that our security fix maintains exact backward compatibility
|
||||
// for all legitimate use cases found in the existing codebase
|
||||
expect(extractFilename('/api/files/serve/test-file.txt')).toBe('test-file.txt')
|
||||
expect(extractFilename('/api/files/serve/nonexistent.txt')).toBe('nonexistent.txt')
|
||||
})
|
||||
|
||||
it('should match old behavior for legitimate cloud files', () => {
|
||||
// These test cases are from the actual delete route tests
|
||||
expect(extractFilename('/api/files/serve/s3/1234567890-test-file.txt')).toBe(
|
||||
's3/1234567890-test-file.txt'
|
||||
)
|
||||
expect(extractFilename('/api/files/serve/blob/1234567890-test-document.pdf')).toBe(
|
||||
'blob/1234567890-test-document.pdf'
|
||||
)
|
||||
})
|
||||
|
||||
it('should match old behavior for simple paths', () => {
|
||||
// These match the mock implementations in serve route tests
|
||||
expect(extractFilename('simple-file.txt')).toBe('simple-file.txt')
|
||||
expect(extractFilename('nested/path/file.txt')).toBe('file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Serving Security Tests', () => {
|
||||
describe('createFileResponse security headers', () => {
|
||||
it('should serve safe images inline with proper headers', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'safe-image.png',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'inline; filename="safe-image.png"'
|
||||
)
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should serve PDFs inline safely', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
filename: 'document.pdf',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="document.pdf"')
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
|
||||
it('should force attachment for HTML files to prevent XSS', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('<script>alert("XSS")</script>'),
|
||||
contentType: 'text/html',
|
||||
filename: 'malicious.html',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="malicious.html"'
|
||||
)
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
|
||||
it('should serve SVG files inline with CSP sandbox protection', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from(
|
||||
'<svg onload="alert(\'XSS\')" xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
),
|
||||
contentType: 'image/svg+xml',
|
||||
filename: 'image.svg',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/svg+xml')
|
||||
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.svg"')
|
||||
expect(response.headers.get('Content-Security-Policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
)
|
||||
})
|
||||
|
||||
it('should not apply CSP sandbox to non-SVG files', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('hello'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'readme.txt',
|
||||
})
|
||||
|
||||
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should force attachment for JavaScript files', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('alert("XSS")'),
|
||||
contentType: 'application/javascript',
|
||||
filename: 'malicious.js',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="malicious.js"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should force attachment for CSS files', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('body { background: url(javascript:alert("XSS")) }'),
|
||||
contentType: 'text/css',
|
||||
filename: 'malicious.css',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="malicious.css"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should force attachment for XML files', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('<?xml version="1.0"?><root><script>alert("XSS")</script></root>'),
|
||||
contentType: 'application/xml',
|
||||
filename: 'malicious.xml',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="malicious.xml"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should serve text files safely', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('Safe text content'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'document.txt',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain')
|
||||
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="document.txt"')
|
||||
})
|
||||
|
||||
it('should force attachment for unknown/unsafe content types', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('unknown content'),
|
||||
contentType: 'application/unknown',
|
||||
filename: 'unknown.bin',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/unknown')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="unknown.bin"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Security Policy', () => {
|
||||
it('should include CSP header only for SVG responses', () => {
|
||||
const svgResponse = createFileResponse({
|
||||
buffer: Buffer.from('<svg></svg>'),
|
||||
contentType: 'image/svg+xml',
|
||||
filename: 'icon.svg',
|
||||
})
|
||||
expect(svgResponse.headers.get('Content-Security-Policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
)
|
||||
|
||||
const txtResponse = createFileResponse({
|
||||
buffer: Buffer.from('test'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
})
|
||||
expect(txtResponse.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should include X-Content-Type-Options header', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('test'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
})
|
||||
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLocalFile - Path Traversal Security Tests', () => {
|
||||
describe('path traversal attack prevention', () => {
|
||||
it.concurrent('should reject classic path traversal attacks', async () => {
|
||||
const maliciousInputs = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\..\\windows\\system32\\config\\sam',
|
||||
'../../../../etc/shadow',
|
||||
'../config.json',
|
||||
'..\\config.ini',
|
||||
]
|
||||
|
||||
for (const input of maliciousInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject encoded path traversal attempts', async () => {
|
||||
const encodedInputs = [
|
||||
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
|
||||
'..%2f..%2fetc%2fpasswd',
|
||||
'..%5c..%5cconfig.ini',
|
||||
]
|
||||
|
||||
for (const input of encodedInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject mixed path separators', async () => {
|
||||
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
|
||||
|
||||
for (const input of mixedInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject filenames with dangerous characters', async () => {
|
||||
const dangerousInputs = [
|
||||
'file:with:colons.txt',
|
||||
'file|with|pipes.txt',
|
||||
'file?with?questions.txt',
|
||||
'file*with*asterisks.txt',
|
||||
]
|
||||
|
||||
for (const input of dangerousInputs) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject null and empty inputs', async () => {
|
||||
expect(await findLocalFile('')).toBeNull()
|
||||
expect(await findLocalFile(' ')).toBeNull()
|
||||
expect(await findLocalFile('\t\n')).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should reject filenames that become empty after sanitization', async () => {
|
||||
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
|
||||
|
||||
for (const input of emptyAfterSanitization) {
|
||||
const result = await findLocalFile(input)
|
||||
expect(result).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('security validation passes for legitimate files', () => {
|
||||
it.concurrent(
|
||||
'should accept properly formatted filenames without throwing errors',
|
||||
async () => {
|
||||
const legitimateInputs = [
|
||||
'document.pdf',
|
||||
'image.png',
|
||||
'data.csv',
|
||||
'report-2024.doc',
|
||||
'file_with_underscores.txt',
|
||||
'file-with-dashes.json',
|
||||
]
|
||||
|
||||
for (const input of legitimateInputs) {
|
||||
await expect(findLocalFile(input)).resolves.toBeDefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('FilesUtils')
|
||||
|
||||
export interface ApiSuccessResponse {
|
||||
success: true
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
filename: string
|
||||
cacheControl?: string
|
||||
}
|
||||
|
||||
export class FileNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FileNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvalidRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export const contentTypeMap: Record<string, string> = {
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
md: 'text/markdown',
|
||||
html: 'text/html',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
ts: 'application/typescript',
|
||||
pdf: 'application/pdf',
|
||||
googleDoc: 'application/vnd.google-apps.document',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
googleSheet: 'application/vnd.google-apps.spreadsheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
zip: 'application/zip',
|
||||
googleFolder: 'application/vnd.google-apps.folder',
|
||||
}
|
||||
|
||||
export const binaryExtensions = [
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'zip',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
]
|
||||
|
||||
export function getContentType(filename: string): string {
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return contentTypeMap[extension] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function extractFilename(path: string): string {
|
||||
let filename: string
|
||||
|
||||
if (path.startsWith('/api/files/serve/')) {
|
||||
filename = path.substring('/api/files/serve/'.length)
|
||||
} else {
|
||||
filename = path.split('/').pop() || path
|
||||
}
|
||||
|
||||
filename = filename
|
||||
.replace(/\.\./g, '')
|
||||
.replace(/\/\.\./g, '')
|
||||
.replace(/\.\.\//g, '')
|
||||
|
||||
if (filename.startsWith('s3/') || filename.startsWith('blob/')) {
|
||||
const parts = filename.split('/')
|
||||
const prefix = parts[0] // 's3' or 'blob'
|
||||
const keyParts = parts.slice(1)
|
||||
|
||||
const sanitizedKeyParts = keyParts
|
||||
.map((part) => part.replace(/\.\./g, '').replace(/^\./g, '').trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
filename = `${prefix}/${sanitizedKeyParts.join('/')}`
|
||||
} else {
|
||||
filename = filename.replace(/[/\\]/g, '')
|
||||
}
|
||||
|
||||
if (!filename || filename.trim().length === 0) {
|
||||
throw new Error('Invalid or empty filename after sanitization')
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
export async function findLocalFile(filename: string): Promise<string | null> {
|
||||
try {
|
||||
const sanitizedFilename = sanitizeFileKey(filename)
|
||||
|
||||
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { existsSync } = await import('fs')
|
||||
const path = await import('path')
|
||||
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
|
||||
|
||||
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
|
||||
|
||||
if (
|
||||
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
|
||||
resolvedPath === UPLOAD_DIR_SERVER
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error in findLocalFile:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_INLINE_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
])
|
||||
|
||||
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml'])
|
||||
|
||||
function getSecureFileHeaders(filename: string, originalContentType: string) {
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) {
|
||||
return {
|
||||
contentType: 'application/octet-stream',
|
||||
disposition: 'attachment',
|
||||
}
|
||||
}
|
||||
|
||||
let safeContentType = originalContentType
|
||||
|
||||
if (originalContentType === 'text/html') {
|
||||
safeContentType = 'text/plain'
|
||||
}
|
||||
|
||||
const disposition = SAFE_INLINE_TYPES.has(safeContentType) ? 'inline' : 'attachment'
|
||||
|
||||
return {
|
||||
contentType: safeContentType,
|
||||
disposition,
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFilenameForHeader(storageKey: string): string {
|
||||
const filename = storageKey.split('/').pop() || storageKey
|
||||
|
||||
const hasNonAscii = /[^\x00-\x7F]/.test(filename)
|
||||
|
||||
if (!hasNonAscii) {
|
||||
return `filename="${filename}"`
|
||||
}
|
||||
|
||||
const encodedFilename = encodeURIComponent(filename)
|
||||
const asciiSafe = filename.replace(/[^\x00-\x7F]/g, '_')
|
||||
return `filename="${asciiSafe}"; filename*=UTF-8''${encodedFilename}`
|
||||
}
|
||||
|
||||
export function createFileResponse(file: FileResponse): NextResponse {
|
||||
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
|
||||
'Cache-Control': file.cacheControl || 'public, max-age=31536000',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
|
||||
if (contentType === 'image/svg+xml') {
|
||||
headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
}
|
||||
|
||||
return new NextResponse(file.buffer as BodyInit, { status: 200, headers })
|
||||
}
|
||||
|
||||
export function createErrorResponse(error: Error, status = 500): NextResponse {
|
||||
const statusCode =
|
||||
error instanceof FileNotFoundError ? 404 : error instanceof InvalidRequestError ? 400 : status
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
},
|
||||
{ status: statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
export function createSuccessResponse(data: ApiSuccessResponse): NextResponse {
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fileViewContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { type StorageContext, USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
const logger = createLogger('FilesViewAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const parsed = await parseRequest(fileViewContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const record = await getFileMetadataById(id)
|
||||
if (!record) {
|
||||
logger.warn('File not found by ID', { id })
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Authorize before disclosing anything about the file. Pass the record's own context so access is
|
||||
// resolved from the DB row (workspace and mothership both map to workspace membership) rather than
|
||||
// re-inferred from the key, and deny with 404 so a caller without access cannot distinguish a
|
||||
// file's existence or context from a missing id.
|
||||
const hasAccess = await verifyFileAccess(
|
||||
record.key,
|
||||
authResult.userId,
|
||||
undefined,
|
||||
record.context as StorageContext | 'general'
|
||||
)
|
||||
if (!hasAccess) {
|
||||
logger.warn('Unauthorized file view attempt', { id, userId: authResult.userId })
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Only workspace-scoped files are embeddable/viewable here. Other contexts (e.g. chat-scoped
|
||||
// `mothership` uploads) are not durable workspace artifacts; now that the caller is known to have
|
||||
// access, reject with a legible 422 so the embed fails cleanly and the file agent can self-correct.
|
||||
if (record.context !== 'workspace') {
|
||||
logger.warn('Rejected non-workspace file view', { id, context: record.context })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File ${id} has context "${record.context}" and is not embeddable. Only workspace files can be viewed via /api/files/view. Save it into the workspace and reference the workspace copy.`,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
|
||||
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
|
||||
logger.info('Redirecting file view to serve path', { id, servePath })
|
||||
|
||||
// Emit a relative Location so the browser resolves it against the public origin it requested.
|
||||
// `NextResponse.redirect(new URL(servePath, request.url))` bakes in the host from `request.url`,
|
||||
// which behind the load balancer is the internal pod host (e.g. ip-10-0-x.ec2.internal:3000) —
|
||||
// unreachable from the browser, so embedded <img src="/api/files/view/<id>"> loads fail. A
|
||||
// relative Location resolves against the original public URL (matching how the Files tab fetches
|
||||
// serve URLs directly).
|
||||
return new NextResponse(null, { status: 302, headers: { Location: servePath } })
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user