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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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()
})
})
+803
View File
@@ -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 {}
}
+216
View File
@@ -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()
})
})
+125
View File
@@ -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)
}
+118
View File
@@ -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
)
}
})
+203
View File
@@ -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()
}
)
})
+481
View File
@@ -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 }
)
}
})
+938
View File
@@ -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)
})
})
})
+349
View File
@@ -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 = `![a](/api/files/view/${FILE_ID})`) {
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(`![a](/api/files/serve/${encodeURIComponent(IMG_KEY)}?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(`![a](/api/files/view/${FILE_ID})`, '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
}
}
+795
View File
@@ -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')
})
})
})
+485
View File
@@ -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'))
}
})
+420
View File
@@ -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()
}
}
)
})
})
+240
View File
@@ -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)
}
+72
View File
@@ -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 } })
}
)