Skip to content

Commit 78b2fed

Browse files
committed
Merge branch 'staging' into feat/workspace-files-folders
2 parents 62393f2 + bad21cb commit 78b2fed

122 files changed

Lines changed: 3424 additions & 691 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"fumadocs-openapi": "10.8.1",
2727
"fumadocs-ui": "16.8.5",
2828
"lucide-react": "^0.511.0",
29-
"next": "16.2.4",
29+
"next": "16.2.6",
3030
"next-themes": "^0.4.6",
3131
"postgres": "^3.4.5",
3232
"react": "19.2.4",

apps/sim/app/(landing)/integrations/data/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
258258
extend_v2: ExtendIcon,
259259
fathom: FathomIcon,
260260
file_v3: DocumentIcon,
261+
file_v4: DocumentIcon,
261262
firecrawl: FirecrawlIcon,
262263
fireflies_v2: FirefliesIcon,
263264
gamma: GammaIcon,

apps/sim/app/(landing)/integrations/data/integrations.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4032,18 +4032,22 @@
40324032
"tags": ["meeting", "note-taking"]
40334033
},
40344034
{
4035-
"type": "file_v3",
4035+
"type": "file_v4",
40364036
"slug": "file",
40374037
"name": "File",
4038-
"description": "Read and write workspace files",
4039-
"longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.",
4038+
"description": "Read, fetch, write, and append files",
4039+
"longDescription": "Read workspace files by picker or canonical ID, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.",
40404040
"bgColor": "#40916C",
40414041
"iconName": "DocumentIcon",
40424042
"docsUrl": "https://docs.sim.ai/tools/file",
40434043
"operations": [
40444044
{
40454045
"name": "Read",
4046-
"description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)"
4046+
"description": "Get a workspace file object from a selected file or canonical workspace file ID."
4047+
},
4048+
{
4049+
"name": "Fetch",
4050+
"description": "Parse a file from a URL with optional custom headers for authenticated downloads."
40474051
},
40484052
{
40494053
"name": "Write",
@@ -4054,7 +4058,7 @@
40544058
"description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file."
40554059
}
40564060
],
4057-
"operationCount": 3,
4061+
"operationCount": 4,
40584062
"triggers": [],
40594063
"triggerCount": 0,
40604064
"authType": "none",

apps/sim/app/api/files/authorization.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import { isUuid } from '@/executor/constants'
1414

1515
const logger = createLogger('FileAuthorization')
1616

17+
/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
18+
export class FileAccessDeniedError extends Error {
19+
constructor() {
20+
super('File not found')
21+
this.name = 'FileAccessDeniedError'
22+
}
23+
}
24+
1725
interface AuthorizationResult {
1826
granted: boolean
1927
reason: string
@@ -598,18 +606,14 @@ async function authorizeFileAccess(
598606
*/
599607
export async function assertToolFileAccess(
600608
key: unknown,
601-
userId: string | undefined,
609+
userId: string,
602610
requestId: string,
603611
routeLogger: ReturnType<typeof createLogger>
604612
): Promise<NextResponse | null> {
605613
if (typeof key !== 'string' || key.length === 0) {
606614
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
607615
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
608616
}
609-
if (!userId) {
610-
routeLogger.warn(`[${requestId}] File access check requires userId but none available`)
611-
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
612-
}
613617
const hasAccess = await verifyFileAccess(key, userId)
614618
if (!hasAccess) {
615619
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })

apps/sim/app/api/files/multipart/route.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({
5353

5454
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
5555

56+
const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
57+
mockCheckStorageQuota: vi.fn(),
58+
mockInitiateS3MultipartUpload: vi.fn(),
59+
}))
60+
61+
vi.mock('@/lib/billing/storage', () => ({
62+
checkStorageQuota: mockCheckStorageQuota,
63+
}))
64+
5665
import { POST } from '@/app/api/files/multipart/route'
5766

5867
const tokenPayload = {
@@ -200,3 +209,69 @@ describe('POST /api/files/multipart action=complete', () => {
200209
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
201210
})
202211
})
212+
213+
describe('POST /api/files/multipart action=initiate quota enforcement', () => {
214+
const makeInitiateRequest = (body: unknown) =>
215+
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
216+
method: 'POST',
217+
headers: { 'Content-Type': 'application/json' },
218+
body: JSON.stringify(body),
219+
})
220+
221+
beforeEach(() => {
222+
vi.clearAllMocks()
223+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
224+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
225+
mockIsUsingCloudStorage.mockReturnValue(true)
226+
mockGetStorageProvider.mockReturnValue('s3')
227+
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
228+
mockSignUploadToken.mockReturnValue('signed-token')
229+
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
230+
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
231+
})
232+
233+
it('blocks upload when fileSize: 0 exceeds quota', async () => {
234+
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
235+
236+
const res = await makeInitiateRequest({
237+
fileName: 'file.bin',
238+
contentType: 'application/octet-stream',
239+
fileSize: 0,
240+
workspaceId: 'ws-1',
241+
context: 'knowledge-base',
242+
})
243+
244+
const response = await POST(res)
245+
expect(response.status).toBe(413)
246+
const body = await response.json()
247+
expect(body.error).toContain('Storage limit exceeded')
248+
})
249+
250+
it('does not check quota for quota-exempt contexts (og-images)', async () => {
251+
const res = await makeInitiateRequest({
252+
fileName: 'img.png',
253+
contentType: 'image/png',
254+
fileSize: 99999,
255+
workspaceId: 'ws-1',
256+
context: 'og-images',
257+
})
258+
259+
const response = await POST(res)
260+
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
261+
})
262+
263+
it('rejects logs context — not allowed via the multipart endpoint', async () => {
264+
const res = await makeInitiateRequest({
265+
fileName: 'exec.log',
266+
contentType: 'text/plain',
267+
fileSize: 1000,
268+
workspaceId: 'ws-1',
269+
context: 'logs',
270+
})
271+
272+
const response = await POST(res)
273+
expect(response.status).toBe(400)
274+
const body = await response.json()
275+
expect(body.error).toMatch(/invalid storage context/i)
276+
})
277+
})

apps/sim/app/api/files/multipart/route.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type UploadTokenPayload,
2323
verifyUploadToken,
2424
} from '@/lib/uploads/core/upload-token'
25-
import type { StorageConfig } from '@/lib/uploads/shared/types'
25+
import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
2626
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2727

2828
const logger = createLogger('MultipartUploadAPI')
@@ -36,7 +36,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
3636
'workspace',
3737
'profile-pictures',
3838
'og-images',
39-
'logs',
4039
'workspace-logos',
4140
])
4241

@@ -135,6 +134,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
135134

136135
const config = getStorageConfig(storageContext)
137136

137+
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) {
138+
const { checkStorageQuota } = await import('@/lib/billing/storage')
139+
const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
140+
if (!quotaCheck.allowed) {
141+
return NextResponse.json(
142+
{ error: quotaCheck.error || 'Storage limit exceeded' },
143+
{ status: 413 }
144+
)
145+
}
146+
}
147+
138148
let customKey: string | undefined
139149
if (context === 'workspace' || context === 'mothership') {
140150
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
@@ -149,15 +159,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
149159
'@/lib/uploads/contexts/workspace/workspace-file-manager'
150160
)
151161
customKey = generateWorkspaceFileKey(workspaceId, fileName)
152-
153-
const { checkStorageQuota } = await import('@/lib/billing/storage')
154-
const quotaCheck = await checkStorageQuota(userId, fileSize)
155-
if (!quotaCheck.allowed) {
156-
return NextResponse.json(
157-
{ error: quotaCheck.error || 'Storage limit exceeded' },
158-
{ status: 413 }
159-
)
160-
}
161162
} else if (context === 'execution') {
162163
const workflowId = (data as { workflowId?: unknown }).workflowId
163164
const executionId = (data as { executionId?: unknown }).executionId

apps/sim/app/api/files/parse/route.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createMockRequest,
99
hybridAuthMockFns,
1010
inputValidationMock,
11+
inputValidationMockFns,
1112
permissionsMock,
1213
permissionsMockFns,
1314
storageServiceMock,
@@ -310,6 +311,39 @@ describe('File Parse API Route', () => {
310311
expect(data.results).toHaveLength(2)
311312
})
312313

314+
it('should pass custom headers when fetching external URLs', async () => {
315+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
316+
isValid: true,
317+
resolvedIP: '203.0.113.10',
318+
})
319+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
320+
new Response('private file content', {
321+
status: 200,
322+
headers: { 'content-type': 'text/plain' },
323+
})
324+
)
325+
326+
const headers = { Authorization: 'Bearer xoxb-test-token' }
327+
const req = createMockRequest('POST', {
328+
filePath: 'https://files.slack.com/files-pri/T000-F000/download/report.txt',
329+
headers,
330+
})
331+
332+
const response = await POST(req)
333+
const data = await response.json()
334+
335+
expect(response.status).toBe(200)
336+
expect(data.success).toBe(true)
337+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
338+
'https://files.slack.com/files-pri/T000-F000/download/report.txt',
339+
'203.0.113.10',
340+
expect.objectContaining({
341+
timeout: 30000,
342+
headers,
343+
})
344+
)
345+
})
346+
313347
it('should process execution file URLs with context query param', async () => {
314348
setupFileApiMocks({
315349
cloudEnabled: true,

apps/sim/app/api/files/parse/route.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
110110
)
111111
if (!parsed.success) return parsed.response
112112

113-
const { filePath, fileType, workspaceId, workflowId, executionId } = parsed.data.body
113+
const { filePath, fileType, headers, workspaceId, workflowId, executionId } = parsed.data.body
114114

115115
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
116116
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
@@ -128,6 +128,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
128128
workspaceId,
129129
userId,
130130
hasExecutionContext: !!executionContext,
131+
hasHeaders: Boolean(headers && Object.keys(headers).length > 0),
131132
})
132133

133134
if (Array.isArray(filePath)) {
@@ -146,7 +147,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
146147
fileType,
147148
workspaceId,
148149
userId,
149-
executionContext
150+
executionContext,
151+
headers
150152
)
151153
if (result.metadata) {
152154
result.metadata.processingTime = Date.now() - startTime
@@ -180,7 +182,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
180182
})
181183
}
182184

183-
const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
185+
const result = await parseFileSingle(
186+
filePath,
187+
fileType,
188+
workspaceId,
189+
userId,
190+
executionContext,
191+
headers
192+
)
184193

185194
if (result.metadata) {
186195
result.metadata.processingTime = Date.now() - startTime
@@ -225,7 +234,8 @@ async function parseFileSingle(
225234
fileType: string,
226235
workspaceId: string,
227236
userId: string,
228-
executionContext?: ExecutionContext
237+
executionContext?: ExecutionContext,
238+
headers?: Record<string, string>
229239
): Promise<ParseResult> {
230240
logger.info('Parsing file:', filePath)
231241

@@ -251,7 +261,7 @@ async function parseFileSingle(
251261
}
252262

253263
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
254-
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
264+
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext, headers)
255265
}
256266

257267
if (isUsingCloudStorage()) {
@@ -298,7 +308,8 @@ async function handleExternalUrl(
298308
fileType: string,
299309
workspaceId: string,
300310
userId: string,
301-
executionContext?: ExecutionContext
311+
executionContext?: ExecutionContext,
312+
headers?: Record<string, string>
302313
): Promise<ParseResult> {
303314
try {
304315
logger.info('Fetching external URL:', url)
@@ -382,6 +393,7 @@ async function handleExternalUrl(
382393

383394
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
384395
timeout: DOWNLOAD_TIMEOUT_MS,
396+
...(headers && Object.keys(headers).length > 0 && { headers }),
385397
})
386398
if (!response.ok) {
387399
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)

apps/sim/app/api/tools/agiloft/attach/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
1111
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1212
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
13+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1314
import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils'
1415

1516
export const dynamic = 'force-dynamic'
@@ -22,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2223
try {
2324
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2425

25-
if (!authResult.success) {
26+
if (!authResult.success || !authResult.userId) {
2627
logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`)
2728
return NextResponse.json(
2829
{ success: false, error: authResult.error || 'Authentication required' },
@@ -66,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6667
`[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)`
6768
)
6869

70+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
71+
if (denied) return denied
6972
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
7073
const resolvedFileName = data.fileName || userFile.name || 'attachment'
7174

0 commit comments

Comments
 (0)