Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/sim/app/api/files/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ function grantAccess(cloudKey: string) {
}

describe('verifyKBFileAccess (binding-only)', () => {
it.each(['mothership', 'profile-pictures', 'general'] as const)(
'refuses organization image keys through legacy %s authorization',
async (context) => {
await expect(
verifyFileAccess('assistant/org-1/user-1/upload-1/image.png', USER_ID, undefined, context)
).resolves.toBe(false)
}
)
beforeEach(() => {
vi.clearAllMocks()
// Default liveness query result: one active document references the exact storage key.
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export async function verifyFileAccess(
isLocal?: boolean,
options?: { requireWrite?: boolean; knowledgeAccess?: KnowledgeFileAccess }
): Promise<boolean> {
/** Organization images require the Principal-aware Assistant application resolver. */
if (cloudKey.startsWith('assistant/')) return false
const requireWrite = options?.requireWrite ?? false
try {
const keyContext = inferContextFromKey(cloudKey)
Expand Down
50 changes: 49 additions & 1 deletion apps/sim/app/api/files/serve/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
*
* @vitest-environment node
*/
import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
import {
authMockFns,
hybridAuthMockFns,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
Expand Down Expand Up @@ -34,6 +39,7 @@ const {
mockCreateErrorResponse,
FileNotFoundError,
serveLogger,
mockReadOrganizationAssistantImage,
} = vi.hoisted(() => {
class FileNotFoundErrorClass extends Error {
constructor(message: string) {
Expand All @@ -43,6 +49,7 @@ const {
}
return {
serveLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
mockReadOrganizationAssistantImage: vi.fn(),
mockVerifyFileAccess: vi.fn(),
mockReadFile: vi.fn(),
mockIsUsingCloudStorage: vi.fn(),
Expand All @@ -62,6 +69,10 @@ const {
}
})

vi.mock('@/lib/uploads/contexts/organization-assistant/application', () => ({
readOrganizationAssistantImage: mockReadOrganizationAssistantImage,
}))

vi.mock('fs/promises', () => ({
readFile: mockReadFile,
access: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -204,6 +215,43 @@ describe('File Serve API Route', () => {
})
})

it('serves private Assistant images through session authorization and disables caching', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
const key = 'assistant/org-1/user-1/upload-1/image.png'
mockReadOrganizationAssistantImage.mockResolvedValue({
name: 'image.png',
contentType: 'image/webp',
buffer: Buffer.from('decoded-image'),
})
const response = await GET(new NextRequest(`http://localhost/api/files/serve/${key}`), {
params: Promise.resolve({ path: key.split('/') }),
})
expect(response.status).toBe(200)
expect(mockReadOrganizationAssistantImage).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
key,
signal: expect.any(AbortSignal),
})
expect(mockCreateFileResponse).toHaveBeenCalledWith(
expect.objectContaining({ cacheControl: 'private, no-store', contentType: 'image/webp' })
)
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(hybridAuthMockFns.mockCheckSessionOrInternalAuth).not.toHaveBeenCalled()
})

it('requires a real session for private Assistant images even when legacy auth succeeds', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const key = 'assistant/org-1/user-1/upload-1/image.png'
const response = await GET(new NextRequest(`http://localhost/api/files/serve/${key}`), {
params: Promise.resolve({ path: key.split('/') }),
})
expect(response.status).toBe(401)
expect(mockReadOrganizationAssistantImage).not.toHaveBeenCalled()
})

it('bounds the local read rather than trusting the stored size', async () => {
await GET(new NextRequest('http://localhost:3000/api/files/serve/workspace/ws/test-file.txt'), {
params: Promise.resolve({ path: ['workspace', 'ws', 'test-file.txt'] }),
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts
import {
concealCrossTenantResourceError,
InternalUnauthenticatedError,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile'
Expand All @@ -16,6 +17,7 @@ import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { readOrganizationAssistantImage } from '@/lib/uploads/contexts/organization-assistant/application'
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
Expand Down Expand Up @@ -218,6 +220,21 @@ export const GET = withRouteHandler(
const isCloudPath = isS3Path || isBlobPath || isGcsPath
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath

if (cloudKey.startsWith('assistant/')) {
const principal = await internalSessionAuth.authenticate()
const image = await readOrganizationAssistantImage({
principal,
key: cloudKey,
signal: request.signal,
})
return createFileResponse({
buffer: image.buffer,
filename: image.name,
contentType: image.contentType,
cacheControl: 'private, no-store',
})
}

const isPublicByKeyPrefix =
cloudKey.startsWith('profile-pictures/') ||
cloudKey.startsWith('og-images/') ||
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/files/uploads/finalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types
import { captureServerEvent } from '@/lib/posthog/server'
import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import { finalizeOrganizationAssistantAttachment } from '@/lib/uploads/contexts/organization-assistant/application'
import {
getWorkspaceFile,
registerUploadedWorkspaceFile,
Expand Down Expand Up @@ -107,6 +108,9 @@ export async function finalizeUploadPurpose({
case 'workspace_logo':
return finalizeWorkspaceLogo(session, actor, request)
case 'mothership_attachment':
if (session.workspaceId === null) {
return { value: await finalizeOrganizationAssistantAttachment(principal, session) }
}
return finalizeMothershipAttachment(session)
case 'execution_attachment':
return finalizeExecutionAttachment(session)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/files/uploads/purposes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export async function createPurposeUploadSession(
localOrigin,
})
case 'mothership_attachment':
if (!body.workspaceId) throw new UploadSessionError('validation', 'workspaceId is required')
await requireWorkspacePermission(userId, body.workspaceId, 'write')
return createUploadSession({
purpose: body.purpose,
Expand Down
43 changes: 43 additions & 0 deletions apps/sim/app/api/files/uploads/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,49 @@ describe('/api/files/uploads', () => {
)
})

it.each([
{ organizationId: 'org-1', contentType: 'application/pdf', size: 100 },
{ organizationId: 'org-1', contentType: 'image/svg+xml', size: 100 },
{ organizationId: 'org-1', contentType: 'image/png', size: 5 * 1024 * 1024 + 1 },
{ organizationId: 'org-1', workspaceId: 'ws-1', contentType: 'image/png', size: 100 },
])('rejects unsupported organization attachments before application loading', async (body) => {
const response = await createUpload(
new NextRequest('http://localhost/api/files/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purpose: 'mothership_attachment', name: 'image.png', ...body }),
})
)
expect(response.status).toBe(400)
expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled()
})

it('creates organization image attachments through the same upload lifecycle', async () => {
mockCreateInternalPurposeUploadSession.mockResolvedValue({
...session({ purpose: 'mothership_attachment', storageContext: 'mothership' }),
transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} },
})
const response = await createUpload(
new NextRequest('http://localhost/api/files/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
purpose: 'mothership_attachment',
organizationId: 'org-1',
name: 'image.png',
contentType: 'image/png',
size: 100,
}),
})
)
expect(response.status).toBe(201)
expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'session', userId: 'user-1' }),
expect.objectContaining({ purpose: 'mothership_attachment', organizationId: 'org-1' }),
expect.anything()
)
})

it('rejects mothership attachments above the 5 GiB direct-to-storage limit', async () => {
const request = new NextRequest('http://localhost/api/files/uploads', {
method: 'POST',
Expand Down
Loading
Loading