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
1 change: 1 addition & 0 deletions apps/sim/app/(landing)/integrations/data/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
extend_v2: ExtendIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
file_v4: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,
gamma: GammaIcon,
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/app/(landing)/integrations/data/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4032,18 +4032,22 @@
"tags": ["meeting", "note-taking"]
},
{
"type": "file_v3",
"type": "file_v4",
"slug": "file",
"name": "File",
"description": "Read and write workspace files",
"longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.",
"description": "Read, fetch, write, and append files",
"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.",
"bgColor": "#40916C",
"iconName": "DocumentIcon",
"docsUrl": "https://docs.sim.ai/tools/file",
"operations": [
{
"name": "Read",
"description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)"
"description": "Get a workspace file object from a selected file or canonical workspace file ID."
},
{
"name": "Fetch",
"description": "Parse a file from a URL with optional custom headers for authenticated downloads."
},
{
"name": "Write",
Expand All @@ -4054,7 +4058,7 @@
"description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file."
}
],
"operationCount": 3,
"operationCount": 4,
"triggers": [],
"triggerCount": 0,
"authType": "none",
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/app/api/files/parse/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
permissionsMock,
permissionsMockFns,
storageServiceMock,
Expand Down Expand Up @@ -310,6 +311,39 @@ describe('File Parse API Route', () => {
expect(data.results).toHaveLength(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 process execution file URLs with context query param', async () => {
setupFileApiMocks({
cloudEnabled: true,
Expand Down
24 changes: 18 additions & 6 deletions apps/sim/app/api/files/parse/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
if (!parsed.success) return parsed.response

const { filePath, fileType, workspaceId, workflowId, executionId } = parsed.data.body
const { filePath, fileType, headers, workspaceId, workflowId, executionId } = parsed.data.body

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

if (Array.isArray(filePath)) {
Expand All @@ -146,7 +147,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
fileType,
workspaceId,
userId,
executionContext
executionContext,
headers
)
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
Expand Down Expand Up @@ -180,7 +182,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}

const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
const result = await parseFileSingle(
filePath,
fileType,
workspaceId,
userId,
executionContext,
headers
)

if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
Expand Down Expand Up @@ -225,7 +234,8 @@ async function parseFileSingle(
fileType: string,
workspaceId: string,
userId: string,
executionContext?: ExecutionContext
executionContext?: ExecutionContext,
headers?: Record<string, string>
): Promise<ParseResult> {
logger.info('Parsing file:', filePath)

Expand All @@ -251,7 +261,7 @@ async function parseFileSingle(
}

if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext, headers)
}

if (isUsingCloudStorage()) {
Expand Down Expand Up @@ -298,7 +308,8 @@ async function handleExternalUrl(
fileType: string,
workspaceId: string,
userId: string,
executionContext?: ExecutionContext
executionContext?: ExecutionContext,
headers?: Record<string, string>
): Promise<ParseResult> {
try {
logger.info('Fetching external URL:', url)
Expand Down Expand Up @@ -382,6 +393,7 @@ async function handleExternalUrl(

const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
timeout: DOWNLOAD_TIMEOUT_MS,
...(headers && Object.keys(headers).length > 0 && { headers }),
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
Expand Down
149 changes: 140 additions & 9 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,100 @@ export const dynamic = 'force-dynamic'

const logger = createLogger('FileManageAPI')

const workspaceFileToUserFile = (file: Awaited<ReturnType<typeof getWorkspaceFile>>) => {
if (!file) return null

return {
id: file.id,
name: file.name,
url: ensureAbsoluteUrl(file.path),
size: file.size,
type: file.type,
key: file.key,
context: 'workspace',
}
}

const fileInputToUserFile = (fileInput: unknown) => {
if (!fileInput || typeof fileInput !== 'object' || Array.isArray(fileInput)) return null

const record = fileInput as Record<string, unknown>
const id =
typeof record.id === 'string'
? record.id.trim()
: typeof record.fileId === 'string'
? record.fileId.trim()
: ''

// Objects with ids are resolved through workspace metadata. This fallback is for
// picker/upload values that only carry storage fields.
if (id) return null

const key = typeof record.key === 'string' ? record.key.trim() : ''
const path = typeof record.path === 'string' ? record.path.trim() : ''
const url = typeof record.url === 'string' ? record.url.trim() : ''
const fileUrl =
url || path || (key ? `/api/files/serve/${encodeURIComponent(key)}?context=workspace` : '')

if (!fileUrl && !key) return null

return {
id: key || fileUrl,
name:
typeof record.name === 'string' && record.name.trim() ? record.name.trim() : 'workspace-file',
url: fileUrl ? ensureAbsoluteUrl(fileUrl) : '',
size: typeof record.size === 'number' ? record.size : 0,
type:
typeof record.type === 'string' && record.type.trim()
? record.type.trim()
: 'application/octet-stream',
key,
context: 'workspace',
}
}

const normalizeFileIdList = (value: unknown): string[] => {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []

try {
return normalizeFileIdList(JSON.parse(trimmed))
} catch {
return [trimmed]
}
}

if (!Array.isArray(value)) return []

return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((id) => id.length > 0)
}

const extractUserFilesFromInput = (fileInput: unknown) => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []
return inputs
.map((input) => fileInputToUserFile(input))
.filter((file): file is NonNullable<ReturnType<typeof fileInputToUserFile>> => Boolean(file))
}

const extractFileIdsFromInput = (fileInput: unknown): string[] => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []

return inputs
.flatMap((input) => {
if (typeof input === 'string') return normalizeFileIdList(input)
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>
if (typeof record.id === 'string') return normalizeFileIdList(record.id)
if (typeof record.fileId === 'string') return normalizeFileIdList(record.fileId)
}
return []
})
.filter((id) => id.length > 0)
}

export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
Expand Down Expand Up @@ -76,15 +170,52 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({
success: true,
data: {
file: {
id: file.id,
name: file.name,
url: ensureAbsoluteUrl(file.path),
size: file.size,
type: file.type,
key: file.key,
context: 'workspace',
},
file: workspaceFileToUserFile(file),
},
})
}

case 'read': {
const { fileId, fileInput } = body
const selectedFileIds = Array.isArray(fileId)
? fileId.map((id) => id.trim()).filter(Boolean)
: fileId
? normalizeFileIdList(fileId)
: extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)

if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}

const files = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !files[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}

const userFiles = files
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)

logger.info('Files retrieved', {
count: userFiles.length,
fileIds: userFiles.map((file) => file.id),
})

return NextResponse.json({
success: true,
data: {
file: userFiles[0],
files: userFiles,
},
})
}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ async function handleExecutePost(
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
executionId: requestedExecutionId,
triggerBlockId: parsedTriggerBlockId,
startBlockId,
stopAfterBlockId,
Expand Down Expand Up @@ -508,7 +509,8 @@ async function handleExecutePost(
)
}

const executionId = generateId()
const executionId =
isClientSession && requestedExecutionId ? requestedExecutionId : generateId()
reqLogger = reqLogger.withMetadata({ userId, executionId })

reqLogger.info('Starting server-side execution', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ export function useWorkflowExecution() {
size: fileData.file.size,
type: fileData.file.type,
key: result.key,
context: 'execution',
})
} catch (uploadError) {
if (
Expand Down Expand Up @@ -565,6 +566,7 @@ export function useWorkflowExecution() {
size: r.size,
type: r.type,
key: r.key,
context: r.context || 'execution',
uploadedAt: r.uploadedAt,
expiresAt: r.expiresAt,
})
Expand Down Expand Up @@ -1126,6 +1128,7 @@ export function useWorkflowExecution() {
await executionStream.execute({
workflowId: activeWorkflowId,
input: finalWorkflowInput,
executionId,
startBlockId,
selectedOutputs,
triggerType: overrideTriggerType || 'manual',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ export async function executeWorkflowWithFullLogging(
triggerType: options.overrideTriggerType || 'manual',
useDraftState: options.useDraftState ?? true,
isClientSession: true,
...(options.executionId ? { executionId: options.executionId } : {}),
...(options.triggerBlockId ? { triggerBlockId: options.triggerBlockId } : {}),
...(options.stopAfterBlockId ? { stopAfterBlockId: options.stopAfterBlockId } : {}),
...(options.runFromBlock
Expand Down
Loading
Loading