From bea0a685ae8047c244d44403503da5b84c8fbeb6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 31 Jan 2026 18:50:14 -0800 Subject: [PATCH 01/39] improvement(collab): do not refetch active workflow id --- apps/sim/hooks/use-collaborative-workflow.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index caf0aad9f0..0dd603218f 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1170,8 +1170,6 @@ export function useCollaborativeWorkflow() { const operationId = crypto.randomUUID() - const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - addToQueue({ id: operationId, operation: { @@ -1179,7 +1177,7 @@ export function useCollaborativeWorkflow() { target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, - workflowId: currentActiveWorkflowId || '', + workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) }, From 1da3407f41eda21698f88624036cef0b2a47a64b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 1 Feb 2026 11:14:32 -0800 Subject: [PATCH 02/39] progress on files --- apps/docs/content/docs/en/execution/files.mdx | 134 +++++++++++++++ apps/docs/content/docs/en/execution/meta.json | 2 +- .../api/tools/discord/send-message/route.ts | 3 +- apps/sim/app/api/tools/gmail/draft/route.ts | 3 +- apps/sim/app/api/tools/gmail/send/route.ts | 3 +- .../api/tools/google_drive/upload/route.ts | 3 +- .../microsoft_teams/write_channel/route.ts | 3 +- .../tools/microsoft_teams/write_chat/route.ts | 3 +- apps/sim/app/api/tools/mistral/parse/route.ts | 121 +++++++++----- .../app/api/tools/onedrive/upload/route.ts | 21 +-- apps/sim/app/api/tools/outlook/draft/route.ts | 3 +- apps/sim/app/api/tools/outlook/send/route.ts | 3 +- apps/sim/app/api/tools/s3/put-object/route.ts | 3 +- apps/sim/app/api/tools/sftp/upload/route.ts | 10 +- .../app/api/tools/sharepoint/upload/route.ts | 3 +- .../app/api/tools/slack/send-message/route.ts | 3 +- apps/sim/app/api/tools/smtp/send/route.ts | 3 +- .../tools/supabase/storage-upload/route.ts | 3 +- .../api/tools/telegram/send-document/route.ts | 3 +- .../sim/app/api/tools/vision/analyze/route.ts | 71 +++++++- .../app/api/tools/wordpress/upload/route.ts | 3 +- .../deploy-modal/components/a2a/a2a.tsx | 2 +- .../components/starter/input-format.tsx | 6 +- .../components/tag-dropdown/tag-dropdown.tsx | 2 +- .../hooks/use-block-output-fields.ts | 2 +- apps/sim/background/webhook-execution.ts | 4 +- apps/sim/blocks/blocks/chat_trigger.ts | 2 +- apps/sim/blocks/blocks/dropbox.ts | 26 ++- apps/sim/blocks/blocks/file.ts | 119 ++++++++++++- apps/sim/blocks/blocks/gmail.ts | 4 +- apps/sim/blocks/blocks/google_drive.ts | 2 +- apps/sim/blocks/blocks/google_slides.ts | 24 ++- apps/sim/blocks/blocks/google_vault.ts | 2 +- apps/sim/blocks/blocks/image_generator.ts | 2 +- apps/sim/blocks/blocks/imap.ts | 2 +- apps/sim/blocks/blocks/jira.ts | 2 +- apps/sim/blocks/blocks/linear.ts | 2 +- apps/sim/blocks/blocks/microsoft_teams.ts | 2 +- apps/sim/blocks/blocks/mistral_parse.ts | 12 +- apps/sim/blocks/blocks/onedrive.ts | 2 +- apps/sim/blocks/blocks/outlook.ts | 2 +- apps/sim/blocks/blocks/pipedrive.ts | 2 +- apps/sim/blocks/blocks/s3.ts | 1 + apps/sim/blocks/blocks/sharepoint.ts | 2 +- apps/sim/blocks/blocks/slack.ts | 2 +- apps/sim/blocks/blocks/spotify.ts | 26 ++- apps/sim/blocks/blocks/supabase.ts | 6 +- apps/sim/blocks/blocks/telegram.ts | 120 +++++++++---- apps/sim/blocks/blocks/tts.ts | 2 +- apps/sim/blocks/blocks/video_generator.ts | 2 +- apps/sim/blocks/blocks/vision.ts | 86 +++++++++- apps/sim/blocks/registry.ts | 6 +- apps/sim/blocks/types.ts | 3 +- apps/sim/executor/execution/block-executor.ts | 2 +- .../executor/utils/block-reference.test.ts | 6 +- apps/sim/executor/utils/block-reference.ts | 2 +- apps/sim/lib/execution/files.ts | 2 +- apps/sim/lib/mcp/workflow-tool-schema.ts | 2 +- .../execution/execution-file-manager.ts | 22 +-- apps/sim/lib/uploads/utils/file-schemas.ts | 24 +++ apps/sim/lib/uploads/utils/file-utils.ts | 48 +++++- apps/sim/lib/webhooks/attachment-processor.ts | 11 +- .../sim/lib/workflows/blocks/block-outputs.ts | 27 +-- .../workflows/operations/deployment-utils.ts | 2 +- apps/sim/lib/workflows/types.ts | 2 +- apps/sim/tools/discord/types.ts | 4 +- apps/sim/tools/dropbox/download.ts | 68 +++++--- apps/sim/tools/dropbox/types.ts | 5 +- apps/sim/tools/file/index.ts | 3 +- apps/sim/tools/file/parser.ts | 157 +++++++++++------- apps/sim/tools/file/types.ts | 11 ++ apps/sim/tools/google_drive/download.ts | 10 +- apps/sim/tools/google_drive/types.ts | 3 +- apps/sim/tools/microsoft_teams/types.ts | 3 +- apps/sim/tools/mistral/parser.ts | 77 ++++++++- apps/sim/tools/mistral/types.ts | 14 +- apps/sim/tools/onedrive/types.ts | 3 +- apps/sim/tools/outlook/types.ts | 5 +- apps/sim/tools/registry.ts | 3 +- apps/sim/tools/s3/get_object.ts | 27 ++- apps/sim/tools/s3/types.ts | 2 + apps/sim/tools/sendgrid/types.ts | 13 +- apps/sim/tools/sftp/types.ts | 3 +- apps/sim/tools/sharepoint/types.ts | 3 +- apps/sim/tools/slack/types.ts | 3 +- apps/sim/tools/smtp/types.ts | 3 +- apps/sim/tools/telegram/types.ts | 3 +- apps/sim/tools/vision/tool.ts | 2 +- apps/sim/tools/vision/types.ts | 3 +- apps/sim/triggers/generic/webhook.ts | 2 +- apps/sim/triggers/microsoftteams/webhook.ts | 2 +- 91 files changed, 1125 insertions(+), 340 deletions(-) create mode 100644 apps/docs/content/docs/en/execution/files.mdx create mode 100644 apps/sim/lib/uploads/utils/file-schemas.ts diff --git a/apps/docs/content/docs/en/execution/files.mdx b/apps/docs/content/docs/en/execution/files.mdx new file mode 100644 index 0000000000..14c5121d4f --- /dev/null +++ b/apps/docs/content/docs/en/execution/files.mdx @@ -0,0 +1,134 @@ +--- +title: Passing Files +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Sim makes it easy to work with files throughout your workflows. Blocks can receive files, process them, and pass them to other blocks seamlessly. + +## File Objects + +When blocks output files (like Gmail attachments, generated images, or parsed documents), they return a standardized file object: + +```json +{ + "name": "report.pdf", + "url": "https://...", + "base64": "JVBERi0xLjQK...", + "type": "application/pdf", + "size": 245678 +} +``` + +You can access any of these properties when referencing files from previous blocks. + +## Passing Files Between Blocks + +Reference files from previous blocks using the tag dropdown. Click in any file input field and type `<` to see available outputs. + +**Common patterns:** + +``` +// Single file from a block + + +// Pass the whole file object + + +// Access specific properties + + +``` + +Most blocks accept the full file object and extract what they need automatically. You don't need to manually extract `base64` or `url` in most cases. + +## Triggering Workflows with Files + +When calling a workflow via API that expects file input, include files in your request: + + + + ```bash + curl -X POST "https://sim.ai/api/workflows/YOUR_WORKFLOW_ID/execute" \ + -H "Content-Type: application/json" \ + -H "x-api-key: YOUR_API_KEY" \ + -d '{ + "document": { + "name": "report.pdf", + "base64": "JVBERi0xLjQK...", + "type": "application/pdf" + } + }' + ``` + + + ```bash + curl -X POST "https://sim.ai/api/workflows/YOUR_WORKFLOW_ID/execute" \ + -H "Content-Type: application/json" \ + -H "x-api-key: YOUR_API_KEY" \ + -d '{ + "document": { + "name": "report.pdf", + "url": "https://example.com/report.pdf", + "type": "application/pdf" + } + }' + ``` + + + +The workflow's Start block should have an input field configured to receive the file parameter. + +## Receiving Files in API Responses + +When a workflow outputs files, they're included in the response: + +```json +{ + "success": true, + "output": { + "generatedFile": { + "name": "output.png", + "url": "https://...", + "base64": "iVBORw0KGgo...", + "type": "image/png", + "size": 34567 + } + } +} +``` + +Use `url` for direct downloads or `base64` for inline processing. + +## Blocks That Work with Files + +**File inputs:** +- **File** - Parse documents, images, and text files +- **Vision** - Analyze images with AI models +- **Mistral Parser** - Extract text from PDFs + +**File outputs:** +- **Gmail** - Email attachments +- **Slack** - Downloaded files +- **TTS** - Generated audio files +- **Video Generator** - Generated videos +- **Image Generator** - Generated images + +**File storage:** +- **Supabase** - Upload/download from storage +- **S3** - AWS S3 operations +- **Google Drive** - Drive file operations +- **Dropbox** - Dropbox file operations + + + Files are automatically available to downstream blocks. The execution engine handles all file transfer and format conversion. + + +## Best Practices + +1. **Use file objects directly** - Pass the full file object rather than extracting individual properties. Blocks handle the conversion automatically. + +2. **Check file types** - Ensure the file type matches what the receiving block expects. The Vision block needs images, the File block handles documents. + +3. **Consider file size** - Large files increase execution time. For very large files, consider using storage blocks (S3, Supabase) for intermediate storage. diff --git a/apps/docs/content/docs/en/execution/meta.json b/apps/docs/content/docs/en/execution/meta.json index 37cac68f5a..fd2124b9dd 100644 --- a/apps/docs/content/docs/en/execution/meta.json +++ b/apps/docs/content/docs/en/execution/meta.json @@ -1,3 +1,3 @@ { - "pages": ["index", "basics", "api", "logging", "costs"] + "pages": ["index", "basics", "files", "api", "logging", "costs"] } diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 273657a61d..c597ae4670 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -15,7 +16,7 @@ const DiscordSendMessageSchema = z.object({ botToken: z.string().min(1, 'Bot token is required'), channelId: z.string().min(1, 'Channel ID is required'), content: z.string().optional().nullable(), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index 627ab0ad48..7a6c6cf0c1 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -28,7 +29,7 @@ const GmailDraftSchema = z.object({ replyToMessageId: z.string().optional().nullable(), cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), - attachments: z.array(z.any()).optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 535587aa04..26c0ce3f7a 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -28,7 +29,7 @@ const GmailSendSchema = z.object({ replyToMessageId: z.string().optional().nullable(), cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), - attachments: z.array(z.any()).optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 9cf53e41d3..3549245fd5 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -20,7 +21,7 @@ const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files' const GoogleDriveUploadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), fileName: z.string().min(1, 'File name is required'), - file: z.any().optional().nullable(), + file: RawFileInputSchema.optional().nullable(), mimeType: z.string().optional().nullable(), folderId: z.string().optional().nullable(), }) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index dcaa0f738c..bcfcb0b40e 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -16,7 +17,7 @@ const TeamsWriteChannelSchema = z.object({ teamId: z.string().min(1, 'Team ID is required'), channelId: z.string().min(1, 'Channel ID is required'), content: z.string().min(1, 'Message content is required'), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 14454fafaf..6b940e17c5 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -15,7 +16,7 @@ const TeamsWriteChatSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), chatId: z.string().min(1, 'Chat ID is required'), content: z.string().min(1, 'Message content is required'), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index a40e5d502a..89ff35b772 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -18,7 +18,8 @@ const logger = createLogger('MistralParseAPI') const MistralParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().min(1, 'File path is required'), + filePath: z.string().min(1, 'File path is required').optional(), + fileData: z.unknown().optional(), resultType: z.string().optional(), pages: z.array(z.number()).optional(), includeImageBase64: z.boolean().optional(), @@ -49,66 +50,96 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = MistralParseSchema.parse(body) + const fileData = validatedData.fileData + const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath + + if (!fileData && (!filePath || filePath.trim() === '')) { + return NextResponse.json( + { + success: false, + error: 'File input is required', + }, + { status: 400 } + ) + } + logger.info(`[${requestId}] Mistral parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), + hasFileData: Boolean(fileData), + filePath, + isWorkspaceFile: filePath ? isInternalFileUrl(filePath) : false, userId, }) - let fileUrl = validatedData.filePath - - if (isInternalFileUrl(validatedData.filePath)) { - try { - const storageKey = extractStorageKey(validatedData.filePath) - - const context = inferContextFromKey(storageKey) + const mistralBody: any = { + model: 'mistral-ocr-latest', + } - const hasAccess = await verifyFileAccess( - storageKey, - userId, - undefined, // customConfig - context, // context - false // isLocal + if (fileData && typeof fileData === 'object') { + const base64 = (fileData as { base64?: string }).base64 + const mimeType = (fileData as { type?: string }).type || 'application/pdf' + if (!base64) { + return NextResponse.json( + { + success: false, + error: 'File base64 content is required', + }, + { status: 400 } ) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: storageKey, - context, - }) + } + const base64Payload = base64.startsWith('data:') + ? base64 + : `data:${mimeType};base64,${base64}` + mistralBody.document = { + type: 'document_base64', + document_base64: base64Payload, + } + } else if (filePath) { + let fileUrl = filePath + + if (isInternalFileUrl(filePath)) { + try { + const storageKey = extractStorageKey(filePath) + + const context = inferContextFromKey(storageKey) + + const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) return NextResponse.json( { success: false, - error: 'File not found', + error: 'Failed to generate file access URL', }, - { status: 404 } + { status: 500 } ) } - - fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) - return NextResponse.json( - { - success: false, - error: 'Failed to generate file access URL', - }, - { status: 500 } - ) + } else if (filePath.startsWith('/')) { + const baseUrl = getBaseUrl() + fileUrl = `${baseUrl}${filePath}` } - } else if (validatedData.filePath?.startsWith('/')) { - const baseUrl = getBaseUrl() - fileUrl = `${baseUrl}${validatedData.filePath}` - } - const mistralBody: any = { - model: 'mistral-ocr-latest', - document: { + mistralBody.document = { type: 'document_url', document_url: fileUrl, - }, + } } if (validatedData.pages) { diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 759b41da32..c7ffcaf7a9 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getExtensionFromMimeType, processSingleFileToUserFile, @@ -29,7 +30,7 @@ const ExcelValuesSchema = z.union([ const OneDriveUploadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), fileName: z.string().min(1, 'File name is required'), - file: z.any().optional(), + file: RawFileInputSchema.optional(), folderId: z.string().optional().nullable(), mimeType: z.string().nullish(), values: ExcelValuesSchema.optional().nullable(), @@ -88,25 +89,9 @@ export async function POST(request: NextRequest) { ) } - let fileToProcess - if (Array.isArray(rawFile)) { - if (rawFile.length === 0) { - return NextResponse.json( - { - success: false, - error: 'No file provided', - }, - { status: 400 } - ) - } - fileToProcess = rawFile[0] - } else { - fileToProcess = rawFile - } - let userFile try { - userFile = processSingleFileToUserFile(fileToProcess, requestId, logger) + userFile = processSingleFileToUserFile(rawFile, requestId, logger) } catch (error) { return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 39bb3f5ef6..eeee0f14e1 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -18,7 +19,7 @@ const OutlookDraftSchema = z.object({ contentType: z.enum(['text', 'html']).optional().nullable(), cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), - attachments: z.array(z.any()).optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index 3293188809..88578bcef6 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -20,7 +21,7 @@ const OutlookSendSchema = z.object({ bcc: z.string().optional().nullable(), replyToMessageId: z.string().optional().nullable(), conversationId: z.string().optional().nullable(), - attachments: z.array(z.any()).optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index c33f250bc0..c55950bc9a 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -17,7 +18,7 @@ const S3PutObjectSchema = z.object({ region: z.string().min(1, 'Region is required'), bucketName: z.string().min(1, 'Bucket name is required'), objectKey: z.string().min(1, 'Object key is required'), - file: z.any().optional().nullable(), + file: RawFileInputSchema.optional().nullable(), content: z.string().optional().nullable(), contentType: z.string().optional().nullable(), acl: z.string().optional().nullable(), diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index 90f5e6ab7d..54851e595b 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -26,14 +27,7 @@ const UploadSchema = z.object({ privateKey: z.string().nullish(), passphrase: z.string().nullish(), remotePath: z.string().min(1, 'Remote path is required'), - files: z - .union([z.array(z.any()), z.string(), z.number(), z.null(), z.undefined()]) - .transform((val) => { - if (Array.isArray(val)) return val - if (val === null || val === undefined || val === '') return undefined - return undefined - }) - .nullish(), + files: RawFileInputArraySchema.optional().nullable(), fileContent: z.string().nullish(), fileName: z.string().nullish(), overwrite: z.boolean().default(true), diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 26ce0b1d26..b5826c6ecd 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -16,7 +17,7 @@ const SharepointUploadSchema = z.object({ driveId: z.string().optional().nullable(), folderPath: z.string().optional().nullable(), fileName: z.string().optional().nullable(), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 3938b89d15..21f60faf6c 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { sendSlackMessage } from '../utils' export const dynamic = 'force-dynamic' @@ -16,7 +17,7 @@ const SlackSendMessageSchema = z userId: z.string().optional().nullable(), text: z.string().min(1, 'Message text is required'), thread_ts: z.string().optional().nullable(), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), }) .refine((data) => data.channel || data.userId, { message: 'Either channel or userId is required', diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index 910ae43687..ca2fdf41c0 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -4,6 +4,7 @@ import nodemailer from 'nodemailer' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -28,7 +29,7 @@ const SmtpSendSchema = z.object({ cc: z.string().optional().nullable(), bcc: z.string().optional().nullable(), replyTo: z.string().optional().nullable(), - attachments: z.array(z.any()).optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), }) export async function POST(request: NextRequest) { diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 46122fc19e..c0677bb35a 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -16,7 +17,7 @@ const SupabaseStorageUploadSchema = z.object({ bucket: z.string().min(1, 'Bucket name is required'), fileName: z.string().min(1, 'File name is required'), path: z.string().optional().nullable(), - fileData: z.any(), + fileData: FileInputSchema, contentType: z.string().optional().nullable(), upsert: z.boolean().optional().default(false), }) diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 8435ee68f6..27d3277d4f 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { convertMarkdownToHTML } from '@/tools/telegram/utils' @@ -14,7 +15,7 @@ const logger = createLogger('TelegramSendDocumentAPI') const TelegramSendDocumentSchema = z.object({ botToken: z.string().min(1, 'Bot token is required'), chatId: z.string().min(1, 'Chat ID is required'), - files: z.array(z.any()).optional().nullable(), + files: RawFileInputArraySchema.optional().nullable(), caption: z.string().optional().nullable(), }) diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 165005142d..5b35f13700 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -1,10 +1,13 @@ +import { GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils' export const dynamic = 'force-dynamic' @@ -13,8 +16,8 @@ const logger = createLogger('VisionAnalyzeAPI') const VisionAnalyzeSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), imageUrl: z.string().optional().nullable(), - imageFile: z.any().optional().nullable(), - model: z.string().optional().default('gpt-4o'), + imageFile: RawFileInputSchema.optional().nullable(), + model: z.string().optional().default('gpt-5.2'), prompt: z.string().optional().nullable(), }) @@ -88,7 +91,8 @@ export async function POST(request: NextRequest) { const defaultPrompt = 'Please analyze this image and describe what you see in detail.' const prompt = validatedData.prompt || defaultPrompt - const isClaude = validatedData.model.startsWith('claude-3') + const isClaude = validatedData.model.startsWith('claude-') + const isGemini = validatedData.model.startsWith('gemini-') const apiUrl = isClaude ? 'https://api.anthropic.com/v1/messages' : 'https://api.openai.com/v1/chat/completions' @@ -106,6 +110,65 @@ export async function POST(request: NextRequest) { let requestBody: any + if (isGemini) { + let base64Payload = imageSource + if (!base64Payload.startsWith('data:')) { + const response = await fetch(base64Payload) + if (!response.ok) { + return NextResponse.json( + { success: false, error: 'Failed to fetch image for Gemini' }, + { status: 400 } + ) + } + const contentType = + response.headers.get('content-type') || validatedData.imageFile?.type || 'image/jpeg' + const arrayBuffer = await response.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + base64Payload = `data:${contentType};base64,${base64}` + } + + const base64Marker = ';base64,' + const markerIndex = base64Payload.indexOf(base64Marker) + if (!base64Payload.startsWith('data:') || markerIndex === -1) { + return NextResponse.json( + { success: false, error: 'Invalid base64 image format' }, + { status: 400 } + ) + } + const rawMimeType = base64Payload.slice('data:'.length, markerIndex) + const mediaType = rawMimeType.split(';')[0] || 'image/jpeg' + const base64Data = base64Payload.slice(markerIndex + base64Marker.length) + if (!base64Data) { + return NextResponse.json( + { success: false, error: 'Invalid base64 image format' }, + { status: 400 } + ) + } + + const ai = new GoogleGenAI({ apiKey: validatedData.apiKey }) + const geminiResponse = await ai.models.generateContent({ + model: validatedData.model, + contents: [ + { + role: 'user', + parts: [{ text: prompt }, { inlineData: { mimeType: mediaType, data: base64Data } }], + }, + ], + }) + + const content = extractTextContent(geminiResponse.candidates?.[0]) + const usage = convertUsageMetadata(geminiResponse.usageMetadata) + + return NextResponse.json({ + success: true, + output: { + content, + model: validatedData.model, + tokens: usage.totalTokenCount || undefined, + }, + }) + } + if (isClaude) { if (imageSource.startsWith('data:')) { const base64Match = imageSource.match(/^data:([^;]+);base64,(.+)$/) @@ -172,7 +235,7 @@ export async function POST(request: NextRequest) { ], }, ], - max_tokens: 1000, + max_completion_tokens: 1000, } } diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index 8c2604bce0..5cf9a1b6f6 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getFileExtension, getMimeTypeFromExtension, @@ -19,7 +20,7 @@ const WORDPRESS_COM_API_BASE = 'https://public-api.wordpress.com/wp/v2/sites' const WordPressUploadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), siteId: z.string().min(1, 'Site ID is required'), - file: z.any().optional().nullable(), + file: RawFileInputSchema.optional().nullable(), filename: z.string().optional().nullable(), title: z.string().optional().nullable(), caption: z.string().optional().nullable(), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx index 86be4ba5ee..50bcc9c6a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx @@ -179,7 +179,7 @@ export function A2aDeploy({ newFields.push({ id: crypto.randomUUID(), name: 'files', - type: 'files', + type: 'file[]', value: '', collapsed: false, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 8900c23187..f12ceb3e21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -26,7 +26,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/ interface Field { id: string name: string - type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' + type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' value?: string description?: string collapsed?: boolean @@ -57,7 +57,7 @@ const TYPE_OPTIONS: ComboboxOption[] = [ { label: 'Boolean', value: 'boolean' }, { label: 'Object', value: 'object' }, { label: 'Array', value: 'array' }, - { label: 'Files', value: 'files' }, + { label: 'Files', value: 'file[]' }, ] /** @@ -448,7 +448,7 @@ export function FieldFormat({ ) } - if (field.type === 'files') { + if (field.type === 'file[]') { const lineCount = fieldValue.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index bc982daec4..0d25696905 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -1746,7 +1746,7 @@ export const TagDropdown: React.FC = ({ mergedSubBlocks ) - if (fieldType === 'files' || fieldType === 'file[]' || fieldType === 'array') { + if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') { const blockName = parts[0] const remainingPath = parts.slice(2).join('.') processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts index af5f675299..233f06e58d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts @@ -188,7 +188,7 @@ export function useBlockOutputFields({ baseOutputs = { input: { type: 'string', description: 'User message' }, conversationId: { type: 'string', description: 'Conversation ID' }, - files: { type: 'files', description: 'Uploaded files' }, + files: { type: 'file[]', description: 'Uploaded files' }, } } else { const inputFormatValue = mergedSubBlocks?.inputFormat?.value diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index e5e3d3007f..40f60971b7 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -417,11 +417,11 @@ async function executeWebhookJobInternal( if (triggerBlock?.subBlocks?.inputFormat?.value) { const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{ name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' }> logger.debug(`[${requestId}] Processing generic webhook files from inputFormat`) - const fileFields = inputFormat.filter((field) => field.type === 'files') + const fileFields = inputFormat.filter((field) => field.type === 'file[]') if (fileFields.length > 0 && typeof input === 'object' && input !== null) { const executionContext = { diff --git a/apps/sim/blocks/blocks/chat_trigger.ts b/apps/sim/blocks/blocks/chat_trigger.ts index 2efb6612fc..34fa5d0cce 100644 --- a/apps/sim/blocks/blocks/chat_trigger.ts +++ b/apps/sim/blocks/blocks/chat_trigger.ts @@ -26,7 +26,7 @@ export const ChatTriggerBlock: BlockConfig = { outputs: { input: { type: 'string', description: 'User message' }, conversationId: { type: 'string', description: 'Conversation ID' }, - files: { type: 'files', description: 'Uploaded files' }, + files: { type: 'file[]', description: 'Uploaded files' }, }, triggers: { enabled: true, diff --git a/apps/sim/blocks/blocks/dropbox.ts b/apps/sim/blocks/blocks/dropbox.ts index cffc5ac848..b08f6403df 100644 --- a/apps/sim/blocks/blocks/dropbox.ts +++ b/apps/sim/blocks/blocks/dropbox.ts @@ -60,12 +60,25 @@ export const DropboxBlock: BlockConfig = { required: true, }, { - id: 'fileContent', - title: 'File Content', - type: 'long-input', - placeholder: 'Base64 encoded file content or file reference', + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'fileContent', + placeholder: 'Upload file to send to Dropbox', + mode: 'basic', + multiple: false, + required: true, condition: { field: 'operation', value: 'dropbox_upload' }, + }, + { + id: 'fileContent', + title: 'File', + type: 'short-input', + canonicalParamId: 'fileContent', + placeholder: 'Reference file from previous blocks', + mode: 'advanced', required: true, + condition: { field: 'operation', value: 'dropbox_upload' }, }, { id: 'mode', @@ -337,7 +350,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, path: { type: 'string', description: 'Path in Dropbox' }, autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, // Upload inputs - fileContent: { type: 'string', description: 'Base64 encoded file content' }, + uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, + fileContent: { type: 'json', description: 'File reference or UserFile object' }, fileName: { type: 'string', description: 'Optional filename' }, mode: { type: 'string', description: 'Write mode: add or overwrite' }, mute: { type: 'boolean', description: 'Mute notifications' }, @@ -360,7 +374,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, }, outputs: { // Upload/Download outputs - file: { type: 'json', description: 'File metadata' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, content: { type: 'string', description: 'File content (base64)' }, temporaryLink: { type: 'string', description: 'Temporary download link' }, // List folder outputs diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 7e478f42a2..9867fa979c 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { DocumentIcon } from '@/components/icons' import type { BlockConfig, SubBlockType } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' -import type { FileParserOutput } from '@/tools/file/types' +import type { FileParserOutput, FileParserV3Output } from '@/tools/file/types' const logger = createLogger('FileBlock') @@ -116,7 +116,7 @@ export const FileBlock: BlockConfig = { }, outputs: { files: { - type: 'json', + type: 'file[]', description: 'Array of parsed file objects with content, metadata, and file properties', }, combinedContent: { @@ -124,7 +124,7 @@ export const FileBlock: BlockConfig = { description: 'All file contents merged into a single text string', }, processedFiles: { - type: 'files', + type: 'file[]', description: 'Array of UserFile objects for downstream use (attachments, uploads, etc.)', }, }, @@ -133,9 +133,9 @@ export const FileBlock: BlockConfig = { export const FileV2Block: BlockConfig = { ...FileBlock, type: 'file_v2', - name: 'File', + name: 'File (Legacy)', description: 'Read and parse multiple files', - hideFromToolbar: false, + hideFromToolbar: true, subBlocks: [ { id: 'file', @@ -209,7 +209,7 @@ export const FileV2Block: BlockConfig = { }, outputs: { files: { - type: 'json', + type: 'file[]', description: 'Array of parsed file objects with content, metadata, and file properties', }, combinedContent: { @@ -218,3 +218,110 @@ export const FileV2Block: BlockConfig = { }, }, } + +export const FileV3Block: BlockConfig = { + type: 'file_v3', + name: 'File', + description: 'Read and parse multiple files', + longDescription: 'Upload files or reference files from previous blocks to extract text content.', + docsLink: 'https://docs.sim.ai/tools/file', + category: 'tools', + bgColor: '#40916C', + icon: DocumentIcon, + subBlocks: [ + { + id: 'file', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'fileInput', + acceptedTypes: + '.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf', + placeholder: 'Upload files to process', + multiple: true, + mode: 'basic', + maxSize: 100, + required: true, + }, + { + id: 'fileRef', + title: 'Files', + type: 'short-input' as SubBlockType, + canonicalParamId: 'fileInput', + placeholder: 'File reference from previous block', + mode: 'advanced', + required: true, + }, + ], + tools: { + access: ['file_parser_v3'], + config: { + tool: () => 'file_parser_v3', + params: (params) => { + const fileInput = params.fileInput ?? params.file ?? params.filePath + if (!fileInput) { + logger.error('No file input provided') + throw new Error('File input is required') + } + + if (typeof fileInput === 'string') { + return { + filePath: fileInput.trim(), + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } + } + + if (Array.isArray(fileInput)) { + const filePaths = fileInput + .map((file) => (file as { url?: string; path?: string }).url || file.path) + .filter((path): path is string => Boolean(path)) + if (filePaths.length === 0) { + logger.error('No valid file paths found in file input array') + throw new Error('File input is required') + } + return { + filePath: filePaths.length === 1 ? filePaths[0] : filePaths, + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } + } + + if (typeof fileInput === 'object') { + const filePath = (fileInput as { url?: string; path?: string }).url || fileInput.path + if (!filePath) { + logger.error('File input object missing path or url') + throw new Error('File input is required') + } + return { + filePath, + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } + } + + logger.error('Invalid file input format') + throw new Error('File input is required') + }, + }, + }, + inputs: { + fileInput: { type: 'json', description: 'File input (upload or UserFile reference)' }, + fileType: { type: 'string', description: 'File type' }, + }, + outputs: { + files: { + type: 'file[]', + description: 'Parsed files as UserFile objects', + }, + combinedContent: { + type: 'string', + description: 'All file contents merged into a single text string', + }, + }, +} diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index 223b69ec70..e0138a88d5 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -516,7 +516,7 @@ Return ONLY the search query - no explanations, no extra text.`, // Tool outputs content: { type: 'string', description: 'Response content' }, metadata: { type: 'json', description: 'Email metadata' }, - attachments: { type: 'json', description: 'Email attachments array' }, + attachments: { type: 'file[]', description: 'Email attachments array' }, // Trigger outputs email_id: { type: 'string', description: 'Gmail message ID' }, thread_id: { type: 'string', description: 'Gmail thread ID' }, @@ -579,7 +579,7 @@ export const GmailV2Block: BlockConfig = { date: { type: 'string', description: 'Date' }, body: { type: 'string', description: 'Email body text (best-effort)' }, results: { type: 'json', description: 'Search/read summary results' }, - attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' }, + attachments: { type: 'file[]', description: 'Downloaded attachments (if enabled)' }, // Draft-specific outputs draftId: { diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 209bd12f91..23660a2385 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -861,7 +861,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr permissionId: { type: 'string', description: 'Permission ID to remove' }, }, outputs: { - file: { type: 'json', description: 'File metadata or downloaded file data' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, files: { type: 'json', description: 'List of files' }, metadata: { type: 'json', description: 'Complete file metadata (from download)' }, content: { type: 'string', description: 'File content as text' }, diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 016d04a74d..a724d7e121 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -314,13 +314,27 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, condition: { field: 'operation', value: 'add_image' }, required: true, }, + { + id: 'imageFile', + title: 'Image', + type: 'file-upload', + canonicalParamId: 'imageSource', + placeholder: 'Upload image (PNG, JPEG, or GIF)', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.png,.jpg,.jpeg,.gif', + condition: { field: 'operation', value: 'add_image' }, + }, { id: 'imageUrl', - title: 'Image URL', + title: 'Image', type: 'short-input', - placeholder: 'Public URL of the image (PNG, JPEG, or GIF)', - condition: { field: 'operation', value: 'add_image' }, + canonicalParamId: 'imageSource', + placeholder: 'Reference image from previous blocks or enter URL', + mode: 'advanced', required: true, + condition: { field: 'operation', value: 'add_image' }, }, { id: 'imageWidth', @@ -809,7 +823,9 @@ Return ONLY the text content - no explanations, no markdown formatting markers, placeholderIdMappings: { type: 'string', description: 'JSON array of placeholder ID mappings' }, // Add image operation pageObjectId: { type: 'string', description: 'Slide object ID for image' }, - imageUrl: { type: 'string', description: 'Image URL' }, + imageFile: { type: 'json', description: 'Uploaded image (UserFile)' }, + imageUrl: { type: 'string', description: 'Image URL or reference' }, + imageSource: { type: 'json', description: 'Image source (file or URL)' }, imageWidth: { type: 'number', description: 'Image width in points' }, imageHeight: { type: 'number', description: 'Image height in points' }, positionX: { type: 'number', description: 'X position in points' }, diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts index 25a6a9fb90..47e53d56da 100644 --- a/apps/sim/blocks/blocks/google_vault.ts +++ b/apps/sim/blocks/blocks/google_vault.ts @@ -526,7 +526,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, description: 'Single hold object (for create_matters_holds or list_matters_holds with holdId)', }, - file: { type: 'json', description: 'Downloaded export file (UserFile) from execution files' }, + file: { type: 'file', description: 'Downloaded export file (UserFile) from execution files' }, nextPageToken: { type: 'string', description: 'Token for fetching next page of results (for list operations)', diff --git a/apps/sim/blocks/blocks/image_generator.ts b/apps/sim/blocks/blocks/image_generator.ts index befe3ecb4e..e2efad69d5 100644 --- a/apps/sim/blocks/blocks/image_generator.ts +++ b/apps/sim/blocks/blocks/image_generator.ts @@ -149,7 +149,7 @@ export const ImageGeneratorBlock: BlockConfig = { }, outputs: { content: { type: 'string', description: 'Generation response' }, - image: { type: 'string', description: 'Generated image URL' }, + image: { type: 'file', description: 'Generated image file (UserFile)' }, metadata: { type: 'json', description: 'Generation metadata' }, }, } diff --git a/apps/sim/blocks/blocks/imap.ts b/apps/sim/blocks/blocks/imap.ts index 33cc6e0ec6..e23727b20c 100644 --- a/apps/sim/blocks/blocks/imap.ts +++ b/apps/sim/blocks/blocks/imap.ts @@ -44,7 +44,7 @@ export const ImapBlock: BlockConfig = { bodyHtml: { type: 'string', description: 'HTML email body' }, mailbox: { type: 'string', description: 'Mailbox/folder where email was received' }, hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }, - attachments: { type: 'json', description: 'Array of email attachments' }, + attachments: { type: 'file[]', description: 'Array of email attachments' }, timestamp: { type: 'string', description: 'Event timestamp' }, }, triggers: { diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index d2d61b77e1..66d42a43dd 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1049,7 +1049,7 @@ Return ONLY the comment text - no explanations.`, // jira_get_attachments outputs attachments: { - type: 'json', + type: 'file[]', description: 'Array of attachments with id, filename, size, mimeType, created, author', }, diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index f576829325..300c51b706 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -2341,7 +2341,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n cycles: { type: 'json', description: 'Cycles list' }, // Attachment outputs attachment: { type: 'json', description: 'Attachment data' }, - attachments: { type: 'json', description: 'Attachments list' }, + attachments: { type: 'file[]', description: 'Attachments list' }, // Relation outputs relation: { type: 'json', description: 'Issue relation data' }, relations: { type: 'json', description: 'Issue relations list' }, diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 498d2ae6c0..69dedc8af6 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -462,7 +462,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { messages: { type: 'json', description: 'Array of message objects' }, totalAttachments: { type: 'number', description: 'Total number of attachments' }, attachmentTypes: { type: 'json', description: 'Array of attachment content types' }, - attachments: { type: 'array', description: 'Downloaded message attachments' }, + attachments: { type: 'file[]', description: 'Downloaded message attachments' }, updatedContent: { type: 'boolean', description: 'Whether content was successfully updated/sent', diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index 62773a71cd..42c5b63a1a 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -159,14 +159,16 @@ export const MistralParseV2Block: BlockConfig = { placeholder: 'Upload a PDF document', mode: 'basic', maxSize: 50, + required: true, }, { id: 'filePath', - title: 'PDF Document', + title: 'File Reference', type: 'short-input' as SubBlockType, canonicalParamId: 'document', - placeholder: 'Document URL', + placeholder: 'File reference from previous block', mode: 'advanced', + required: true, }, { id: 'resultType', @@ -216,7 +218,7 @@ export const MistralParseV2Block: BlockConfig = { throw new Error('PDF document is required') } if (typeof documentInput === 'object') { - parameters.fileUpload = documentInput + parameters.fileData = documentInput } else if (typeof documentInput === 'string') { parameters.filePath = documentInput.trim() } @@ -254,8 +256,8 @@ export const MistralParseV2Block: BlockConfig = { }, }, inputs: { - document: { type: 'json', description: 'Document input (file upload or URL reference)' }, - filePath: { type: 'string', description: 'PDF document URL (advanced mode)' }, + document: { type: 'json', description: 'Document input (file upload or file reference)' }, + filePath: { type: 'string', description: 'File reference (advanced mode)' }, fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' }, apiKey: { type: 'string', description: 'Mistral API key' }, resultType: { type: 'string', description: 'Output format type' }, diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index d9753cced0..494bfa3b02 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -393,7 +393,7 @@ export const OneDriveBlock: BlockConfig = { deleted: { type: 'boolean', description: 'Whether the file was deleted' }, fileId: { type: 'string', description: 'The ID of the deleted file' }, file: { - type: 'json', + type: 'file', description: 'The OneDrive file object, including details such as id, name, size, and more.', }, files: { diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 9bf8c312e4..cfbe253044 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -440,7 +440,7 @@ export const OutlookBlock: BlockConfig = { sentDateTime: { type: 'string', description: 'Email sent timestamp' }, hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }, attachments: { - type: 'json', + type: 'file[]', description: 'Email attachments (if includeAttachments is enabled)', }, isRead: { type: 'boolean', description: 'Whether email is read' }, diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index b6bd6fb8e6..1d3b939d47 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -803,7 +803,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n outputs: { deals: { type: 'json', description: 'Array of deal objects' }, deal: { type: 'json', description: 'Single deal object' }, - files: { type: 'json', description: 'Array of file objects' }, + files: { type: 'file[]', description: 'Array of file objects' }, messages: { type: 'json', description: 'Array of mail message objects' }, pipelines: { type: 'json', description: 'Array of pipeline objects' }, projects: { type: 'json', description: 'Array of project objects' }, diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index 6dba63175f..f364a78887 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -418,6 +418,7 @@ export const S3Block: BlockConfig = { type: 'string', description: 'S3 URI (s3://bucket/key) for use with other AWS services', }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, objects: { type: 'json', description: 'List of objects (for list operation)' }, deleted: { type: 'boolean', description: 'Deletion status' }, metadata: { type: 'json', description: 'Operation metadata' }, diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index 5fe1dfb6df..b6cdbfbdaf 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -522,7 +522,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, description: 'Array of SharePoint list items with fields', }, uploadedFiles: { - type: 'json', + type: 'file[]', description: 'Array of uploaded file objects with id, name, webUrl, size', }, fileCount: { diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 7e432f27f0..9bd6292b7b 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -859,7 +859,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, // slack_download outputs file: { - type: 'json', + type: 'file', description: 'Downloaded file stored in execution files', }, diff --git a/apps/sim/blocks/blocks/spotify.ts b/apps/sim/blocks/blocks/spotify.ts index c4341e6afa..417724f6ec 100644 --- a/apps/sim/blocks/blocks/spotify.ts +++ b/apps/sim/blocks/blocks/spotify.ts @@ -450,10 +450,24 @@ export const SpotifyBlock: BlockConfig = { // === PLAYLIST COVER === { - id: 'imageBase64', - title: 'Image (Base64)', - type: 'long-input', - placeholder: 'Base64-encoded JPEG image (max 256KB)', + id: 'coverImageFile', + title: 'Cover Image', + type: 'file-upload', + canonicalParamId: 'coverImage', + placeholder: 'Upload cover image (JPEG, max 256KB)', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.jpg,.jpeg', + condition: { field: 'operation', value: 'spotify_add_playlist_cover' }, + }, + { + id: 'coverImageRef', + title: 'Cover Image', + type: 'short-input', + canonicalParamId: 'coverImage', + placeholder: 'Reference image from previous blocks', + mode: 'advanced', required: true, condition: { field: 'operation', value: 'spotify_add_playlist_cover' }, }, @@ -804,7 +818,9 @@ export const SpotifyBlock: BlockConfig = { newName: { type: 'string', description: 'New playlist name' }, description: { type: 'string', description: 'Playlist description' }, public: { type: 'boolean', description: 'Whether playlist is public' }, - imageBase64: { type: 'string', description: 'Base64-encoded JPEG image' }, + coverImage: { type: 'json', description: 'Cover image (UserFile)' }, + coverImageFile: { type: 'json', description: 'Cover image upload (basic mode)' }, + coverImageRef: { type: 'json', description: 'Cover image reference (advanced mode)' }, range_start: { type: 'number', description: 'Start index for reorder' }, insert_before: { type: 'number', description: 'Insert before index' }, range_length: { type: 'number', description: 'Number of items to move' }, diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 8b5fc75f74..49c59cb35e 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -675,9 +675,9 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e { id: 'fileContent', title: 'File Content', - type: 'code', + type: 'short-input', canonicalParamId: 'fileData', - placeholder: 'Base64 encoded for binary files, or plain text', + placeholder: 'File reference from previous block', condition: { field: 'operation', value: 'storage_upload' }, mode: 'advanced', required: true, @@ -1173,7 +1173,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e description: 'Row count for count operations', }, file: { - type: 'files', + type: 'file', description: 'Downloaded file stored in execution files', }, publicUrl: { diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 6c40812a49..e45f27cee1 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -65,39 +65,91 @@ export const TelegramBlock: BlockConfig = { required: true, condition: { field: 'operation', value: 'telegram_message' }, }, + { + id: 'photoFile', + title: 'Photo', + type: 'file-upload', + canonicalParamId: 'photo', + placeholder: 'Upload photo', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp', + condition: { field: 'operation', value: 'telegram_send_photo' }, + }, { id: 'photo', title: 'Photo', type: 'short-input', - placeholder: 'Enter photo URL or file_id', - description: 'Photo to send. Pass a file_id or HTTP URL', + canonicalParamId: 'photo', + placeholder: 'Reference photo from previous blocks or enter URL/file_id', + mode: 'advanced', required: true, condition: { field: 'operation', value: 'telegram_send_photo' }, }, + { + id: 'videoFile', + title: 'Video', + type: 'file-upload', + canonicalParamId: 'video', + placeholder: 'Upload video', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.mp4,.mov,.avi,.mkv,.webm', + condition: { field: 'operation', value: 'telegram_send_video' }, + }, { id: 'video', title: 'Video', type: 'short-input', - placeholder: 'Enter video URL or file_id', - description: 'Video to send. Pass a file_id or HTTP URL', + canonicalParamId: 'video', + placeholder: 'Reference video from previous blocks or enter URL/file_id', + mode: 'advanced', required: true, condition: { field: 'operation', value: 'telegram_send_video' }, }, + { + id: 'audioFile', + title: 'Audio', + type: 'file-upload', + canonicalParamId: 'audio', + placeholder: 'Upload audio', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.mp3,.m4a,.wav,.ogg,.flac', + condition: { field: 'operation', value: 'telegram_send_audio' }, + }, { id: 'audio', title: 'Audio', type: 'short-input', - placeholder: 'Enter audio URL or file_id', - description: 'Audio file to send. Pass a file_id or HTTP URL', + canonicalParamId: 'audio', + placeholder: 'Reference audio from previous blocks or enter URL/file_id', + mode: 'advanced', required: true, condition: { field: 'operation', value: 'telegram_send_audio' }, }, + { + id: 'animationFile', + title: 'Animation', + type: 'file-upload', + canonicalParamId: 'animation', + placeholder: 'Upload animation (GIF)', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.gif,.mp4', + condition: { field: 'operation', value: 'telegram_send_animation' }, + }, { id: 'animation', title: 'Animation', type: 'short-input', - placeholder: 'Enter animation URL or file_id', - description: 'Animation (GIF) to send. Pass a file_id or HTTP URL', + canonicalParamId: 'animation', + placeholder: 'Reference animation from previous blocks or enter URL/file_id', + mode: 'advanced', required: true, condition: { field: 'operation', value: 'telegram_send_animation' }, }, @@ -215,42 +267,50 @@ export const TelegramBlock: BlockConfig = { ...commonParams, messageId: params.messageId, } - case 'telegram_send_photo': - if (!params.photo) { - throw new Error('Photo URL or file_id is required.') + case 'telegram_send_photo': { + const photoSource = params.photoFile || params.photo + if (!photoSource) { + throw new Error('Photo is required.') } return { ...commonParams, - photo: params.photo, + photo: photoSource, caption: params.caption, } - case 'telegram_send_video': - if (!params.video) { - throw new Error('Video URL or file_id is required.') + } + case 'telegram_send_video': { + const videoSource = params.videoFile || params.video + if (!videoSource) { + throw new Error('Video is required.') } return { ...commonParams, - video: params.video, + video: videoSource, caption: params.caption, } - case 'telegram_send_audio': - if (!params.audio) { - throw new Error('Audio URL or file_id is required.') + } + case 'telegram_send_audio': { + const audioSource = params.audioFile || params.audio + if (!audioSource) { + throw new Error('Audio is required.') } return { ...commonParams, - audio: params.audio, + audio: audioSource, caption: params.caption, } - case 'telegram_send_animation': - if (!params.animation) { - throw new Error('Animation URL or file_id is required.') + } + case 'telegram_send_animation': { + const animationSource = params.animationFile || params.animation + if (!animationSource) { + throw new Error('Animation is required.') } return { ...commonParams, - animation: params.animation, + animation: animationSource, caption: params.caption, } + } case 'telegram_send_document': { // Handle file upload const fileParam = params.attachmentFiles || params.files @@ -274,10 +334,14 @@ export const TelegramBlock: BlockConfig = { botToken: { type: 'string', description: 'Telegram bot token' }, chatId: { type: 'string', description: 'Chat identifier' }, text: { type: 'string', description: 'Message text' }, - photo: { type: 'string', description: 'Photo URL or file_id' }, - video: { type: 'string', description: 'Video URL or file_id' }, - audio: { type: 'string', description: 'Audio URL or file_id' }, - animation: { type: 'string', description: 'Animation URL or file_id' }, + photoFile: { type: 'json', description: 'Uploaded photo (UserFile)' }, + photo: { type: 'json', description: 'Photo reference or URL/file_id' }, + videoFile: { type: 'json', description: 'Uploaded video (UserFile)' }, + video: { type: 'json', description: 'Video reference or URL/file_id' }, + audioFile: { type: 'json', description: 'Uploaded audio (UserFile)' }, + audio: { type: 'json', description: 'Audio reference or URL/file_id' }, + animationFile: { type: 'json', description: 'Uploaded animation (UserFile)' }, + animation: { type: 'json', description: 'Animation reference or URL/file_id' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)', diff --git a/apps/sim/blocks/blocks/tts.ts b/apps/sim/blocks/blocks/tts.ts index c3ab813fdf..eebc8acd3e 100644 --- a/apps/sim/blocks/blocks/tts.ts +++ b/apps/sim/blocks/blocks/tts.ts @@ -578,7 +578,7 @@ export const TtsBlock: BlockConfig = { outputs: { audioUrl: { type: 'string', description: 'URL to the generated audio file' }, - audioFile: { type: 'json', description: 'Generated audio file object (UserFile)' }, + audioFile: { type: 'file', description: 'Generated audio file object (UserFile)' }, duration: { type: 'number', description: 'Audio duration in seconds', diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index 88672a17be..2743cf2c14 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -420,7 +420,7 @@ export const VideoGeneratorBlock: BlockConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 8a94d2240b..58d6c1354d 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -3,10 +3,27 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { VisionResponse } from '@/tools/vision/types' +const VISION_MODEL_OPTIONS = [ + { label: 'GPT 5.2', id: 'gpt-5.2' }, + { label: 'GPT 5.1', id: 'gpt-5.1' }, + { label: 'GPT 5', id: 'gpt-5' }, + { label: 'GPT 5 Mini', id: 'gpt-5-mini' }, + { label: 'GPT 5 Nano', id: 'gpt-5-nano' }, + { label: 'Claude Opus 4.5', id: 'claude-opus-4-5' }, + { label: 'Claude Sonnet 4.5', id: 'claude-sonnet-4-5' }, + { label: 'Claude Haiku 4.5', id: 'claude-haiku-4-5' }, + { label: 'Gemini 3 Pro Preview', id: 'gemini-3-pro-preview' }, + { label: 'Gemini 3 Flash Preview', id: 'gemini-3-flash-preview' }, + { label: 'Gemini 2.5 Pro', id: 'gemini-2.5-pro' }, + { label: 'Gemini 2.5 Flash', id: 'gemini-2.5-flash' }, + { label: 'Gemini 2.5 Flash Lite', id: 'gemini-2.5-flash-lite' }, +] + export const VisionBlock: BlockConfig = { type: 'vision', - name: 'Vision', + name: 'Vision (Legacy)', description: 'Analyze images with vision models', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: 'Integrate Vision into the workflow. Can analyze images with vision models.', docsLink: 'https://docs.sim.ai/tools/vision', @@ -47,12 +64,8 @@ export const VisionBlock: BlockConfig = { id: 'model', title: 'Vision Model', type: 'dropdown', - options: [ - { label: 'gpt-4o', id: 'gpt-4o' }, - { label: 'claude-3-opus', id: 'claude-3-opus-20240229' }, - { label: 'claude-3-sonnet', id: 'claude-3-sonnet-20240229' }, - ], - value: () => 'gpt-4o', + options: VISION_MODEL_OPTIONS, + value: () => 'gpt-5.2', }, { id: 'prompt', @@ -87,3 +100,62 @@ export const VisionBlock: BlockConfig = { tokens: { type: 'number', description: 'Token usage' }, }, } + +export const VisionV2Block: BlockConfig = { + ...VisionBlock, + type: 'vision_v2', + name: 'Vision', + description: 'Analyze images with vision models', + hideFromToolbar: false, + subBlocks: [ + { + id: 'imageFile', + title: 'Image File', + type: 'file-upload', + canonicalParamId: 'imageFile', + placeholder: 'Upload an image file', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp', + }, + { + id: 'imageFileReference', + title: 'Image File Reference', + type: 'short-input', + canonicalParamId: 'imageFile', + placeholder: 'Reference an image from previous blocks', + mode: 'advanced', + required: true, + }, + { + id: 'model', + title: 'Vision Model', + type: 'dropdown', + options: VISION_MODEL_OPTIONS, + value: () => 'gpt-5.2', + }, + { + id: 'prompt', + title: 'Prompt', + type: 'long-input', + placeholder: 'Enter prompt for image analysis', + required: true, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your API key', + password: true, + required: true, + }, + ], + inputs: { + apiKey: { type: 'string', description: 'Provider API key' }, + imageFile: { type: 'json', description: 'Image file (UserFile)' }, + imageFileReference: { type: 'json', description: 'Image file reference' }, + model: { type: 'string', description: 'Vision model' }, + prompt: { type: 'string', description: 'Analysis prompt' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 225a3b2240..6aa34b6c28 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -28,7 +28,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch' import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { ExaBlock } from '@/blocks/blocks/exa' -import { FileBlock, FileV2Block } from '@/blocks/blocks/file' +import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirefliesBlock } from '@/blocks/blocks/fireflies' import { FunctionBlock } from '@/blocks/blocks/function' @@ -139,7 +139,7 @@ import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice' import { TypeformBlock } from '@/blocks/blocks/typeform' import { VariablesBlock } from '@/blocks/blocks/variables' import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator' -import { VisionBlock } from '@/blocks/blocks/vision' +import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision' import { WaitBlock } from '@/blocks/blocks/wait' import { WealthboxBlock } from '@/blocks/blocks/wealthbox' import { WebflowBlock } from '@/blocks/blocks/webflow' @@ -192,6 +192,7 @@ export const registry: Record = { exa: ExaBlock, file: FileBlock, file_v2: FileV2Block, + file_v3: FileV3Block, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, function: FunctionBlock, @@ -314,6 +315,7 @@ export const registry: Record = { video_generator: VideoGeneratorBlock, video_generator_v2: VideoGeneratorV2Block, vision: VisionBlock, + vision_v2: VisionV2Block, wait: WaitBlock, wealthbox: WealthboxBlock, webflow: WebflowBlock, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index c59ad427cf..352f106429 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -9,7 +9,8 @@ export type PrimitiveValueType = | 'boolean' | 'json' | 'array' - | 'files' + | 'file' + | 'file[]' | 'any' export type BlockCategory = 'blocks' | 'tools' | 'triggers' diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d17da0e7c7..2e52585042 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -137,7 +137,7 @@ export class BlockExecutor { normalizedOutput = this.normalizeOutput(output) } - if (ctx.includeFileBase64 && containsUserFileWithMetadata(normalizedOutput)) { + if (containsUserFileWithMetadata(normalizedOutput)) { normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, { requestId: ctx.metadata.requestId, executionId: ctx.executionId, diff --git a/apps/sim/executor/utils/block-reference.test.ts b/apps/sim/executor/utils/block-reference.test.ts index 6f110c2bc6..470522b772 100644 --- a/apps/sim/executor/utils/block-reference.test.ts +++ b/apps/sim/executor/utils/block-reference.test.ts @@ -133,7 +133,7 @@ describe('resolveBlockReference', () => { 'block-1': { input: { type: 'string' }, conversationId: { type: 'string' }, - files: { type: 'files' }, + files: { type: 'file[]' }, }, }, }) @@ -206,7 +206,7 @@ describe('resolveBlockReference', () => { }, }, blockOutputSchemas: { - 'block-1': { files: { type: 'files' } }, + 'block-1': { files: { type: 'file[]' } }, }, }) @@ -218,7 +218,7 @@ describe('resolveBlockReference', () => { const ctx = createContext({ blockData: { 'block-1': { files: [] } }, blockOutputSchemas: { - 'block-1': { files: { type: 'files' } }, + 'block-1': { files: { type: 'file[]' } }, }, }) diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 590e9d869d..4ae41a2b12 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -32,7 +32,7 @@ export class InvalidFieldError extends Error { function isFileType(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false const typed = value as { type?: string } - return typed.type === 'file[]' || typed.type === 'files' + return typed.type === 'file' || typed.type === 'file[]' } function isArrayType(value: unknown): value is { type: 'array'; items?: unknown } { diff --git a/apps/sim/lib/execution/files.ts b/apps/sim/lib/execution/files.ts index 9eb26905e4..d80f2ae77f 100644 --- a/apps/sim/lib/execution/files.ts +++ b/apps/sim/lib/execution/files.ts @@ -163,7 +163,7 @@ export async function processInputFileFields( } const inputFormat = extractInputFormatFromBlock(startBlock) - const fileFields = inputFormat.filter((field) => field.type === 'files') + const fileFields = inputFormat.filter((field) => field.type === 'file[]') if (fileFields.length === 0) { return input diff --git a/apps/sim/lib/mcp/workflow-tool-schema.ts b/apps/sim/lib/mcp/workflow-tool-schema.ts index 7af927ff15..4678e96b3d 100644 --- a/apps/sim/lib/mcp/workflow-tool-schema.ts +++ b/apps/sim/lib/mcp/workflow-tool-schema.ts @@ -153,7 +153,7 @@ export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToo // Handle array types if (fieldType === 'array') { - if (field.type === 'files') { + if (field.type === 'file[]') { property.items = { type: 'object', properties: { diff --git a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts index bbf2a123eb..6c237668c7 100644 --- a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' +import { StorageService } from '@/lib/uploads' import type { ExecutionContext } from '@/lib/uploads/contexts/execution/utils' import { generateExecutionFileKey, generateFileId } from '@/lib/uploads/contexts/execution/utils' import type { UserFile } from '@/executor/types' @@ -89,10 +90,7 @@ export async function uploadExecutionFile( } try { - const { uploadFile, generatePresignedDownloadUrl } = await import( - '@/lib/uploads/core/storage-service' - ) - const fileInfo = await uploadFile({ + const fileInfo = await StorageService.uploadFile({ file: fileBuffer, fileName: storageKey, contentType, @@ -102,21 +100,24 @@ export async function uploadExecutionFile( metadata, // Pass metadata for cloud storage and database tracking }) - // Generate presigned URL for file access (10 minutes expiration) - const fullUrl = await generatePresignedDownloadUrl(fileInfo.key, 'execution', 600) + const presignedUrl = await StorageService.generatePresignedDownloadUrl( + fileInfo.key, + 'execution', + 5 * 60 + ) const userFile: UserFile = { id: fileId, name: fileName, size: fileBuffer.length, type: contentType, - url: fullUrl, // Presigned URL for external access and downstream workflow usage + url: presignedUrl, key: fileInfo.key, - context: 'execution', // Preserve context in file object + context: 'execution', + base64: fileBuffer.toString('base64'), } logger.info(`Successfully uploaded execution file: ${fileName} (${fileBuffer.length} bytes)`, { - url: fullUrl, key: fileInfo.key, }) return userFile @@ -135,8 +136,7 @@ export async function downloadExecutionFile(userFile: UserFile): Promise logger.info(`Downloading execution file: ${userFile.name}`) try { - const { downloadFile } = await import('@/lib/uploads/core/storage-service') - const fileBuffer = await downloadFile({ + const fileBuffer = await StorageService.downloadFile({ key: userFile.key, context: 'execution', }) diff --git a/apps/sim/lib/uploads/utils/file-schemas.ts b/apps/sim/lib/uploads/utils/file-schemas.ts new file mode 100644 index 0000000000..0939131ff9 --- /dev/null +++ b/apps/sim/lib/uploads/utils/file-schemas.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const RawFileInputSchema = z + .object({ + id: z.string().optional(), + key: z.string().optional(), + path: z.string().optional(), + url: z.string().optional(), + name: z.string().min(1), + size: z.number().nonnegative(), + type: z.string().optional(), + uploadedAt: z.union([z.string(), z.date()]).optional(), + expiresAt: z.union([z.string(), z.date()]).optional(), + context: z.string().optional(), + base64: z.string().optional(), + }) + .passthrough() + .refine((data) => Boolean(data.key || data.path || data.url), { + message: 'File must include key, path, or url', + }) + +export const RawFileInputArraySchema = z.array(RawFileInputSchema) + +export const FileInputSchema = z.union([RawFileInputSchema, z.string()]) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 7b1d925ec1..e234f70690 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -458,17 +458,23 @@ function isCompleteUserFile(file: RawFileInput): file is UserFile { /** * Converts a single raw file object to UserFile format - * @param file - Raw file object + * @param file - Raw file object (must be a single file, not an array) * @param requestId - Request ID for logging * @param logger - Logger instance * @returns UserFile object - * @throws Error if file has no storage key + * @throws Error if file is an array or has no storage key */ export function processSingleFileToUserFile( file: RawFileInput, requestId: string, logger: Logger ): UserFile { + if (Array.isArray(file)) { + const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).` + logger.error(`[${requestId}] ${errorMsg}`) + throw new Error(errorMsg) + } + if (isCompleteUserFile(file)) { return file } @@ -495,21 +501,51 @@ export function processSingleFileToUserFile( /** * Converts raw file objects (from file-upload or variable references) to UserFile format - * @param files - Array of raw file objects + * Accepts either a single file or an array of files and normalizes to array output + * @param files - Single file or array of raw file objects * @param requestId - Request ID for logging * @param logger - Logger instance * @returns Array of UserFile objects */ export function processFilesToUserFiles( - files: RawFileInput[], + files: RawFileInput | RawFileInput[], requestId: string, logger: Logger ): UserFile[] { + const filesArray = Array.isArray(files) ? files : [files] const userFiles: UserFile[] = [] - for (const file of files) { + for (const file of filesArray) { try { - const userFile = processSingleFileToUserFile(file, requestId, logger) + if (Array.isArray(file)) { + logger.warn(`[${requestId}] Skipping nested array in file input`) + continue + } + + if (isCompleteUserFile(file)) { + userFiles.push(file) + continue + } + + const storageKey = file.key || (file.path ? extractStorageKey(file.path) : null) + + if (!storageKey) { + logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`) + continue + } + + const userFile: UserFile = { + id: file.id || `file-${Date.now()}`, + name: file.name, + url: file.url || file.path || '', + size: file.size, + type: file.type || 'application/octet-stream', + key: storageKey, + } + + logger.info( + `[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})` + ) userFiles.push(userFile) } catch (error) { logger.warn( diff --git a/apps/sim/lib/webhooks/attachment-processor.ts b/apps/sim/lib/webhooks/attachment-processor.ts index cf2adbcef2..0cbbf494ef 100644 --- a/apps/sim/lib/webhooks/attachment-processor.ts +++ b/apps/sim/lib/webhooks/attachment-processor.ts @@ -77,7 +77,7 @@ export class WebhookAttachmentProcessor { userId?: string } ): Promise { - return uploadFileFromRawData( + const userFile = await uploadFileFromRawData( { name: attachment.name, data: attachment.data, @@ -86,5 +86,14 @@ export class WebhookAttachmentProcessor { executionContext, executionContext.userId ) + + if (userFile.base64) { + return userFile + } + + return { + ...userFile, + base64: attachment.data.toString('base64'), + } } } diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 96833fa871..edb95fdf0f 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -123,13 +123,13 @@ function filterOutputsByCondition( const CHAT_OUTPUTS: OutputDefinition = { input: { type: 'string', description: 'User message' }, conversationId: { type: 'string', description: 'Conversation ID' }, - files: { type: 'files', description: 'Uploaded files' }, + files: { type: 'file[]', description: 'Uploaded files' }, } const UNIFIED_START_OUTPUTS: OutputDefinition = { input: { type: 'string', description: 'Primary user input or message' }, conversationId: { type: 'string', description: 'Conversation thread identifier' }, - files: { type: 'files', description: 'User uploaded files' }, + files: { type: 'file[]', description: 'User uploaded files' }, } function applyInputFormatFields( @@ -341,6 +341,17 @@ function expandFileTypeProperties(path: string): string[] { return USER_FILE_ACCESSIBLE_PROPERTIES.map((prop) => `${path}.${prop}`) } +type FileOutputType = 'file' | 'file[]' + +function isFileOutputDefinition(value: unknown): value is { type: FileOutputType } { + if (!value || typeof value !== 'object' || !('type' in value)) { + return false + } + + const { type } = value as { type?: unknown } + return type === 'file' || type === 'file[]' +} + export function getBlockOutputPaths( blockType: string, subBlocks?: Record, @@ -373,13 +384,7 @@ function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): st current = (current as Record)[part] } - if ( - current && - typeof current === 'object' && - 'type' in current && - ((current as { type: unknown }).type === 'files' || - (current as { type: unknown }).type === 'file[]') - ) { + if (isFileOutputDefinition(current)) { return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES] } @@ -485,7 +490,7 @@ function generateOutputPaths(outputs: Record, prefix = ''): string[ paths.push(currentPath) } else if (typeof value === 'object' && value !== null) { if ('type' in value && typeof value.type === 'string') { - if (value.type === 'files' || value.type === 'file[]') { + if (isFileOutputDefinition(value)) { paths.push(...expandFileTypeProperties(currentPath)) continue } @@ -546,7 +551,7 @@ function generateOutputPathsWithTypes( paths.push({ path: currentPath, type: value }) } else if (typeof value === 'object' && value !== null) { if ('type' in value && typeof value.type === 'string') { - if (value.type === 'files' || value.type === 'file[]') { + if (isFileOutputDefinition(value)) { paths.push({ path: currentPath, type: value.type }) for (const prop of USER_FILE_ACCESSIBLE_PROPERTIES) { paths.push({ diff --git a/apps/sim/lib/workflows/operations/deployment-utils.ts b/apps/sim/lib/workflows/operations/deployment-utils.ts index c0dce11aab..7da79ce2be 100644 --- a/apps/sim/lib/workflows/operations/deployment-utils.ts +++ b/apps/sim/lib/workflows/operations/deployment-utils.ts @@ -98,7 +98,7 @@ export function getInputFormatExample( case 'array': exampleData[field.name] = [1, 2, 3] break - case 'files': + case 'file[]': exampleData[field.name] = [ { data: 'data:application/pdf;base64,...', diff --git a/apps/sim/lib/workflows/types.ts b/apps/sim/lib/workflows/types.ts index 4596ce9e95..9e51d7ff1a 100644 --- a/apps/sim/lib/workflows/types.ts +++ b/apps/sim/lib/workflows/types.ts @@ -1,6 +1,6 @@ export interface InputFormatField { name?: string - type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string + type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' | string description?: string value?: unknown } diff --git a/apps/sim/tools/discord/types.ts b/apps/sim/tools/discord/types.ts index cad8f68c7f..9573309e94 100644 --- a/apps/sim/tools/discord/types.ts +++ b/apps/sim/tools/discord/types.ts @@ -1,3 +1,5 @@ +import type { UserFile } from '@/executor/types' + export interface DiscordMessage { id: string content: string @@ -58,7 +60,7 @@ export interface DiscordSendMessageParams extends DiscordAuthParams { description?: string color?: string | number } - files?: any[] + files?: UserFile[] } export interface DiscordGetMessagesParams extends DiscordAuthParams { diff --git a/apps/sim/tools/dropbox/download.ts b/apps/sim/tools/dropbox/download.ts index e489b3d21c..24292ebda2 100644 --- a/apps/sim/tools/dropbox/download.ts +++ b/apps/sim/tools/dropbox/download.ts @@ -4,7 +4,7 @@ import type { ToolConfig } from '@/tools/types' export const dropboxDownloadTool: ToolConfig = { id: 'dropbox_download', name: 'Dropbox Download File', - description: 'Download a file from Dropbox and get a temporary link', + description: 'Download a file from Dropbox with metadata and content', version: '1.0.0', oauth: { @@ -22,7 +22,7 @@ export const dropboxDownloadTool: ToolConfig { if (!params.accessToken) { @@ -30,45 +30,73 @@ export const dropboxDownloadTool: ToolConfig ({ - path: params.path, - }), }, - transformResponse: async (response) => { - const data = await response.json() - + transformResponse: async (response, params) => { if (!response.ok) { + const errorText = await response.text() return { success: false, - error: data.error_summary || data.error?.message || 'Failed to download file', + error: errorText || 'Failed to download file', output: {}, } } + const apiResultHeader = + response.headers.get('dropbox-api-result') || response.headers.get('Dropbox-API-Result') + const metadata = apiResultHeader ? JSON.parse(apiResultHeader) : undefined + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const resolvedName = metadata?.name || params?.path?.split('/').pop() || 'download' + + let temporaryLink: string | undefined + if (params?.accessToken) { + try { + const linkResponse = await fetch('https://api.dropboxapi.com/2/files/get_temporary_link', { + method: 'POST', + headers: { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path: params.path }), + }) + if (linkResponse.ok) { + const linkData = await linkResponse.json() + temporaryLink = linkData.link + } + } catch { + temporaryLink = undefined + } + } + return { success: true, output: { - file: data.metadata, - content: '', // Content will be available via the temporary link - temporaryLink: data.link, + file: { + name: resolvedName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }, + content: buffer.toString('base64'), + metadata, + temporaryLink, }, } }, outputs: { file: { - type: 'object', + type: 'file', + description: 'Downloaded file stored in execution files', + }, + metadata: { + type: 'json', description: 'The file metadata', - properties: { - id: { type: 'string', description: 'Unique identifier for the file' }, - name: { type: 'string', description: 'Name of the file' }, - path_display: { type: 'string', description: 'Display path of the file' }, - size: { type: 'number', description: 'Size of the file in bytes' }, - }, }, temporaryLink: { type: 'string', diff --git a/apps/sim/tools/dropbox/types.ts b/apps/sim/tools/dropbox/types.ts index b48f30cc83..f789bcfceb 100644 --- a/apps/sim/tools/dropbox/types.ts +++ b/apps/sim/tools/dropbox/types.ts @@ -1,4 +1,4 @@ -import type { ToolResponse } from '@/tools/types' +import type { ToolFileData, ToolResponse } from '@/tools/types' // ===== Core Types ===== @@ -91,8 +91,9 @@ export interface DropboxDownloadParams extends DropboxBaseParams { export interface DropboxDownloadResponse extends ToolResponse { output: { - file?: DropboxFileMetadata + file?: ToolFileData content?: string // Base64 encoded file content + metadata?: DropboxFileMetadata temporaryLink?: string } } diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 236461d1a9..6714c7dddc 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,4 +1,5 @@ -import { fileParserTool, fileParserV2Tool } from '@/tools/file/parser' +import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' export const fileParseTool = fileParserTool export { fileParserV2Tool } +export { fileParserV3Tool } diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index a20c6dd4cd..5e3e32ca42 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -7,6 +7,8 @@ import type { FileParserInput, FileParserOutput, FileParserOutputData, + FileParserV3Output, + FileParserV3OutputData, } from '@/tools/file/types' import type { ToolConfig } from '@/tools/types' @@ -29,6 +31,66 @@ interface ToolBodyParams extends Partial { } } +const parseFileParserResponse = async (response: Response): Promise => { + logger.info('Received response status:', response.status) + + const result = (await response.json()) as FileParseApiResponse | FileParseApiMultiResponse + logger.info('Response parsed successfully') + + // Handle multiple files response + if ('results' in result) { + logger.info('Processing multiple files response') + + // Extract individual file results + const fileResults: FileParseResult[] = result.results.map((fileResult) => { + return fileResult.output || (fileResult as unknown as FileParseResult) + }) + + // Collect UserFile objects from results + const processedFiles: UserFile[] = fileResults + .filter((file): file is FileParseResult & { file: UserFile } => Boolean(file.file)) + .map((file) => file.file) + + // Combine all file contents with clear dividers + const combinedContent = fileResults + .map((file, index) => { + const divider = `\n${'='.repeat(80)}\n` + + return file.content + (index < fileResults.length - 1 ? divider : '') + }) + .join('\n') + + // Create the base output + const output: FileParserOutputData = { + files: fileResults, + combinedContent, + ...(processedFiles.length > 0 && { processedFiles }), + } + + return { + success: true, + output, + } + } + + // Handle single file response + logger.info('Successfully parsed file:', result.output?.name || 'unknown') + + const fileOutput: FileParseResult = result.output || (result as unknown as FileParseResult) + + // For a single file, create the output with just array format + const output: FileParserOutputData = { + files: [fileOutput], + combinedContent: fileOutput?.content || result.content || '', + ...(fileOutput?.file && { processedFiles: [fileOutput.file] }), + } + + return { + success: true, + output, + } +} + export const fileParserTool: ToolConfig = { id: 'file_parser', name: 'File Parser', @@ -38,7 +100,7 @@ export const fileParserTool: ToolConfig = { params: { filePath: { type: 'string', - required: true, + required: false, visibility: 'user-only', description: 'Path to the file(s). Can be a single path, URL, or an array of paths.', }, @@ -111,65 +173,7 @@ export const fileParserTool: ToolConfig = { }, }, - transformResponse: async (response: Response): Promise => { - logger.info('Received response status:', response.status) - - const result = (await response.json()) as FileParseApiResponse | FileParseApiMultiResponse - logger.info('Response parsed successfully') - - // Handle multiple files response - if ('results' in result) { - logger.info('Processing multiple files response') - - // Extract individual file results - const fileResults: FileParseResult[] = result.results.map((fileResult) => { - return fileResult.output || (fileResult as unknown as FileParseResult) - }) - - // Collect UserFile objects from results - const processedFiles: UserFile[] = fileResults - .filter((file): file is FileParseResult & { file: UserFile } => Boolean(file.file)) - .map((file) => file.file) - - // Combine all file contents with clear dividers - const combinedContent = fileResults - .map((file, index) => { - const divider = `\n${'='.repeat(80)}\n` - - return file.content + (index < fileResults.length - 1 ? divider : '') - }) - .join('\n') - - // Create the base output - const output: FileParserOutputData = { - files: fileResults, - combinedContent, - ...(processedFiles.length > 0 && { processedFiles }), - } - - return { - success: true, - output, - } - } - - // Handle single file response - logger.info('Successfully parsed file:', result.output?.name || 'unknown') - - const fileOutput: FileParseResult = result.output || (result as unknown as FileParseResult) - - // For a single file, create the output with just array format - const output: FileParserOutputData = { - files: [fileOutput], - combinedContent: fileOutput?.content || result.content || '', - ...(fileOutput?.file && { processedFiles: [fileOutput.file] }), - } - - return { - success: true, - output, - } - }, + transformResponse: parseFileParserResponse, outputs: { files: { type: 'array', description: 'Array of parsed files with content and metadata' }, @@ -186,7 +190,7 @@ export const fileParserV2Tool: ToolConfig = { params: fileParserTool.params, request: fileParserTool.request, - transformResponse: fileParserTool.transformResponse, + transformResponse: parseFileParserResponse, outputs: { files: { @@ -199,3 +203,34 @@ export const fileParserV2Tool: ToolConfig = { }, }, } + +export const fileParserV3Tool: ToolConfig = { + id: 'file_parser_v3', + name: 'File Parser', + description: 'Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)', + version: '3.0.0', + params: fileParserTool.params, + request: fileParserTool.request, + transformResponse: async (response: Response): Promise => { + const parsed = await parseFileParserResponse(response) + const output = parsed.output as FileParserOutputData + const files = + Array.isArray(output.processedFiles) && output.processedFiles.length > 0 + ? output.processedFiles + : [] + + const cleanedOutput: FileParserV3OutputData = { + files, + combinedContent: output.combinedContent, + } + + return { + success: true, + output: cleanedOutput, + } + }, + outputs: { + files: { type: 'file[]', description: 'Parsed files as UserFile objects' }, + combinedContent: { type: 'string', description: 'Combined content of all parsed files' }, + }, +} diff --git a/apps/sim/tools/file/types.ts b/apps/sim/tools/file/types.ts index 252c9f21a3..086e16b9c1 100644 --- a/apps/sim/tools/file/types.ts +++ b/apps/sim/tools/file/types.ts @@ -34,6 +34,17 @@ export interface FileParserOutput extends ToolResponse { output: FileParserOutputData } +export interface FileParserV3OutputData { + /** Array of parsed files as UserFile objects */ + files: UserFile[] + /** Combined text content from all files */ + combinedContent: string +} + +export interface FileParserV3Output extends ToolResponse { + output: FileParserV3OutputData +} + /** API response structure for single file parse */ export interface FileParseApiResponse { success: boolean diff --git a/apps/sim/tools/google_drive/download.ts b/apps/sim/tools/google_drive/download.ts index 0f6d2b8ef4..2def338f88 100644 --- a/apps/sim/tools/google_drive/download.ts +++ b/apps/sim/tools/google_drive/download.ts @@ -224,14 +224,8 @@ export const downloadTool: ToolConfig = { +const mistralParserV2Params = { + fileData: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'File data from a previous block', + }, + filePath: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'File path or URL (legacy)', + }, + resultType: mistralParserTool.params.resultType, + includeImageBase64: mistralParserTool.params.includeImageBase64, + pages: mistralParserTool.params.pages, + imageLimit: mistralParserTool.params.imageLimit, + imageMinSize: mistralParserTool.params.imageMinSize, + apiKey: mistralParserTool.params.apiKey, +} satisfies ToolConfig['params'] + +export const mistralParserV2Tool: ToolConfig = { id: 'mistral_parser_v2', name: 'Mistral PDF Parser', description: 'Parse PDF documents using Mistral OCR API', version: '2.0.0', - params: mistralParserTool.params, - request: mistralParserTool.request, + params: mistralParserV2Params, + request: { + url: '/api/tools/mistral/parse', + method: 'POST', + headers: (params) => { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + }, + body: (params) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') { + throw new Error('Missing or invalid API key: A valid Mistral API key is required') + } + + const fileData = params.fileData ?? params.filePath + if (!fileData) { + throw new Error('File input is required') + } + + const requestBody: Record = { + apiKey: params.apiKey, + resultType: params.resultType || 'markdown', + } + + if (typeof fileData === 'string') { + requestBody.filePath = fileData.trim() + } else { + requestBody.fileData = fileData + } + + if (params.pages) { + requestBody.pages = params.pages + } + if (params.includeImageBase64 !== undefined) { + requestBody.includeImageBase64 = params.includeImageBase64 + } + if (params.imageLimit !== undefined) { + requestBody.imageLimit = params.imageLimit + } + if (params.imageMinSize !== undefined) { + requestBody.imageMinSize = params.imageMinSize + } + + return requestBody + }, + }, transformResponse: async (response: Response) => { let ocrResult diff --git a/apps/sim/tools/mistral/types.ts b/apps/sim/tools/mistral/types.ts index db912b76cf..2ac78c2a02 100644 --- a/apps/sim/tools/mistral/types.ts +++ b/apps/sim/tools/mistral/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -137,7 +138,7 @@ export const MISTRAL_PARSER_METADATA_OUTPUT: OutputProperty = { export interface MistralParserInput { filePath: string - fileUpload?: any + fileUpload?: UserFile _internalFilePath?: string apiKey: string resultType?: 'markdown' | 'text' | 'json' @@ -147,6 +148,17 @@ export interface MistralParserInput { imageMinSize?: number } +export interface MistralParserV2Input { + fileData?: UserFile | string + filePath?: string + apiKey: string + resultType?: 'markdown' | 'text' | 'json' + includeImageBase64?: boolean + pages?: number[] + imageLimit?: number + imageMinSize?: number +} + export interface MistralOcrUsageInfo { pagesProcessed: number docSizeBytes: number | null diff --git a/apps/sim/tools/onedrive/types.ts b/apps/sim/tools/onedrive/types.ts index 6ceb005c5e..df30e26553 100644 --- a/apps/sim/tools/onedrive/types.ts +++ b/apps/sim/tools/onedrive/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface MicrosoftGraphDriveItem { @@ -91,7 +92,7 @@ export interface OneDriveToolParams { folderName?: string fileId?: string fileName?: string - file?: unknown // UserFile or UserFile array + file?: UserFile content?: string mimeType?: string query?: string diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index 6e7e630e24..a494245eb1 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -117,7 +118,7 @@ export interface OutlookSendParams { conversationId?: string cc?: string bcc?: string - attachments?: any[] + attachments?: UserFile[] } export interface OutlookSendResponse extends ToolResponse { @@ -150,7 +151,7 @@ export interface OutlookDraftParams { subject: string body: string contentType?: 'text' | 'html' - attachments?: any[] + attachments?: UserFile[] } export interface OutlookDraftResponse extends ToolResponse { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 0d396d8c0a..4f342e027b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -239,7 +239,7 @@ import { exaResearchTool, exaSearchTool, } from '@/tools/exa' -import { fileParserV2Tool, fileParseTool } from '@/tools/file' +import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -1778,6 +1778,7 @@ export const tools: Record = { vision_tool: visionTool, file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, + file_parser_v3: fileParserV3Tool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool, diff --git a/apps/sim/tools/s3/get_object.ts b/apps/sim/tools/s3/get_object.ts index 89f2f4a7b3..5856042658 100644 --- a/apps/sim/tools/s3/get_object.ts +++ b/apps/sim/tools/s3/get_object.ts @@ -50,7 +50,7 @@ export const s3GetObjectTool: ToolConfig = { ) } }, - method: 'HEAD', + method: 'GET', headers: (params) => { try { // Parse S3 URI if not already parsed @@ -66,7 +66,7 @@ export const s3GetObjectTool: ToolConfig = { const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '') const dateStamp = amzDate.slice(0, 8) - const method = 'HEAD' + const method = 'GET' const encodedPath = encodeS3PathComponent(params.objectKey) const canonicalUri = `/${encodedPath}` const canonicalQueryString = '' @@ -108,11 +108,18 @@ export const s3GetObjectTool: ToolConfig = { params.objectKey = objectKey } - // Get file metadata + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Failed to download S3 object: ${response.status} ${response.statusText} ${errorText}` + ) + } + const contentType = response.headers.get('content-type') || 'application/octet-stream' - const contentLength = Number.parseInt(response.headers.get('content-length') || '0', 10) const lastModified = response.headers.get('last-modified') || new Date().toISOString() const fileName = params.objectKey.split('/').pop() || params.objectKey + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) // Generate pre-signed URL for download const url = generatePresignedUrl(params, 3600) @@ -121,9 +128,15 @@ export const s3GetObjectTool: ToolConfig = { success: true, output: { url, + file: { + name: fileName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }, metadata: { fileType: contentType, - size: contentLength, + size: buffer.length, name: fileName, lastModified: lastModified, }, @@ -136,6 +149,10 @@ export const s3GetObjectTool: ToolConfig = { type: 'string', description: 'Pre-signed URL for downloading the S3 object', }, + file: { + type: 'file', + description: 'Downloaded file stored in execution files', + }, metadata: { type: 'object', description: 'File metadata including type, size, name, and last modified date', diff --git a/apps/sim/tools/s3/types.ts b/apps/sim/tools/s3/types.ts index 67f1fc49d0..44612c44c0 100644 --- a/apps/sim/tools/s3/types.ts +++ b/apps/sim/tools/s3/types.ts @@ -1,8 +1,10 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface S3Response extends ToolResponse { output: { url?: string + file?: UserFile objects?: Array<{ key: string size: number diff --git a/apps/sim/tools/sendgrid/types.ts b/apps/sim/tools/sendgrid/types.ts index a3f2928831..cc9593a702 100644 --- a/apps/sim/tools/sendgrid/types.ts +++ b/apps/sim/tools/sendgrid/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' // Shared type definitions @@ -48,6 +49,14 @@ export interface SendGridPersonalization { dynamic_template_data?: Record } +export interface SendGridAttachment { + content: string + filename: string + type?: string + disposition?: string + content_id?: string +} + export interface SendGridMailBody { personalizations: SendGridPersonalization[] from: { email: string; name?: string } @@ -55,7 +64,7 @@ export interface SendGridMailBody { template_id?: string content?: Array<{ type: 'text/plain' | 'text/html'; value?: string }> reply_to?: { email: string; name?: string } - attachments?: any[] + attachments?: SendGridAttachment[] | UserFile[] } export interface SendGridContactObject { @@ -95,7 +104,7 @@ export interface SendMailParams extends SendGridBaseParams { bcc?: string replyTo?: string replyToName?: string - attachments?: string + attachments?: UserFile[] | SendGridAttachment[] | string templateId?: string dynamicTemplateData?: string } diff --git a/apps/sim/tools/sftp/types.ts b/apps/sim/tools/sftp/types.ts index e1ed9004e0..de32d3cc2d 100644 --- a/apps/sim/tools/sftp/types.ts +++ b/apps/sim/tools/sftp/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface SftpConnectionConfig { @@ -12,7 +13,7 @@ export interface SftpConnectionConfig { // Upload file params export interface SftpUploadParams extends SftpConnectionConfig { remotePath: string - files?: any[] // UserFile array from file-upload component + files?: UserFile[] fileContent?: string // Direct content for text files fileName?: string // File name when using direct content overwrite?: boolean diff --git a/apps/sim/tools/sharepoint/types.ts b/apps/sim/tools/sharepoint/types.ts index 5be8061ce6..c587d03f95 100644 --- a/apps/sim/tools/sharepoint/types.ts +++ b/apps/sim/tools/sharepoint/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface SharepointSite { @@ -180,7 +181,7 @@ export interface SharepointToolParams { driveId?: string folderPath?: string fileName?: string - files?: any[] + files?: UserFile[] } export interface GraphApiResponse { diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index 9dfc8e621c..54c9bc21ff 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -516,7 +517,7 @@ export interface SlackMessageParams extends SlackBaseParams { userId?: string text: string thread_ts?: string - files?: any[] + files?: UserFile[] } export interface SlackCanvasParams extends SlackBaseParams { diff --git a/apps/sim/tools/smtp/types.ts b/apps/sim/tools/smtp/types.ts index 9792262a96..a7b93891b3 100644 --- a/apps/sim/tools/smtp/types.ts +++ b/apps/sim/tools/smtp/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface SmtpConnectionConfig { @@ -21,7 +22,7 @@ export interface SmtpSendMailParams extends SmtpConnectionConfig { cc?: string bcc?: string replyTo?: string - attachments?: any[] + attachments?: UserFile[] } export interface SmtpSendMailResult extends ToolResponse { diff --git a/apps/sim/tools/telegram/types.ts b/apps/sim/tools/telegram/types.ts index f1ab4eb9d3..0e54deeb43 100644 --- a/apps/sim/tools/telegram/types.ts +++ b/apps/sim/tools/telegram/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface TelegramMessage { @@ -116,7 +117,7 @@ export interface TelegramSendAnimationParams extends TelegramAuthParams { } export interface TelegramSendDocumentParams extends TelegramAuthParams { - files?: any + files?: UserFile[] caption?: string } diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index 2eb186becc..a9c334a19f 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -52,7 +52,7 @@ export const visionTool: ToolConfig = { apiKey: params.apiKey, imageUrl: params.imageUrl || null, imageFile: params.imageFile || null, - model: params.model || 'gpt-4o', + model: params.model || 'gpt-5.2', prompt: params.prompt || null, } }, diff --git a/apps/sim/tools/vision/types.ts b/apps/sim/tools/vision/types.ts index a0c3412b22..cda8c45598 100644 --- a/apps/sim/tools/vision/types.ts +++ b/apps/sim/tools/vision/types.ts @@ -1,9 +1,10 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface VisionParams { apiKey: string imageUrl?: string - imageFile?: any + imageFile?: UserFile model?: string prompt?: string } diff --git a/apps/sim/triggers/generic/webhook.ts b/apps/sim/triggers/generic/webhook.ts index 5dd24a489a..92235cfe28 100644 --- a/apps/sim/triggers/generic/webhook.ts +++ b/apps/sim/triggers/generic/webhook.ts @@ -53,7 +53,7 @@ export const genericWebhookTrigger: TriggerConfig = { title: 'Input Format', type: 'input-format', description: - 'Define the expected JSON input schema for this webhook (optional). Use type "files" for file uploads.', + 'Define the expected JSON input schema for this webhook (optional). Use type "file[]" for file uploads.', mode: 'trigger', }, { diff --git a/apps/sim/triggers/microsoftteams/webhook.ts b/apps/sim/triggers/microsoftteams/webhook.ts index e1d6f181d3..abec619823 100644 --- a/apps/sim/triggers/microsoftteams/webhook.ts +++ b/apps/sim/triggers/microsoftteams/webhook.ts @@ -98,7 +98,7 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = { }, message: { raw: { - attachments: { type: 'array', description: 'Array of attachments' }, + attachments: { type: 'file[]', description: 'Array of attachments' }, channelData: { team: { id: { type: 'string', description: 'Team ID' } }, tenant: { id: { type: 'string', description: 'Tenant ID' } }, From 39ca1f61c7638f24dcc90d10a261ccaaa301a659 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 01:08:38 -0800 Subject: [PATCH 03/39] more integrations --- apps/sim/app/api/files/parse/route.ts | 5 +- .../api/tools/discord/send-message/route.ts | 13 ++ apps/sim/app/api/tools/image/route.ts | 5 +- .../api/tools/jira/add-attachment/route.ts | 121 ++++++++++++++++++ apps/sim/app/api/tools/jsm/queues/route.ts | 3 +- apps/sim/app/api/tools/jsm/request/route.ts | 5 +- apps/sim/app/api/tools/jsm/requests/route.ts | 3 +- .../app/api/tools/jsm/requesttypes/route.ts | 3 +- .../app/api/tools/jsm/servicedesks/route.ts | 3 +- apps/sim/app/api/tools/jsm/sla/route.ts | 3 +- .../sim/app/api/tools/jsm/transition/route.ts | 3 +- .../app/api/tools/jsm/transitions/route.ts | 3 +- .../microsoft_teams/write_channel/route.ts | 16 ++- .../tools/microsoft_teams/write_chat/route.ts | 16 ++- apps/sim/app/api/tools/mistral/parse/route.ts | 3 +- apps/sim/app/api/tools/sftp/download/route.ts | 9 ++ .../app/api/tools/sharepoint/upload/route.ts | 3 +- apps/sim/app/api/tools/slack/utils.ts | 34 ++++- .../app/api/tools/ssh/download-file/route.ts | 9 ++ .../app/api/tools/stagehand/extract/route.ts | 3 +- apps/sim/app/api/tools/stt/route.ts | 3 +- .../api/tools/telegram/send-document/route.ts | 9 ++ .../file-download/file-download.tsx | 7 +- .../w/[workflowId]/components/chat/chat.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 13 +- apps/sim/blocks/blocks/discord.ts | 12 +- apps/sim/blocks/blocks/elevenlabs.ts | 1 + apps/sim/blocks/blocks/jira.ts | 48 ++++++- apps/sim/blocks/blocks/linear.ts | 56 +++++++- apps/sim/blocks/blocks/microsoft_teams.ts | 6 +- apps/sim/blocks/blocks/pipedrive.ts | 2 +- apps/sim/blocks/blocks/sftp.ts | 1 + apps/sim/blocks/blocks/sharepoint.ts | 2 +- apps/sim/blocks/blocks/slack.ts | 6 +- apps/sim/blocks/blocks/ssh.ts | 1 + apps/sim/blocks/blocks/telegram.ts | 8 +- apps/sim/lib/core/utils/logging.ts | 19 +++ .../knowledge/documents/document-processor.ts | 3 +- apps/sim/lib/webhooks/utils.server.ts | 5 +- apps/sim/tools/browser_use/run_task.ts | 3 +- apps/sim/tools/discord/send_message.ts | 1 + apps/sim/tools/discord/types.ts | 2 + apps/sim/tools/elevenlabs/types.ts | 3 + apps/sim/tools/jira/add_attachment.ts | 83 ++++++++++++ apps/sim/tools/jira/index.ts | 2 + apps/sim/tools/jira/types.ts | 21 ++- apps/sim/tools/linear/create_attachment.ts | 15 ++- apps/sim/tools/linear/types.ts | 4 +- apps/sim/tools/microsoft_planner/read_task.ts | 3 +- apps/sim/tools/microsoft_teams/types.ts | 3 +- .../tools/microsoft_teams/write_channel.ts | 1 + apps/sim/tools/microsoft_teams/write_chat.ts | 1 + apps/sim/tools/mistral/parser.ts | 10 +- apps/sim/tools/registry.ts | 2 + apps/sim/tools/sftp/download.ts | 2 + apps/sim/tools/sftp/types.ts | 3 +- apps/sim/tools/sharepoint/get_list.ts | 10 +- apps/sim/tools/slack/message.ts | 1 + apps/sim/tools/slack/types.ts | 3 +- apps/sim/tools/ssh/download_file.ts | 2 + apps/sim/tools/ssh/types.ts | 3 +- apps/sim/tools/stagehand/agent.ts | 5 +- apps/sim/tools/supabase/types.ts | 3 +- apps/sim/tools/telegram/send_document.ts | 1 + apps/sim/tools/telegram/types.ts | 3 +- apps/sim/tools/wordpress/types.ts | 3 +- apps/sim/triggers/microsoftteams/webhook.ts | 2 +- 67 files changed, 583 insertions(+), 79 deletions(-) create mode 100644 apps/sim/app/api/tools/jira/add-attachment/route.ts create mode 100644 apps/sim/lib/core/utils/logging.ts create mode 100644 apps/sim/tools/jira/add_attachment.ts diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 50dc55572a..89d5867bf0 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -7,6 +7,7 @@ import binaryExtensionsList from 'binary-extensions' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' @@ -367,7 +368,7 @@ async function handleExternalUrl( throw new Error(`File too large: ${buffer.length} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) } - logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`) + logger.info(`Downloaded file from URL: ${sanitizeUrlForLog(url)}, size: ${buffer.length} bytes`) let userFile: UserFile | undefined const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension) @@ -420,7 +421,7 @@ async function handleExternalUrl( return parseResult } catch (error) { - logger.error(`Error handling external URL ${url}:`, error) + logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error) return { success: false, error: `Error fetching URL: ${(error as Error).message}`, diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index c597ae4670..f5bf7d27f3 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -102,6 +102,12 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Processing ${validatedData.files.length} file(s)`) const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger) + const filesOutput: Array<{ + name: string + mimeType: string + data: string + size: number + }> = [] if (userFiles.length === 0) { logger.warn(`[${requestId}] No valid files to upload, falling back to text-only`) @@ -138,6 +144,12 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`) const buffer = await downloadFileFromStorage(userFile, requestId, logger) + filesOutput.push({ + name: userFile.name, + mimeType: userFile.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type }) formData.append(`files[${i}]`, blob, userFile.name) @@ -174,6 +186,7 @@ export async function POST(request: NextRequest) { message: data.content, data: data, fileCount: userFiles.length, + files: filesOutput, }, }) } catch (error) { diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 633e61068e..96dc58cad7 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateImageUrl } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('ImageProxyAPI') @@ -29,13 +30,13 @@ export async function GET(request: NextRequest) { const urlValidation = validateImageUrl(imageUrl) if (!urlValidation.isValid) { logger.warn(`[${requestId}] Blocked image proxy request`, { - url: imageUrl.substring(0, 100), + url: sanitizeUrlForLog(imageUrl), error: urlValidation.error, }) return new NextResponse(urlValidation.error || 'Invalid image URL', { status: 403 }) } - logger.info(`[${requestId}] Proxying image request for: ${imageUrl}`) + logger.info(`[${requestId}] Proxying image request for: ${sanitizeUrlForLog(imageUrl)}`) try { const imageResponse = await fetch(imageUrl, { diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts new file mode 100644 index 0000000000..52b36b24a9 --- /dev/null +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -0,0 +1,121 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { getJiraCloudId } from '@/tools/jira/utils' + +const logger = createLogger('JiraAddAttachmentAPI') + +export const dynamic = 'force-dynamic' + +const JiraAddAttachmentSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + domain: z.string().min(1, 'Domain is required'), + issueKey: z.string().min(1, 'Issue key is required'), + files: RawFileInputArraySchema, + cloudId: z.string().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = `jira-attach-${Date.now()}` + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = JiraAddAttachmentSchema.parse(body) + + const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json( + { success: false, error: 'No valid files provided for upload' }, + { status: 400 } + ) + } + + const cloudId = + validatedData.cloudId || + (await getJiraCloudId(validatedData.domain, validatedData.accessToken)) + + const formData = new FormData() + const filesOutput: Array<{ name: string; mimeType: string; data: string; size: number }> = [] + + for (const file of userFiles) { + const buffer = await downloadFileFromStorage(file, requestId, logger) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) + const blob = new Blob([new Uint8Array(buffer)], { + type: file.type || 'application/octet-stream', + }) + formData.append('file', blob, file.name) + } + + const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${validatedData.issueKey}/attachments` + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'X-Atlassian-Token': 'no-check', + }, + body: formData, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Jira attachment upload failed`, { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { + success: false, + error: `Failed to upload attachments: ${response.statusText}`, + }, + { status: response.status } + ) + } + + const attachments = await response.json() + const attachmentIds = Array.isArray(attachments) + ? attachments.map((attachment) => attachment.id).filter(Boolean) + : [] + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueKey: validatedData.issueKey, + attachmentIds, + files: filesOutput, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Jira attachment upload error`, error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/queues/route.ts b/apps/sim/app/api/tools/jsm/queues/route.ts index 2921008efc..f7dc234f39 100644 --- a/apps/sim/app/api/tools/jsm/queues/route.ts +++ b/apps/sim/app/api/tools/jsm/queues/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -62,7 +63,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk/${serviceDeskId}/queue${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching queues from:', url) + logger.info('Fetching queues from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/request/route.ts b/apps/sim/app/api/tools/jsm/request/route.ts index 92e5e9f4c2..2137867065 100644 --- a/apps/sim/app/api/tools/jsm/request/route.ts +++ b/apps/sim/app/api/tools/jsm/request/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -66,7 +67,7 @@ export async function POST(request: NextRequest) { } const url = `${baseUrl}/request` - logger.info('Creating request at:', url) + logger.info('Creating request at:', sanitizeUrlForLog(url)) const requestBody: Record = { serviceDeskId, @@ -128,7 +129,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}` - logger.info('Fetching request from:', url) + logger.info('Fetching request from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/requests/route.ts b/apps/sim/app/api/tools/jsm/requests/route.ts index f2f0dc0e71..fff27fe82c 100644 --- a/apps/sim/app/api/tools/jsm/requests/route.ts +++ b/apps/sim/app/api/tools/jsm/requests/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -68,7 +69,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching requests from:', url) + logger.info('Fetching requests from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/requesttypes/route.ts b/apps/sim/app/api/tools/jsm/requesttypes/route.ts index 8591f116bc..fa7f826aef 100644 --- a/apps/sim/app/api/tools/jsm/requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypes/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -53,7 +54,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk/${serviceDeskId}/requesttype${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching request types from:', url) + logger.info('Fetching request types from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/servicedesks/route.ts b/apps/sim/app/api/tools/jsm/servicedesks/route.ts index 607508a610..8752805757 100644 --- a/apps/sim/app/api/tools/jsm/servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/servicedesks/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -43,7 +44,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching service desks from:', url) + logger.info('Fetching service desks from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/sla/route.ts b/apps/sim/app/api/tools/jsm/sla/route.ts index dc414ac831..ea5b885594 100644 --- a/apps/sim/app/api/tools/jsm/sla/route.ts +++ b/apps/sim/app/api/tools/jsm/sla/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -53,7 +54,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/sla${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching SLA info from:', url) + logger.info('Fetching SLA info from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/transition/route.ts b/apps/sim/app/api/tools/jsm/transition/route.ts index 45a9e3a5c2..5f1065b6f6 100644 --- a/apps/sim/app/api/tools/jsm/transition/route.ts +++ b/apps/sim/app/api/tools/jsm/transition/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -69,7 +70,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/transition` - logger.info('Transitioning request at:', url) + logger.info('Transitioning request at:', sanitizeUrlForLog(url)) const body: Record = { id: transitionId, diff --git a/apps/sim/app/api/tools/jsm/transitions/route.ts b/apps/sim/app/api/tools/jsm/transitions/route.ts index 5d5f2e260e..c80a27ab8e 100644 --- a/apps/sim/app/api/tools/jsm/transitions/route.ts +++ b/apps/sim/app/api/tools/jsm/transitions/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -49,7 +50,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/transition` - logger.info('Fetching transitions from:', url) + logger.info('Fetching transitions from:', sanitizeUrlForLog(url)) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index bcfcb0b40e..a3789ca998 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -55,6 +56,12 @@ export async function POST(request: NextRequest) { }) const attachments: any[] = [] + const filesOutput: Array<{ + name: string + mimeType: string + data: string + size: number + }> = [] if (validatedData.files && validatedData.files.length > 0) { const rawFiles = validatedData.files logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) @@ -66,13 +73,19 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) const buffer = await downloadFileFromStorage(file, requestId, logger) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) const uploadUrl = 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + encodeURIComponent(file.name) + ':/content' - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) + logger.info(`[${requestId}] Uploading to Teams: ${sanitizeUrlForLog(uploadUrl)}`) const uploadResponse = await fetch(uploadUrl, { method: 'PUT', @@ -238,6 +251,7 @@ export async function POST(request: NextRequest) { url: responseData.webUrl || '', attachmentCount: attachments.length, }, + files: filesOutput, }, }) } catch (error) { diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 6b940e17c5..1137cc9a16 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -53,6 +54,12 @@ export async function POST(request: NextRequest) { }) const attachments: any[] = [] + const filesOutput: Array<{ + name: string + mimeType: string + data: string + size: number + }> = [] if (validatedData.files && validatedData.files.length > 0) { const rawFiles = validatedData.files logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to Teams`) @@ -64,13 +71,19 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) const buffer = await downloadFileFromStorage(file, requestId, logger) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) const uploadUrl = 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + encodeURIComponent(file.name) + ':/content' - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) + logger.info(`[${requestId}] Uploading to Teams: ${sanitizeUrlForLog(uploadUrl)}`) const uploadResponse = await fetch(uploadUrl, { method: 'PUT', @@ -234,6 +247,7 @@ export async function POST(request: NextRequest) { url: responseData.webUrl || '', attachmentCount: attachments.length, }, + files: filesOutput, }, }) } catch (error) { diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 89ff35b772..642bb15e21 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -5,6 +5,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { StorageService } from '@/lib/uploads' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { extractStorageKey, inferContextFromKey, @@ -19,7 +20,7 @@ const logger = createLogger('MistralParseAPI') const MistralParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), filePath: z.string().min(1, 'File path is required').optional(), - fileData: z.unknown().optional(), + fileData: FileInputSchema.optional(), resultType: z.string().optional(), pages: z.array(z.number()).optional(), includeImageBase64: z.boolean().optional(), diff --git a/apps/sim/app/api/tools/sftp/download/route.ts b/apps/sim/app/api/tools/sftp/download/route.ts index 4914703fcc..849e1ee094 100644 --- a/apps/sim/app/api/tools/sftp/download/route.ts +++ b/apps/sim/app/api/tools/sftp/download/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils' export const dynamic = 'force-dynamic' @@ -111,6 +112,8 @@ export async function POST(request: NextRequest) { const buffer = Buffer.concat(chunks) const fileName = path.basename(remotePath) + const extension = getFileExtension(fileName) + const mimeType = getMimeTypeFromExtension(extension) let content: string if (params.encoding === 'base64') { @@ -124,6 +127,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, fileName, + file: { + name: fileName, + mimeType, + data: buffer.toString('base64'), + size: buffer.length, + }, content, size: buffer.length, encoding: params.encoding, diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index b5826c6ecd..b15421d00d 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -144,7 +145,7 @@ export async function POST(request: NextRequest) { const uploadUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drives/${effectiveDriveId}/root:${encodedPath}:/content` - logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) + logger.info(`[${requestId}] Uploading to: ${sanitizeUrlForLog(uploadUrl)}`) const uploadResponse = await fetch(uploadUrl, { method: 'PUT', diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index 4a18071bfc..a5527d95d9 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -1,6 +1,7 @@ import type { Logger } from '@sim/logger' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { ToolFileData } from '@/tools/types' /** * Sends a message to a Slack channel using chat.postMessage @@ -70,14 +71,21 @@ export async function uploadFilesToSlack( accessToken: string, requestId: string, logger: Logger -): Promise { +): Promise<{ fileIds: string[]; files: ToolFileData[] }> { const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] + const uploadedFiles: ToolFileData[] = [] for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) const buffer = await downloadFileFromStorage(userFile, requestId, logger) + uploadedFiles.push({ + name: userFile.name, + mimeType: userFile.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { method: 'POST', @@ -114,7 +122,7 @@ export async function uploadFilesToSlack( uploadedFileIds.push(urlData.file_id) } - return uploadedFileIds + return { fileIds: uploadedFileIds, files: uploadedFiles } } /** @@ -217,7 +225,13 @@ export async function sendSlackMessage( logger: Logger ): Promise<{ success: boolean - output?: { message: any; ts: string; channel: string; fileCount?: number } + output?: { + message: any + ts: string + channel: string + fileCount?: number + files?: ToolFileData[] + } error?: string }> { const { accessToken, text, threadTs, files } = params @@ -249,10 +263,15 @@ export async function sendSlackMessage( // Process files logger.info(`[${requestId}] Processing ${files.length} file(s)`) - const uploadedFileIds = await uploadFilesToSlack(files, accessToken, requestId, logger) + const { fileIds, files: uploadedFiles } = await uploadFilesToSlack( + files, + accessToken, + requestId, + logger + ) // No valid files uploaded - send text-only - if (uploadedFileIds.length === 0) { + if (fileIds.length === 0) { logger.warn(`[${requestId}] No valid files to upload, sending text-only message`) const data = await postSlackMessage(accessToken, channel, text, threadTs) @@ -265,7 +284,7 @@ export async function sendSlackMessage( } // Complete file upload - const completeData = await completeSlackFileUpload(uploadedFileIds, channel, text, accessToken) + const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken) if (!completeData.ok) { logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) @@ -282,7 +301,8 @@ export async function sendSlackMessage( message: fileMessage, ts: fileMessage.ts, channel, - fileCount: uploadedFileIds.length, + fileCount: fileIds.length, + files: uploadedFiles, }, } } diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index e3bffd29d1..818d0ed410 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHDownloadFileAPI') @@ -96,6 +97,8 @@ export async function POST(request: NextRequest) { }) const fileName = path.basename(remotePath) + const extension = getFileExtension(fileName) + const mimeType = getMimeTypeFromExtension(extension) // Encode content as base64 for binary safety const base64Content = content.toString('base64') @@ -104,6 +107,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ downloaded: true, + file: { + name: fileName, + mimeType, + data: base64Content, + size: stats.size, + }, content: base64Content, fileName: fileName, remotePath: remotePath, diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index b663f575d8..db0d2848a2 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandExtractAPI') @@ -120,7 +121,7 @@ export async function POST(request: NextRequest) { const page = stagehand.context.pages()[0] - logger.info(`Navigating to ${url}`) + logger.info(`Navigating to ${sanitizeUrlForLog(url)}`) await page.goto(url, { waitUntil: 'networkidle' }) logger.info('Navigation complete') diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 8a3ed3ef22..d14db91755 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { UserFile } from '@/executor/types' import type { TranscriptSegment } from '@/tools/stt/types' @@ -88,7 +89,7 @@ export async function POST(request: NextRequest) { audioFileName = file.name audioMimeType = file.type } else if (body.audioUrl) { - logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`) + logger.info(`[${requestId}] Downloading from URL: ${sanitizeUrlForLog(body.audioUrl)}`) const response = await fetch(body.audioUrl) if (!response.ok) { diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 27d3277d4f..0ddaac702a 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -94,6 +94,14 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading document: ${userFile.name}`) const buffer = await downloadFileFromStorage(userFile, requestId, logger) + const filesOutput = [ + { + name: userFile.name, + mimeType: userFile.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }, + ] logger.info(`[${requestId}] Downloaded file: ${buffer.length} bytes`) @@ -136,6 +144,7 @@ export async function POST(request: NextRequest) { output: { message: 'Document sent successfully', data: data.result, + files: filesOutput, }, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 74397b9bbd..9e3b163a5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { ArrowDown, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button } from '@/components/emcn' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileCards') @@ -57,7 +58,7 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) if (file.key.startsWith('url/')) { if (file.url) { window.open(file.url, '_blank') - logger.info(`Opened URL-type file directly: ${file.url}`) + logger.info(`Opened URL-type file directly: ${sanitizeUrlForLog(file.url)}`) return } throw new Error('URL is required for URL-type files') @@ -77,13 +78,13 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) const serveUrl = file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution` window.open(serveUrl, '_blank') - logger.info(`Opened execution file serve URL: ${serveUrl}`) + logger.info(`Opened execution file serve URL: ${sanitizeUrlForLog(serveUrl)}`) } else { const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null if (viewerUrl) { router.push(viewerUrl) - logger.info(`Navigated to viewer URL: ${viewerUrl}`) + logger.info(`Navigated to viewer URL: ${sanitizeUrlForLog(viewerUrl)}`) } else { logger.warn( `Could not construct viewer URL for file: ${file.name}, falling back to serve URL` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 1c1d468bef..7b66b83695 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -807,7 +807,7 @@ export function Chat() { const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map( (fieldName) => { - const defaultType = fieldName === 'files' ? 'files' : 'string' + const defaultType = fieldName === 'files' ? 'file[]' : 'string' return { id: crypto.randomUUID(), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 0d25696905..770dd3f176 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -225,7 +225,7 @@ const getOutputTypeForPath = ( const chatModeTypes: Record = { input: 'string', conversationId: 'string', - files: 'files', + files: 'file[]', } return chatModeTypes[outputPath] || 'any' } @@ -1563,16 +1563,11 @@ export const TagDropdown: React.FC = ({ blockTagGroups.sort((a, b) => a.distance - b.distance) finalBlockTagGroups.push(...blockTagGroups) - const contextualTags: string[] = [] - if (loopBlockGroup) { - contextualTags.push(...loopBlockGroup.tags) - } - if (parallelBlockGroup) { - contextualTags.push(...parallelBlockGroup.tags) - } + const groupTags = finalBlockTagGroups.flatMap((group) => group.tags) + const tags = [...groupTags, ...variableTags] return { - tags: [...allBlockTags, ...variableTags, ...contextualTags], + tags, variableInfoMap, blockTagGroups: finalBlockTagGroups, } diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 0d1108a097..94c27d4482 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -578,13 +578,20 @@ export const DiscordBlock: BlockConfig = { if (!params.serverId) throw new Error('Server ID is required') switch (params.operation) { - case 'discord_send_message': + case 'discord_send_message': { + const fileParam = params.attachmentFiles || params.files + const normalizedFiles = fileParam + ? Array.isArray(fileParam) + ? fileParam + : [fileParam] + : undefined return { ...commonParams, channelId: params.channelId, content: params.content, - files: params.attachmentFiles || params.files, + files: normalizedFiles, } + } case 'discord_get_messages': return { ...commonParams, @@ -789,6 +796,7 @@ export const DiscordBlock: BlockConfig = { }, outputs: { message: { type: 'string', description: 'Status message' }, + files: { type: 'file[]', description: 'Files attached to the message' }, data: { type: 'json', description: 'Response data' }, }, } diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts index 9589a9f473..58d79fe67a 100644 --- a/apps/sim/blocks/blocks/elevenlabs.ts +++ b/apps/sim/blocks/blocks/elevenlabs.ts @@ -73,5 +73,6 @@ export const ElevenLabsBlock: BlockConfig = { outputs: { audioUrl: { type: 'string', description: 'Generated audio URL' }, + audioFile: { type: 'file', description: 'Generated audio file' }, }, } diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 66d42a43dd..c2e64ce1ec 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -34,6 +34,7 @@ export const JiraBlock: BlockConfig = { { label: 'Update Comment', id: 'update_comment' }, { label: 'Delete Comment', id: 'delete_comment' }, { label: 'Get Attachments', id: 'get_attachments' }, + { label: 'Add Attachment', id: 'add_attachment' }, { label: 'Delete Attachment', id: 'delete_attachment' }, { label: 'Add Worklog', id: 'add_worklog' }, { label: 'Get Worklogs', id: 'get_worklogs' }, @@ -137,6 +138,7 @@ export const JiraBlock: BlockConfig = { 'update_comment', 'delete_comment', 'get_attachments', + 'add_attachment', 'add_worklog', 'get_worklogs', 'update_worklog', @@ -168,6 +170,7 @@ export const JiraBlock: BlockConfig = { 'update_comment', 'delete_comment', 'get_attachments', + 'add_attachment', 'add_worklog', 'get_worklogs', 'update_worklog', @@ -407,6 +410,27 @@ Return ONLY the comment text - no explanations.`, condition: { field: 'operation', value: ['update_comment', 'delete_comment'] }, }, // Attachment fields + { + id: 'attachmentFiles', + title: 'Attachments', + type: 'file-upload', + canonicalParamId: 'files', + placeholder: 'Upload files', + condition: { field: 'operation', value: 'add_attachment' }, + mode: 'basic', + multiple: true, + required: true, + }, + { + id: 'files', + title: 'File References', + type: 'short-input', + canonicalParamId: 'files', + placeholder: 'File reference from previous block', + condition: { field: 'operation', value: 'add_attachment' }, + mode: 'advanced', + required: true, + }, { id: 'attachmentId', title: 'Attachment ID', @@ -576,6 +600,7 @@ Return ONLY the comment text - no explanations.`, 'jira_update_comment', 'jira_delete_comment', 'jira_get_attachments', + 'jira_add_attachment', 'jira_delete_attachment', 'jira_add_worklog', 'jira_get_worklogs', @@ -623,6 +648,8 @@ Return ONLY the comment text - no explanations.`, return 'jira_delete_comment' case 'get_attachments': return 'jira_get_attachments' + case 'add_attachment': + return 'jira_add_attachment' case 'delete_attachment': return 'jira_delete_attachment' case 'add_worklog': @@ -838,6 +865,21 @@ Return ONLY the comment text - no explanations.`, issueKey: effectiveIssueKey, } } + case 'add_attachment': { + if (!effectiveIssueKey) { + throw new Error('Issue Key is required to add attachments.') + } + const fileParam = params.attachmentFiles || params.files + if (!fileParam || (Array.isArray(fileParam) && fileParam.length === 0)) { + throw new Error('At least one attachment file is required.') + } + const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] + return { + ...baseParams, + issueKey: effectiveIssueKey, + files: normalizedFiles, + } + } case 'delete_attachment': { return { ...baseParams, @@ -982,6 +1024,8 @@ Return ONLY the comment text - no explanations.`, commentBody: { type: 'string', description: 'Text content for comment operations' }, commentId: { type: 'string', description: 'Comment ID for update/delete operations' }, // Attachment operation inputs + attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, + files: { type: 'array', description: 'Files to attach (UserFile array)' }, attachmentId: { type: 'string', description: 'Attachment ID for delete operation' }, // Worklog operation inputs timeSpentSeconds: { @@ -1049,9 +1093,11 @@ Return ONLY the comment text - no explanations.`, // jira_get_attachments outputs attachments: { - type: 'file[]', + type: 'json', description: 'Array of attachments with id, filename, size, mimeType, created, author', }, + files: { type: 'file[]', description: 'Uploaded attachment files' }, + attachmentIds: { type: 'json', description: 'Uploaded attachment IDs' }, // jira_delete_attachment, jira_delete_comment, jira_delete_issue, jira_delete_worklog, jira_delete_issue_link outputs attachmentId: { type: 'string', description: 'Deleted attachment ID' }, diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 300c51b706..fefe38bc97 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -668,17 +668,44 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n generationType: 'timestamp', }, }, + // Attachment file + { + id: 'attachmentFileUpload', + title: 'Attachment', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload attachment', + condition: { + field: 'operation', + value: ['linear_create_attachment'], + }, + mode: 'basic', + multiple: false, + }, + { + id: 'file', + title: 'File Reference', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'File reference from previous block', + condition: { + field: 'operation', + value: ['linear_create_attachment'], + }, + mode: 'advanced', + }, // Attachment URL { id: 'url', title: 'URL', type: 'short-input', placeholder: 'Enter URL', - required: true, + required: false, condition: { field: 'operation', value: ['linear_create_attachment'], }, + mode: 'advanced', }, // Attachment title { @@ -1742,16 +1769,31 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n teamId: effectiveTeamId, } - case 'linear_create_attachment': - if (!params.issueId?.trim() || !params.url?.trim()) { - throw new Error('Issue ID and URL are required.') + case 'linear_create_attachment': { + if (!params.issueId?.trim()) { + throw new Error('Issue ID is required.') + } + if (Array.isArray(params.file)) { + throw new Error('Attachment file must be a single file.') + } + if (Array.isArray(params.attachmentFileUpload)) { + throw new Error('Attachment file must be a single file.') + } + const attachmentFile = params.attachmentFileUpload || params.file + const attachmentUrl = + params.url?.trim() || + (attachmentFile && !Array.isArray(attachmentFile) ? attachmentFile.url : undefined) + if (!attachmentUrl) { + throw new Error('URL or file is required.') } return { ...baseParams, issueId: params.issueId.trim(), - url: params.url.trim(), + url: attachmentUrl, + file: attachmentFile, title: params.attachmentTitle, } + } case 'linear_list_attachments': if (!params.issueId?.trim()) { @@ -2248,6 +2290,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n endDate: { type: 'string', description: 'End date' }, targetDate: { type: 'string', description: 'Target date' }, url: { type: 'string', description: 'URL' }, + attachmentFileUpload: { type: 'json', description: 'File to attach (UI upload)' }, + file: { type: 'json', description: 'File to attach (UserFile)' }, attachmentTitle: { type: 'string', description: 'Attachment title' }, attachmentId: { type: 'string', description: 'Attachment identifier' }, relationType: { type: 'string', description: 'Relation type' }, @@ -2341,7 +2385,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n cycles: { type: 'json', description: 'Cycles list' }, // Attachment outputs attachment: { type: 'json', description: 'Attachment data' }, - attachments: { type: 'file[]', description: 'Attachments list' }, + attachments: { type: 'json', description: 'Attachments list' }, // Relation outputs relation: { type: 'json', description: 'Issue relation data' }, relations: { type: 'json', description: 'Issue relations list' }, diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 69dedc8af6..04cb2d242c 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -346,7 +346,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { // Add files if provided const fileParam = attachmentFiles || files if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) { - baseParams.files = fileParam + const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] + if (normalizedFiles.length > 0) { + baseParams.files = normalizedFiles + } } // Add messageId if provided @@ -463,6 +466,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { totalAttachments: { type: 'number', description: 'Total number of attachments' }, attachmentTypes: { type: 'json', description: 'Array of attachment content types' }, attachments: { type: 'file[]', description: 'Downloaded message attachments' }, + files: { type: 'file[]', description: 'Files attached to the message' }, updatedContent: { type: 'boolean', description: 'Whether content was successfully updated/sent', diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index 1d3b939d47..b6bd6fb8e6 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -803,7 +803,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n outputs: { deals: { type: 'json', description: 'Array of deal objects' }, deal: { type: 'json', description: 'Single deal object' }, - files: { type: 'file[]', description: 'Array of file objects' }, + files: { type: 'json', description: 'Array of file objects' }, messages: { type: 'json', description: 'Array of mail message objects' }, pipelines: { type: 'json', description: 'Array of pipeline objects' }, projects: { type: 'json', description: 'Array of project objects' }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index f459c1a036..3621ee5b43 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -293,6 +293,7 @@ export const SftpBlock: BlockConfig = { outputs: { success: { type: 'boolean', description: 'Whether the operation was successful' }, uploadedFiles: { type: 'json', description: 'Array of uploaded file details' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, fileName: { type: 'string', description: 'Downloaded file name' }, content: { type: 'string', description: 'Downloaded file content' }, size: { type: 'number', description: 'File size in bytes' }, diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index b6cdbfbdaf..5fe1dfb6df 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -522,7 +522,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, description: 'Array of SharePoint list items with fields', }, uploadedFiles: { - type: 'file[]', + type: 'json', description: 'Array of uploaded file objects with id, name, webUrl, size', }, fileCount: { diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 9bd6292b7b..0ce640032b 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -622,7 +622,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, } const fileParam = attachmentFiles || files if (fileParam) { - baseParams.files = fileParam + const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] + if (normalizedFiles.length > 0) { + baseParams.files = normalizedFiles + } } break } @@ -796,6 +799,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'number', description: 'Number of files uploaded (when files are attached)', }, + files: { type: 'file[]', description: 'Files attached to the message' }, // slack_canvas outputs canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' }, diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 924b26c457..32dbffd722 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -507,6 +507,7 @@ export const SSHBlock: BlockConfig = { stderr: { type: 'string', description: 'Command standard error' }, exitCode: { type: 'number', description: 'Command exit code' }, success: { type: 'boolean', description: 'Operation success status' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, fileContent: { type: 'string', description: 'Downloaded/read file content' }, entries: { type: 'json', description: 'Directory entries' }, exists: { type: 'boolean', description: 'File/directory existence' }, diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index e45f27cee1..65b18677ab 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -314,9 +314,14 @@ export const TelegramBlock: BlockConfig = { case 'telegram_send_document': { // Handle file upload const fileParam = params.attachmentFiles || params.files + const normalizedFiles = fileParam + ? Array.isArray(fileParam) + ? fileParam + : [fileParam] + : undefined return { ...commonParams, - files: fileParam, + files: normalizedFiles, caption: params.caption, } } @@ -359,6 +364,7 @@ export const TelegramBlock: BlockConfig = { }, message: { type: 'string', description: 'Success or error message' }, data: { type: 'json', description: 'Response data' }, + files: { type: 'file[]', description: 'Files attached to the message' }, // Specific result fields messageId: { type: 'number', description: 'Sent message ID' }, chatId: { type: 'number', description: 'Chat ID where message was sent' }, diff --git a/apps/sim/lib/core/utils/logging.ts b/apps/sim/lib/core/utils/logging.ts new file mode 100644 index 0000000000..5670d6a5d5 --- /dev/null +++ b/apps/sim/lib/core/utils/logging.ts @@ -0,0 +1,19 @@ +/** + * Sanitize URLs for logging by stripping query/hash and truncating. + */ +export function sanitizeUrlForLog(url: string, maxLength = 120): string { + if (!url) return '' + + const trimmed = url.trim() + try { + const hasProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed) + const parsed = new URL(trimmed, hasProtocol ? undefined : 'http://localhost') + const origin = parsed.origin === 'null' ? '' : parsed.origin + const sanitized = `${origin}${parsed.pathname}` + const result = sanitized || parsed.pathname || trimmed + return result.length > maxLength ? `${result.slice(0, maxLength)}...` : result + } catch { + const withoutQuery = trimmed.split('?')[0].split('#')[0] + return withoutQuery.length > maxLength ? `${withoutQuery.slice(0, maxLength)}...` : withoutQuery + } +} diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 632e91fa80..fadd43fa1f 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -3,6 +3,7 @@ import { PDFDocument } from 'pdf-lib' import { getBYOKKey } from '@/lib/api-key/byok' import { type Chunk, JsonYamlChunker, StructuredDataChunker, TextChunker } from '@/lib/chunkers' import { env } from '@/lib/core/config/env' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { parseBuffer, parseFile } from '@/lib/file-parsers' import type { FileParseMetadata } from '@/lib/file-parsers/types' import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' @@ -489,7 +490,7 @@ async function parseWithMistralOCR( workspaceId ) - logger.info(`Mistral OCR: Using presigned URL for ${filename}: ${httpsUrl.substring(0, 120)}...`) + logger.info(`Mistral OCR: Using presigned URL for ${filename}: ${sanitizeUrlForLog(httpsUrl)}`) let pageCount = 0 if (mimeType === 'application/pdf' && buffer) { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index f3cd6b4365..ad17cd774b 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -11,6 +11,7 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' import { @@ -114,7 +115,7 @@ async function fetchWithDNSPinning( const urlValidation = await validateUrlWithDNS(url, 'contentUrl') if (!urlValidation.isValid) { logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { - url: url.substring(0, 100), + url: sanitizeUrlForLog(url), }) return null } @@ -133,7 +134,7 @@ async function fetchWithDNSPinning( } catch (error) { logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { error: error instanceof Error ? error.message : String(error), - url: url.substring(0, 100), + url: sanitizeUrlForLog(url), }) return null } diff --git a/apps/sim/tools/browser_use/run_task.ts b/apps/sim/tools/browser_use/run_task.ts index 9dbeeb5b6f..76edcfe8ba 100644 --- a/apps/sim/tools/browser_use/run_task.ts +++ b/apps/sim/tools/browser_use/run_task.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -183,7 +184,7 @@ async function pollForCompletion( } if (!liveUrlLogged && taskData.live_url) { - logger.info(`BrowserUse task ${taskId} live URL: ${taskData.live_url}`) + logger.info(`BrowserUse task ${taskId} live URL: ${sanitizeUrlForLog(taskData.live_url)}`) liveUrlLogged = true } diff --git a/apps/sim/tools/discord/send_message.ts b/apps/sim/tools/discord/send_message.ts index 074285e484..abe95515d3 100644 --- a/apps/sim/tools/discord/send_message.ts +++ b/apps/sim/tools/discord/send_message.ts @@ -72,6 +72,7 @@ export const discordSendMessageTool: ToolConfig< outputs: { message: { type: 'string', description: 'Success or error message' }, + files: { type: 'file[]', description: 'Files attached to the message' }, data: { type: 'object', description: 'Discord message data', diff --git a/apps/sim/tools/discord/types.ts b/apps/sim/tools/discord/types.ts index 9573309e94..76a5d016e4 100644 --- a/apps/sim/tools/discord/types.ts +++ b/apps/sim/tools/discord/types.ts @@ -1,4 +1,5 @@ import type { UserFile } from '@/executor/types' +import type { ToolFileData } from '@/tools/types' export interface DiscordMessage { id: string @@ -85,6 +86,7 @@ interface BaseDiscordResponse { export interface DiscordSendMessageResponse extends BaseDiscordResponse { output: { message: string + files?: ToolFileData[] data?: DiscordMessage } } diff --git a/apps/sim/tools/elevenlabs/types.ts b/apps/sim/tools/elevenlabs/types.ts index 06d0436980..5627d1f72c 100644 --- a/apps/sim/tools/elevenlabs/types.ts +++ b/apps/sim/tools/elevenlabs/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface ElevenLabsTtsParams { @@ -12,11 +13,13 @@ export interface ElevenLabsTtsParams { export interface ElevenLabsTtsResponse extends ToolResponse { output: { audioUrl: string + audioFile?: UserFile } } export interface ElevenLabsBlockResponse extends ToolResponse { output: { audioUrl: string + audioFile?: UserFile } } diff --git a/apps/sim/tools/jira/add_attachment.ts b/apps/sim/tools/jira/add_attachment.ts new file mode 100644 index 0000000000..8055c72304 --- /dev/null +++ b/apps/sim/tools/jira/add_attachment.ts @@ -0,0 +1,83 @@ +import type { JiraAddAttachmentParams, JiraAddAttachmentResponse } from '@/tools/jira/types' +import type { ToolConfig } from '@/tools/types' + +export const jiraAddAttachmentTool: ToolConfig = + { + id: 'jira_add_attachment', + name: 'Jira Add Attachment', + description: 'Add attachments to a Jira issue', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + issueKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Jira issue key to add attachments to (e.g., PROJ-123)', + }, + files: { + type: 'file[]', + required: true, + visibility: 'user-only', + description: 'Files to attach to the Jira issue', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: '/api/tools/jira/add-attachment', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: JiraAddAttachmentParams) => ({ + accessToken: params.accessToken, + domain: params.domain, + issueKey: params.issueKey, + files: params.files, + cloudId: params.cloudId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to add Jira attachment') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueKey: { type: 'string', description: 'Issue key' }, + attachmentIds: { type: 'json', description: 'IDs of uploaded attachments' }, + files: { type: 'file[]', description: 'Uploaded attachment files' }, + }, + } diff --git a/apps/sim/tools/jira/index.ts b/apps/sim/tools/jira/index.ts index a9bed66999..ced24d2d06 100644 --- a/apps/sim/tools/jira/index.ts +++ b/apps/sim/tools/jira/index.ts @@ -1,3 +1,4 @@ +import { jiraAddAttachmentTool } from '@/tools/jira/add_attachment' import { jiraAddCommentTool } from '@/tools/jira/add_comment' import { jiraAddWatcherTool } from '@/tools/jira/add_watcher' import { jiraAddWorklogTool } from '@/tools/jira/add_worklog' @@ -32,6 +33,7 @@ export { jiraTransitionIssueTool, jiraSearchIssuesTool, jiraAddCommentTool, + jiraAddAttachmentTool, jiraGetCommentsTool, jiraUpdateCommentTool, jiraDeleteCommentTool, diff --git a/apps/sim/tools/jira/types.ts b/apps/sim/tools/jira/types.ts index b7d840f955..0ed2ba6466 100644 --- a/apps/sim/tools/jira/types.ts +++ b/apps/sim/tools/jira/types.ts @@ -1,4 +1,5 @@ -import type { ToolResponse } from '@/tools/types' +import type { UserFile } from '@/executor/types' +import type { ToolFileData, ToolResponse } from '@/tools/types' export interface JiraRetrieveParams { accessToken: string @@ -312,6 +313,23 @@ export interface JiraDeleteAttachmentResponse extends ToolResponse { } } +export interface JiraAddAttachmentParams { + accessToken: string + domain: string + issueKey: string + files: UserFile[] + cloudId?: string +} + +export interface JiraAddAttachmentResponse extends ToolResponse { + output: { + ts: string + issueKey: string + attachmentIds: string[] + files: ToolFileData[] + } +} + // Worklogs export interface JiraAddWorklogParams { accessToken: string @@ -482,6 +500,7 @@ export type JiraResponse = | JiraUpdateCommentResponse | JiraDeleteCommentResponse | JiraGetAttachmentsResponse + | JiraAddAttachmentResponse | JiraDeleteAttachmentResponse | JiraAddWorklogResponse | JiraGetWorklogsResponse diff --git a/apps/sim/tools/linear/create_attachment.ts b/apps/sim/tools/linear/create_attachment.ts index 5e366c5c68..03019de912 100644 --- a/apps/sim/tools/linear/create_attachment.ts +++ b/apps/sim/tools/linear/create_attachment.ts @@ -28,10 +28,16 @@ export const linearCreateAttachmentTool: ToolConfig< }, url: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', description: 'URL of the attachment', }, + file: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'File to attach', + }, title: { type: 'string', required: true, @@ -59,9 +65,14 @@ export const linearCreateAttachmentTool: ToolConfig< } }, body: (params) => { + const attachmentUrl = params.url || params.file?.url + if (!attachmentUrl) { + throw new Error('URL or file is required') + } + const input: Record = { issueId: params.issueId, - url: params.url, + url: attachmentUrl, title: params.title, } diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts index 5a24f0a826..b66bae5b46 100644 --- a/apps/sim/tools/linear/types.ts +++ b/apps/sim/tools/linear/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -875,7 +876,8 @@ export interface LinearGetActiveCycleParams { export interface LinearCreateAttachmentParams { issueId: string - url: string + url?: string + file?: UserFile title?: string subtitle?: string accessToken?: string diff --git a/apps/sim/tools/microsoft_planner/read_task.ts b/apps/sim/tools/microsoft_planner/read_task.ts index e44d5e175c..730d3f788b 100644 --- a/apps/sim/tools/microsoft_planner/read_task.ts +++ b/apps/sim/tools/microsoft_planner/read_task.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { MicrosoftPlannerReadResponse, MicrosoftPlannerToolParams, @@ -76,7 +77,7 @@ export const readTaskTool: ToolConfig = { jira_update_comment: jiraUpdateCommentTool, jira_delete_comment: jiraDeleteCommentTool, jira_get_attachments: jiraGetAttachmentsTool, + jira_add_attachment: jiraAddAttachmentTool, jira_delete_attachment: jiraDeleteAttachmentTool, jira_add_worklog: jiraAddWorklogTool, jira_get_worklogs: jiraGetWorklogsTool, diff --git a/apps/sim/tools/sftp/download.ts b/apps/sim/tools/sftp/download.ts index d02532e86f..0c026258df 100644 --- a/apps/sim/tools/sftp/download.ts +++ b/apps/sim/tools/sftp/download.ts @@ -94,6 +94,7 @@ export const sftpDownloadTool: ToolConfig = success: true, output: { downloaded: true, + file: data.file, fileContent: data.content, fileName: data.fileName, remotePath: data.remotePath, @@ -91,6 +92,7 @@ export const downloadFileTool: ToolConfig = outputs: { downloaded: { type: 'boolean', description: 'Whether the file was downloaded successfully' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, fileContent: { type: 'string', description: 'File content (base64 encoded for binary files)' }, fileName: { type: 'string', description: 'Name of the downloaded file' }, remotePath: { type: 'string', description: 'Source path on the remote server' }, diff --git a/apps/sim/tools/ssh/types.ts b/apps/sim/tools/ssh/types.ts index e68f6302f4..5c4267f984 100644 --- a/apps/sim/tools/ssh/types.ts +++ b/apps/sim/tools/ssh/types.ts @@ -1,4 +1,4 @@ -import type { ToolResponse } from '@/tools/types' +import type { ToolFileData, ToolResponse } from '@/tools/types' // Base SSH connection configuration export interface SSHConnectionConfig { @@ -149,6 +149,7 @@ export interface SSHResponse extends ToolResponse { uploaded?: boolean downloaded?: boolean + file?: ToolFileData fileContent?: string fileName?: string remotePath?: string diff --git a/apps/sim/tools/stagehand/agent.ts b/apps/sim/tools/stagehand/agent.ts index f3d055a8ea..7884b45754 100644 --- a/apps/sim/tools/stagehand/agent.ts +++ b/apps/sim/tools/stagehand/agent.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { StagehandAgentParams, StagehandAgentResponse } from '@/tools/stagehand/types' import { STAGEHAND_AGENT_RESULT_OUTPUT_PROPERTIES } from '@/tools/stagehand/types' import type { ToolConfig } from '@/tools/types' @@ -61,7 +62,9 @@ export const agentTool: ToolConfig let startUrl = params.startUrl if (startUrl && !startUrl.match(/^https?:\/\//i)) { startUrl = `https://${startUrl.trim()}` - logger.info(`Normalized URL from ${params.startUrl} to ${startUrl}`) + logger.info( + `Normalized URL from ${sanitizeUrlForLog(params.startUrl)} to ${sanitizeUrlForLog(startUrl)}` + ) } return { diff --git a/apps/sim/tools/supabase/types.ts b/apps/sim/tools/supabase/types.ts index b26e065b40..cf2ca9c000 100644 --- a/apps/sim/tools/supabase/types.ts +++ b/apps/sim/tools/supabase/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -441,7 +442,7 @@ export interface SupabaseStorageUploadParams { bucket: string fileName: string path?: string - fileData: any // UserFile object (basic mode) or string (advanced mode: base64/plain text) + fileData: UserFile | string contentType?: string upsert?: boolean } diff --git a/apps/sim/tools/telegram/send_document.ts b/apps/sim/tools/telegram/send_document.ts index a9800e8096..85c3724a8d 100644 --- a/apps/sim/tools/telegram/send_document.ts +++ b/apps/sim/tools/telegram/send_document.ts @@ -75,6 +75,7 @@ export const telegramSendDocumentTool: ToolConfig< outputs: { message: { type: 'string', description: 'Success or error message' }, + files: { type: 'file[]', description: 'Files attached to the message' }, data: { type: 'object', description: 'Telegram message data including document', diff --git a/apps/sim/tools/telegram/types.ts b/apps/sim/tools/telegram/types.ts index 0e54deeb43..b7a887856e 100644 --- a/apps/sim/tools/telegram/types.ts +++ b/apps/sim/tools/telegram/types.ts @@ -1,5 +1,5 @@ import type { UserFile } from '@/executor/types' -import type { ToolResponse } from '@/tools/types' +import type { ToolFileData, ToolResponse } from '@/tools/types' export interface TelegramMessage { message_id: number @@ -167,6 +167,7 @@ export interface TelegramSendDocumentResponse extends ToolResponse { output: { message: string data?: TelegramMedia + files?: ToolFileData[] } } diff --git a/apps/sim/tools/wordpress/types.ts b/apps/sim/tools/wordpress/types.ts index fbb9483356..595e9e3e69 100644 --- a/apps/sim/tools/wordpress/types.ts +++ b/apps/sim/tools/wordpress/types.ts @@ -1,4 +1,5 @@ // Common types for WordPress REST API tools +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' // Common parameters for all WordPress tools (WordPress.com OAuth) @@ -254,7 +255,7 @@ export interface WordPressListPagesResponse extends ToolResponse { // Upload Media export interface WordPressUploadMediaParams extends WordPressBaseParams { - file: any // UserFile object from file upload + file: UserFile filename?: string // Optional filename override title?: string caption?: string diff --git a/apps/sim/triggers/microsoftteams/webhook.ts b/apps/sim/triggers/microsoftteams/webhook.ts index abec619823..1f17a77194 100644 --- a/apps/sim/triggers/microsoftteams/webhook.ts +++ b/apps/sim/triggers/microsoftteams/webhook.ts @@ -98,7 +98,7 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = { }, message: { raw: { - attachments: { type: 'file[]', description: 'Array of attachments' }, + attachments: { type: 'json', description: 'Array of attachments' }, channelData: { team: { id: { type: 'string', description: 'Team ID' } }, tenant: { id: { type: 'string', description: 'Tenant ID' } }, From 9ec0c8f3f54ad4d08b655323f8743c88b92c2403 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 15:00:53 -0800 Subject: [PATCH 04/39] separate server and client logic --- apps/sim/app/api/a2a/serve/[agentId]/route.ts | 4 +- apps/sim/app/api/files/parse/route.ts | 12 +- .../app/api/tools/a2a/send-message/route.ts | 9 + .../tools/a2a/set-push-notification/route.ts | 4 +- .../api/tools/github/latest-commit/route.ts | 168 ++++++++++ .../api/tools/google_drive/download/route.ts | 231 ++++++++++++++ .../download-export-file/route.ts | 132 ++++++++ apps/sim/app/api/tools/image/route.ts | 15 +- apps/sim/app/api/tools/jsm/queues/route.ts | 3 +- apps/sim/app/api/tools/jsm/request/route.ts | 5 +- apps/sim/app/api/tools/jsm/requests/route.ts | 3 +- .../app/api/tools/jsm/requesttypes/route.ts | 3 +- .../app/api/tools/jsm/servicedesks/route.ts | 3 +- apps/sim/app/api/tools/jsm/sla/route.ts | 3 +- .../sim/app/api/tools/jsm/transition/route.ts | 3 +- .../app/api/tools/jsm/transitions/route.ts | 3 +- .../microsoft_teams/write_channel/route.ts | 95 ++++-- .../tools/microsoft_teams/write_chat/route.ts | 95 ++++-- apps/sim/app/api/tools/mistral/parse/route.ts | 136 ++++---- .../app/api/tools/onedrive/download/route.ts | 159 ++++++++++ .../app/api/tools/onedrive/upload/route.ts | 95 ++++-- .../api/tools/pipedrive/get-files/route.ts | 153 +++++++++ apps/sim/app/api/tools/pulse/parse/route.ts | 164 ++++++++-- apps/sim/app/api/tools/reducto/parse/route.ts | 170 +++++++--- .../app/api/tools/sharepoint/upload/route.ts | 50 ++- .../sim/app/api/tools/slack/download/route.ts | 170 ++++++++++ apps/sim/app/api/tools/slack/utils.ts | 32 +- .../app/api/tools/stagehand/agent/route.ts | 5 + .../app/api/tools/stagehand/extract/route.ts | 8 +- apps/sim/app/api/tools/stt/route.ts | 61 +++- .../sim/app/api/tools/textract/parse/route.ts | 163 +++++----- .../api/tools/twilio/get-recording/route.ts | 219 +++++++++++++ .../sim/app/api/tools/vision/analyze/route.ts | 84 ++++- .../api/tools/zoom/get-recordings/route.ts | 182 +++++++++++ .../file-download/file-download.tsx | 7 +- apps/sim/blocks/blocks/discord.ts | 2 +- apps/sim/blocks/blocks/file.ts | 70 +++-- apps/sim/blocks/blocks/fireflies.ts | 91 ++++++ apps/sim/blocks/blocks/google_sheets.ts | 61 ++-- apps/sim/blocks/blocks/google_slides.ts | 116 +++++++ apps/sim/blocks/blocks/jira.ts | 2 +- apps/sim/blocks/blocks/microsoft_excel.ts | 25 +- apps/sim/blocks/blocks/mistral_parse.ts | 27 +- apps/sim/blocks/blocks/notion.ts | 1 + apps/sim/blocks/blocks/outlook.ts | 2 +- apps/sim/blocks/blocks/pipedrive.ts | 1 + apps/sim/blocks/blocks/pulse.ts | 79 ++++- apps/sim/blocks/blocks/reducto.ts | 95 +++++- apps/sim/blocks/blocks/sendgrid.ts | 2 +- apps/sim/blocks/blocks/sftp.ts | 2 +- apps/sim/blocks/blocks/smtp.ts | 2 +- apps/sim/blocks/blocks/stt.ts | 62 ++++ apps/sim/blocks/blocks/telegram.ts | 2 +- apps/sim/blocks/blocks/textract.ts | 89 +++++- apps/sim/blocks/blocks/vision.ts | 11 + apps/sim/blocks/registry.ts | 18 +- apps/sim/executor/handlers/api/api-handler.ts | 14 +- .../sim/executor/utils/file-tool-processor.ts | 10 +- apps/sim/lib/a2a/push-notifications.ts | 5 +- apps/sim/lib/a2a/utils.ts | 7 +- .../core/security/input-validation.server.ts | 290 ++++++++++++++++++ .../core/security/input-validation.test.ts | 2 +- .../sim/lib/core/security/input-validation.ts | 261 ---------------- apps/sim/lib/core/utils/urls.ts | 16 + apps/sim/lib/execution/files.ts | 31 +- .../knowledge/documents/document-processor.ts | 6 +- apps/sim/lib/uploads/utils/file-schemas.ts | 28 ++ .../lib/uploads/utils/file-utils.server.ts | 47 ++- apps/sim/lib/uploads/utils/file-utils.ts | 58 +++- apps/sim/lib/webhooks/rss-polling-service.ts | 5 +- apps/sim/lib/webhooks/utils.server.ts | 4 +- apps/sim/tools/browser_use/run_task.ts | 3 +- apps/sim/tools/file/parser.ts | 64 +++- apps/sim/tools/file/types.ts | 10 +- apps/sim/tools/github/get_file_content.ts | 51 +++ apps/sim/tools/github/latest_commit.ts | 98 +----- apps/sim/tools/github/types.ts | 3 +- apps/sim/tools/gmail/types.ts | 2 +- apps/sim/tools/gmail/utils.ts | 2 +- apps/sim/tools/google_drive/download.ts | 184 +---------- .../google_vault/download_export_file.ts | 97 +----- apps/sim/tools/index.ts | 5 +- apps/sim/tools/microsoft_planner/read_task.ts | 3 +- apps/sim/tools/microsoft_teams/types.ts | 24 ++ apps/sim/tools/mistral/parser.ts | 206 ++++--------- apps/sim/tools/mistral/types.ts | 13 +- apps/sim/tools/onedrive/download.ts | 96 +----- apps/sim/tools/openai/image.ts | 32 -- apps/sim/tools/outlook/read.ts | 2 +- apps/sim/tools/outlook/types.ts | 2 +- apps/sim/tools/pipedrive/get_files.ts | 70 ++--- apps/sim/tools/pipedrive/types.ts | 4 +- apps/sim/tools/pulse/index.ts | 2 +- apps/sim/tools/pulse/parser.ts | 183 +++++++---- apps/sim/tools/pulse/types.ts | 37 ++- apps/sim/tools/reducto/index.ts | 4 +- apps/sim/tools/reducto/parser.ts | 183 +++++++---- apps/sim/tools/reducto/types.ts | 25 +- apps/sim/tools/registry.ts | 22 +- apps/sim/tools/sharepoint/get_list.ts | 7 +- apps/sim/tools/slack/download.ts | 104 +------ apps/sim/tools/stagehand/agent.ts | 5 +- apps/sim/tools/stt/assemblyai.ts | 48 ++- apps/sim/tools/stt/deepgram.ts | 42 ++- apps/sim/tools/stt/elevenlabs.ts | 38 ++- apps/sim/tools/stt/gemini.ts | 38 ++- apps/sim/tools/stt/index.ts | 23 +- apps/sim/tools/stt/types.ts | 2 + apps/sim/tools/stt/whisper.ts | 46 ++- apps/sim/tools/textract/index.ts | 2 +- apps/sim/tools/textract/parser.ts | 88 +++++- apps/sim/tools/textract/types.ts | 19 +- apps/sim/tools/twilio_voice/get_recording.ts | 117 +------ apps/sim/tools/twilio_voice/types.ts | 3 +- apps/sim/tools/typeform/files.ts | 15 + apps/sim/tools/typeform/types.ts | 3 +- apps/sim/tools/utils.server.ts | 77 +++++ apps/sim/tools/utils.test.ts | 2 +- apps/sim/tools/utils.ts | 56 +--- apps/sim/tools/video/falai.ts | 2 +- apps/sim/tools/video/luma.ts | 2 +- apps/sim/tools/video/minimax.ts | 2 +- apps/sim/tools/video/runway.ts | 4 +- apps/sim/tools/video/veo.ts | 2 +- apps/sim/tools/vision/index.ts | 4 +- apps/sim/tools/vision/tool.ts | 28 +- apps/sim/tools/vision/types.ts | 7 + apps/sim/tools/zoom/get_meeting_recordings.ts | 92 ++---- apps/sim/tools/zoom/types.ts | 4 +- 129 files changed, 4563 insertions(+), 1939 deletions(-) create mode 100644 apps/sim/app/api/tools/github/latest-commit/route.ts create mode 100644 apps/sim/app/api/tools/google_drive/download/route.ts create mode 100644 apps/sim/app/api/tools/google_vault/download-export-file/route.ts create mode 100644 apps/sim/app/api/tools/onedrive/download/route.ts create mode 100644 apps/sim/app/api/tools/pipedrive/get-files/route.ts create mode 100644 apps/sim/app/api/tools/slack/download/route.ts create mode 100644 apps/sim/app/api/tools/twilio/get-recording/route.ts create mode 100644 apps/sim/app/api/tools/zoom/get-recordings/route.ts create mode 100644 apps/sim/lib/core/security/input-validation.server.ts create mode 100644 apps/sim/tools/utils.server.ts diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index 45fe2906bc..f8acda5a89 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -16,7 +16,7 @@ import { import { checkHybridAuth } from '@/lib/auth/hybrid' import { getBrandConfig } from '@/lib/branding/branding' import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' -import { validateExternalUrl } from '@/lib/core/security/input-validation' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { markExecutionCancelled } from '@/lib/execution/cancellation' @@ -1119,7 +1119,7 @@ async function handlePushNotificationSet( ) } - const urlValidation = validateExternalUrl( + const urlValidation = await validateUrlWithDNS( params.pushNotificationConfig.url, 'Push notification URL' ) diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 89d5867bf0..25112133fc 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -6,7 +6,10 @@ import { createLogger } from '@sim/logger' import binaryExtensionsList from 'binary-extensions' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' @@ -20,6 +23,7 @@ import { getMimeTypeFromExtension, getViewerUrl, inferContextFromKey, + isInternalFileUrl, } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -216,7 +220,7 @@ async function parseFileSingle( } } - if (filePath.includes('/api/files/serve/')) { + if (isInternalFileUrl(filePath)) { return handleCloudFile(filePath, fileType, undefined, userId, executionContext) } @@ -247,7 +251,7 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string return { isValid: false, error: 'Invalid path: tilde character not allowed' } } - if (filePath.startsWith('/') && !filePath.startsWith('/api/files/serve/')) { + if (filePath.startsWith('/') && !isInternalFileUrl(filePath)) { return { isValid: false, error: 'Path outside allowed directory' } } @@ -368,7 +372,7 @@ async function handleExternalUrl( throw new Error(`File too large: ${buffer.length} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) } - logger.info(`Downloaded file from URL: ${sanitizeUrlForLog(url)}, size: ${buffer.length} bytes`) + logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`) let userFile: UserFile | undefined const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension) diff --git a/apps/sim/app/api/tools/a2a/send-message/route.ts b/apps/sim/app/api/tools/a2a/send-message/route.ts index a66c2b3d37..4c98dc67a4 100644 --- a/apps/sim/app/api/tools/a2a/send-message/route.ts +++ b/apps/sim/app/api/tools/a2a/send-message/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' export const dynamic = 'force-dynamic' @@ -95,6 +96,14 @@ export async function POST(request: NextRequest) { if (validatedData.files && validatedData.files.length > 0) { for (const file of validatedData.files) { if (file.type === 'url') { + const urlValidation = await validateUrlWithDNS(file.data, 'fileUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: urlValidation.error }, + { status: 400 } + ) + } + const filePart: FilePart = { kind: 'file', file: { diff --git a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts index 11dbf7684a..132bb6be22 100644 --- a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { validateExternalUrl } from '@/lib/core/security/input-validation' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' export const dynamic = 'force-dynamic' @@ -40,7 +40,7 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = A2ASetPushNotificationSchema.parse(body) - const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL') + const urlValidation = await validateUrlWithDNS(validatedData.webhookUrl, 'Webhook URL') if (!urlValidation.isValid) { logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/github/latest-commit/route.ts b/apps/sim/app/api/tools/github/latest-commit/route.ts new file mode 100644 index 0000000000..23df8cf90e --- /dev/null +++ b/apps/sim/app/api/tools/github/latest-commit/route.ts @@ -0,0 +1,168 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GitHubLatestCommitAPI') + +const GitHubLatestCommitSchema = z.object({ + owner: z.string().min(1, 'Owner is required'), + repo: z.string().min(1, 'Repo is required'), + branch: z.string().optional().nullable(), + apiKey: z.string().min(1, 'API key is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized GitHub latest commit attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = GitHubLatestCommitSchema.parse(body) + + const { owner, repo, branch, apiKey } = validatedData + + const baseUrl = `https://api.github.com/repos/${owner}/${repo}` + const commitUrl = branch ? `${baseUrl}/commits/${branch}` : `${baseUrl}/commits/HEAD` + + logger.info(`[${requestId}] Fetching latest commit from GitHub`, { owner, repo, branch }) + + const urlValidation = await validateUrlWithDNS(commitUrl, 'commitUrl') + if (!urlValidation.isValid) { + return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 }) + } + + const response = await secureFetchWithPinnedIP(commitUrl, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${apiKey}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error(`[${requestId}] GitHub API error`, { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { success: false, error: errorData.message || `GitHub API error: ${response.status}` }, + { status: 400 } + ) + } + + const data = await response.json() + + const content = `Latest commit: "${data.commit.message}" by ${data.commit.author.name} on ${data.commit.author.date}. SHA: ${data.sha}` + + const files = data.files || [] + const fileDetailsWithContent = [] + + for (const file of files) { + const fileDetail: Record = { + filename: file.filename, + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + status: file.status, + raw_url: file.raw_url, + blob_url: file.blob_url, + patch: file.patch, + content: undefined, + } + + if (file.status !== 'removed' && file.raw_url) { + try { + const rawUrlValidation = await validateUrlWithDNS(file.raw_url, 'rawUrl') + if (rawUrlValidation.isValid) { + const contentResponse = await secureFetchWithPinnedIP( + file.raw_url, + rawUrlValidation.resolvedIP!, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ) + + if (contentResponse.ok) { + fileDetail.content = await contentResponse.text() + } + } + } catch (error) { + logger.warn(`[${requestId}] Failed to fetch content for ${file.filename}:`, error) + } + } + + fileDetailsWithContent.push(fileDetail) + } + + logger.info(`[${requestId}] Latest commit fetched successfully`, { + sha: data.sha, + fileCount: files.length, + }) + + return NextResponse.json({ + success: true, + output: { + content, + metadata: { + sha: data.sha, + html_url: data.html_url, + commit_message: data.commit.message, + author: { + name: data.commit.author.name, + login: data.author?.login || 'Unknown', + avatar_url: data.author?.avatar_url || '', + html_url: data.author?.html_url || '', + }, + committer: { + name: data.commit.committer.name, + login: data.committer?.login || 'Unknown', + avatar_url: data.committer?.avatar_url || '', + html_url: data.committer?.html_url || '', + }, + stats: data.stats + ? { + additions: data.stats.additions, + deletions: data.stats.deletions, + total: data.stats.total, + } + : undefined, + files: fileDetailsWithContent.length > 0 ? fileDetailsWithContent : undefined, + }, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching GitHub latest commit:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts new file mode 100644 index 0000000000..1341d54d82 --- /dev/null +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -0,0 +1,231 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types' +import { + ALL_FILE_FIELDS, + ALL_REVISION_FIELDS, + DEFAULT_EXPORT_FORMATS, + GOOGLE_WORKSPACE_MIME_TYPES, +} from '@/tools/google_drive/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GoogleDriveDownloadAPI') + +const GoogleDriveDownloadSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + fileId: z.string().min(1, 'File ID is required'), + mimeType: z.string().optional().nullable(), + fileName: z.string().optional().nullable(), + includeRevisions: z.boolean().optional().default(true), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Google Drive download attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = GoogleDriveDownloadSchema.parse(body) + + const { + accessToken, + fileId, + mimeType: exportMimeType, + fileName, + includeRevisions, + } = validatedData + const authHeader = `Bearer ${accessToken}` + + logger.info(`[${requestId}] Getting file metadata from Google Drive`, { fileId }) + + const metadataUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?fields=${ALL_FILE_FIELDS}&supportsAllDrives=true` + const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl') + if (!metadataUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: metadataUrlValidation.error }, + { status: 400 } + ) + } + + const metadataResponse = await secureFetchWithPinnedIP( + metadataUrl, + metadataUrlValidation.resolvedIP!, + { + headers: { Authorization: authHeader }, + } + ) + + if (!metadataResponse.ok) { + const errorDetails = await metadataResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to get file metadata`, { + status: metadataResponse.status, + error: errorDetails, + }) + return NextResponse.json( + { success: false, error: errorDetails.error?.message || 'Failed to get file metadata' }, + { status: 400 } + ) + } + + const metadata: GoogleDriveFile = await metadataResponse.json() + const fileMimeType = metadata.mimeType + + let fileBuffer: Buffer + let finalMimeType = fileMimeType + + if (GOOGLE_WORKSPACE_MIME_TYPES.includes(fileMimeType)) { + const exportFormat = exportMimeType || DEFAULT_EXPORT_FORMATS[fileMimeType] || 'text/plain' + finalMimeType = exportFormat + + logger.info(`[${requestId}] Exporting Google Workspace file`, { + fileId, + mimeType: fileMimeType, + exportFormat, + }) + + const exportUrl = `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(exportFormat)}&supportsAllDrives=true` + const exportUrlValidation = await validateUrlWithDNS(exportUrl, 'exportUrl') + if (!exportUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: exportUrlValidation.error }, + { status: 400 } + ) + } + + const exportResponse = await secureFetchWithPinnedIP( + exportUrl, + exportUrlValidation.resolvedIP!, + { headers: { Authorization: authHeader } } + ) + + if (!exportResponse.ok) { + const exportError = await exportResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to export file`, { + status: exportResponse.status, + error: exportError, + }) + return NextResponse.json( + { + success: false, + error: exportError.error?.message || 'Failed to export Google Workspace file', + }, + { status: 400 } + ) + } + + const arrayBuffer = await exportResponse.arrayBuffer() + fileBuffer = Buffer.from(arrayBuffer) + } else { + logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType }) + + const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true` + const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') + if (!downloadUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: downloadUrlValidation.error }, + { status: 400 } + ) + } + + const downloadResponse = await secureFetchWithPinnedIP( + downloadUrl, + downloadUrlValidation.resolvedIP!, + { headers: { Authorization: authHeader } } + ) + + if (!downloadResponse.ok) { + const downloadError = await downloadResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to download file`, { + status: downloadResponse.status, + error: downloadError, + }) + return NextResponse.json( + { success: false, error: downloadError.error?.message || 'Failed to download file' }, + { status: 400 } + ) + } + + const arrayBuffer = await downloadResponse.arrayBuffer() + fileBuffer = Buffer.from(arrayBuffer) + } + + const canReadRevisions = metadata.capabilities?.canReadRevisions === true + if (includeRevisions && canReadRevisions) { + try { + const revisionsUrl = `https://www.googleapis.com/drive/v3/files/${fileId}/revisions?fields=revisions(${ALL_REVISION_FIELDS})&pageSize=100` + const revisionsUrlValidation = await validateUrlWithDNS(revisionsUrl, 'revisionsUrl') + if (revisionsUrlValidation.isValid) { + const revisionsResponse = await secureFetchWithPinnedIP( + revisionsUrl, + revisionsUrlValidation.resolvedIP!, + { headers: { Authorization: authHeader } } + ) + + if (revisionsResponse.ok) { + const revisionsData = await revisionsResponse.json() + metadata.revisions = revisionsData.revisions as GoogleDriveRevision[] + logger.info(`[${requestId}] Fetched file revisions`, { + fileId, + revisionCount: metadata.revisions?.length || 0, + }) + } + } + } catch (error) { + logger.warn(`[${requestId}] Error fetching revisions, continuing without them`, { error }) + } + } + + const resolvedName = fileName || metadata.name || 'download' + + logger.info(`[${requestId}] File downloaded successfully`, { + fileId, + name: resolvedName, + size: fileBuffer.length, + mimeType: finalMimeType, + }) + + const base64Data = fileBuffer.toString('base64') + + return NextResponse.json({ + success: true, + output: { + file: { + name: resolvedName, + mimeType: finalMimeType, + data: base64Data, + size: fileBuffer.length, + }, + metadata, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error downloading Google Drive file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts new file mode 100644 index 0000000000..e33e362d7e --- /dev/null +++ b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts @@ -0,0 +1,132 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { enhanceGoogleVaultError } from '@/tools/google_vault/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GoogleVaultDownloadExportFileAPI') + +const GoogleVaultDownloadExportFileSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + matterId: z.string().min(1, 'Matter ID is required'), + bucketName: z.string().min(1, 'Bucket name is required'), + objectName: z.string().min(1, 'Object name is required'), + fileName: z.string().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Google Vault download attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = GoogleVaultDownloadExportFileSchema.parse(body) + + const { accessToken, bucketName, objectName, fileName } = validatedData + + const bucket = encodeURIComponent(bucketName) + const object = encodeURIComponent(objectName) + const downloadUrl = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?alt=media` + + logger.info(`[${requestId}] Downloading file from Google Vault`, { bucketName, objectName }) + + const urlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: enhanceGoogleVaultError(urlValidation.error || 'Invalid URL') }, + { status: 400 } + ) + } + + const downloadResponse = await secureFetchWithPinnedIP(downloadUrl, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!downloadResponse.ok) { + const errorText = await downloadResponse.text().catch(() => '') + const errorMessage = `Failed to download file: ${errorText || downloadResponse.statusText}` + logger.error(`[${requestId}] Failed to download Vault export file`, { + status: downloadResponse.status, + error: errorText, + }) + return NextResponse.json( + { success: false, error: enhanceGoogleVaultError(errorMessage) }, + { status: 400 } + ) + } + + const contentType = downloadResponse.headers.get('content-type') || 'application/octet-stream' + const disposition = downloadResponse.headers.get('content-disposition') || '' + const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="([^"]+)"/) + + let resolvedName = fileName + if (!resolvedName) { + if (match?.[1]) { + try { + resolvedName = decodeURIComponent(match[1]) + } catch { + resolvedName = match[1] + } + } else if (match?.[2]) { + resolvedName = match[2] + } else if (objectName) { + const parts = objectName.split('/') + resolvedName = parts[parts.length - 1] || 'vault-export.bin' + } else { + resolvedName = 'vault-export.bin' + } + } + + const arrayBuffer = await downloadResponse.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + logger.info(`[${requestId}] Vault export file downloaded successfully`, { + name: resolvedName, + size: buffer.length, + mimeType: contentType, + }) + + return NextResponse.json({ + success: true, + output: { + file: { + name: resolvedName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error downloading Google Vault export file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 96dc58cad7..86192958f0 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateImageUrl } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('ImageProxyAPI') @@ -27,19 +29,20 @@ export async function GET(request: NextRequest) { return new NextResponse('Missing URL parameter', { status: 400 }) } - const urlValidation = validateImageUrl(imageUrl) + const urlValidation = await validateUrlWithDNS(imageUrl, 'imageUrl') if (!urlValidation.isValid) { logger.warn(`[${requestId}] Blocked image proxy request`, { - url: sanitizeUrlForLog(imageUrl), + url: imageUrl.substring(0, 100), error: urlValidation.error, }) return new NextResponse(urlValidation.error || 'Invalid image URL', { status: 403 }) } - logger.info(`[${requestId}] Proxying image request for: ${sanitizeUrlForLog(imageUrl)}`) + logger.info(`[${requestId}] Proxying image request for: ${imageUrl}`) try { - const imageResponse = await fetch(imageUrl, { + const imageResponse = await secureFetchWithPinnedIP(imageUrl, urlValidation.resolvedIP!, { + method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', diff --git a/apps/sim/app/api/tools/jsm/queues/route.ts b/apps/sim/app/api/tools/jsm/queues/route.ts index f7dc234f39..2921008efc 100644 --- a/apps/sim/app/api/tools/jsm/queues/route.ts +++ b/apps/sim/app/api/tools/jsm/queues/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -63,7 +62,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk/${serviceDeskId}/queue${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching queues from:', sanitizeUrlForLog(url)) + logger.info('Fetching queues from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/request/route.ts b/apps/sim/app/api/tools/jsm/request/route.ts index 2137867065..92e5e9f4c2 100644 --- a/apps/sim/app/api/tools/jsm/request/route.ts +++ b/apps/sim/app/api/tools/jsm/request/route.ts @@ -6,7 +6,6 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -67,7 +66,7 @@ export async function POST(request: NextRequest) { } const url = `${baseUrl}/request` - logger.info('Creating request at:', sanitizeUrlForLog(url)) + logger.info('Creating request at:', url) const requestBody: Record = { serviceDeskId, @@ -129,7 +128,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}` - logger.info('Fetching request from:', sanitizeUrlForLog(url)) + logger.info('Fetching request from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/requests/route.ts b/apps/sim/app/api/tools/jsm/requests/route.ts index fff27fe82c..f2f0dc0e71 100644 --- a/apps/sim/app/api/tools/jsm/requests/route.ts +++ b/apps/sim/app/api/tools/jsm/requests/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -69,7 +68,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching requests from:', sanitizeUrlForLog(url)) + logger.info('Fetching requests from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/requesttypes/route.ts b/apps/sim/app/api/tools/jsm/requesttypes/route.ts index fa7f826aef..8591f116bc 100644 --- a/apps/sim/app/api/tools/jsm/requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypes/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -54,7 +53,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk/${serviceDeskId}/requesttype${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching request types from:', sanitizeUrlForLog(url)) + logger.info('Fetching request types from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/servicedesks/route.ts b/apps/sim/app/api/tools/jsm/servicedesks/route.ts index 8752805757..607508a610 100644 --- a/apps/sim/app/api/tools/jsm/servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/servicedesks/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -44,7 +43,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/servicedesk${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching service desks from:', sanitizeUrlForLog(url)) + logger.info('Fetching service desks from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/sla/route.ts b/apps/sim/app/api/tools/jsm/sla/route.ts index ea5b885594..dc414ac831 100644 --- a/apps/sim/app/api/tools/jsm/sla/route.ts +++ b/apps/sim/app/api/tools/jsm/sla/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -54,7 +53,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/sla${params.toString() ? `?${params.toString()}` : ''}` - logger.info('Fetching SLA info from:', sanitizeUrlForLog(url)) + logger.info('Fetching SLA info from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/jsm/transition/route.ts b/apps/sim/app/api/tools/jsm/transition/route.ts index 5f1065b6f6..45a9e3a5c2 100644 --- a/apps/sim/app/api/tools/jsm/transition/route.ts +++ b/apps/sim/app/api/tools/jsm/transition/route.ts @@ -6,7 +6,6 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -70,7 +69,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/transition` - logger.info('Transitioning request at:', sanitizeUrlForLog(url)) + logger.info('Transitioning request at:', url) const body: Record = { id: transitionId, diff --git a/apps/sim/app/api/tools/jsm/transitions/route.ts b/apps/sim/app/api/tools/jsm/transitions/route.ts index c80a27ab8e..5d5f2e260e 100644 --- a/apps/sim/app/api/tools/jsm/transitions/route.ts +++ b/apps/sim/app/api/tools/jsm/transitions/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -50,7 +49,7 @@ export async function POST(request: NextRequest) { const url = `${baseUrl}/request/${issueIdOrKey}/transition` - logger.info('Fetching transitions from:', sanitizeUrlForLog(url)) + logger.info('Fetching transitions from:', url) const response = await fetch(url, { method: 'GET', diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index a3789ca998..3fb575dd4f 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -2,11 +2,19 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { + GraphApiErrorResponse, + GraphChatMessage, + GraphDriveItem, +} from '@/tools/microsoft_teams/types' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -21,6 +29,22 @@ const TeamsWriteChannelSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) +async function secureFetchGraph( + url: string, + options: { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + }, + paramName: string +) { + const urlValidation = await validateUrlWithDNS(url, paramName) + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -85,26 +109,32 @@ export async function POST(request: NextRequest) { encodeURIComponent(file.name) + ':/content' - logger.info(`[${requestId}] Uploading to Teams: ${sanitizeUrlForLog(uploadUrl)}`) - - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', + logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) + + const uploadResponse = await secureFetchGraph( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: buffer, }, - body: new Uint8Array(buffer), - }) + 'uploadUrl' + ) if (!uploadResponse.ok) { - const errorData = await uploadResponse.json().catch(() => ({})) + const errorData = (await uploadResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Teams upload failed:`, errorData) throw new Error( `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` ) } - const uploadedFile = await uploadResponse.json() + const uploadedFile = (await uploadResponse.json()) as GraphDriveItem logger.info(`[${requestId}] File uploaded to Teams successfully`, { id: uploadedFile.id, webUrl: uploadedFile.webUrl, @@ -112,21 +142,28 @@ export async function POST(request: NextRequest) { const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - const fileDetailsResponse = await fetch(fileDetailsUrl, { - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, + const fileDetailsResponse = await secureFetchGraph( + fileDetailsUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + }, }, - }) + 'fileDetailsUrl' + ) if (!fileDetailsResponse.ok) { - const errorData = await fileDetailsResponse.json().catch(() => ({})) + const errorData = (await fileDetailsResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Failed to get file details:`, errorData) throw new Error( `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` ) } - const fileDetails = await fileDetailsResponse.json() + const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem logger.info(`[${requestId}] Got file details`, { webDavUrl: fileDetails.webDavUrl, eTag: fileDetails.eTag, @@ -211,17 +248,21 @@ export async function POST(request: NextRequest) { const teamsUrl = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(validatedData.teamId)}/channels/${encodeURIComponent(validatedData.channelId)}/messages` - const teamsResponse = await fetch(teamsUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, + const teamsResponse = await secureFetchGraph( + teamsUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${validatedData.accessToken}`, + }, + body: JSON.stringify(messageBody), }, - body: JSON.stringify(messageBody), - }) + 'teamsUrl' + ) if (!teamsResponse.ok) { - const errorData = await teamsResponse.json().catch(() => ({})) + const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Microsoft Teams API error:`, errorData) return NextResponse.json( { @@ -232,7 +273,7 @@ export async function POST(request: NextRequest) { ) } - const responseData = await teamsResponse.json() + const responseData = (await teamsResponse.json()) as GraphChatMessage logger.info(`[${requestId}] Teams channel message sent successfully`, { messageId: responseData.id, attachmentCount: attachments.length, diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 1137cc9a16..6a4e929bad 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -2,11 +2,19 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { + GraphApiErrorResponse, + GraphChatMessage, + GraphDriveItem, +} from '@/tools/microsoft_teams/types' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -20,6 +28,22 @@ const TeamsWriteChatSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) +async function secureFetchGraph( + url: string, + options: { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + }, + paramName: string +) { + const urlValidation = await validateUrlWithDNS(url, paramName) + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -83,26 +107,32 @@ export async function POST(request: NextRequest) { encodeURIComponent(file.name) + ':/content' - logger.info(`[${requestId}] Uploading to Teams: ${sanitizeUrlForLog(uploadUrl)}`) - - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', + logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) + + const uploadResponse = await secureFetchGraph( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: buffer, }, - body: new Uint8Array(buffer), - }) + 'uploadUrl' + ) if (!uploadResponse.ok) { - const errorData = await uploadResponse.json().catch(() => ({})) + const errorData = (await uploadResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Teams upload failed:`, errorData) throw new Error( `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` ) } - const uploadedFile = await uploadResponse.json() + const uploadedFile = (await uploadResponse.json()) as GraphDriveItem logger.info(`[${requestId}] File uploaded to Teams successfully`, { id: uploadedFile.id, webUrl: uploadedFile.webUrl, @@ -110,21 +140,28 @@ export async function POST(request: NextRequest) { const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - const fileDetailsResponse = await fetch(fileDetailsUrl, { - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, + const fileDetailsResponse = await secureFetchGraph( + fileDetailsUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + }, }, - }) + 'fileDetailsUrl' + ) if (!fileDetailsResponse.ok) { - const errorData = await fileDetailsResponse.json().catch(() => ({})) + const errorData = (await fileDetailsResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Failed to get file details:`, errorData) throw new Error( `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` ) } - const fileDetails = await fileDetailsResponse.json() + const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem logger.info(`[${requestId}] Got file details`, { webDavUrl: fileDetails.webDavUrl, eTag: fileDetails.eTag, @@ -208,17 +245,21 @@ export async function POST(request: NextRequest) { const teamsUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(validatedData.chatId)}/messages` - const teamsResponse = await fetch(teamsUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, + const teamsResponse = await secureFetchGraph( + teamsUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${validatedData.accessToken}`, + }, + body: JSON.stringify(messageBody), }, - body: JSON.stringify(messageBody), - }) + 'teamsUrl' + ) if (!teamsResponse.ok) { - const errorData = await teamsResponse.json().catch(() => ({})) + const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse logger.error(`[${requestId}] Microsoft Teams API error:`, errorData) return NextResponse.json( { @@ -229,7 +270,7 @@ export async function POST(request: NextRequest) { ) } - const responseData = await teamsResponse.json() + const responseData = (await teamsResponse.json()) as GraphChatMessage logger.info(`[${requestId}] Teams message sent successfully`, { messageId: responseData.id, attachmentCount: attachments.length, diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 642bb15e21..bf7c66905e 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -2,16 +2,17 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { StorageService } from '@/lib/uploads' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { - extractStorageKey, - inferContextFromKey, - isInternalFileUrl, -} from '@/lib/uploads/utils/file-utils' -import { verifyFileAccess } from '@/app/api/files/authorization' + downloadFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' @@ -21,6 +22,7 @@ const MistralParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), filePath: z.string().min(1, 'File path is required').optional(), fileData: FileInputSchema.optional(), + file: FileInputSchema.optional(), resultType: z.string().optional(), pages: z.array(z.number()).optional(), includeImageBase64: z.boolean().optional(), @@ -51,7 +53,7 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = MistralParseSchema.parse(body) - const fileData = validatedData.fileData + const fileData = validatedData.file || validatedData.fileData const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath if (!fileData && (!filePath || filePath.trim() === '')) { @@ -76,65 +78,72 @@ export async function POST(request: NextRequest) { } if (fileData && typeof fileData === 'object') { - const base64 = (fileData as { base64?: string }).base64 - const mimeType = (fileData as { type?: string }).type || 'application/pdf' - if (!base64) { + const rawFile = fileData + let userFile + try { + userFile = processSingleFileToUserFile(rawFile, requestId, logger) + } catch (error) { return NextResponse.json( { success: false, - error: 'File base64 content is required', + error: error instanceof Error ? error.message : 'Failed to process file', }, { status: 400 } ) } + + const mimeType = userFile.type || 'application/pdf' + let base64 = userFile.base64 + if (!base64) { + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + base64 = buffer.toString('base64') + } const base64Payload = base64.startsWith('data:') ? base64 : `data:${mimeType};base64,${base64}` mistralBody.document = { - type: 'document_base64', - document_base64: base64Payload, + type: 'document_url', + document_url: base64Payload, } } else if (filePath) { let fileUrl = filePath - if (isInternalFileUrl(filePath)) { - try { - const storageKey = extractStorageKey(filePath) - - const context = inferContextFromKey(storageKey) - - const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: storageKey, - context, - }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) - } - - fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + const isInternalFilePath = isInternalFileUrl(filePath) + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger) + if (resolution.error) { return NextResponse.json( { success: false, - error: 'Failed to generate file access URL', + error: resolution.error.message, }, - { status: 500 } + { status: resolution.error.status } ) } + fileUrl = resolution.fileUrl || fileUrl } else if (filePath.startsWith('/')) { - const baseUrl = getBaseUrl() - fileUrl = `${baseUrl}${filePath}` + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: filePath.substring(0, 50), + }) + return NextResponse.json( + { + success: false, + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ) + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') + if (!urlValidation.isValid) { + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } } mistralBody.document = { @@ -156,15 +165,34 @@ export async function POST(request: NextRequest) { mistralBody.image_min_size = validatedData.imageMinSize } - const mistralResponse = await fetch('https://api.mistral.ai/v1/ocr', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${validatedData.apiKey}`, - }, - body: JSON.stringify(mistralBody), - }) + const mistralEndpoint = 'https://api.mistral.ai/v1/ocr' + const mistralValidation = await validateUrlWithDNS(mistralEndpoint, 'Mistral API URL') + if (!mistralValidation.isValid) { + logger.error(`[${requestId}] Mistral API URL validation failed`, { + error: mistralValidation.error, + }) + return NextResponse.json( + { + success: false, + error: 'Failed to reach Mistral API', + }, + { status: 502 } + ) + } + + const mistralResponse = await secureFetchWithPinnedIP( + mistralEndpoint, + mistralValidation.resolvedIP!, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${validatedData.apiKey}`, + }, + body: JSON.stringify(mistralBody), + } + ) if (!mistralResponse.ok) { const errorText = await mistralResponse.text() diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts new file mode 100644 index 0000000000..c4ebf5b29c --- /dev/null +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -0,0 +1,159 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OneDriveDownloadAPI') + +const OneDriveDownloadSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + fileId: z.string().min(1, 'File ID is required'), + fileName: z.string().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OneDrive download attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = OneDriveDownloadSchema.parse(body) + + const { accessToken, fileId, fileName } = validatedData + const authHeader = `Bearer ${accessToken}` + + logger.info(`[${requestId}] Getting file metadata from OneDrive`, { fileId }) + + const metadataUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}` + const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl') + if (!metadataUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: metadataUrlValidation.error }, + { status: 400 } + ) + } + + const metadataResponse = await secureFetchWithPinnedIP( + metadataUrl, + metadataUrlValidation.resolvedIP!, + { + headers: { Authorization: authHeader }, + } + ) + + if (!metadataResponse.ok) { + const errorDetails = await metadataResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to get file metadata`, { + status: metadataResponse.status, + error: errorDetails, + }) + return NextResponse.json( + { success: false, error: errorDetails.error?.message || 'Failed to get file metadata' }, + { status: 400 } + ) + } + + const metadata = await metadataResponse.json() + + if (metadata.folder && !metadata.file) { + logger.error(`[${requestId}] Attempted to download a folder`, { + itemId: metadata.id, + itemName: metadata.name, + }) + return NextResponse.json( + { + success: false, + error: `Cannot download folder "${metadata.name}". Please select a file instead.`, + }, + { status: 400 } + ) + } + + const mimeType = metadata.file?.mimeType || 'application/octet-stream' + + logger.info(`[${requestId}] Downloading file from OneDrive`, { fileId, mimeType }) + + const downloadUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content` + const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') + if (!downloadUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: downloadUrlValidation.error }, + { status: 400 } + ) + } + + const downloadResponse = await secureFetchWithPinnedIP( + downloadUrl, + downloadUrlValidation.resolvedIP!, + { + headers: { Authorization: authHeader }, + } + ) + + if (!downloadResponse.ok) { + const downloadError = await downloadResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to download file`, { + status: downloadResponse.status, + error: downloadError, + }) + return NextResponse.json( + { success: false, error: downloadError.error?.message || 'Failed to download file' }, + { status: 400 } + ) + } + + const arrayBuffer = await downloadResponse.arrayBuffer() + const fileBuffer = Buffer.from(arrayBuffer) + + const resolvedName = fileName || metadata.name || 'download' + + logger.info(`[${requestId}] File downloaded successfully`, { + fileId, + name: resolvedName, + size: fileBuffer.length, + mimeType, + }) + + const base64Data = fileBuffer.toString('base64') + + return NextResponse.json({ + success: true, + output: { + file: { + name: resolvedName, + mimeType, + data: base64Data, + size: fileBuffer.length, + }, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error downloading OneDrive file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index c7ffcaf7a9..87902f8828 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -3,7 +3,11 @@ import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateMicrosoftGraphId, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { @@ -36,6 +40,22 @@ const OneDriveUploadSchema = z.object({ values: ExcelValuesSchema.optional().nullable(), }) +async function secureFetchGraph( + url: string, + options: { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + }, + paramName: string +) { + const urlValidation = await validateUrlWithDNS(url, paramName) + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -164,14 +184,18 @@ export async function POST(request: NextRequest) { uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content` } - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': mimeType, + const uploadResponse = await secureFetchGraph( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': mimeType, + }, + body: fileBuffer, }, - body: new Uint8Array(fileBuffer), - }) + 'uploadUrl' + ) if (!uploadResponse.ok) { const errorText = await uploadResponse.text() @@ -194,8 +218,11 @@ export async function POST(request: NextRequest) { if (shouldWriteExcelContent) { try { let workbookSessionId: string | undefined - const sessionResp = await fetch( - `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`, + const sessionUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( + fileData.id + )}/workbook/createSession` + const sessionResp = await secureFetchGraph( + sessionUrl, { method: 'POST', headers: { @@ -203,7 +230,8 @@ export async function POST(request: NextRequest) { 'Content-Type': 'application/json', }, body: JSON.stringify({ persistChanges: true }), - } + }, + 'sessionUrl' ) if (sessionResp.ok) { @@ -216,12 +244,17 @@ export async function POST(request: NextRequest) { const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( fileData.id )}/workbook/worksheets?$select=name&$orderby=position&$top=1` - const listResp = await fetch(listUrl, { - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + const listResp = await secureFetchGraph( + listUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + }, }, - }) + 'listUrl' + ) if (listResp.ok) { const listData = await listResp.json() const firstSheetName = listData?.value?.[0]?.name @@ -282,15 +315,19 @@ export async function POST(request: NextRequest) { )}')/range(address='${encodeURIComponent(computedRangeAddress)}')` ) - const excelWriteResponse = await fetch(url.toString(), { - method: 'PATCH', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': 'application/json', - ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + const excelWriteResponse = await secureFetchGraph( + url.toString(), + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/json', + ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + }, + body: JSON.stringify({ values: processedValues }), }, - body: JSON.stringify({ values: processedValues }), - }) + 'excelWriteUrl' + ) if (!excelWriteResponse || !excelWriteResponse.ok) { const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response' @@ -319,15 +356,19 @@ export async function POST(request: NextRequest) { if (workbookSessionId) { try { - const closeResp = await fetch( - `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/closeSession`, + const closeUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( + fileData.id + )}/workbook/closeSession` + const closeResp = await secureFetchGraph( + closeUrl, { method: 'POST', headers: { Authorization: `Bearer ${validatedData.accessToken}`, 'workbook-session-id': workbookSessionId, }, - } + }, + 'closeSessionUrl' ) if (!closeResp.ok) { const closeText = await closeResp.text() diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts new file mode 100644 index 0000000000..b2332454ca --- /dev/null +++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts @@ -0,0 +1,153 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PipedriveGetFilesAPI') + +const PipedriveGetFilesSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + deal_id: z.string().optional().nullable(), + person_id: z.string().optional().nullable(), + org_id: z.string().optional().nullable(), + limit: z.string().optional().nullable(), + downloadFiles: z.boolean().optional().default(false), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Pipedrive get files attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = PipedriveGetFilesSchema.parse(body) + + const { accessToken, deal_id, person_id, org_id, limit, downloadFiles } = validatedData + + const baseUrl = 'https://api.pipedrive.com/v1/files' + const queryParams = new URLSearchParams() + + if (deal_id) queryParams.append('deal_id', deal_id) + if (person_id) queryParams.append('person_id', person_id) + if (org_id) queryParams.append('org_id', org_id) + if (limit) queryParams.append('limit', limit) + + const queryString = queryParams.toString() + const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl + + logger.info(`[${requestId}] Fetching files from Pipedrive`, { deal_id, person_id, org_id }) + + const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl') + if (!urlValidation.isValid) { + return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 }) + } + + const response = await secureFetchWithPinnedIP(apiUrl, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + const data = await response.json() + + if (!data.success) { + logger.error(`[${requestId}] Pipedrive API request failed`, { data }) + return NextResponse.json( + { success: false, error: data.error || 'Failed to fetch files from Pipedrive' }, + { status: 400 } + ) + } + + const files = data.data || [] + const downloadedFiles: Array<{ + name: string + mimeType: string + data: string + size: number + }> = [] + + if (downloadFiles) { + for (const file of files) { + if (!file?.url) continue + + try { + const fileUrlValidation = await validateUrlWithDNS(file.url, 'fileUrl') + if (!fileUrlValidation.isValid) continue + + const downloadResponse = await secureFetchWithPinnedIP( + file.url, + fileUrlValidation.resolvedIP!, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (!downloadResponse.ok) continue + + const arrayBuffer = await downloadResponse.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const extension = getFileExtension(file.name || '') + const mimeType = + downloadResponse.headers.get('content-type') || getMimeTypeFromExtension(extension) + const fileName = file.name || `pipedrive-file-${file.id || Date.now()}` + + downloadedFiles.push({ + name: fileName, + mimeType, + data: buffer.toString('base64'), + size: buffer.length, + }) + } catch (error) { + logger.warn(`[${requestId}] Failed to download file ${file.id}:`, error) + } + } + } + + logger.info(`[${requestId}] Pipedrive files fetched successfully`, { + fileCount: files.length, + downloadedCount: downloadedFiles.length, + }) + + return NextResponse.json({ + success: true, + output: { + files, + downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined, + total_items: files.length, + success: true, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Pipedrive files:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 59adeec155..906f869d27 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -2,14 +2,19 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { StorageService } from '@/lib/uploads' +import { type StorageContext, StorageService } from '@/lib/uploads' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { - extractStorageKey, inferContextFromKey, isInternalFileUrl, + processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' +import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +23,8 @@ const logger = createLogger('PulseParseAPI') const PulseParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().min(1, 'File path is required'), + filePath: z.string().optional(), + file: RawFileInputSchema.optional(), pages: z.string().optional(), extractFigure: z.boolean().optional(), figureDescription: z.boolean().optional(), @@ -50,25 +56,48 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = PulseParseSchema.parse(body) - logger.info(`[${requestId}] Pulse parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) - - let fileUrl = validatedData.filePath + const fileInput = validatedData.file + let fileUrl = '' + if (fileInput) { + logger.info(`[${requestId}] Pulse parse request`, { + fileName: fileInput.name, + userId, + }) - if (isInternalFileUrl(validatedData.filePath)) { + let userFile try { - const storageKey = extractStorageKey(validatedData.filePath) - const context = inferContextFromKey(storageKey) - - const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + userFile = processSingleFileToUserFile(fileInput, requestId, logger) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to process file', + }, + { status: 400 } + ) + } + fileUrl = userFile.url || '' + if (fileUrl && isInternalFileUrl(fileUrl)) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return NextResponse.json( + { + success: false, + error: resolution.error.message, + }, + { status: resolution.error.status } + ) + } + fileUrl = resolution.fileUrl || '' + } + if (!fileUrl && userFile.key) { + const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) + const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) if (!hasAccess) { logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { userId, - key: storageKey, + key: userFile.key, context, }) return NextResponse.json( @@ -79,22 +108,68 @@ export async function POST(request: NextRequest) { { status: 404 } ) } + fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + } + } else if (validatedData.filePath) { + logger.info(`[${requestId}] Pulse parse request`, { + filePath: validatedData.filePath, + isWorkspaceFile: isInternalFileUrl(validatedData.filePath), + userId, + }) - fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + fileUrl = validatedData.filePath + const isInternalFilePath = isInternalFileUrl(validatedData.filePath) + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl( + validatedData.filePath, + userId, + requestId, + logger + ) + if (resolution.error) { + return NextResponse.json( + { + success: false, + error: resolution.error.message, + }, + { status: resolution.error.status } + ) + } + fileUrl = resolution.fileUrl || fileUrl + } else if (validatedData.filePath.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: validatedData.filePath.substring(0, 50), + }) return NextResponse.json( { success: false, - error: 'Failed to generate file access URL', + error: 'Invalid file path. Only uploaded files are supported for internal paths.', }, - { status: 500 } + { status: 400 } ) + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') + if (!urlValidation.isValid) { + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } } - } else if (validatedData.filePath?.startsWith('/')) { - const baseUrl = getBaseUrl() - fileUrl = `${baseUrl}${validatedData.filePath}` + } + + if (!fileUrl) { + return NextResponse.json( + { + success: false, + error: 'File input is required', + }, + { status: 400 } + ) } const formData = new FormData() @@ -119,13 +194,36 @@ export async function POST(request: NextRequest) { formData.append('chunk_size', String(validatedData.chunkSize)) } - const pulseResponse = await fetch('https://api.runpulse.com/extract', { - method: 'POST', - headers: { - 'x-api-key': validatedData.apiKey, - }, - body: formData, - }) + const pulseEndpoint = 'https://api.runpulse.com/extract' + const pulseValidation = await validateUrlWithDNS(pulseEndpoint, 'Pulse API URL') + if (!pulseValidation.isValid) { + logger.error(`[${requestId}] Pulse API URL validation failed`, { + error: pulseValidation.error, + }) + return NextResponse.json( + { + success: false, + error: 'Failed to reach Pulse API', + }, + { status: 502 } + ) + } + + const pulsePayload = new Response(formData) + const contentType = pulsePayload.headers.get('content-type') || 'multipart/form-data' + const bodyBuffer = Buffer.from(await pulsePayload.arrayBuffer()) + const pulseResponse = await secureFetchWithPinnedIP( + pulseEndpoint, + pulseValidation.resolvedIP!, + { + method: 'POST', + headers: { + 'x-api-key': validatedData.apiKey, + 'Content-Type': contentType, + }, + body: bodyBuffer, + } + ) if (!pulseResponse.ok) { const errorText = await pulseResponse.text() diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index e8fd960ff0..dc885b1f89 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -2,14 +2,19 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { StorageService } from '@/lib/uploads' +import { type StorageContext, StorageService } from '@/lib/uploads' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { - extractStorageKey, inferContextFromKey, isInternalFileUrl, + processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' +import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +23,8 @@ const logger = createLogger('ReductoParseAPI') const ReductoParseSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().min(1, 'File path is required'), + filePath: z.string().optional(), + file: RawFileInputSchema.optional(), pages: z.array(z.number()).optional(), tableOutputFormat: z.enum(['html', 'md']).optional(), }) @@ -46,31 +52,49 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = ReductoParseSchema.parse(body) - logger.info(`[${requestId}] Reducto parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) - - let fileUrl = validatedData.filePath + const fileInput = validatedData.file + let fileUrl = '' + if (fileInput) { + logger.info(`[${requestId}] Reducto parse request`, { + fileName: fileInput.name, + userId, + }) - if (isInternalFileUrl(validatedData.filePath)) { + let userFile try { - const storageKey = extractStorageKey(validatedData.filePath) - const context = inferContextFromKey(storageKey) - - const hasAccess = await verifyFileAccess( - storageKey, - userId, - undefined, // customConfig - context, // context - false // isLocal + userFile = processSingleFileToUserFile(fileInput, requestId, logger) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to process file', + }, + { status: 400 } ) + } + + fileUrl = userFile.url || '' + if (fileUrl && isInternalFileUrl(fileUrl)) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return NextResponse.json( + { + success: false, + error: resolution.error.message, + }, + { status: resolution.error.status } + ) + } + fileUrl = resolution.fileUrl || '' + } + if (!fileUrl && userFile.key) { + const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) + const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) if (!hasAccess) { logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { userId, - key: storageKey, + key: userFile.key, context, }) return NextResponse.json( @@ -82,21 +106,68 @@ export async function POST(request: NextRequest) { ) } - fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + } + } else if (validatedData.filePath) { + logger.info(`[${requestId}] Reducto parse request`, { + filePath: validatedData.filePath, + isWorkspaceFile: isInternalFileUrl(validatedData.filePath), + userId, + }) + + fileUrl = validatedData.filePath + const isInternalFilePath = isInternalFileUrl(validatedData.filePath) + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl( + validatedData.filePath, + userId, + requestId, + logger + ) + if (resolution.error) { + return NextResponse.json( + { + success: false, + error: resolution.error.message, + }, + { status: resolution.error.status } + ) + } + fileUrl = resolution.fileUrl || fileUrl + } else if (validatedData.filePath.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: validatedData.filePath.substring(0, 50), + }) return NextResponse.json( { success: false, - error: 'Failed to generate file access URL', + error: 'Invalid file path. Only uploaded files are supported for internal paths.', }, - { status: 500 } + { status: 400 } ) + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') + if (!urlValidation.isValid) { + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } } - } else if (validatedData.filePath?.startsWith('/')) { - const baseUrl = getBaseUrl() - fileUrl = `${baseUrl}${validatedData.filePath}` + } + + if (!fileUrl) { + return NextResponse.json( + { + success: false, + error: 'File input is required', + }, + { status: 400 } + ) } const reductoBody: Record = { @@ -115,15 +186,34 @@ export async function POST(request: NextRequest) { } } - const reductoResponse = await fetch('https://platform.reducto.ai/parse', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${validatedData.apiKey}`, - }, - body: JSON.stringify(reductoBody), - }) + const reductoEndpoint = 'https://platform.reducto.ai/parse' + const reductoValidation = await validateUrlWithDNS(reductoEndpoint, 'Reducto API URL') + if (!reductoValidation.isValid) { + logger.error(`[${requestId}] Reducto API URL validation failed`, { + error: reductoValidation.error, + }) + return NextResponse.json( + { + success: false, + error: 'Failed to reach Reducto API', + }, + { status: 502 } + ) + } + + const reductoResponse = await secureFetchWithPinnedIP( + reductoEndpoint, + reductoValidation.resolvedIP!, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${validatedData.apiKey}`, + }, + body: JSON.stringify(reductoBody), + } + ) if (!reductoResponse.ok) { const errorText = await reductoResponse.text() diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index b15421d00d..43a39ee4c6 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -2,7 +2,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -21,6 +24,22 @@ const SharepointUploadSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) +async function secureFetchGraph( + url: string, + options: { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + }, + paramName: string +) { + const urlValidation = await validateUrlWithDNS(url, paramName) + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -81,14 +100,17 @@ export async function POST(request: NextRequest) { let effectiveDriveId = validatedData.driveId if (!effectiveDriveId) { logger.info(`[${requestId}] No driveId provided, fetching default drive for site`) - const driveResponse = await fetch( - `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive`, + const driveUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive` + const driveResponse = await secureFetchGraph( + driveUrl, { + method: 'GET', headers: { Authorization: `Bearer ${validatedData.accessToken}`, Accept: 'application/json', }, - } + }, + 'driveUrl' ) if (!driveResponse.ok) { @@ -145,16 +167,20 @@ export async function POST(request: NextRequest) { const uploadUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drives/${effectiveDriveId}/root:${encodedPath}:/content` - logger.info(`[${requestId}] Uploading to: ${sanitizeUrlForLog(uploadUrl)}`) + logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': userFile.type || 'application/octet-stream', + const uploadResponse = await secureFetchGraph( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': userFile.type || 'application/octet-stream', + }, + body: buffer, }, - body: new Uint8Array(buffer), - }) + 'uploadUrl' + ) if (!uploadResponse.ok) { const errorData = await uploadResponse.json().catch(() => ({})) diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts new file mode 100644 index 0000000000..45c34bcd11 --- /dev/null +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -0,0 +1,170 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SlackDownloadAPI') + +const SlackDownloadSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + fileId: z.string().min(1, 'File ID is required'), + fileName: z.string().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Slack download attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + logger.info(`[${requestId}] Authenticated Slack download request via ${authResult.authType}`, { + userId: authResult.userId, + }) + + const body = await request.json() + const validatedData = SlackDownloadSchema.parse(body) + + const { accessToken, fileId, fileName } = validatedData + + logger.info(`[${requestId}] Getting file info from Slack`, { fileId }) + + const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!infoResponse.ok) { + const errorDetails = await infoResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Failed to get file info from Slack`, { + status: infoResponse.status, + statusText: infoResponse.statusText, + error: errorDetails, + }) + return NextResponse.json( + { + success: false, + error: errorDetails.error || 'Failed to get file info', + }, + { status: 400 } + ) + } + + const data = await infoResponse.json() + + if (!data.ok) { + logger.error(`[${requestId}] Slack API returned error`, { error: data.error }) + return NextResponse.json( + { + success: false, + error: data.error || 'Slack API error', + }, + { status: 400 } + ) + } + + const file = data.file + const resolvedFileName = fileName || file.name || 'download' + const mimeType = file.mimetype || 'application/octet-stream' + const urlPrivate = file.url_private + + if (!urlPrivate) { + return NextResponse.json( + { + success: false, + error: 'File does not have a download URL', + }, + { status: 400 } + ) + } + + const urlValidation = await validateUrlWithDNS(urlPrivate, 'urlPrivate') + if (!urlValidation.isValid) { + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Downloading file from Slack`, { + fileId, + fileName: resolvedFileName, + mimeType, + }) + + const downloadResponse = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!downloadResponse.ok) { + logger.error(`[${requestId}] Failed to download file content`, { + status: downloadResponse.status, + statusText: downloadResponse.statusText, + }) + return NextResponse.json( + { + success: false, + error: 'Failed to download file content', + }, + { status: 400 } + ) + } + + const arrayBuffer = await downloadResponse.arrayBuffer() + const fileBuffer = Buffer.from(arrayBuffer) + + logger.info(`[${requestId}] File downloaded successfully`, { + fileId, + name: resolvedFileName, + size: fileBuffer.length, + mimeType, + }) + + const base64Data = fileBuffer.toString('base64') + + return NextResponse.json({ + success: true, + output: { + file: { + name: resolvedFileName, + mimeType, + data: base64Data, + size: fileBuffer.length, + }, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error downloading Slack file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index a5527d95d9..c4128f4eb7 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -1,8 +1,28 @@ import type { Logger } from '@sim/logger' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { ToolFileData } from '@/tools/types' +async function secureFetchExternal( + url: string, + options: { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + }, + paramName: string +) { + const urlValidation = await validateUrlWithDNS(url, paramName) + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) +} + /** * Sends a message to a Slack channel using chat.postMessage */ @@ -108,10 +128,14 @@ export async function uploadFilesToSlack( logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`) - const uploadResponse = await fetch(urlData.upload_url, { - method: 'POST', - body: new Uint8Array(buffer), - }) + const uploadResponse = await secureFetchExternal( + urlData.upload_url, + { + method: 'POST', + body: buffer, + }, + 'uploadUrl' + ) if (!uploadResponse.ok) { logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`) diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index f8cddf143d..0d6f697658 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' @@ -123,6 +124,10 @@ export async function POST(request: NextRequest) { const variablesObject = processVariables(params.variables) const startUrl = normalizeUrl(rawStartUrl) + const urlValidation = await validateUrlWithDNS(startUrl, 'startUrl') + if (!urlValidation.isValid) { + return NextResponse.json({ error: urlValidation.error }, { status: 400 }) + } logger.info('Starting Stagehand agent process', { rawStartUrl, diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index db0d2848a2..8523db6c70 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandExtractAPI') @@ -52,6 +52,10 @@ export async function POST(request: NextRequest) { const params = validationResult.data const { url: rawUrl, instruction, selector, provider, apiKey, schema } = params const url = normalizeUrl(rawUrl) + const urlValidation = await validateUrlWithDNS(url, 'url') + if (!urlValidation.isValid) { + return NextResponse.json({ error: urlValidation.error }, { status: 400 }) + } logger.info('Starting Stagehand extraction process', { rawUrl, @@ -121,7 +125,7 @@ export async function POST(request: NextRequest) { const page = stagehand.context.pages()[0] - logger.info(`Navigating to ${sanitizeUrlForLog(url)}`) + logger.info(`Navigating to ${url}`) await page.goto(url, { waitUntil: 'networkidle' }) logger.info('Navigation complete') diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index d14db91755..5917db6809 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -2,8 +2,15 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { + downloadFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' import type { UserFile } from '@/executor/types' import type { TranscriptSegment } from '@/tools/stt/types' @@ -46,6 +53,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const userId = authResult.userId const body: SttRequestBody = await request.json() const { provider, @@ -73,6 +81,9 @@ export async function POST(request: NextRequest) { let audioMimeType: string if (body.audioFile) { + if (Array.isArray(body.audioFile) && body.audioFile.length !== 1) { + return NextResponse.json({ error: 'audioFile must be a single file' }, { status: 400 }) + } const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile logger.info(`[${requestId}] Processing uploaded file: ${file.name}`) @@ -80,6 +91,12 @@ export async function POST(request: NextRequest) { audioFileName = file.name audioMimeType = file.type } else if (body.audioFileReference) { + if (Array.isArray(body.audioFileReference) && body.audioFileReference.length !== 1) { + return NextResponse.json( + { error: 'audioFileReference must be a single file' }, + { status: 400 } + ) + } const file = Array.isArray(body.audioFileReference) ? body.audioFileReference[0] : body.audioFileReference @@ -89,16 +106,50 @@ export async function POST(request: NextRequest) { audioFileName = file.name audioMimeType = file.type } else if (body.audioUrl) { - logger.info(`[${requestId}] Downloading from URL: ${sanitizeUrlForLog(body.audioUrl)}`) + logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`) + + let audioUrl = body.audioUrl.trim() + if (audioUrl.startsWith('/') && !isInternalFileUrl(audioUrl)) { + return NextResponse.json( + { + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ) + } + + if (isInternalFileUrl(audioUrl)) { + if (!userId) { + return NextResponse.json( + { error: 'Authentication required for internal file access' }, + { status: 401 } + ) + } + const resolution = await resolveInternalFileUrl(audioUrl, userId, requestId, logger) + if (resolution.error) { + return NextResponse.json( + { error: resolution.error.message }, + { status: resolution.error.status } + ) + } + audioUrl = resolution.fileUrl || audioUrl + } + + const urlValidation = await validateUrlWithDNS(audioUrl, 'audioUrl') + if (!urlValidation.isValid) { + return NextResponse.json({ error: urlValidation.error }, { status: 400 }) + } - const response = await fetch(body.audioUrl) + const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, { + method: 'GET', + }) if (!response.ok) { throw new Error(`Failed to download audio from URL: ${response.statusText}`) } const arrayBuffer = await response.arrayBuffer() audioBuffer = Buffer.from(arrayBuffer) - audioFileName = body.audioUrl.split('/').pop() || 'audio_file' + audioFileName = audioUrl.split('/').pop() || 'audio_file' audioMimeType = response.headers.get('content-type') || 'audio/mpeg' } else { return NextResponse.json( diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 86fa83512f..eb40ff2f2e 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -4,18 +4,18 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { + secureFetchWithPinnedIP, validateAwsRegion, - validateExternalUrl, validateS3BucketName, -} from '@/lib/core/security/input-validation' + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { StorageService } from '@/lib/uploads' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { - extractStorageKey, - inferContextFromKey, - isInternalFileUrl, -} from '@/lib/uploads/utils/file-utils' -import { verifyFileAccess } from '@/app/api/files/authorization' + downloadFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' export const maxDuration = 300 // 5 minutes for large multi-page PDF processing @@ -35,6 +35,7 @@ const TextractParseSchema = z region: z.string().min(1, 'AWS region is required'), processingMode: z.enum(['sync', 'async']).optional().default('sync'), filePath: z.string().optional(), + file: RawFileInputSchema.optional(), s3Uri: z.string().optional(), featureTypes: z .array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'])) @@ -50,6 +51,20 @@ const TextractParseSchema = z path: ['region'], }) } + if (data.processingMode === 'async' && !data.s3Uri) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'S3 URI is required for multi-page processing (s3://bucket/key)', + path: ['s3Uri'], + }) + } + if (data.processingMode !== 'async' && !data.file && !data.filePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'File input is required for single-page processing', + path: ['filePath'], + }) + } }) function getSignatureKey( @@ -111,7 +126,14 @@ function signAwsRequest( } async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> { - const response = await fetch(url) + const urlValidation = await validateUrlWithDNS(url, 'Document URL') + if (!urlValidation.isValid) { + throw new Error(urlValidation.error || 'Invalid document URL') + } + + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { + method: 'GET', + }) if (!response.ok) { throw new Error(`Failed to fetch document: ${response.statusText}`) } @@ -318,8 +340,8 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Textract parse request`, { processingMode, - filePath: validatedData.filePath?.substring(0, 50), - s3Uri: validatedData.s3Uri?.substring(0, 50), + hasFile: Boolean(validatedData.file), + hasS3Uri: Boolean(validatedData.s3Uri), featureTypes, userId, }) @@ -414,90 +436,89 @@ export async function POST(request: NextRequest) { }) } - if (!validatedData.filePath) { - return NextResponse.json( - { - success: false, - error: 'File path is required for single-page processing', - }, - { status: 400 } - ) - } - - let fileUrl = validatedData.filePath - - const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath) + let bytes = '' + let contentType = 'application/octet-stream' + let isPdf = false - if (isInternalFilePath) { + if (validatedData.file) { + let userFile try { - const storageKey = extractStorageKey(validatedData.filePath) - const context = inferContextFromKey(storageKey) + userFile = processSingleFileToUserFile(validatedData.file, requestId, logger) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to process file', + }, + { status: 400 } + ) + } - const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + bytes = buffer.toString('base64') + contentType = userFile.type || 'application/octet-stream' + isPdf = contentType.includes('pdf') || userFile.name?.toLowerCase().endsWith('.pdf') + } else if (validatedData.filePath) { + let fileUrl = validatedData.filePath - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: storageKey, - context, - }) + const isInternalFilePath = isInternalFileUrl(fileUrl) + + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { return NextResponse.json( { success: false, - error: 'File not found', + error: resolution.error.message, }, - { status: 404 } + { status: resolution.error.status } ) } - - fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + fileUrl = resolution.fileUrl || fileUrl + } else if (fileUrl.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: fileUrl.substring(0, 50), + }) return NextResponse.json( { success: false, - error: 'Failed to generate file access URL', + error: 'Invalid file path. Only uploaded files are supported for internal paths.', }, - { status: 500 } + { status: 400 } ) + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] SSRF attempt blocked`, { + userId, + url: fileUrl.substring(0, 100), + error: urlValidation.error, + }) + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } } - } else if (validatedData.filePath?.startsWith('/')) { - // Reject arbitrary absolute paths that don't contain /api/files/serve/ - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: validatedData.filePath.substring(0, 50), - }) + + const fetched = await fetchDocumentBytes(fileUrl) + bytes = fetched.bytes + contentType = fetched.contentType + isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') + } else { return NextResponse.json( { success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', + error: 'File input is required for single-page processing', }, { status: 400 } ) - } else { - const urlValidation = validateExternalUrl(fileUrl, 'Document URL') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] SSRF attempt blocked`, { - userId, - url: fileUrl.substring(0, 100), - error: urlValidation.error, - }) - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } } - const { bytes, contentType } = await fetchDocumentBytes(fileUrl) - - // Track if this is a PDF for better error messaging - const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') - const uri = '/' let textractBody: Record diff --git a/apps/sim/app/api/tools/twilio/get-recording/route.ts b/apps/sim/app/api/tools/twilio/get-recording/route.ts new file mode 100644 index 0000000000..5909b1e64f --- /dev/null +++ b/apps/sim/app/api/tools/twilio/get-recording/route.ts @@ -0,0 +1,219 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TwilioGetRecordingAPI') + +const TwilioGetRecordingSchema = z.object({ + accountSid: z.string().min(1, 'Account SID is required'), + authToken: z.string().min(1, 'Auth token is required'), + recordingSid: z.string().min(1, 'Recording SID is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Twilio get recording attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = TwilioGetRecordingSchema.parse(body) + + const { accountSid, authToken, recordingSid } = validatedData + + if (!accountSid.startsWith('AC')) { + return NextResponse.json( + { + success: false, + error: `Invalid Account SID format. Account SID must start with "AC" (you provided: ${accountSid.substring(0, 2)}...)`, + }, + { status: 400 } + ) + } + + const twilioAuth = Buffer.from(`${accountSid}:${authToken}`).toString('base64') + + logger.info(`[${requestId}] Getting recording info from Twilio`, { recordingSid }) + + const infoUrl = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Recordings/${recordingSid}.json` + const infoUrlValidation = await validateUrlWithDNS(infoUrl, 'infoUrl') + if (!infoUrlValidation.isValid) { + return NextResponse.json({ success: false, error: infoUrlValidation.error }, { status: 400 }) + } + + const infoResponse = await secureFetchWithPinnedIP(infoUrl, infoUrlValidation.resolvedIP!, { + method: 'GET', + headers: { Authorization: `Basic ${twilioAuth}` }, + }) + + if (!infoResponse.ok) { + const errorData = await infoResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Twilio API error`, { + status: infoResponse.status, + error: errorData, + }) + return NextResponse.json( + { success: false, error: errorData.message || `Twilio API error: ${infoResponse.status}` }, + { status: 400 } + ) + } + + const data = await infoResponse.json() + + if (data.error_code) { + return NextResponse.json({ + success: false, + output: { + success: false, + error: data.message || data.error_message || 'Failed to retrieve recording', + }, + error: data.message || data.error_message || 'Failed to retrieve recording', + }) + } + + const baseUrl = 'https://api.twilio.com' + const mediaUrl = data.uri ? `${baseUrl}${data.uri.replace('.json', '')}` : undefined + + let transcriptionText: string | undefined + let transcriptionStatus: string | undefined + let transcriptionPrice: string | undefined + let transcriptionPriceUnit: string | undefined + let file: + | { + name: string + mimeType: string + data: string + size: number + } + | undefined + + try { + const transcriptionUrl = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Transcriptions.json?RecordingSid=${data.sid}` + logger.info(`[${requestId}] Checking for transcriptions`) + + const transcriptionUrlValidation = await validateUrlWithDNS( + transcriptionUrl, + 'transcriptionUrl' + ) + if (transcriptionUrlValidation.isValid) { + const transcriptionResponse = await secureFetchWithPinnedIP( + transcriptionUrl, + transcriptionUrlValidation.resolvedIP!, + { + method: 'GET', + headers: { Authorization: `Basic ${twilioAuth}` }, + } + ) + + if (transcriptionResponse.ok) { + const transcriptionData = await transcriptionResponse.json() + + if (transcriptionData.transcriptions && transcriptionData.transcriptions.length > 0) { + const transcription = transcriptionData.transcriptions[0] + transcriptionText = transcription.transcription_text + transcriptionStatus = transcription.status + transcriptionPrice = transcription.price + transcriptionPriceUnit = transcription.price_unit + logger.info(`[${requestId}] Transcription found`, { + status: transcriptionStatus, + textLength: transcriptionText?.length, + }) + } + } + } + } catch (error) { + logger.warn(`[${requestId}] Failed to fetch transcription:`, error) + } + + if (mediaUrl) { + try { + const mediaUrlValidation = await validateUrlWithDNS(mediaUrl, 'mediaUrl') + if (mediaUrlValidation.isValid) { + const mediaResponse = await secureFetchWithPinnedIP( + mediaUrl, + mediaUrlValidation.resolvedIP!, + { + method: 'GET', + headers: { Authorization: `Basic ${twilioAuth}` }, + } + ) + + if (mediaResponse.ok) { + const contentType = + mediaResponse.headers.get('content-type') || 'application/octet-stream' + const extension = getExtensionFromMimeType(contentType) || 'dat' + const arrayBuffer = await mediaResponse.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const fileName = `${data.sid || recordingSid}.${extension}` + + file = { + name: fileName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + } + } + } + } catch (error) { + logger.warn(`[${requestId}] Failed to download recording media:`, error) + } + } + + logger.info(`[${requestId}] Twilio recording fetched successfully`, { + recordingSid: data.sid, + hasFile: !!file, + hasTranscription: !!transcriptionText, + }) + + return NextResponse.json({ + success: true, + output: { + success: true, + recordingSid: data.sid, + callSid: data.call_sid, + duration: data.duration ? Number.parseInt(data.duration, 10) : undefined, + status: data.status, + channels: data.channels, + source: data.source, + mediaUrl, + file, + price: data.price, + priceUnit: data.price_unit, + uri: data.uri, + transcriptionText, + transcriptionStatus, + transcriptionPrice, + transcriptionPriceUnit, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Twilio recording:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 5b35f13700..684094b2bd 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -3,10 +3,17 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' -import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { + downloadFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils' export const dynamic = 'force-dynamic' @@ -42,6 +49,7 @@ export async function POST(request: NextRequest) { userId: authResult.userId, }) + const userId = authResult.userId const body = await request.json() const validatedData = VisionAnalyzeSchema.parse(body) @@ -80,12 +88,65 @@ export async function POST(request: NextRequest) { ) } - const buffer = await downloadFileFromStorage(userFile, requestId, logger) - - const base64 = buffer.toString('base64') + let base64 = userFile.base64 + let bufferLength = 0 + if (!base64) { + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + base64 = buffer.toString('base64') + bufferLength = buffer.length + } const mimeType = userFile.type || 'image/jpeg' imageSource = `data:${mimeType};base64,${base64}` - logger.info(`[${requestId}] Converted image to base64 (${buffer.length} bytes)`) + if (bufferLength > 0) { + logger.info(`[${requestId}] Converted image to base64 (${bufferLength} bytes)`) + } + } + + let imageUrlValidation: Awaited> | null = null + if (imageSource && !imageSource.startsWith('data:')) { + if (imageSource.startsWith('/') && !isInternalFileUrl(imageSource)) { + return NextResponse.json( + { + success: false, + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ) + } + + if (isInternalFileUrl(imageSource)) { + if (!userId) { + return NextResponse.json( + { + success: false, + error: 'Authentication required for internal file access', + }, + { status: 401 } + ) + } + const resolution = await resolveInternalFileUrl(imageSource, userId, requestId, logger) + if (resolution.error) { + return NextResponse.json( + { + success: false, + error: resolution.error.message, + }, + { status: resolution.error.status } + ) + } + imageSource = resolution.fileUrl || imageSource + } + + imageUrlValidation = await validateUrlWithDNS(imageSource, 'imageUrl') + if (!imageUrlValidation.isValid) { + return NextResponse.json( + { + success: false, + error: imageUrlValidation.error, + }, + { status: 400 } + ) + } } const defaultPrompt = 'Please analyze this image and describe what you see in detail.' @@ -113,7 +174,15 @@ export async function POST(request: NextRequest) { if (isGemini) { let base64Payload = imageSource if (!base64Payload.startsWith('data:')) { - const response = await fetch(base64Payload) + const urlValidation = + imageUrlValidation || (await validateUrlWithDNS(base64Payload, 'imageUrl')) + if (!urlValidation.isValid) { + return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 }) + } + + const response = await secureFetchWithPinnedIP(base64Payload, urlValidation.resolvedIP!, { + method: 'GET', + }) if (!response.ok) { return NextResponse.json( { success: false, error: 'Failed to fetch image for Gemini' }, @@ -126,7 +195,6 @@ export async function POST(request: NextRequest) { const base64 = Buffer.from(arrayBuffer).toString('base64') base64Payload = `data:${contentType};base64,${base64}` } - const base64Marker = ';base64,' const markerIndex = base64Payload.indexOf(base64Marker) if (!base64Payload.startsWith('data:') || markerIndex === -1) { diff --git a/apps/sim/app/api/tools/zoom/get-recordings/route.ts b/apps/sim/app/api/tools/zoom/get-recordings/route.ts new file mode 100644 index 0000000000..ed5d086040 --- /dev/null +++ b/apps/sim/app/api/tools/zoom/get-recordings/route.ts @@ -0,0 +1,182 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ZoomGetRecordingsAPI') + +const ZoomGetRecordingsSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + meetingId: z.string().min(1, 'Meeting ID is required'), + includeFolderItems: z.boolean().optional(), + ttl: z.number().optional(), + downloadFiles: z.boolean().optional().default(false), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Zoom get recordings attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const body = await request.json() + const validatedData = ZoomGetRecordingsSchema.parse(body) + + const { accessToken, meetingId, includeFolderItems, ttl, downloadFiles } = validatedData + + const baseUrl = `https://api.zoom.us/v2/meetings/${encodeURIComponent(meetingId)}/recordings` + const queryParams = new URLSearchParams() + + if (includeFolderItems != null) { + queryParams.append('include_folder_items', String(includeFolderItems)) + } + if (ttl) { + queryParams.append('ttl', String(ttl)) + } + + const queryString = queryParams.toString() + const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl + + logger.info(`[${requestId}] Fetching recordings from Zoom`, { meetingId }) + + const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl') + if (!urlValidation.isValid) { + return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 }) + } + + const response = await secureFetchWithPinnedIP(apiUrl, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error(`[${requestId}] Zoom API error`, { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { success: false, error: errorData.message || `Zoom API error: ${response.status}` }, + { status: 400 } + ) + } + + const data = await response.json() + const files: Array<{ + name: string + mimeType: string + data: string + size: number + }> = [] + + if (downloadFiles && Array.isArray(data.recording_files)) { + for (const file of data.recording_files) { + if (!file?.download_url) continue + + try { + const fileUrlValidation = await validateUrlWithDNS(file.download_url, 'downloadUrl') + if (!fileUrlValidation.isValid) continue + + const downloadResponse = await secureFetchWithPinnedIP( + file.download_url, + fileUrlValidation.resolvedIP!, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (!downloadResponse.ok) continue + + const contentType = + downloadResponse.headers.get('content-type') || 'application/octet-stream' + const arrayBuffer = await downloadResponse.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const extension = + file.file_extension?.toString().toLowerCase() || + getExtensionFromMimeType(contentType) || + 'dat' + const fileName = `zoom-recording-${file.id || file.recording_start || Date.now()}.${extension}` + + files.push({ + name: fileName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }) + } catch (error) { + logger.warn(`[${requestId}] Failed to download recording file:`, error) + } + } + } + + logger.info(`[${requestId}] Zoom recordings fetched successfully`, { + recordingCount: data.recording_files?.length || 0, + downloadedCount: files.length, + }) + + return NextResponse.json({ + success: true, + output: { + recording: { + uuid: data.uuid, + id: data.id, + account_id: data.account_id, + host_id: data.host_id, + topic: data.topic, + type: data.type, + start_time: data.start_time, + duration: data.duration, + total_size: data.total_size, + recording_count: data.recording_count, + share_url: data.share_url, + recording_files: (data.recording_files || []).map((file: any) => ({ + id: file.id, + meeting_id: file.meeting_id, + recording_start: file.recording_start, + recording_end: file.recording_end, + file_type: file.file_type, + file_extension: file.file_extension, + file_size: file.file_size, + play_url: file.play_url, + download_url: file.download_url, + status: file.status, + recording_type: file.recording_type, + })), + }, + files: files.length > 0 ? files : undefined, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Zoom recordings:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 9e3b163a5d..74397b9bbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger' import { ArrowDown, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button } from '@/components/emcn' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileCards') @@ -58,7 +57,7 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) if (file.key.startsWith('url/')) { if (file.url) { window.open(file.url, '_blank') - logger.info(`Opened URL-type file directly: ${sanitizeUrlForLog(file.url)}`) + logger.info(`Opened URL-type file directly: ${file.url}`) return } throw new Error('URL is required for URL-type files') @@ -78,13 +77,13 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) const serveUrl = file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution` window.open(serveUrl, '_blank') - logger.info(`Opened execution file serve URL: ${sanitizeUrlForLog(serveUrl)}`) + logger.info(`Opened execution file serve URL: ${serveUrl}`) } else { const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null if (viewerUrl) { router.push(viewerUrl) - logger.info(`Navigated to viewer URL: ${sanitizeUrlForLog(viewerUrl)}`) + logger.info(`Navigated to viewer URL: ${viewerUrl}`) } else { logger.warn( `Could not construct viewer URL for file: ${file.name}, falling back to serve URL` diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 94c27d4482..998570b06c 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -779,7 +779,7 @@ export const DiscordBlock: BlockConfig = { reason: { type: 'string', description: 'Reason for moderation action' }, archived: { type: 'string', description: 'Archive status (true/false)' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - files: { type: 'array', description: 'Files to attach (UserFile array)' }, + files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, limit: { type: 'number', description: 'Message limit' }, autoArchiveDuration: { type: 'number', description: 'Thread auto-archive duration in minutes' }, channelType: { type: 'number', description: 'Discord channel type (0=text, 2=voice, etc.)' }, diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 9867fa979c..521b74e927 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1,11 +1,48 @@ import { createLogger } from '@sim/logger' import { DocumentIcon } from '@/components/icons' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import type { BlockConfig, SubBlockType } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' import type { FileParserOutput, FileParserV3Output } from '@/tools/file/types' const logger = createLogger('FileBlock') +const resolveFilePathFromInput = (fileInput: unknown): string | null => { + if (!fileInput || typeof fileInput !== 'object') { + return null + } + + const record = fileInput as Record + if (typeof record.path === 'string' && record.path.trim() !== '') { + return record.path + } + if (typeof record.url === 'string' && record.url.trim() !== '') { + return record.url + } + if (typeof record.key === 'string' && record.key.trim() !== '') { + const key = record.key.trim() + const context = typeof record.context === 'string' ? record.context : inferContextFromKey(key) + return `/api/files/serve/${encodeURIComponent(key)}?context=${context}` + } + + return null +} + +const resolveFilePathsFromInput = (fileInput: unknown): string[] => { + if (!fileInput) { + return [] + } + + if (Array.isArray(fileInput)) { + return fileInput + .map((file) => resolveFilePathFromInput(file)) + .filter((path): path is string => Boolean(path)) + } + + const resolved = resolveFilePathFromInput(fileInput) + return resolved ? [resolved] : [] +} + export const FileBlock: BlockConfig = { type: 'file', name: 'File (Legacy)', @@ -79,24 +116,14 @@ export const FileBlock: BlockConfig = { // Handle file upload input if (inputMethod === 'upload') { - // Handle case where 'file' is an array (multiple files) - if (params.file && Array.isArray(params.file) && params.file.length > 0) { - const filePaths = params.file.map((file) => file.path) - + const filePaths = resolveFilePathsFromInput(params.file) + if (filePaths.length > 0) { return { filePath: filePaths.length === 1 ? filePaths[0] : filePaths, fileType: params.fileType || 'auto', } } - // Handle case where 'file' is a single file object - if (params.file?.path) { - return { - filePath: params.file.path, - fileType: params.fileType || 'auto', - } - } - // If no files, return error logger.error('No files provided for upload method') throw new Error('Please upload a file') @@ -182,16 +209,17 @@ export const FileV2Block: BlockConfig = { } if (Array.isArray(fileInput) && fileInput.length > 0) { - const filePaths = fileInput.map((file) => file.path) + const filePaths = resolveFilePathsFromInput(fileInput) return { filePath: filePaths.length === 1 ? filePaths[0] : filePaths, fileType: params.fileType || 'auto', } } - if (fileInput?.path) { + const resolvedSingle = resolveFilePathsFromInput(fileInput) + if (resolvedSingle.length > 0) { return { - filePath: fileInput.path, + filePath: resolvedSingle[0], fileType: params.fileType || 'auto', } } @@ -274,9 +302,7 @@ export const FileV3Block: BlockConfig = { } if (Array.isArray(fileInput)) { - const filePaths = fileInput - .map((file) => (file as { url?: string; path?: string }).url || file.path) - .filter((path): path is string => Boolean(path)) + const filePaths = resolveFilePathsFromInput(fileInput) if (filePaths.length === 0) { logger.error('No valid file paths found in file input array') throw new Error('File input is required') @@ -291,13 +317,13 @@ export const FileV3Block: BlockConfig = { } if (typeof fileInput === 'object') { - const filePath = (fileInput as { url?: string; path?: string }).url || fileInput.path - if (!filePath) { - logger.error('File input object missing path or url') + const resolvedPaths = resolveFilePathsFromInput(fileInput) + if (resolvedPaths.length === 0) { + logger.error('File input object missing path, url, or key') throw new Error('File input is required') } return { - filePath, + filePath: resolvedPaths[0], fileType: params.fileType || 'auto', workspaceId: params._context?.workspaceId, workflowId: params._context?.workflowId, diff --git a/apps/sim/blocks/blocks/fireflies.ts b/apps/sim/blocks/blocks/fireflies.ts index b092471902..16c3a3fdb3 100644 --- a/apps/sim/blocks/blocks/fireflies.ts +++ b/apps/sim/blocks/blocks/fireflies.ts @@ -4,6 +4,26 @@ import { AuthMode } from '@/blocks/types' import type { FirefliesResponse } from '@/tools/fireflies/types' import { getTrigger } from '@/triggers' +const resolveHttpsUrlFromFileInput = (fileInput: unknown): string | null => { + if (!fileInput || typeof fileInput !== 'object') { + return null + } + + const record = fileInput as Record + const url = + typeof record.url === 'string' + ? record.url.trim() + : typeof record.path === 'string' + ? record.path.trim() + : '' + + if (!url || !url.startsWith('https://')) { + return null + } + + return url +} + export const FirefliesBlock: BlockConfig = { type: 'fireflies', name: 'Fireflies', @@ -587,3 +607,74 @@ Return ONLY the summary text - no quotes, no labels.`, available: ['fireflies_transcription_complete'], }, } + +const firefliesV2SubBlocks = (FirefliesBlock.subBlocks || []).filter( + (subBlock) => subBlock.id !== 'audioUrl' +) +const firefliesV2Inputs = FirefliesBlock.inputs + ? Object.fromEntries(Object.entries(FirefliesBlock.inputs).filter(([key]) => key !== 'audioUrl')) + : {} + +export const FirefliesV2Block: BlockConfig = { + ...FirefliesBlock, + type: 'fireflies_v2', + name: 'Fireflies (File Only)', + description: 'Interact with Fireflies.ai meeting transcripts and recordings', + hideFromToolbar: true, + subBlocks: firefliesV2SubBlocks, + tools: { + ...FirefliesBlock.tools, + config: { + ...FirefliesBlock.tools?.config, + tool: (params) => + FirefliesBlock.tools?.config?.tool + ? FirefliesBlock.tools.config.tool(params) + : params.operation || 'fireflies_list_transcripts', + params: (params) => { + const baseParams = FirefliesBlock.tools?.config?.params + if (!baseParams) { + return params + } + + if (params.operation === 'fireflies_upload_audio') { + let audioInput = params.audioFile || params.audioFileReference + if (!audioInput) { + throw new Error('Audio file is required.') + } + if (typeof audioInput === 'string') { + try { + audioInput = JSON.parse(audioInput) + } catch { + throw new Error('Audio file must be a valid file reference.') + } + } + if (Array.isArray(audioInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof audioInput !== 'object' || audioInput === null) { + throw new Error('Audio file must be a file reference.') + } + const audioUrl = resolveHttpsUrlFromFileInput(audioInput) + if (!audioUrl) { + throw new Error('Audio file must include a https URL.') + } + + return baseParams({ + ...params, + audioUrl, + audioFile: undefined, + audioFileReference: undefined, + }) + } + + return baseParams(params) + }, + }, + }, + inputs: { + ...firefliesV2Inputs, + audioFileReference: { type: 'json', description: 'Audio/video file reference' }, + }, +} diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index 2598425848..a849b718c5 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -1,6 +1,7 @@ import { GoogleSheetsIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types' // Legacy block - hidden from toolbar @@ -681,34 +682,38 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, 'google_sheets_copy_sheet_v2', ], config: { - tool: (params) => { - switch (params.operation) { - case 'read': - return 'google_sheets_read_v2' - case 'write': - return 'google_sheets_write_v2' - case 'update': - return 'google_sheets_update_v2' - case 'append': - return 'google_sheets_append_v2' - case 'clear': - return 'google_sheets_clear_v2' - case 'get_info': - return 'google_sheets_get_spreadsheet_v2' - case 'create': - return 'google_sheets_create_spreadsheet_v2' - case 'batch_get': - return 'google_sheets_batch_get_v2' - case 'batch_update': - return 'google_sheets_batch_update_v2' - case 'batch_clear': - return 'google_sheets_batch_clear_v2' - case 'copy_sheet': - return 'google_sheets_copy_sheet_v2' - default: - throw new Error(`Invalid Google Sheets V2 operation: ${params.operation}`) - } - }, + tool: createVersionedToolSelector({ + baseToolSelector: (params) => { + switch (params.operation) { + case 'read': + return 'google_sheets_read' + case 'write': + return 'google_sheets_write' + case 'update': + return 'google_sheets_update' + case 'append': + return 'google_sheets_append' + case 'clear': + return 'google_sheets_clear' + case 'get_info': + return 'google_sheets_get_spreadsheet' + case 'create': + return 'google_sheets_create_spreadsheet' + case 'batch_get': + return 'google_sheets_batch_get' + case 'batch_update': + return 'google_sheets_batch_update' + case 'batch_clear': + return 'google_sheets_batch_clear' + case 'copy_sheet': + return 'google_sheets_copy_sheet' + default: + throw new Error(`Invalid Google Sheets operation: ${params.operation}`) + } + }, + suffix: '_v2', + fallbackToolId: 'google_sheets_read_v2', + }), params: (params) => { const { credential, diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index a724d7e121..bd910735d4 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -3,6 +3,26 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleSlidesResponse } from '@/tools/google_slides/types' +const resolveHttpsUrlFromFileInput = (fileInput: unknown): string | null => { + if (!fileInput || typeof fileInput !== 'object') { + return null + } + + const record = fileInput as Record + const url = + typeof record.url === 'string' + ? record.url.trim() + : typeof record.path === 'string' + ? record.path.trim() + : '' + + if (!url || !url.startsWith('https://')) { + return null + } + + return url +} + export const GoogleSlidesBlock: BlockConfig = { type: 'google_slides', name: 'Google Slides', @@ -903,3 +923,99 @@ Return ONLY the text content - no explanations, no markdown formatting markers, text: { type: 'string', description: 'Text that was inserted' }, }, } + +const googleSlidesV2SubBlocks = (GoogleSlidesBlock.subBlocks || []).flatMap((subBlock) => { + if (subBlock.id === 'imageFile') { + return [ + { + ...subBlock, + canonicalParamId: 'imageFile', + }, + ] + } + + if (subBlock.id !== 'imageUrl') { + return [subBlock] + } + + return [ + { + id: 'imageFileReference', + title: 'Image', + type: 'short-input', + canonicalParamId: 'imageFile', + placeholder: 'Reference image from previous blocks', + mode: 'advanced', + required: true, + condition: { field: 'operation', value: 'add_image' }, + }, + ] +}) + +const googleSlidesV2Inputs = GoogleSlidesBlock.inputs + ? Object.fromEntries( + Object.entries(GoogleSlidesBlock.inputs).filter( + ([key]) => key !== 'imageUrl' && key !== 'imageSource' + ) + ) + : {} + +export const GoogleSlidesV2Block: BlockConfig = { + ...GoogleSlidesBlock, + type: 'google_slides_v2', + name: 'Google Slides (File Only)', + description: 'Read, write, and create presentations', + hideFromToolbar: true, + subBlocks: googleSlidesV2SubBlocks, + tools: { + ...GoogleSlidesBlock.tools, + config: { + ...GoogleSlidesBlock.tools?.config, + params: (params) => { + const baseParams = GoogleSlidesBlock.tools?.config?.params + if (!baseParams) { + return params + } + + if (params.operation === 'add_image') { + let imageInput = params.imageFile || params.imageFileReference || params.imageSource + if (!imageInput) { + throw new Error('Image file is required.') + } + if (typeof imageInput === 'string') { + try { + imageInput = JSON.parse(imageInput) + } catch { + throw new Error('Image file must be a valid file reference.') + } + } + if (Array.isArray(imageInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof imageInput !== 'object' || imageInput === null) { + throw new Error('Image file must be a file reference.') + } + const imageUrl = resolveHttpsUrlFromFileInput(imageInput) + if (!imageUrl) { + throw new Error('Image file must include a https URL.') + } + + return baseParams({ + ...params, + imageUrl, + imageFileReference: undefined, + imageSource: undefined, + }) + } + + return baseParams(params) + }, + }, + }, + inputs: { + ...googleSlidesV2Inputs, + imageFileReference: { type: 'json', description: 'Image file reference' }, + }, +} diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index c2e64ce1ec..16f7b9ddf9 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1025,7 +1025,7 @@ Return ONLY the comment text - no explanations.`, commentId: { type: 'string', description: 'Comment ID for update/delete operations' }, // Attachment operation inputs attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - files: { type: 'array', description: 'Files to attach (UserFile array)' }, + files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, attachmentId: { type: 'string', description: 'Attachment ID for delete operation' }, // Worklog operation inputs timeSpentSeconds: { diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index adb4c5fa17..3438c5bdc9 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -1,6 +1,7 @@ import { MicrosoftExcelIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { MicrosoftExcelResponse, MicrosoftExcelV2Response, @@ -489,16 +490,20 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, tools: { access: ['microsoft_excel_read_v2', 'microsoft_excel_write_v2'], config: { - tool: (params) => { - switch (params.operation) { - case 'read': - return 'microsoft_excel_read_v2' - case 'write': - return 'microsoft_excel_write_v2' - default: - throw new Error(`Invalid Microsoft Excel V2 operation: ${params.operation}`) - } - }, + tool: createVersionedToolSelector({ + baseToolSelector: (params) => { + switch (params.operation) { + case 'read': + return 'microsoft_excel_read' + case 'write': + return 'microsoft_excel_write' + default: + throw new Error(`Invalid Microsoft Excel operation: ${params.operation}`) + } + }, + suffix: '_v2', + fallbackToolId: 'microsoft_excel_read_v2', + }), params: (params) => { const { credential, diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index 42c5b63a1a..4330f2b042 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -94,7 +94,7 @@ export const MistralParseBlock: BlockConfig = { if (!params.fileUpload) { throw new Error('Please upload a PDF document') } - parameters.fileUpload = params.fileUpload + parameters.file = params.fileUpload } let pagesArray: number[] | undefined @@ -162,7 +162,7 @@ export const MistralParseV2Block: BlockConfig = { required: true, }, { - id: 'filePath', + id: 'fileReference', title: 'File Reference', type: 'short-input' as SubBlockType, canonicalParamId: 'document', @@ -213,15 +213,26 @@ export const MistralParseV2Block: BlockConfig = { resultType: params.resultType || 'markdown', } - const documentInput = params.fileUpload || params.filePath || params.document + let documentInput = params.fileUpload || params.fileReference || params.document if (!documentInput) { throw new Error('PDF document is required') } - if (typeof documentInput === 'object') { - parameters.fileData = documentInput - } else if (typeof documentInput === 'string') { - parameters.filePath = documentInput.trim() + if (typeof documentInput === 'string') { + try { + documentInput = JSON.parse(documentInput) + } catch { + throw new Error('PDF document must be a valid file reference') + } + } + if (Array.isArray(documentInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof documentInput !== 'object' || documentInput === null) { + throw new Error('PDF document must be a file reference') } + parameters.file = documentInput let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { @@ -257,7 +268,7 @@ export const MistralParseV2Block: BlockConfig = { }, inputs: { document: { type: 'json', description: 'Document input (file upload or file reference)' }, - filePath: { type: 'string', description: 'File reference (advanced mode)' }, + fileReference: { type: 'json', description: 'File reference (advanced mode)' }, fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' }, apiKey: { type: 'string', description: 'Mistral API key' }, resultType: { type: 'string', description: 'Output format type' }, diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 9bc6e6bf38..a970de73f8 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -412,6 +412,7 @@ export const NotionV2Block: BlockConfig = { 'notion_read_database_v2', 'notion_write_v2', 'notion_create_page_v2', + 'notion_update_page_v2', 'notion_query_database_v2', 'notion_search_v2', 'notion_create_database_v2', diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index cfbe253044..e35f425f52 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -392,7 +392,7 @@ export const OutlookBlock: BlockConfig = { body: { type: 'string', description: 'Email content' }, contentType: { type: 'string', description: 'Content type (Text or HTML)' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, // Forward operation inputs messageId: { type: 'string', description: 'Message ID to forward' }, comment: { type: 'string', description: 'Optional comment for forwarding' }, diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index b6bd6fb8e6..22d81d7822 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -804,6 +804,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n deals: { type: 'json', description: 'Array of deal objects' }, deal: { type: 'json', description: 'Single deal object' }, files: { type: 'json', description: 'Array of file objects' }, + downloadedFiles: { type: 'file[]', description: 'Downloaded files from Pipedrive' }, messages: { type: 'json', description: 'Array of mail message objects' }, pipelines: { type: 'json', description: 'Array of pipeline objects' }, projects: { type: 'json', description: 'Array of project objects' }, diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 0e2f5658fe..38cbb674c3 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -1,11 +1,13 @@ import { PulseIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { PulseParserOutput } from '@/tools/pulse/types' export const PulseBlock: BlockConfig = { type: 'pulse', name: 'Pulse', description: 'Extract text from documents using Pulse OCR', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.', @@ -77,7 +79,7 @@ export const PulseBlock: BlockConfig = { throw new Error('Document is required') } if (typeof documentInput === 'object') { - parameters.fileUpload = documentInput + parameters.file = documentInput } else if (typeof documentInput === 'string') { parameters.filePath = documentInput.trim() } @@ -126,3 +128,78 @@ export const PulseBlock: BlockConfig = { figures: { type: 'json', description: 'Extracted figures if figure extraction was enabled' }, }, } + +const pulseV2Inputs = PulseBlock.inputs + ? Object.fromEntries(Object.entries(PulseBlock.inputs).filter(([key]) => key !== 'filePath')) + : {} +const pulseV2SubBlocks = (PulseBlock.subBlocks || []).filter( + (subBlock) => subBlock.id !== 'filePath' +) + +export const PulseV2Block: BlockConfig = { + ...PulseBlock, + type: 'pulse_v2', + name: 'Pulse (File Only)', + hideFromToolbar: false, + longDescription: + 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via upload.', + subBlocks: pulseV2SubBlocks, + tools: { + access: ['pulse_parser_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'pulse_parser', + suffix: '_v2', + fallbackToolId: 'pulse_parser_v2', + }), + params: (params) => { + if (!params || !params.apiKey || params.apiKey.trim() === '') { + throw new Error('Pulse API key is required') + } + + const parameters: Record = { + apiKey: params.apiKey.trim(), + } + + let documentInput = params.fileUpload || params.document + if (!documentInput) { + throw new Error('Document file is required') + } + if (typeof documentInput === 'string') { + try { + documentInput = JSON.parse(documentInput) + } catch { + throw new Error('Document file must be a valid file reference') + } + } + if (Array.isArray(documentInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof documentInput !== 'object' || documentInput === null) { + throw new Error('Document file must be a file reference') + } + parameters.file = documentInput + + if (params.pages && params.pages.trim() !== '') { + parameters.pages = params.pages.trim() + } + + if (params.chunking && params.chunking.trim() !== '') { + parameters.chunking = params.chunking.trim() + } + + if (params.chunkSize && params.chunkSize.trim() !== '') { + const size = Number.parseInt(params.chunkSize.trim(), 10) + if (!Number.isNaN(size) && size > 0) { + parameters.chunkSize = size + } + } + + return parameters + }, + }, + }, + inputs: pulseV2Inputs, +} diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 681c2aa207..1050b3b132 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -1,11 +1,13 @@ import { ReductoIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { ReductoParserOutput } from '@/tools/reducto/types' export const ReductoBlock: BlockConfig = { type: 'reducto', name: 'Reducto', description: 'Extract text from PDF documents', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`, docsLink: 'https://docs.sim.ai/tools/reducto', @@ -74,7 +76,7 @@ export const ReductoBlock: BlockConfig = { } if (typeof documentInput === 'object') { - parameters.fileUpload = documentInput + parameters.file = documentInput } else if (typeof documentInput === 'string') { parameters.filePath = documentInput.trim() } @@ -132,3 +134,94 @@ export const ReductoBlock: BlockConfig = { studio_link: { type: 'string', description: 'Link to Reducto studio interface' }, }, } + +const reductoV2Inputs = ReductoBlock.inputs + ? Object.fromEntries(Object.entries(ReductoBlock.inputs).filter(([key]) => key !== 'filePath')) + : {} +const reductoV2SubBlocks = (ReductoBlock.subBlocks || []).filter( + (subBlock) => subBlock.id !== 'filePath' +) + +export const ReductoV2Block: BlockConfig = { + ...ReductoBlock, + type: 'reducto_v2', + name: 'Reducto (File Only)', + hideFromToolbar: false, + longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents.`, + subBlocks: reductoV2SubBlocks, + tools: { + access: ['reducto_parser_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'reducto_parser', + suffix: '_v2', + fallbackToolId: 'reducto_parser_v2', + }), + params: (params) => { + if (!params || !params.apiKey || params.apiKey.trim() === '') { + throw new Error('Reducto API key is required') + } + + const parameters: Record = { + apiKey: params.apiKey.trim(), + } + + let documentInput = params.fileUpload || params.document + if (!documentInput) { + throw new Error('PDF document file is required') + } + if (typeof documentInput === 'string') { + try { + documentInput = JSON.parse(documentInput) + } catch { + throw new Error('PDF document file must be a valid file reference') + } + } + if (Array.isArray(documentInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof documentInput !== 'object' || documentInput === null) { + throw new Error('PDF document file must be a file reference') + } + parameters.file = documentInput + + let pagesArray: number[] | undefined + if (params.pages && params.pages.trim() !== '') { + try { + pagesArray = params.pages + .split(',') + .map((p: string) => p.trim()) + .filter((p: string) => p.length > 0) + .map((p: string) => { + const num = Number.parseInt(p, 10) + if (Number.isNaN(num) || num < 0) { + throw new Error(`Invalid page number: ${p}`) + } + return num + }) + + if (pagesArray && pagesArray.length === 0) { + pagesArray = undefined + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Page number format error: ${errorMessage}`) + } + } + + if (pagesArray && pagesArray.length > 0) { + parameters.pages = pagesArray + } + + if (params.tableOutputFormat) { + parameters.tableOutputFormat = params.tableOutputFormat + } + + return parameters + }, + }, + }, + inputs: reductoV2Inputs, +} diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts index 422f9b57fa..d50beb7070 100644 --- a/apps/sim/blocks/blocks/sendgrid.ts +++ b/apps/sim/blocks/blocks/sendgrid.ts @@ -600,7 +600,7 @@ Return ONLY the HTML content.`, mailTemplateId: { type: 'string', description: 'Template ID for sending mail' }, dynamicTemplateData: { type: 'json', description: 'Dynamic template data' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, // Contact inputs email: { type: 'string', description: 'Contact email' }, firstName: { type: 'string', description: 'Contact first name' }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 3621ee5b43..c738e45920 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -279,7 +279,7 @@ export const SftpBlock: BlockConfig = { privateKey: { type: 'string', description: 'Private key for authentication' }, passphrase: { type: 'string', description: 'Passphrase for encrypted key' }, remotePath: { type: 'string', description: 'Remote path on the SFTP server' }, - files: { type: 'array', description: 'Files to upload (UserFile array)' }, + files: { type: 'file[]', description: 'Files to upload (UserFile array)' }, fileContent: { type: 'string', description: 'Direct content to upload' }, fileName: { type: 'string', description: 'File name for direct content' }, overwrite: { type: 'boolean', description: 'Overwrite existing files' }, diff --git a/apps/sim/blocks/blocks/smtp.ts b/apps/sim/blocks/blocks/smtp.ts index c292281b6c..3f22ae04c6 100644 --- a/apps/sim/blocks/blocks/smtp.ts +++ b/apps/sim/blocks/blocks/smtp.ts @@ -196,7 +196,7 @@ export const SmtpBlock: BlockConfig = { cc: { type: 'string', description: 'CC recipients (comma-separated)' }, bcc: { type: 'string', description: 'BCC recipients (comma-separated)' }, replyTo: { type: 'string', description: 'Reply-to email address' }, - attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, }, outputs: { diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 99611d732c..a7ab181553 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -1,11 +1,13 @@ import { STTIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { SttBlockResponse } from '@/tools/stt/types' export const SttBlock: BlockConfig = { type: 'stt', name: 'Speech-to-Text', description: 'Convert speech to text using AI', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: 'Transcribe audio and video files to text using leading AI providers. Supports multiple languages, timestamps, and speaker diarization.', @@ -345,3 +347,63 @@ export const SttBlock: BlockConfig = { }, }, } + +const sttV2Inputs = SttBlock.inputs + ? Object.fromEntries(Object.entries(SttBlock.inputs).filter(([key]) => key !== 'audioUrl')) + : {} +const sttV2SubBlocks = (SttBlock.subBlocks || []).filter((subBlock) => subBlock.id !== 'audioUrl') + +export const SttV2Block: BlockConfig = { + ...SttBlock, + type: 'stt_v2', + name: 'Speech-to-Text (File Only)', + hideFromToolbar: false, + subBlocks: sttV2SubBlocks, + tools: { + access: [ + 'stt_whisper_v2', + 'stt_deepgram_v2', + 'stt_elevenlabs_v2', + 'stt_assemblyai_v2', + 'stt_gemini_v2', + ], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: (params) => { + switch (params.provider) { + case 'whisper': + return 'stt_whisper' + case 'deepgram': + return 'stt_deepgram' + case 'elevenlabs': + return 'stt_elevenlabs' + case 'assemblyai': + return 'stt_assemblyai' + case 'gemini': + return 'stt_gemini' + default: + return 'stt_whisper' + } + }, + suffix: '_v2', + fallbackToolId: 'stt_whisper_v2', + }), + params: (params) => ({ + provider: params.provider, + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language, + timestamps: params.timestamps, + diarization: params.diarization, + translateToEnglish: params.translateToEnglish, + sentiment: params.sentiment, + entityDetection: params.entityDetection, + piiRedaction: params.piiRedaction, + summarization: params.summarization, + }), + }, + }, + inputs: sttV2Inputs, +} diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 65b18677ab..40f94c484e 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -351,7 +351,7 @@ export const TelegramBlock: BlockConfig = { type: 'json', description: 'Files to attach (UI upload)', }, - files: { type: 'array', description: 'Files to attach (UserFile array)' }, + files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, caption: { type: 'string', description: 'Caption for media' }, messageId: { type: 'string', description: 'Message ID to delete' }, }, diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 2b83887089..51e798970e 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -1,11 +1,13 @@ import { TextractIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { TextractParserOutput } from '@/tools/textract/types' export const TextractBlock: BlockConfig = { type: 'textract', name: 'AWS Textract', description: 'Extract text, tables, and forms from documents', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: `Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.`, docsLink: 'https://docs.sim.ai/tools/textract', @@ -140,7 +142,7 @@ export const TextractBlock: BlockConfig = { throw new Error('Document is required') } if (typeof documentInput === 'object') { - parameters.fileUpload = documentInput + parameters.file = documentInput } else if (typeof documentInput === 'string') { parameters.filePath = documentInput.trim() } @@ -189,3 +191,88 @@ export const TextractBlock: BlockConfig = { }, }, } + +const textractV2Inputs = TextractBlock.inputs + ? Object.fromEntries(Object.entries(TextractBlock.inputs).filter(([key]) => key !== 'filePath')) + : {} +const textractV2SubBlocks = (TextractBlock.subBlocks || []).filter( + (subBlock) => subBlock.id !== 'filePath' +) + +export const TextractV2Block: BlockConfig = { + ...TextractBlock, + type: 'textract_v2', + name: 'AWS Textract (File Only)', + hideFromToolbar: false, + subBlocks: textractV2SubBlocks, + tools: { + access: ['textract_parser_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'textract_parser', + suffix: '_v2', + fallbackToolId: 'textract_parser_v2', + }), + params: (params) => { + if (!params.accessKeyId || params.accessKeyId.trim() === '') { + throw new Error('AWS Access Key ID is required') + } + if (!params.secretAccessKey || params.secretAccessKey.trim() === '') { + throw new Error('AWS Secret Access Key is required') + } + if (!params.region || params.region.trim() === '') { + throw new Error('AWS Region is required') + } + + const processingMode = params.processingMode || 'sync' + const parameters: Record = { + accessKeyId: params.accessKeyId.trim(), + secretAccessKey: params.secretAccessKey.trim(), + region: params.region.trim(), + processingMode, + } + + if (processingMode === 'async') { + if (!params.s3Uri || params.s3Uri.trim() === '') { + throw new Error('S3 URI is required for multi-page processing') + } + parameters.s3Uri = params.s3Uri.trim() + } else { + let documentInput = params.fileUpload || params.document + if (!documentInput) { + throw new Error('Document file is required') + } + if (typeof documentInput === 'string') { + try { + documentInput = JSON.parse(documentInput) + } catch { + throw new Error('Document file must be a valid file reference') + } + } + if (Array.isArray(documentInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + if (typeof documentInput !== 'object' || documentInput === null) { + throw new Error('Document file must be a file reference') + } + parameters.file = documentInput + } + + const featureTypes: string[] = [] + if (params.extractTables) featureTypes.push('TABLES') + if (params.extractForms) featureTypes.push('FORMS') + if (params.detectSignatures) featureTypes.push('SIGNATURES') + if (params.analyzeLayout) featureTypes.push('LAYOUT') + + if (featureTypes.length > 0) { + parameters.featureTypes = featureTypes + } + + return parameters + }, + }, + }, + inputs: textractV2Inputs, +} diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 58d6c1354d..7cc22bb91f 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -1,6 +1,7 @@ import { EyeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { VisionResponse } from '@/tools/vision/types' const VISION_MODEL_OPTIONS = [ @@ -107,6 +108,16 @@ export const VisionV2Block: BlockConfig = { name: 'Vision', description: 'Analyze images with vision models', hideFromToolbar: false, + tools: { + access: ['vision_tool_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'vision_tool', + suffix: '_v2', + fallbackToolId: 'vision_tool_v2', + }), + }, + }, subBlocks: [ { id: 'imageFile', diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 6aa34b6c28..7c90cfdd5a 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -30,7 +30,7 @@ import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { ExaBlock } from '@/blocks/blocks/exa' import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' -import { FirefliesBlock } from '@/blocks/blocks/fireflies' +import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies' import { FunctionBlock } from '@/blocks/blocks/function' import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook' import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github' @@ -44,7 +44,7 @@ import { GoogleFormsBlock } from '@/blocks/blocks/google_forms' import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups' import { GoogleMapsBlock } from '@/blocks/blocks/google_maps' import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets' -import { GoogleSlidesBlock } from '@/blocks/blocks/google_slides' +import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides' import { GoogleVaultBlock } from '@/blocks/blocks/google_vault' import { GrafanaBlock } from '@/blocks/blocks/grafana' import { GrainBlock } from '@/blocks/blocks/grain' @@ -94,11 +94,11 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive' import { PolymarketBlock } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostHogBlock } from '@/blocks/blocks/posthog' -import { PulseBlock } from '@/blocks/blocks/pulse' +import { PulseBlock, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock } from '@/blocks/blocks/qdrant' import { RDSBlock } from '@/blocks/blocks/rds' import { RedditBlock } from '@/blocks/blocks/reddit' -import { ReductoBlock } from '@/blocks/blocks/reducto' +import { ReductoBlock, ReductoV2Block } from '@/blocks/blocks/reducto' import { ResendBlock } from '@/blocks/blocks/resend' import { ResponseBlock } from '@/blocks/blocks/response' import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' @@ -124,11 +124,11 @@ import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { StarterBlock } from '@/blocks/blocks/starter' import { StripeBlock } from '@/blocks/blocks/stripe' -import { SttBlock } from '@/blocks/blocks/stt' +import { SttBlock, SttV2Block } from '@/blocks/blocks/stt' import { SupabaseBlock } from '@/blocks/blocks/supabase' import { TavilyBlock } from '@/blocks/blocks/tavily' import { TelegramBlock } from '@/blocks/blocks/telegram' -import { TextractBlock } from '@/blocks/blocks/textract' +import { TextractBlock, TextractV2Block } from '@/blocks/blocks/textract' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { TinybirdBlock } from '@/blocks/blocks/tinybird' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -195,6 +195,7 @@ export const registry: Record = { file_v3: FileV3Block, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, + fireflies_v2: FirefliesV2Block, function: FunctionBlock, generic_webhook: GenericWebhookBlock, github: GitHubBlock, @@ -213,6 +214,7 @@ export const registry: Record = { google_sheets: GoogleSheetsBlock, google_sheets_v2: GoogleSheetsV2Block, google_slides: GoogleSlidesBlock, + google_slides_v2: GoogleSlidesV2Block, google_vault: GoogleVaultBlock, grafana: GrafanaBlock, grain: GrainBlock, @@ -268,10 +270,12 @@ export const registry: Record = { postgresql: PostgreSQLBlock, posthog: PostHogBlock, pulse: PulseBlock, + pulse_v2: PulseV2Block, qdrant: QdrantBlock, rds: RDSBlock, reddit: RedditBlock, reducto: ReductoBlock, + reducto_v2: ReductoV2Block, resend: ResendBlock, response: ResponseBlock, router: RouterBlock, @@ -299,10 +303,12 @@ export const registry: Record = { starter: StarterBlock, stripe: StripeBlock, stt: SttBlock, + stt_v2: SttV2Block, supabase: SupabaseBlock, tavily: TavilyBlock, telegram: TelegramBlock, textract: TextractBlock, + textract_v2: TextractV2Block, thinking: ThinkingBlock, tinybird: TinybirdBlock, translate: TranslateBlock, diff --git a/apps/sim/executor/handlers/api/api-handler.ts b/apps/sim/executor/handlers/api/api-handler.ts index 775b886745..562067cdfe 100644 --- a/apps/sim/executor/handlers/api/api-handler.ts +++ b/apps/sim/executor/handlers/api/api-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { BlockType, HTTP } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' @@ -41,16 +42,9 @@ export class ApiBlockHandler implements BlockHandler { } } - if (!urlToValidate.match(/^https?:\/\//i)) { - throw new Error( - `Invalid URL: "${urlToValidate}" - URL must include protocol (try "https://${urlToValidate}")` - ) - } - - try { - new URL(urlToValidate) - } catch (e: any) { - throw new Error(`Invalid URL format: "${urlToValidate}" - ${e.message}`) + const urlValidation = await validateUrlWithDNS(urlToValidate, 'url') + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) } } diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts index b5d7e9dd2e..dd113f40f0 100644 --- a/apps/sim/executor/utils/file-tool-processor.ts +++ b/apps/sim/executor/utils/file-tool-processor.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution' +import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import type { ExecutionContext, UserFile } from '@/executor/types' import type { ToolConfig, ToolFileData } from '@/tools/types' @@ -127,14 +128,7 @@ export class FileToolProcessor { } if (!buffer && fileData.url) { - const response = await fetch(fileData.url) - - if (!response.ok) { - throw new Error(`Failed to download file from ${fileData.url}: ${response.statusText}`) - } - - const arrayBuffer = await response.arrayBuffer() - buffer = Buffer.from(arrayBuffer) + buffer = await downloadFileFromUrl(fileData.url) } if (buffer) { diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts index da7fa3ad0d..fc0adf1957 100644 --- a/apps/sim/lib/a2a/push-notifications.ts +++ b/apps/sim/lib/a2a/push-notifications.ts @@ -4,7 +4,10 @@ import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' -import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' const logger = createLogger('A2APushNotifications') diff --git a/apps/sim/lib/a2a/utils.ts b/apps/sim/lib/a2a/utils.ts index 3eddb5d8d1..11d3c7ab51 100644 --- a/apps/sim/lib/a2a/utils.ts +++ b/apps/sim/lib/a2a/utils.ts @@ -7,6 +7,7 @@ import { ClientFactoryOptions, } from '@a2a-js/sdk/client' import { createLogger } from '@sim/logger' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { A2A_TERMINAL_STATES } from './constants' const logger = createLogger('A2AUtils') @@ -94,11 +95,13 @@ export function extractFileContent(message: Message): A2AFile[] { .map((part) => { const file = part.file as unknown as Record const uri = (file.url as string) || (file.uri as string) + const hasBytes = Boolean(file.bytes) + const canUseUri = Boolean(uri) && (!hasBytes || (uri ? !isInternalFileUrl(uri) : true)) return { name: file.name as string | undefined, mimeType: file.mimeType as string | undefined, - ...(uri ? { uri } : {}), - ...(file.bytes ? { bytes: file.bytes as string } : {}), + ...(canUseUri ? { uri } : {}), + ...(hasBytes ? { bytes: file.bytes as string } : {}), } }) } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts new file mode 100644 index 0000000000..a9a46b6d2e --- /dev/null +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -0,0 +1,290 @@ +import dns from 'dns/promises' +import http from 'http' +import https from 'https' +import type { LookupFunction } from 'net' +import { createLogger } from '@sim/logger' +import * as ipaddr from 'ipaddr.js' +import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' + +const logger = createLogger('InputValidation') + +/** + * Result type for async URL validation with resolved IP + */ +export interface AsyncValidationResult extends ValidationResult { + resolvedIP?: string + originalHostname?: string +} + +/** + * Checks if an IP address is private or reserved (not routable on the public internet) + * Uses ipaddr.js for robust handling of all IP formats including: + * - Octal notation (0177.0.0.1) + * - Hex notation (0x7f000001) + * - IPv4-mapped IPv6 (::ffff:127.0.0.1) + * - Various edge cases that regex patterns miss + */ +function isPrivateOrReservedIP(ip: string): boolean { + try { + if (!ipaddr.isValid(ip)) { + return true + } + + const addr = ipaddr.process(ip) + const range = addr.range() + + return range !== 'unicast' + } catch { + return true + } +} + +/** + * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding + * + * This function: + * 1. Performs basic URL validation (protocol, format) + * 2. Resolves the hostname to an IP address + * 3. Validates the resolved IP is not private/reserved + * 4. Returns the resolved IP for use in the actual request + * + * @param url - The URL to validate + * @param paramName - Name of the parameter for error messages + * @returns AsyncValidationResult with resolved IP for DNS pinning + */ +export async function validateUrlWithDNS( + url: string | null | undefined, + paramName = 'url' +): Promise { + const basicValidation = validateExternalUrl(url, paramName) + if (!basicValidation.isValid) { + return basicValidation + } + + const parsedUrl = new URL(url!) + const hostname = parsedUrl.hostname + + try { + const { address } = await dns.lookup(hostname) + + if (isPrivateOrReservedIP(address)) { + logger.warn('URL resolves to blocked IP address', { + paramName, + hostname, + resolvedIP: address, + }) + return { + isValid: false, + error: `${paramName} resolves to a blocked IP address`, + } + } + + return { + isValid: true, + resolvedIP: address, + originalHostname: hostname, + } + } catch (error) { + logger.warn('DNS lookup failed for URL', { + paramName, + hostname, + error: error instanceof Error ? error.message : String(error), + }) + return { + isValid: false, + error: `${paramName} hostname could not be resolved`, + } + } +} + +export interface SecureFetchOptions { + method?: string + headers?: Record + body?: string | Buffer | Uint8Array + timeout?: number + maxRedirects?: number +} + +export class SecureFetchHeaders { + private headers: Map + + constructor(headers: Record) { + this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) + } + + get(name: string): string | null { + return this.headers.get(name.toLowerCase()) ?? null + } + + toRecord(): Record { + const record: Record = {} + for (const [key, value] of this.headers) { + record[key] = value + } + return record + } + + [Symbol.iterator]() { + return this.headers.entries() + } +} + +export interface SecureFetchResponse { + ok: boolean + status: number + statusText: string + headers: SecureFetchHeaders + text: () => Promise + json: () => Promise + arrayBuffer: () => Promise +} + +const DEFAULT_MAX_REDIRECTS = 5 + +function isRedirectStatus(status: number): boolean { + return status >= 300 && status < 400 && status !== 304 +} + +function resolveRedirectUrl(baseUrl: string, location: string): string { + try { + return new URL(location, baseUrl).toString() + } catch { + throw new Error(`Invalid redirect location: ${location}`) + } +} + +/** + * Performs a fetch with IP pinning to prevent DNS rebinding attacks. + * Uses the pre-resolved IP address while preserving the original hostname for TLS SNI. + * Follows redirects securely by validating each redirect target. + */ +export async function secureFetchWithPinnedIP( + url: string, + resolvedIP: string, + options: SecureFetchOptions = {}, + redirectCount = 0 +): Promise { + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS + + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const isHttps = parsed.protocol === 'https:' + const defaultPort = isHttps ? 443 : 80 + const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort + + const isIPv6 = resolvedIP.includes(':') + const family = isIPv6 ? 6 : 4 + + const lookup: LookupFunction = (_hostname, options, callback) => { + if (options.all) { + callback(null, [{ address: resolvedIP, family }]) + } else { + callback(null, resolvedIP, family) + } + } + + const agentOptions: http.AgentOptions = { lookup } + + const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) + + // Remove accept-encoding since Node.js http/https doesn't auto-decompress + // Headers are lowercase due to Web Headers API normalization in executeToolRequest + const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {} + + const requestOptions: http.RequestOptions = { + hostname: parsed.hostname, + port, + path: parsed.pathname + parsed.search, + method: options.method || 'GET', + headers: sanitizedHeaders, + agent, + timeout: options.timeout || 300000, // Default 5 minutes + } + + const protocol = isHttps ? https : http + const req = protocol.request(requestOptions, (res) => { + const statusCode = res.statusCode || 0 + const location = res.headers.location + + if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) { + res.resume() + const redirectUrl = resolveRedirectUrl(url, location) + + validateUrlWithDNS(redirectUrl, 'redirectUrl') + .then((validation) => { + if (!validation.isValid) { + reject(new Error(`Redirect blocked: ${validation.error}`)) + return + } + return secureFetchWithPinnedIP( + redirectUrl, + validation.resolvedIP!, + options, + redirectCount + 1 + ) + }) + .then((response) => { + if (response) resolve(response) + }) + .catch(reject) + return + } + + if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) { + res.resume() + reject(new Error(`Too many redirects (max: ${maxRedirects})`)) + return + } + + const chunks: Buffer[] = [] + + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + + res.on('error', (error) => { + reject(error) + }) + + res.on('end', () => { + const bodyBuffer = Buffer.concat(chunks) + const body = bodyBuffer.toString('utf-8') + const headersRecord: Record = {} + for (const [key, value] of Object.entries(res.headers)) { + if (typeof value === 'string') { + headersRecord[key.toLowerCase()] = value + } else if (Array.isArray(value)) { + headersRecord[key.toLowerCase()] = value.join(', ') + } + } + + resolve({ + ok: statusCode >= 200 && statusCode < 300, + status: statusCode, + statusText: res.statusMessage || '', + headers: new SecureFetchHeaders(headersRecord), + text: async () => body, + json: async () => JSON.parse(body), + arrayBuffer: async () => + bodyBuffer.buffer.slice( + bodyBuffer.byteOffset, + bodyBuffer.byteOffset + bodyBuffer.byteLength + ), + }) + }) + }) + + req.on('error', (error) => { + reject(error) + }) + + req.on('timeout', () => { + req.destroy() + reject(new Error(`Request timed out after ${requestOptions.timeout}ms`)) + }) + + if (options.body) { + req.write(options.body) + } + + req.end() + }) +} diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 7575b65463..a2b842d40e 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -18,8 +18,8 @@ import { validatePathSegment, validateProxyUrl, validateS3BucketName, - validateUrlWithDNS, } from '@/lib/core/security/input-validation' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' vi.mock('@sim/logger', () => loggerMock) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e27524b2c0..e156c7ad44 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,7 +1,3 @@ -import dns from 'dns/promises' -import http from 'http' -import https from 'https' -import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' @@ -765,263 +761,6 @@ function isPrivateOrReservedIP(ip: string): boolean { } } -/** - * Result type for async URL validation with resolved IP - */ -export interface AsyncValidationResult extends ValidationResult { - resolvedIP?: string - originalHostname?: string -} - -/** - * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding - * - * This function: - * 1. Performs basic URL validation (protocol, format) - * 2. Resolves the hostname to an IP address - * 3. Validates the resolved IP is not private/reserved - * 4. Returns the resolved IP for use in the actual request - * - * @param url - The URL to validate - * @param paramName - Name of the parameter for error messages - * @returns AsyncValidationResult with resolved IP for DNS pinning - */ -export async function validateUrlWithDNS( - url: string | null | undefined, - paramName = 'url' -): Promise { - const basicValidation = validateExternalUrl(url, paramName) - if (!basicValidation.isValid) { - return basicValidation - } - - const parsedUrl = new URL(url!) - const hostname = parsedUrl.hostname - - try { - const { address } = await dns.lookup(hostname) - - if (isPrivateOrReservedIP(address)) { - logger.warn('URL resolves to blocked IP address', { - paramName, - hostname, - resolvedIP: address, - }) - return { - isValid: false, - error: `${paramName} resolves to a blocked IP address`, - } - } - - return { - isValid: true, - resolvedIP: address, - originalHostname: hostname, - } - } catch (error) { - logger.warn('DNS lookup failed for URL', { - paramName, - hostname, - error: error instanceof Error ? error.message : String(error), - }) - return { - isValid: false, - error: `${paramName} hostname could not be resolved`, - } - } -} -export interface SecureFetchOptions { - method?: string - headers?: Record - body?: string - timeout?: number - maxRedirects?: number -} - -export class SecureFetchHeaders { - private headers: Map - - constructor(headers: Record) { - this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) - } - - get(name: string): string | null { - return this.headers.get(name.toLowerCase()) ?? null - } - - toRecord(): Record { - const record: Record = {} - for (const [key, value] of this.headers) { - record[key] = value - } - return record - } - - [Symbol.iterator]() { - return this.headers.entries() - } -} - -export interface SecureFetchResponse { - ok: boolean - status: number - statusText: string - headers: SecureFetchHeaders - text: () => Promise - json: () => Promise - arrayBuffer: () => Promise -} - -const DEFAULT_MAX_REDIRECTS = 5 - -function isRedirectStatus(status: number): boolean { - return status >= 300 && status < 400 && status !== 304 -} - -function resolveRedirectUrl(baseUrl: string, location: string): string { - try { - return new URL(location, baseUrl).toString() - } catch { - throw new Error(`Invalid redirect location: ${location}`) - } -} - -/** - * Performs a fetch with IP pinning to prevent DNS rebinding attacks. - * Uses the pre-resolved IP address while preserving the original hostname for TLS SNI. - * Follows redirects securely by validating each redirect target. - */ -export async function secureFetchWithPinnedIP( - url: string, - resolvedIP: string, - options: SecureFetchOptions = {}, - redirectCount = 0 -): Promise { - const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS - - return new Promise((resolve, reject) => { - const parsed = new URL(url) - const isHttps = parsed.protocol === 'https:' - const defaultPort = isHttps ? 443 : 80 - const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort - - const isIPv6 = resolvedIP.includes(':') - const family = isIPv6 ? 6 : 4 - - const lookup: LookupFunction = (_hostname, options, callback) => { - if (options.all) { - callback(null, [{ address: resolvedIP, family }]) - } else { - callback(null, resolvedIP, family) - } - } - - const agentOptions: http.AgentOptions = { lookup } - - const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) - - // Remove accept-encoding since Node.js http/https doesn't auto-decompress - // Headers are lowercase due to Web Headers API normalization in executeToolRequest - const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {} - - const requestOptions: http.RequestOptions = { - hostname: parsed.hostname, - port, - path: parsed.pathname + parsed.search, - method: options.method || 'GET', - headers: sanitizedHeaders, - agent, - timeout: options.timeout || 300000, // Default 5 minutes - } - - const protocol = isHttps ? https : http - const req = protocol.request(requestOptions, (res) => { - const statusCode = res.statusCode || 0 - const location = res.headers.location - - if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) { - res.resume() - const redirectUrl = resolveRedirectUrl(url, location) - - validateUrlWithDNS(redirectUrl, 'redirectUrl') - .then((validation) => { - if (!validation.isValid) { - reject(new Error(`Redirect blocked: ${validation.error}`)) - return - } - return secureFetchWithPinnedIP( - redirectUrl, - validation.resolvedIP!, - options, - redirectCount + 1 - ) - }) - .then((response) => { - if (response) resolve(response) - }) - .catch(reject) - return - } - - if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) { - res.resume() - reject(new Error(`Too many redirects (max: ${maxRedirects})`)) - return - } - - const chunks: Buffer[] = [] - - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - - res.on('error', (error) => { - reject(error) - }) - - res.on('end', () => { - const bodyBuffer = Buffer.concat(chunks) - const body = bodyBuffer.toString('utf-8') - const headersRecord: Record = {} - for (const [key, value] of Object.entries(res.headers)) { - if (typeof value === 'string') { - headersRecord[key.toLowerCase()] = value - } else if (Array.isArray(value)) { - headersRecord[key.toLowerCase()] = value.join(', ') - } - } - - resolve({ - ok: statusCode >= 200 && statusCode < 300, - status: statusCode, - statusText: res.statusMessage || '', - headers: new SecureFetchHeaders(headersRecord), - text: async () => body, - json: async () => JSON.parse(body), - arrayBuffer: async () => - bodyBuffer.buffer.slice( - bodyBuffer.byteOffset, - bodyBuffer.byteOffset + bodyBuffer.byteLength - ), - }) - }) - }) - - req.on('error', (error) => { - reject(error) - }) - - req.on('timeout', () => { - req.destroy() - reject(new Error(`Request timed out after ${requestOptions.timeout}ms`)) - }) - - if (options.body) { - req.write(options.body) - } - - req.end() - }) -} - /** * Validates an Airtable ID (base, table, or webhook ID) * diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 22e164cf1d..5021d44942 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -24,6 +24,22 @@ export function getBaseUrl(): string { return `${protocol}${baseUrl}` } +/** + * Ensures a URL is absolute by prefixing the base URL when a relative path is provided. + * @param pathOrUrl - Relative path (e.g., /api/files/serve/...) or absolute URL + */ +export function ensureAbsoluteUrl(pathOrUrl: string): string { + if (!pathOrUrl) { + throw new Error('URL is required') + } + + if (pathOrUrl.startsWith('/')) { + return `${getBaseUrl()}${pathOrUrl}` + } + + return pathOrUrl +} + /** * Returns just the domain and port part of the application URL * @returns The domain with port if applicable (e.g., 'localhost:3000' or 'sim.ai') diff --git a/apps/sim/lib/execution/files.ts b/apps/sim/lib/execution/files.ts index d80f2ae77f..5ac2c50b04 100644 --- a/apps/sim/lib/execution/files.ts +++ b/apps/sim/lib/execution/files.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { v4 as uuidv4 } from 'uuid' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' import type { InputFormatField } from '@/lib/workflows/types' @@ -11,7 +10,7 @@ const logger = createLogger('ExecutionFiles') const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB /** - * Process a single file for workflow execution - handles both base64 ('file' type) and URL pass-through ('url' type) + * Process a single file for workflow execution - handles base64 ('file' type) and URL downloads ('url' type) */ export async function processExecutionFile( file: { type: string; data: string; name: string; mime?: string }, @@ -60,14 +59,28 @@ export async function processExecutionFile( } if (file.type === 'url' && file.data) { - return { - id: uuidv4(), - url: file.data, - name: file.name, - size: 0, - type: file.mime || 'application/octet-stream', - key: `url/${file.name}`, + const { downloadFileFromUrl } = await import('@/lib/uploads/utils/file-utils.server') + const buffer = await downloadFileFromUrl(file.data) + + if (buffer.length > MAX_FILE_SIZE) { + const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2) + throw new Error( + `File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)` + ) } + + logger.debug(`[${requestId}] Uploading file from URL: ${file.name} (${buffer.length} bytes)`) + + const userFile = await uploadExecutionFile( + executionContext, + buffer, + file.name, + file.mime || 'application/octet-stream', + userId + ) + + logger.debug(`[${requestId}] Successfully uploaded ${file.name} from URL`) + return userFile } return null diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index fadd43fa1f..37896d9a3f 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -3,11 +3,11 @@ import { PDFDocument } from 'pdf-lib' import { getBYOKKey } from '@/lib/api-key/byok' import { type Chunk, JsonYamlChunker, StructuredDataChunker, TextChunker } from '@/lib/chunkers' import { env } from '@/lib/core/config/env' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { parseBuffer, parseFile } from '@/lib/file-parsers' import type { FileParseMetadata } from '@/lib/file-parsers/types' import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { StorageService } from '@/lib/uploads' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import { mistralParserTool } from '@/tools/mistral/parser' @@ -246,7 +246,7 @@ async function handleFileForOCR( userId?: string, workspaceId?: string | null ) { - const isExternalHttps = fileUrl.startsWith('https://') && !fileUrl.includes('/api/files/serve/') + const isExternalHttps = fileUrl.startsWith('https://') && !isInternalFileUrl(fileUrl) if (isExternalHttps) { if (mimeType === 'application/pdf') { @@ -490,7 +490,7 @@ async function parseWithMistralOCR( workspaceId ) - logger.info(`Mistral OCR: Using presigned URL for ${filename}: ${sanitizeUrlForLog(httpsUrl)}`) + logger.info(`Mistral OCR: Using presigned URL for ${filename}: ${httpsUrl}`) let pageCount = 0 if (mimeType === 'application/pdf' && buffer) { diff --git a/apps/sim/lib/uploads/utils/file-schemas.ts b/apps/sim/lib/uploads/utils/file-schemas.ts index 0939131ff9..b010a99b63 100644 --- a/apps/sim/lib/uploads/utils/file-schemas.ts +++ b/apps/sim/lib/uploads/utils/file-schemas.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' + +const isUrlLike = (value: string) => + value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') export const RawFileInputSchema = z .object({ @@ -18,6 +22,30 @@ export const RawFileInputSchema = z .refine((data) => Boolean(data.key || data.path || data.url), { message: 'File must include key, path, or url', }) + .refine( + (data) => { + if (data.key || data.path) { + return true + } + if (!data.url) { + return true + } + return isInternalFileUrl(data.url) + }, + { message: 'File url must reference an uploaded file' } + ) + .refine( + (data) => { + if (data.key || !data.path) { + return true + } + if (!isUrlLike(data.path)) { + return true + } + return isInternalFileUrl(data.path) + }, + { message: 'File path must reference an uploaded file' } + ) export const RawFileInputArraySchema = z.array(RawFileInputSchema) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index c2f14e97e2..440b2d92c1 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -1,10 +1,19 @@ 'use server' import type { Logger } from '@sim/logger' -import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import type { StorageContext } from '@/lib/uploads' +import { StorageService } from '@/lib/uploads' import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils' -import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { + extractStorageKey, + inferContextFromKey, + isInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' /** @@ -13,7 +22,6 @@ import type { UserFile } from '@/executor/types' * For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning */ export async function downloadFileFromUrl(fileUrl: string, timeoutMs = 180000): Promise { - const { isInternalFileUrl } = await import('./file-utils') const { parseInternalFileUrl } = await import('./file-utils') if (isInternalFileUrl(fileUrl)) { @@ -38,6 +46,39 @@ export async function downloadFileFromUrl(fileUrl: string, timeoutMs = 180000): return Buffer.from(await response.arrayBuffer()) } +export async function resolveInternalFileUrl( + filePath: string, + userId: string, + requestId: string, + logger: Logger +): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> { + if (!isInternalFileUrl(filePath)) { + return { fileUrl: filePath } + } + + try { + const storageKey = extractStorageKey(filePath) + const context = inferContextFromKey(storageKey) + const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + return { fileUrl } + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return { error: { status: 500, message: 'Failed to generate file access URL' } } + } +} + /** * Downloads a file from storage (execution or regular) * @param userFile - UserFile object diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index e234f70690..559f505bca 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -438,6 +438,7 @@ export interface RawFileInput { uploadedAt?: string | Date expiresAt?: string | Date context?: string + base64?: string } /** @@ -456,6 +457,41 @@ function isCompleteUserFile(file: RawFileInput): file is UserFile { ) } +function isUrlLike(value: string): boolean { + return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') +} + +function resolveStorageKeyFromRawFile(file: RawFileInput): string | null { + if (file.key) { + return file.key + } + + if (file.path) { + if (isUrlLike(file.path)) { + return isInternalFileUrl(file.path) ? extractStorageKey(file.path) : null + } + return file.path + } + + if (file.url) { + return isInternalFileUrl(file.url) ? extractStorageKey(file.url) : null + } + + return null +} + +function resolveInternalFileUrl(file: RawFileInput): string { + if (file.url && isInternalFileUrl(file.url)) { + return file.url + } + + if (file.path && isInternalFileUrl(file.path)) { + return file.path + } + + return '' +} + /** * Converts a single raw file object to UserFile format * @param file - Raw file object (must be a single file, not an array) @@ -476,10 +512,13 @@ export function processSingleFileToUserFile( } if (isCompleteUserFile(file)) { - return file + return { + ...file, + url: resolveInternalFileUrl(file), + } } - const storageKey = file.key || (file.path ? extractStorageKey(file.path) : null) + const storageKey = resolveStorageKeyFromRawFile(file) if (!storageKey) { logger.warn(`[${requestId}] File has no storage key: ${file.name || 'unknown'}`) @@ -489,10 +528,12 @@ export function processSingleFileToUserFile( const userFile: UserFile = { id: file.id || `file-${Date.now()}`, name: file.name, - url: file.url || file.path || '', + url: resolveInternalFileUrl(file), size: file.size, type: file.type || 'application/octet-stream', key: storageKey, + context: file.context, + base64: file.base64, } logger.info(`[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})`) @@ -523,11 +564,14 @@ export function processFilesToUserFiles( } if (isCompleteUserFile(file)) { - userFiles.push(file) + userFiles.push({ + ...file, + url: resolveInternalFileUrl(file), + }) continue } - const storageKey = file.key || (file.path ? extractStorageKey(file.path) : null) + const storageKey = resolveStorageKeyFromRawFile(file) if (!storageKey) { logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`) @@ -537,10 +581,12 @@ export function processFilesToUserFiles( const userFile: UserFile = { id: file.id || `file-${Date.now()}`, name: file.name, - url: file.url || file.path || '', + url: resolveInternalFileUrl(file), size: file.size, type: file.type || 'application/octet-stream', key: storageKey, + context: file.context, + base64: file.base64, } logger.info( diff --git a/apps/sim/lib/webhooks/rss-polling-service.ts b/apps/sim/lib/webhooks/rss-polling-service.ts index ce282ef0db..5fbdeaba38 100644 --- a/apps/sim/lib/webhooks/rss-polling-service.ts +++ b/apps/sim/lib/webhooks/rss-polling-service.ts @@ -5,7 +5,10 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import Parser from 'rss-parser' import { pollingIdempotency } from '@/lib/core/idempotency/service' -import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { getBaseUrl } from '@/lib/core/utils/urls' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index ad17cd774b..8b99f7dec4 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -10,7 +10,7 @@ import { type SecureFetchResponse, secureFetchWithPinnedIP, validateUrlWithDNS, -} from '@/lib/core/security/input-validation' +} from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' @@ -115,7 +115,7 @@ async function fetchWithDNSPinning( const urlValidation = await validateUrlWithDNS(url, 'contentUrl') if (!urlValidation.isValid) { logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { - url: sanitizeUrlForLog(url), + url, }) return null } diff --git a/apps/sim/tools/browser_use/run_task.ts b/apps/sim/tools/browser_use/run_task.ts index 76edcfe8ba..9dbeeb5b6f 100644 --- a/apps/sim/tools/browser_use/run_task.ts +++ b/apps/sim/tools/browser_use/run_task.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -184,7 +183,7 @@ async function pollForCompletion( } if (!liveUrlLogged && taskData.live_url) { - logger.info(`BrowserUse task ${taskId} live URL: ${sanitizeUrlForLog(taskData.live_url)}`) + logger.info(`BrowserUse task ${taskId} live URL: ${taskData.live_url}`) liveUrlLogged = true } diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index 5e3e32ca42..bcd8826d2b 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import type { UserFile } from '@/executor/types' import type { FileParseApiMultiResponse, @@ -9,21 +10,14 @@ import type { FileParserOutputData, FileParserV3Output, FileParserV3OutputData, + FileUploadInput, } from '@/tools/file/types' import type { ToolConfig } from '@/tools/types' const logger = createLogger('FileParserTool') -interface FileUploadObject { - path: string - name?: string - size?: number - type?: string -} - interface ToolBodyParams extends Partial { - file?: FileUploadObject | FileUploadObject[] - files?: FileUploadObject[] + files?: FileUploadInput[] _context?: { workspaceId?: string workflowId?: string @@ -104,6 +98,12 @@ export const fileParserTool: ToolConfig = { visibility: 'user-only', description: 'Path to the file(s). Can be a single path, URL, or an array of paths.', }, + file: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Uploaded file(s) to parse', + }, fileType: { type: 'string', required: false, @@ -129,6 +129,28 @@ export const fileParserTool: ToolConfig = { let determinedFilePath: string | string[] | null = null const determinedFileType: string | undefined = params.fileType + const resolveFilePath = (fileInput: unknown): string | null => { + if (!fileInput || typeof fileInput !== 'object') return null + + if ('path' in fileInput && typeof (fileInput as { path?: unknown }).path === 'string') { + return (fileInput as { path: string }).path + } + + if ('url' in fileInput && typeof (fileInput as { url?: unknown }).url === 'string') { + return (fileInput as { url: string }).url + } + + if ('key' in fileInput && typeof (fileInput as { key?: unknown }).key === 'string') { + const fileRecord = fileInput as Record + const key = fileRecord.key as string + const context = + typeof fileRecord.context === 'string' ? fileRecord.context : inferContextFromKey(key) + return `/api/files/serve/${encodeURIComponent(key)}?context=${context}` + } + + return null + } + // Determine the file path(s) based on input parameters. // Precedence: direct filePath > file array > single file object > legacy files array // 1. Check for direct filePath (URL or single path from upload) @@ -139,18 +161,34 @@ export const fileParserTool: ToolConfig = { // 2. Check for file upload (array) else if (params.file && Array.isArray(params.file) && params.file.length > 0) { logger.info('Tool body processing file array upload') - determinedFilePath = params.file.map((file) => file.path) + const filePaths = params.file + .map((file) => resolveFilePath(file)) + .filter(Boolean) as string[] + if (filePaths.length !== params.file.length) { + throw new Error('Invalid file input: One or more files are missing path or URL') + } + determinedFilePath = filePaths } // 3. Check for file upload (single object) - else if (params.file && !Array.isArray(params.file) && params.file.path) { + else if (params.file && !Array.isArray(params.file)) { logger.info('Tool body processing single file object upload') - determinedFilePath = params.file.path + const resolvedPath = resolveFilePath(params.file) + if (!resolvedPath) { + throw new Error('Invalid file input: Missing path or URL') + } + determinedFilePath = resolvedPath } // 4. Check for deprecated multiple files case (from older blocks?) else if (params.files && Array.isArray(params.files)) { logger.info('Tool body processing legacy files array:', params.files.length) if (params.files.length > 0) { - determinedFilePath = params.files.map((file) => file.path) + const filePaths = params.files + .map((file) => resolveFilePath(file)) + .filter(Boolean) as string[] + if (filePaths.length !== params.files.length) { + throw new Error('Invalid file input: One or more files are missing path or URL') + } + determinedFilePath = filePaths } else { logger.warn('Legacy files array provided but is empty') } diff --git a/apps/sim/tools/file/types.ts b/apps/sim/tools/file/types.ts index 086e16b9c1..391df55da4 100644 --- a/apps/sim/tools/file/types.ts +++ b/apps/sim/tools/file/types.ts @@ -2,13 +2,21 @@ import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export interface FileParserInput { - filePath: string | string[] + filePath?: string | string[] + file?: UserFile | UserFile[] | FileUploadInput | FileUploadInput[] fileType?: string workspaceId?: string workflowId?: string executionId?: string } +export interface FileUploadInput { + path: string + name?: string + size?: number + type?: string +} + export interface FileParseResult { content: string fileType: string diff --git a/apps/sim/tools/github/get_file_content.ts b/apps/sim/tools/github/get_file_content.ts index 841e98d57f..812b888a4d 100644 --- a/apps/sim/tools/github/get_file_content.ts +++ b/apps/sim/tools/github/get_file_content.ts @@ -1,3 +1,4 @@ +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import type { FileContentResponse, GetFileContentParams } from '@/tools/github/types' import type { ToolConfig } from '@/tools/types' @@ -77,6 +78,14 @@ export const getFileContentTool: ToolConfig 500 @@ -103,6 +123,7 @@ ${contentPreview}` success: true, output: { content, + file, metadata: { name: data.name, path: data.path, @@ -121,6 +142,11 @@ ${contentPreview}` type: 'string', description: 'Human-readable file information with content preview', }, + file: { + type: 'file', + description: 'Downloaded file stored in execution files', + optional: true, + }, metadata: { type: 'object', description: 'File metadata including name, path, SHA, size, and URLs', @@ -150,6 +176,14 @@ export const getFileContentV2Tool: ToolConfig = { // Decode base64 content if present let decodedContent = '' + let file: + | { + name: string + mimeType: string + data: string + size: number + } + | undefined if (data.content && data.encoding === 'base64') { try { decodedContent = Buffer.from(data.content, 'base64').toString('utf-8') @@ -157,6 +191,17 @@ export const getFileContentV2Tool: ToolConfig = { decodedContent = data.content } } + if (data.content && data.encoding === 'base64' && data.name) { + const base64Data = String(data.content).replace(/\n/g, '') + const extension = getFileExtension(data.name) + const mimeType = getMimeTypeFromExtension(extension) + file = { + name: data.name, + mimeType, + data: base64Data, + size: data.size || 0, + } + } return { success: true, @@ -172,6 +217,7 @@ export const getFileContentV2Tool: ToolConfig = { download_url: data.download_url ?? null, git_url: data.git_url, _links: data._links, + file, }, } }, @@ -188,5 +234,10 @@ export const getFileContentV2Tool: ToolConfig = { download_url: { type: 'string', description: 'Direct download URL', optional: true }, git_url: { type: 'string', description: 'Git blob API URL' }, _links: { type: 'json', description: 'Related links' }, + file: { + type: 'file', + description: 'Downloaded file stored in execution files', + optional: true, + }, }, } diff --git a/apps/sim/tools/github/latest_commit.ts b/apps/sim/tools/github/latest_commit.ts index 1b39c95a7c..fcbbaea284 100644 --- a/apps/sim/tools/github/latest_commit.ts +++ b/apps/sim/tools/github/latest_commit.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { COMMIT_DATA_OUTPUT, type LatestCommitParams, @@ -7,8 +6,6 @@ import { } from '@/tools/github/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('GitHubLatestCommitTool') - export const latestCommitTool: ToolConfig = { id: 'github_latest_commit', name: 'GitHub Latest Commit', @@ -43,92 +40,17 @@ export const latestCommitTool: ToolConfig { - const baseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}` - return params.branch ? `${baseUrl}/commits/${params.branch}` : `${baseUrl}/commits/HEAD` - }, - method: 'GET', - headers: (params) => ({ - Accept: 'application/vnd.github.v3+json', - Authorization: `Bearer ${params.apiKey}`, - 'X-GitHub-Api-Version': '2022-11-28', + url: '/api/tools/github/latest-commit', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + owner: params.owner, + repo: params.repo, + branch: params.branch, + apiKey: params.apiKey, }), - }, - - transformResponse: async (response, params) => { - const data = await response.json() - - const content = `Latest commit: "${data.commit.message}" by ${data.commit.author.name} on ${data.commit.author.date}. SHA: ${data.sha}` - - const files = data.files || [] - const fileDetailsWithContent = [] - - if (files.length > 0) { - for (const file of files) { - const fileDetail = { - filename: file.filename, - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - status: file.status, - raw_url: file.raw_url, - blob_url: file.blob_url, - patch: file.patch, - content: undefined as string | undefined, - } - - if (file.status !== 'removed' && file.raw_url) { - try { - const contentResponse = await fetch(file.raw_url, { - headers: { - Authorization: `Bearer ${params?.apiKey}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }) - - if (contentResponse.ok) { - fileDetail.content = await contentResponse.text() - } - } catch (error) { - logger.error(`Failed to fetch content for ${file.filename}:`, error) - } - } - - fileDetailsWithContent.push(fileDetail) - } - } - - return { - success: true, - output: { - content, - metadata: { - sha: data.sha, - html_url: data.html_url, - commit_message: data.commit.message, - author: { - name: data.commit.author.name, - login: data.author?.login || 'Unknown', - avatar_url: data.author?.avatar_url || '', - html_url: data.author?.html_url || '', - }, - committer: { - name: data.commit.committer.name, - login: data.committer?.login || 'Unknown', - avatar_url: data.committer?.avatar_url || '', - html_url: data.committer?.html_url || '', - }, - stats: data.stats - ? { - additions: data.stats.additions, - deletions: data.stats.deletions, - total: data.stats.total, - } - : undefined, - files: fileDetailsWithContent.length > 0 ? fileDetailsWithContent : undefined, - }, - }, - } }, outputs: { diff --git a/apps/sim/tools/github/types.ts b/apps/sim/tools/github/types.ts index 1d4396c0e7..0bfe8f3111 100644 --- a/apps/sim/tools/github/types.ts +++ b/apps/sim/tools/github/types.ts @@ -1,4 +1,4 @@ -import type { OutputProperty, ToolResponse } from '@/tools/types' +import type { OutputProperty, ToolFileData, ToolResponse } from '@/tools/types' /** * Shared output property definitions for GitHub API responses. @@ -1876,6 +1876,7 @@ export interface TreeItemMetadata { export interface FileContentResponse extends ToolResponse { output: { content: string + file?: ToolFileData metadata: FileContentMetadata } } diff --git a/apps/sim/tools/gmail/types.ts b/apps/sim/tools/gmail/types.ts index b844438687..d902d5c025 100644 --- a/apps/sim/tools/gmail/types.ts +++ b/apps/sim/tools/gmail/types.ts @@ -125,7 +125,7 @@ export interface GmailMessage { // Gmail Attachment Interface (for processed attachments) export interface GmailAttachment { name: string - data: Buffer + data: string mimeType: string size: number } diff --git a/apps/sim/tools/gmail/utils.ts b/apps/sim/tools/gmail/utils.ts index 836b5c2b75..7da950b7dd 100644 --- a/apps/sim/tools/gmail/utils.ts +++ b/apps/sim/tools/gmail/utils.ts @@ -251,7 +251,7 @@ export async function downloadAttachments( downloadedAttachments.push({ name: attachment.filename, - data: buffer, + data: buffer.toString('base64'), mimeType: attachment.mimeType, size: attachment.size, }) diff --git a/apps/sim/tools/google_drive/download.ts b/apps/sim/tools/google_drive/download.ts index 2def338f88..65727b0c63 100644 --- a/apps/sim/tools/google_drive/download.ts +++ b/apps/sim/tools/google_drive/download.ts @@ -1,20 +1,6 @@ -import { createLogger } from '@sim/logger' -import type { - GoogleDriveDownloadResponse, - GoogleDriveFile, - GoogleDriveRevision, - GoogleDriveToolParams, -} from '@/tools/google_drive/types' -import { - ALL_FILE_FIELDS, - ALL_REVISION_FIELDS, - DEFAULT_EXPORT_FORMATS, - GOOGLE_WORKSPACE_MIME_TYPES, -} from '@/tools/google_drive/utils' +import type { GoogleDriveDownloadResponse, GoogleDriveToolParams } from '@/tools/google_drive/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('GoogleDriveDownloadTool') - export const downloadTool: ToolConfig = { id: 'google_drive_download', name: 'Download File from Google Drive', @@ -62,164 +48,18 @@ export const downloadTool: ToolConfig - `https://www.googleapis.com/drive/v3/files/${params.fileId}?fields=${ALL_FILE_FIELDS}&supportsAllDrives=true`, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, + url: '/api/tools/google_drive/download', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + fileId: params.fileId, + mimeType: params.mimeType, + fileName: params.fileName, + includeRevisions: params.includeRevisions, }), - }, - - transformResponse: async (response: Response, params?: GoogleDriveToolParams) => { - try { - if (!response.ok) { - const errorDetails = await response.json().catch(() => ({})) - logger.error('Failed to get file metadata', { - status: response.status, - statusText: response.statusText, - error: errorDetails, - }) - throw new Error(errorDetails.error?.message || 'Failed to get file metadata') - } - - const metadata: GoogleDriveFile = await response.json() - const fileId = metadata.id - const mimeType = metadata.mimeType - const authHeader = `Bearer ${params?.accessToken || ''}` - - let fileBuffer: Buffer - let finalMimeType = mimeType - - if (GOOGLE_WORKSPACE_MIME_TYPES.includes(mimeType)) { - const exportFormat = params?.mimeType || DEFAULT_EXPORT_FORMATS[mimeType] || 'text/plain' - finalMimeType = exportFormat - - logger.info('Exporting Google Workspace file', { - fileId, - mimeType, - exportFormat, - }) - - const exportResponse = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(exportFormat)}&supportsAllDrives=true`, - { - headers: { - Authorization: authHeader, - }, - } - ) - - if (!exportResponse.ok) { - const exportError = await exportResponse.json().catch(() => ({})) - logger.error('Failed to export file', { - status: exportResponse.status, - statusText: exportResponse.statusText, - error: exportError, - }) - throw new Error(exportError.error?.message || 'Failed to export Google Workspace file') - } - - const arrayBuffer = await exportResponse.arrayBuffer() - fileBuffer = Buffer.from(arrayBuffer) - } else { - logger.info('Downloading regular file', { - fileId, - mimeType, - }) - - const downloadResponse = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true`, - { - headers: { - Authorization: authHeader, - }, - } - ) - - if (!downloadResponse.ok) { - const downloadError = await downloadResponse.json().catch(() => ({})) - logger.error('Failed to download file', { - status: downloadResponse.status, - statusText: downloadResponse.statusText, - error: downloadError, - }) - throw new Error(downloadError.error?.message || 'Failed to download file') - } - - const arrayBuffer = await downloadResponse.arrayBuffer() - fileBuffer = Buffer.from(arrayBuffer) - } - - const includeRevisions = params?.includeRevisions !== false - const canReadRevisions = metadata.capabilities?.canReadRevisions === true - if (includeRevisions && canReadRevisions) { - try { - const revisionsResponse = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}/revisions?fields=revisions(${ALL_REVISION_FIELDS})&pageSize=100`, - { - headers: { - Authorization: authHeader, - }, - } - ) - - if (revisionsResponse.ok) { - const revisionsData = await revisionsResponse.json() - metadata.revisions = revisionsData.revisions as GoogleDriveRevision[] - logger.info('Fetched file revisions', { - fileId, - revisionCount: metadata.revisions?.length || 0, - }) - } else { - logger.warn('Failed to fetch revisions, continuing without them', { - status: revisionsResponse.status, - statusText: revisionsResponse.statusText, - }) - } - } catch (revisionError: any) { - logger.warn('Error fetching revisions, continuing without them', { - error: revisionError.message, - }) - } - } else if (includeRevisions && !canReadRevisions) { - logger.info('Skipping revision fetch - user does not have canReadRevisions permission', { - fileId, - }) - } - - const resolvedName = params?.fileName || metadata.name || 'download' - - logger.info('File downloaded successfully', { - fileId, - name: resolvedName, - size: fileBuffer.length, - mimeType: finalMimeType, - hasOwners: !!metadata.owners?.length, - hasPermissions: !!metadata.permissions?.length, - hasRevisions: !!metadata.revisions?.length, - }) - - const base64Data = fileBuffer.toString('base64') - - return { - success: true, - output: { - file: { - name: resolvedName, - mimeType: finalMimeType, - data: base64Data, - size: fileBuffer.length, - }, - metadata, - }, - } - } catch (error: any) { - logger.error('Error in transform response', { - error: error.message, - stack: error.stack, - }) - throw error - } }, outputs: { diff --git a/apps/sim/tools/google_vault/download_export_file.ts b/apps/sim/tools/google_vault/download_export_file.ts index 17abc45fca..26453daee8 100644 --- a/apps/sim/tools/google_vault/download_export_file.ts +++ b/apps/sim/tools/google_vault/download_export_file.ts @@ -1,5 +1,4 @@ import type { GoogleVaultDownloadExportFileParams } from '@/tools/google_vault/types' -import { enhanceGoogleVaultError } from '@/tools/google_vault/utils' import type { ToolConfig } from '@/tools/types' export const downloadExportFileTool: ToolConfig = { @@ -47,92 +46,18 @@ export const downloadExportFileTool: ToolConfig { - const bucket = encodeURIComponent(params.bucketName) - const object = encodeURIComponent(params.objectName) - return `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?alt=media` - }, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, + url: '/api/tools/google_vault/download-export-file', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + matterId: params.matterId, + bucketName: params.bucketName, + objectName: params.objectName, + fileName: params.fileName, }), - }, - - transformResponse: async (response: Response, params?: GoogleVaultDownloadExportFileParams) => { - if (!response.ok) { - let details: any - try { - details = await response.json() - } catch { - try { - const text = await response.text() - details = { error: text } - } catch { - details = undefined - } - } - const errorMessage = - details?.error || `Failed to download Vault export file (${response.status})` - throw new Error(enhanceGoogleVaultError(errorMessage)) - } - - if (!params?.accessToken || !params?.bucketName || !params?.objectName) { - throw new Error('Missing required parameters for download') - } - - const bucket = encodeURIComponent(params.bucketName) - const object = encodeURIComponent(params.objectName) - const downloadUrl = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?alt=media` - - const downloadResponse = await fetch(downloadUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${params.accessToken}`, - }, - }) - - if (!downloadResponse.ok) { - const errorText = await downloadResponse.text().catch(() => '') - const errorMessage = `Failed to download file: ${errorText || downloadResponse.statusText}` - throw new Error(enhanceGoogleVaultError(errorMessage)) - } - - const contentType = downloadResponse.headers.get('content-type') || 'application/octet-stream' - const disposition = downloadResponse.headers.get('content-disposition') || '' - const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="([^"]+)"/) - - let resolvedName = params.fileName - if (!resolvedName) { - if (match?.[1]) { - try { - resolvedName = decodeURIComponent(match[1]) - } catch { - resolvedName = match[1] - } - } else if (match?.[2]) { - resolvedName = match[2] - } else if (params.objectName) { - const parts = params.objectName.split('/') - resolvedName = parts[parts.length - 1] || 'vault-export.bin' - } else { - resolvedName = 'vault-export.bin' - } - } - - const arrayBuffer = await downloadResponse.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - return { - success: true, - output: { - file: { - name: resolvedName, - mimeType: contentType, - data: buffer, - size: buffer.length, - }, - }, - } }, outputs: { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3b1c0f15bd..f497a5cf0e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1,6 +1,9 @@ import { createLogger } from '@sim/logger' import { generateInternalToken } from '@/lib/auth/internal' -import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { parseMcpToolId } from '@/lib/mcp/utils' diff --git a/apps/sim/tools/microsoft_planner/read_task.ts b/apps/sim/tools/microsoft_planner/read_task.ts index 730d3f788b..e44d5e175c 100644 --- a/apps/sim/tools/microsoft_planner/read_task.ts +++ b/apps/sim/tools/microsoft_planner/read_task.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { MicrosoftPlannerReadResponse, MicrosoftPlannerToolParams, @@ -77,7 +76,7 @@ export const readTaskTool: ToolConfig = { + apiKey: params.apiKey, } - let url - try { - url = new URL(filePathToValidate) + if (hasFilePath) { + const filePathToValidate = params.filePath!.trim() - // Validate protocol - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`) - } - - // Validate against known unsupported services - if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) { - throw new Error( - 'Google Drive links are not supported by the Mistral OCR API. ' + - 'Please upload your PDF to a public web server or provide a direct download link ' + - 'that ends with .pdf extension.' - ) - } - - // Validate file appears to be a PDF (stricter check with informative warning) - const pathname = url.pathname.toLowerCase() - if (!pathname.endsWith('.pdf')) { - // Check if PDF is included in the path at all - if (!pathname.includes('pdf')) { - logger.warn( - 'Warning: URL does not appear to point to a PDF document. ' + - 'The Mistral OCR API is designed to work with PDF files. ' + - 'Please ensure your URL points to a valid PDF document (ideally ending with .pdf extension).' + if (filePathToValidate.startsWith('/')) { + if (!isInternalFileUrl(filePathToValidate)) { + throw new Error( + 'Invalid file path. Only uploaded files are supported for internal paths.' ) - } else { - // If "pdf" is in the URL but not at the end, give a different warning - logger.warn( - 'Warning: URL contains "pdf" but does not end with .pdf extension. ' + - 'This might still work if the server returns a valid PDF document despite the missing extension.' + } + requestBody.filePath = filePathToValidate + } else { + let url + try { + url = new URL(filePathToValidate) + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error( + `Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol` + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a PDF document (e.g., https://example.com/document.pdf)` ) } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a PDF document (e.g., https://example.com/document.pdf)` - ) - } - // Create the request body with required parameters - const requestBody: Record = { - apiKey: params.apiKey, - filePath: url.toString(), + requestBody.filePath = url.toString() + } + } else if (hasFileUpload) { + requestBody.file = fileInput + } else { + throw new Error('Missing file input: Please provide a PDF URL or upload a file') } - // Check if this is an internal workspace file path - if (params.fileUpload?.url?.startsWith('/api/files/serve/')) { - // Update filePath to the internal path for workspace files - requestBody.filePath = params.fileUpload.url + if (params.includeImageBase64 !== undefined) { + requestBody.includeImageBase64 = params.includeImageBase64 } - // Add optional parameters with proper validation - // Include images (base64) - if (params.includeImageBase64 !== undefined) { - if (typeof params.includeImageBase64 !== 'boolean') { - logger.warn('includeImageBase64 parameter should be a boolean, using default (false)') - } else { - requestBody.includeImageBase64 = params.includeImageBase64 + if (Array.isArray(params.pages) && params.pages.length > 0) { + const validPages = params.pages.filter( + (page) => typeof page === 'number' && Number.isInteger(page) && page >= 0 + ) + if (validPages.length > 0) { + requestBody.pages = validPages } } - // Page selection - safely handle null and undefined - if (params.pages !== undefined && params.pages !== null) { - if (Array.isArray(params.pages) && params.pages.length > 0) { - // Validate all page numbers are non-negative integers - const validPages = params.pages.filter( - (page) => typeof page === 'number' && Number.isInteger(page) && page >= 0 - ) - - if (validPages.length > 0) { - requestBody.pages = validPages - - if (validPages.length !== params.pages.length) { - logger.warn( - `Some invalid page numbers were removed. Using ${validPages.length} valid pages: ${validPages.join(', ')}` - ) - } - } else { - logger.warn('No valid page numbers provided, processing all pages') - } - } else if (Array.isArray(params.pages) && params.pages.length === 0) { - logger.warn('Empty pages array provided, processing all pages') - } + if (typeof params.resultType === 'string' && params.resultType.trim() !== '') { + requestBody.resultType = params.resultType } - // Image limit - safely handle null and undefined if (params.imageLimit !== undefined && params.imageLimit !== null) { const imageLimit = Number(params.imageLimit) - if (Number.isInteger(imageLimit) && imageLimit > 0) { + if (!Number.isNaN(imageLimit) && imageLimit >= 0) { requestBody.imageLimit = imageLimit - } else { - logger.warn('imageLimit must be a positive integer, ignoring this parameter') } } - // Minimum image size - safely handle null and undefined if (params.imageMinSize !== undefined && params.imageMinSize !== null) { const imageMinSize = Number(params.imageMinSize) - if (Number.isInteger(imageMinSize) && imageMinSize > 0) { + if (!Number.isNaN(imageMinSize) && imageMinSize >= 0) { requestBody.imageMinSize = imageMinSize - } else { - logger.warn('imageMinSize must be a positive integer, ignoring this parameter') } } @@ -422,18 +350,12 @@ export const mistralParserTool: ToolConfig = { id: 'onedrive_download', name: 'Download File from OneDrive', @@ -37,91 +34,16 @@ export const downloadTool: ToolConfig { - return `https://graph.microsoft.com/v1.0/me/drive/items/${params.fileId}` - }, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, + url: '/api/tools/onedrive/download', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + fileId: params.fileId, + fileName: params.fileName, }), - }, - - transformResponse: async (response: Response, params?: OneDriveToolParams) => { - try { - if (!response.ok) { - const errorDetails = await response.json().catch(() => ({})) - logger.error('Failed to get file metadata', { - status: response.status, - statusText: response.statusText, - error: errorDetails, - requestedFileId: params?.fileId, - }) - throw new Error(errorDetails.error?.message || 'Failed to get file metadata') - } - - const metadata = await response.json() - - // Check if this is actually a folder - if (metadata.folder && !metadata.file) { - logger.error('Attempted to download a folder instead of a file', { - itemId: metadata.id, - itemName: metadata.name, - isFolder: true, - }) - throw new Error(`Cannot download folder "${metadata.name}". Please select a file instead.`) - } - - const fileId = metadata.id - const fileName = metadata.name - const mimeType = metadata.file?.mimeType || 'application/octet-stream' - const authHeader = `Bearer ${params?.accessToken || ''}` - - const downloadResponse = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content`, - { - headers: { - Authorization: authHeader, - }, - } - ) - - if (!downloadResponse.ok) { - const downloadError = await downloadResponse.json().catch(() => ({})) - logger.error('Failed to download file', { - status: downloadResponse.status, - statusText: downloadResponse.statusText, - error: downloadError, - }) - throw new Error(downloadError.error?.message || 'Failed to download file') - } - - const arrayBuffer = await downloadResponse.arrayBuffer() - const fileBuffer = Buffer.from(arrayBuffer) - - const resolvedName = params?.fileName || fileName || 'download' - - // Convert buffer to base64 string for proper JSON serialization - // This ensures the file data survives the proxy round-trip - const base64Data = fileBuffer.toString('base64') - - return { - success: true, - output: { - file: { - name: resolvedName, - mimeType, - data: base64Data, - size: fileBuffer.length, - }, - }, - } - } catch (error: any) { - logger.error('Error in transform response', { - error: error.message, - stack: error.stack, - }) - throw error - } }, outputs: { diff --git a/apps/sim/tools/openai/image.ts b/apps/sim/tools/openai/image.ts index 3611230e2f..3d9f1be5ab 100644 --- a/apps/sim/tools/openai/image.ts +++ b/apps/sim/tools/openai/image.ts @@ -77,7 +77,6 @@ export const imageTool: ToolConfig = { n: params.n ? Number(params.n) : 1, } - // Add model-specific parameters if (params.model === 'dall-e-3') { if (params.quality) body.quality = params.quality if (params.style) body.style = params.style @@ -164,37 +163,6 @@ export const imageTool: ToolConfig = { base64Image = buffer.toString('base64') } catch (error) { logger.error('Error fetching or processing image:', error) - - try { - logger.info('Attempting fallback with direct browser fetch...') - const directImageResponse = await fetch(imageUrl, { - cache: 'no-store', - headers: { - Accept: 'image/*, */*', - 'User-Agent': 'Mozilla/5.0 (compatible DalleProxy/1.0)', - }, - }) - - if (!directImageResponse.ok) { - throw new Error(`Direct fetch failed: ${directImageResponse.status}`) - } - - const imageBlob = await directImageResponse.blob() - if (imageBlob.size === 0) { - throw new Error('Empty blob received from direct fetch') - } - - const arrayBuffer = await imageBlob.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - base64Image = buffer.toString('base64') - - logger.info( - 'Successfully converted image to base64 via direct fetch, length:', - base64Image.length - ) - } catch (fallbackError) { - logger.error('Fallback fetch also failed:', fallbackError) - } } } diff --git a/apps/sim/tools/outlook/read.ts b/apps/sim/tools/outlook/read.ts index dcc87235a6..e7bfd7ca30 100644 --- a/apps/sim/tools/outlook/read.ts +++ b/apps/sim/tools/outlook/read.ts @@ -47,7 +47,7 @@ async function downloadAttachments( const buffer = Buffer.from(contentBytes, 'base64') attachments.push({ name: attachment.name, - data: buffer, + data: buffer.toString('base64'), contentType: attachment.contentType, size: attachment.size, }) diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index a494245eb1..805bf74574 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -218,7 +218,7 @@ export interface OutlookMessagesResponse { // Outlook attachment interface (for tool responses) export interface OutlookAttachment { name: string - data: Buffer + data: string contentType: string size: number } diff --git a/apps/sim/tools/pipedrive/get_files.ts b/apps/sim/tools/pipedrive/get_files.ts index 957c3415cb..07211a704b 100644 --- a/apps/sim/tools/pipedrive/get_files.ts +++ b/apps/sim/tools/pipedrive/get_files.ts @@ -1,10 +1,7 @@ -import { createLogger } from '@sim/logger' import type { PipedriveGetFilesParams, PipedriveGetFilesResponse } from '@/tools/pipedrive/types' import { PIPEDRIVE_FILE_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('PipedriveGetFiles') - export const pipedriveGetFilesTool: ToolConfig = { id: 'pipedrive_get_files', @@ -43,52 +40,28 @@ export const pipedriveGetFilesTool: ToolConfig { - const baseUrl = 'https://api.pipedrive.com/v1/files' - const queryParams = new URLSearchParams() - - if (params.deal_id) queryParams.append('deal_id', params.deal_id) - if (params.person_id) queryParams.append('person_id', params.person_id) - if (params.org_id) queryParams.append('org_id', params.org_id) - if (params.limit) queryParams.append('limit', params.limit) - - const queryString = queryParams.toString() - return queryString ? `${baseUrl}?${queryString}` : baseUrl - }, - method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } + downloadFiles: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Download file contents into file outputs', }, }, - transformResponse: async (response: Response) => { - const data = await response.json() - - if (!data.success) { - logger.error('Pipedrive API request failed', { data }) - throw new Error(data.error || 'Failed to fetch files from Pipedrive') - } - - const files = data.data || [] - - return { - success: true, - output: { - files, - total_items: files.length, - success: true, - }, - } + request: { + url: '/api/tools/pipedrive/get-files', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + deal_id: params.deal_id, + person_id: params.person_id, + org_id: params.org_id, + limit: params.limit, + downloadFiles: params.downloadFiles, + }), }, outputs: { @@ -100,6 +73,11 @@ export const pipedriveGetFilesTool: ToolConfig = { id: 'pulse_parser', name: 'Pulse Document Parser', @@ -14,10 +11,16 @@ export const pulseParserTool: ToolConfig = params: { filePath: { type: 'string', - required: true, + required: false, visibility: 'user-only', description: 'URL to a document to be processed', }, + file: { + type: 'file', + required: false, + visibility: 'hidden', + description: 'Document file to be processed', + }, fileUpload: { type: 'object', required: false, @@ -86,70 +89,50 @@ export const pulseParserTool: ToolConfig = throw new Error('Missing or invalid API key: A valid Pulse API key is required') } - if ( - params.fileUpload && - (!params.filePath || params.filePath === 'null' || params.filePath === '') - ) { - if ( - typeof params.fileUpload === 'object' && - params.fileUpload !== null && - (params.fileUpload.url || params.fileUpload.path) - ) { - let uploadedFilePath: string = params.fileUpload.url ?? params.fileUpload.path ?? '' + const requestBody: Record = { + apiKey: params.apiKey.trim(), + } + const fileInput = + params.file && typeof params.file === 'object' ? params.file : params.fileUpload + const hasFileUpload = fileInput && typeof fileInput === 'object' + const hasFilePath = + typeof params.filePath === 'string' && + params.filePath !== 'null' && + params.filePath.trim() !== '' - if (!uploadedFilePath) { - throw new Error('Invalid file upload: Upload data is missing or invalid') - } + if (hasFilePath) { + const filePathToValidate = params.filePath!.trim() - if (uploadedFilePath.startsWith('/')) { - const baseUrl = getBaseUrl() - if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') - uploadedFilePath = `${baseUrl}${uploadedFilePath}` + if (filePathToValidate.startsWith('/')) { + if (!isInternalFileUrl(filePathToValidate)) { + throw new Error( + 'Invalid file path. Only uploaded files are supported for internal paths.' + ) } - - params.filePath = uploadedFilePath - logger.info('Using uploaded file:', uploadedFilePath) + requestBody.filePath = filePathToValidate } else { - throw new Error('Invalid file upload: Upload data is missing or invalid') - } - } + let url + try { + url = new URL(filePathToValidate) - if ( - !params.filePath || - typeof params.filePath !== 'string' || - params.filePath.trim() === '' - ) { - throw new Error('Missing or invalid file path: Please provide a URL to a document') - } - - let filePathToValidate = params.filePath.trim() - if (filePathToValidate.startsWith('/')) { - const baseUrl = getBaseUrl() - if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') - filePathToValidate = `${baseUrl}${filePathToValidate}` - } - - let url - try { - url = new URL(filePathToValidate) + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error( + `Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol` + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document` + ) + } - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`) + requestBody.filePath = url.toString() } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document` - ) - } - - const requestBody: Record = { - apiKey: params.apiKey.trim(), - filePath: url.toString(), - } - - if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { - requestBody.filePath = params.fileUpload.path + } else if (hasFileUpload) { + requestBody.file = fileInput + } else { + throw new Error('Missing file input: Please provide a document URL or upload a file') } if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') { @@ -270,3 +253,77 @@ export const pulseParserTool: ToolConfig = }, }, } + +export const pulseParserV2Tool: ToolConfig = { + ...pulseParserTool, + id: 'pulse_parser_v2', + name: 'Pulse Document Parser (File Only)', + postProcess: undefined, + directExecution: undefined, + transformResponse: pulseParserTool.transformResponse + ? (response: Response, params?: PulseParserV2Input) => + pulseParserTool.transformResponse!(response, params as unknown as PulseParserInput) + : undefined, + params: { + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'Document to be processed', + }, + pages: pulseParserTool.params.pages, + extractFigure: pulseParserTool.params.extractFigure, + figureDescription: pulseParserTool.params.figureDescription, + returnHtml: pulseParserTool.params.returnHtml, + chunking: pulseParserTool.params.chunking, + chunkSize: pulseParserTool.params.chunkSize, + apiKey: pulseParserTool.params.apiKey, + }, + request: { + url: '/api/tools/pulse/parse', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params: PulseParserV2Input) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + + if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') { + throw new Error('Missing or invalid API key: A valid Pulse API key is required') + } + + if (!params.file || typeof params.file !== 'object') { + throw new Error('Missing or invalid file: Please provide a file object') + } + + const requestBody: Record = { + apiKey: params.apiKey.trim(), + file: params.file, + } + + if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') { + requestBody.pages = params.pages.trim() + } + if (params.extractFigure !== undefined) { + requestBody.extractFigure = params.extractFigure + } + if (params.figureDescription !== undefined) { + requestBody.figureDescription = params.figureDescription + } + if (params.returnHtml !== undefined) { + requestBody.returnHtml = params.returnHtml + } + if (params.chunking && typeof params.chunking === 'string' && params.chunking.trim() !== '') { + requestBody.chunking = params.chunking.trim() + } + if (params.chunkSize !== undefined && params.chunkSize > 0) { + requestBody.chunkSize = params.chunkSize + } + + return requestBody + }, + }, +} diff --git a/apps/sim/tools/pulse/types.ts b/apps/sim/tools/pulse/types.ts index d11cb6e8ba..b38e13cb3a 100644 --- a/apps/sim/tools/pulse/types.ts +++ b/apps/sim/tools/pulse/types.ts @@ -1,3 +1,5 @@ +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' /** @@ -5,13 +7,38 @@ import type { ToolResponse } from '@/tools/types' */ export interface PulseParserInput { /** URL to a document to be processed */ - filePath: string + filePath?: string + + file?: RawFileInput /** File upload data (from file-upload component) */ - fileUpload?: { - url?: string - path?: string - } + fileUpload?: RawFileInput + + /** Pulse API key for authentication */ + apiKey: string + + /** Page range to process (1-indexed, e.g., "1-2,5") */ + pages?: string + + /** Whether to extract figures from the document */ + extractFigure?: boolean + + /** Whether to generate figure descriptions/captions */ + figureDescription?: boolean + + /** Whether to include HTML in the response */ + returnHtml?: boolean + + /** Chunking strategies (comma-separated: semantic, header, page, recursive) */ + chunking?: string + + /** Maximum characters per chunk when chunking is enabled */ + chunkSize?: number +} + +export interface PulseParserV2Input { + /** File to be processed */ + file: UserFile /** Pulse API key for authentication */ apiKey: string diff --git a/apps/sim/tools/reducto/index.ts b/apps/sim/tools/reducto/index.ts index 3e5f63211b..40e0ea5c5d 100644 --- a/apps/sim/tools/reducto/index.ts +++ b/apps/sim/tools/reducto/index.ts @@ -1,3 +1,3 @@ -import { reductoParserTool } from '@/tools/reducto/parser' +import { reductoParserTool, reductoParserV2Tool } from '@/tools/reducto/parser' -export { reductoParserTool } +export { reductoParserTool, reductoParserV2Tool } diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts index 6732f4bc0c..cae688c532 100644 --- a/apps/sim/tools/reducto/parser.ts +++ b/apps/sim/tools/reducto/parser.ts @@ -1,10 +1,11 @@ -import { createLogger } from '@sim/logger' -import { getBaseUrl } from '@/lib/core/utils/urls' -import type { ReductoParserInput, ReductoParserOutput } from '@/tools/reducto/types' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import type { + ReductoParserInput, + ReductoParserOutput, + ReductoParserV2Input, +} from '@/tools/reducto/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('ReductoParserTool') - export const reductoParserTool: ToolConfig = { id: 'reducto_parser', name: 'Reducto PDF Parser', @@ -14,10 +15,16 @@ export const reductoParserTool: ToolConfig = { apiKey: params.apiKey, - filePath: url.toString(), } + const fileInput = + params.file && typeof params.file === 'object' ? params.file : params.fileUpload + const hasFileUpload = fileInput && typeof fileInput === 'object' + const hasFilePath = + typeof params.filePath === 'string' && + params.filePath !== 'null' && + params.filePath.trim() !== '' + + if (hasFilePath) { + const filePathToValidate = params.filePath!.trim() + + if (filePathToValidate.startsWith('/')) { + if (!isInternalFileUrl(filePathToValidate)) { + throw new Error( + 'Invalid file path. Only uploaded files are supported for internal paths.' + ) + } + requestBody.filePath = filePathToValidate + } else { + let url + try { + url = new URL(filePathToValidate) + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error( + `Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol` + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a PDF document.` + ) + } - if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { - requestBody.filePath = params.fileUpload.path + requestBody.filePath = url.toString() + } + } else if (hasFileUpload) { + requestBody.file = fileInput + } else { + throw new Error('Missing file input: Please provide a PDF URL or upload a file') } if (params.tableOutputFormat && ['html', 'md'].includes(params.tableOutputFormat)) { @@ -190,3 +181,71 @@ export const reductoParserTool: ToolConfig = { + ...reductoParserTool, + id: 'reducto_parser_v2', + name: 'Reducto PDF Parser (File Only)', + postProcess: undefined, + directExecution: undefined, + transformResponse: reductoParserTool.transformResponse + ? (response: Response, params?: ReductoParserV2Input) => + reductoParserTool.transformResponse!(response, params as unknown as ReductoParserInput) + : undefined, + params: { + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'PDF document to be processed', + }, + pages: reductoParserTool.params.pages, + tableOutputFormat: reductoParserTool.params.tableOutputFormat, + apiKey: reductoParserTool.params.apiKey, + }, + request: { + url: '/api/tools/reducto/parse', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params: ReductoParserV2Input) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + + if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') { + throw new Error('Missing or invalid API key: A valid Reducto API key is required') + } + + if (!params.file || typeof params.file !== 'object') { + throw new Error('Missing or invalid file: Please provide a file object') + } + + const requestBody: Record = { + apiKey: params.apiKey, + file: params.file, + } + + if (params.tableOutputFormat && ['html', 'md'].includes(params.tableOutputFormat)) { + requestBody.tableOutputFormat = params.tableOutputFormat + } + + if (params.pages !== undefined && params.pages !== null) { + if (Array.isArray(params.pages) && params.pages.length > 0) { + const validPages = params.pages.filter( + (page) => typeof page === 'number' && Number.isInteger(page) && page >= 0 + ) + + if (validPages.length > 0) { + requestBody.pages = validPages + } + } + } + + return requestBody + }, + }, +} diff --git a/apps/sim/tools/reducto/types.ts b/apps/sim/tools/reducto/types.ts index 9a86b08d91..4dd0b6fc51 100644 --- a/apps/sim/tools/reducto/types.ts +++ b/apps/sim/tools/reducto/types.ts @@ -1,3 +1,5 @@ +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' /** @@ -5,13 +7,26 @@ import type { ToolResponse } from '@/tools/types' */ export interface ReductoParserInput { /** URL to a document to be processed */ - filePath: string + filePath?: string + + file?: RawFileInput /** File upload data (from file-upload component) */ - fileUpload?: { - url?: string - path?: string - } + fileUpload?: RawFileInput + + /** Reducto API key for authentication */ + apiKey: string + + /** Specific pages to process (1-indexed) */ + pages?: number[] + + /** Table output format (html or md) */ + tableOutputFormat?: 'html' | 'md' +} + +export interface ReductoParserV2Input { + /** File to be processed */ + file: UserFile /** Reducto API key for authentication */ apiKey: string diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2d538beb91..3cac181b5a 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1229,7 +1229,7 @@ import { posthogUpdatePropertyDefinitionTool, posthogUpdateSurveyTool, } from '@/tools/posthog' -import { pulseParserTool } from '@/tools/pulse' +import { pulseParserTool, pulseParserV2Tool } from '@/tools/pulse' import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant' import { rdsDeleteTool, @@ -1254,7 +1254,7 @@ import { redditUnsaveTool, redditVoteTool, } from '@/tools/reddit' -import { reductoParserTool } from '@/tools/reducto' +import { reductoParserTool, reductoParserV2Tool } from '@/tools/reducto' import { mailSendTool } from '@/tools/resend' import { s3CopyObjectTool, @@ -1554,10 +1554,15 @@ import { } from '@/tools/stripe' import { assemblyaiSttTool, + assemblyaiSttV2Tool, deepgramSttTool, + deepgramSttV2Tool, elevenLabsSttTool, + elevenLabsSttV2Tool, geminiSttTool, + geminiSttV2Tool, whisperSttTool, + whisperSttV2Tool, } from '@/tools/stt' import { supabaseCountTool, @@ -1593,7 +1598,7 @@ import { telegramSendPhotoTool, telegramSendVideoTool, } from '@/tools/telegram' -import { textractParserTool } from '@/tools/textract' +import { textractParserTool, textractParserV2Tool } from '@/tools/textract' import { thinkingTool } from '@/tools/thinking' import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird' import { @@ -1633,7 +1638,7 @@ import { runwayVideoTool, veoVideoTool, } from '@/tools/video' -import { visionTool } from '@/tools/vision' +import { visionTool, visionToolV2 } from '@/tools/vision' import { wealthboxReadContactTool, wealthboxReadNoteTool, @@ -1777,6 +1782,7 @@ export const tools: Record = { llm_chat: llmChatTool, function_execute: functionExecuteTool, vision_tool: visionTool, + vision_tool_v2: visionToolV2, file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, @@ -2494,6 +2500,7 @@ export const tools: Record = { perplexity_chat: perplexityChatTool, perplexity_search: perplexitySearchTool, pulse_parser: pulseParserTool, + pulse_parser_v2: pulseParserV2Tool, posthog_capture_event: posthogCaptureEventTool, posthog_batch_events: posthogBatchEventsTool, posthog_list_persons: posthogListPersonsTool, @@ -2618,7 +2625,9 @@ export const tools: Record = { mistral_parser: mistralParserTool, mistral_parser_v2: mistralParserV2Tool, reducto_parser: reductoParserTool, + reducto_parser_v2: reductoParserV2Tool, textract_parser: textractParserTool, + textract_parser_v2: textractParserV2Tool, thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, @@ -2646,10 +2655,15 @@ export const tools: Record = { search_tool: searchTool, elevenlabs_tts: elevenLabsTtsTool, stt_whisper: whisperSttTool, + stt_whisper_v2: whisperSttV2Tool, stt_deepgram: deepgramSttTool, + stt_deepgram_v2: deepgramSttV2Tool, stt_elevenlabs: elevenLabsSttTool, + stt_elevenlabs_v2: elevenLabsSttV2Tool, stt_assemblyai: assemblyaiSttTool, + stt_assemblyai_v2: assemblyaiSttV2Tool, stt_gemini: geminiSttTool, + stt_gemini_v2: geminiSttV2Tool, tts_openai: openaiTtsTool, tts_deepgram: deepgramTtsTool, tts_elevenlabs: elevenLabsTtsUnifiedTool, diff --git a/apps/sim/tools/sharepoint/get_list.ts b/apps/sim/tools/sharepoint/get_list.ts index 0dc540223c..f5528ee95b 100644 --- a/apps/sim/tools/sharepoint/get_list.ts +++ b/apps/sim/tools/sharepoint/get_list.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { SharepointGetListResponse, SharepointList, @@ -58,7 +57,7 @@ export const getListTool: ToolConfig = { id: 'slack_download', name: 'Download File from Slack', @@ -50,99 +47,16 @@ export const slackDownloadTool: ToolConfig `https://slack.com/api/files.info?file=${params.fileId}`, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken || params.botToken}`, + url: '/api/tools/slack/download', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken || params.botToken, + fileId: params.fileId, + fileName: params.fileName, }), - }, - - transformResponse: async (response: Response, params?: SlackDownloadParams) => { - try { - if (!response.ok) { - const errorDetails = await response.json().catch(() => ({})) - logger.error('Failed to get file info from Slack', { - status: response.status, - statusText: response.statusText, - error: errorDetails, - }) - throw new Error(errorDetails.error || 'Failed to get file info') - } - - const data = await response.json() - - if (!data.ok) { - logger.error('Slack API returned error', { - error: data.error, - }) - throw new Error(data.error || 'Slack API error') - } - - const file = data.file - const fileId = file.id - const fileName = file.name - const mimeType = file.mimetype || 'application/octet-stream' - const urlPrivate = file.url_private - const authToken = params?.accessToken || params?.botToken || '' - - if (!urlPrivate) { - throw new Error('File does not have a download URL') - } - - logger.info('Downloading file from Slack', { - fileId, - fileName, - mimeType, - }) - - const downloadResponse = await fetch(urlPrivate, { - headers: { - Authorization: `Bearer ${authToken}`, - }, - }) - - if (!downloadResponse.ok) { - logger.error('Failed to download file content', { - status: downloadResponse.status, - statusText: downloadResponse.statusText, - }) - throw new Error('Failed to download file content') - } - - const arrayBuffer = await downloadResponse.arrayBuffer() - const fileBuffer = Buffer.from(arrayBuffer) - - const resolvedName = params?.fileName || fileName || 'download' - - logger.info('File downloaded successfully', { - fileId, - name: resolvedName, - size: fileBuffer.length, - mimeType, - }) - - // Convert buffer to base64 string for proper JSON serialization - // This ensures the file data survives the proxy round-trip - const base64Data = fileBuffer.toString('base64') - - return { - success: true, - output: { - file: { - name: resolvedName, - mimeType, - data: base64Data, - size: fileBuffer.length, - }, - }, - } - } catch (error: any) { - logger.error('Error in transform response', { - error: error.message, - stack: error.stack, - }) - throw error - } }, outputs: { diff --git a/apps/sim/tools/stagehand/agent.ts b/apps/sim/tools/stagehand/agent.ts index 7884b45754..f3d055a8ea 100644 --- a/apps/sim/tools/stagehand/agent.ts +++ b/apps/sim/tools/stagehand/agent.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { StagehandAgentParams, StagehandAgentResponse } from '@/tools/stagehand/types' import { STAGEHAND_AGENT_RESULT_OUTPUT_PROPERTIES } from '@/tools/stagehand/types' import type { ToolConfig } from '@/tools/types' @@ -62,9 +61,7 @@ export const agentTool: ToolConfig let startUrl = params.startUrl if (startUrl && !startUrl.match(/^https?:\/\//i)) { startUrl = `https://${startUrl.trim()}` - logger.info( - `Normalized URL from ${sanitizeUrlForLog(params.startUrl)} to ${sanitizeUrlForLog(startUrl)}` - ) + logger.info(`Normalized URL from ${params.startUrl} to ${startUrl}`) } return { diff --git a/apps/sim/tools/stt/assemblyai.ts b/apps/sim/tools/stt/assemblyai.ts index d005aba2d9..3c8e15173c 100644 --- a/apps/sim/tools/stt/assemblyai.ts +++ b/apps/sim/tools/stt/assemblyai.ts @@ -1,4 +1,4 @@ -import type { SttParams, SttResponse } from '@/tools/stt/types' +import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_ENTITY_OUTPUT_PROPERTIES, STT_SEGMENT_OUTPUT_PROPERTIES, @@ -183,3 +183,49 @@ export const assemblyaiSttTool: ToolConfig = { summary: { type: 'string', description: 'Auto-generated summary' }, }, } + +const assemblyaiSttV2Params = { + provider: assemblyaiSttTool.params.provider, + apiKey: assemblyaiSttTool.params.apiKey, + model: assemblyaiSttTool.params.model, + audioFile: assemblyaiSttTool.params.audioFile, + audioFileReference: assemblyaiSttTool.params.audioFileReference, + language: assemblyaiSttTool.params.language, + timestamps: assemblyaiSttTool.params.timestamps, + diarization: assemblyaiSttTool.params.diarization, + sentiment: assemblyaiSttTool.params.sentiment, + entityDetection: assemblyaiSttTool.params.entityDetection, + piiRedaction: assemblyaiSttTool.params.piiRedaction, + summarization: assemblyaiSttTool.params.summarization, +} satisfies ToolConfig['params'] + +export const assemblyaiSttV2Tool: ToolConfig = { + ...assemblyaiSttTool, + id: 'stt_assemblyai_v2', + name: 'AssemblyAI STT (File Only)', + params: assemblyaiSttV2Params, + request: { + ...assemblyaiSttTool.request, + body: ( + params: SttV2Params & { + _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + } + ) => ({ + provider: 'assemblyai', + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language || 'auto', + timestamps: params.timestamps || 'none', + diarization: params.diarization || false, + sentiment: params.sentiment || false, + entityDetection: params.entityDetection || false, + piiRedaction: params.piiRedaction || false, + summarization: params.summarization || false, + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + }), + }, +} diff --git a/apps/sim/tools/stt/deepgram.ts b/apps/sim/tools/stt/deepgram.ts index e198a05613..97465ef157 100644 --- a/apps/sim/tools/stt/deepgram.ts +++ b/apps/sim/tools/stt/deepgram.ts @@ -1,4 +1,4 @@ -import type { SttParams, SttResponse } from '@/tools/stt/types' +import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_SEGMENT_OUTPUT_PROPERTIES } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -131,3 +131,43 @@ export const deepgramSttTool: ToolConfig = { confidence: { type: 'number', description: 'Overall confidence score' }, }, } + +const deepgramSttV2Params = { + provider: deepgramSttTool.params.provider, + apiKey: deepgramSttTool.params.apiKey, + model: deepgramSttTool.params.model, + audioFile: deepgramSttTool.params.audioFile, + audioFileReference: deepgramSttTool.params.audioFileReference, + language: deepgramSttTool.params.language, + timestamps: deepgramSttTool.params.timestamps, + diarization: deepgramSttTool.params.diarization, + translateToEnglish: deepgramSttTool.params.translateToEnglish, +} satisfies ToolConfig['params'] + +export const deepgramSttV2Tool: ToolConfig = { + ...deepgramSttTool, + id: 'stt_deepgram_v2', + name: 'Deepgram STT (File Only)', + params: deepgramSttV2Params, + request: { + ...deepgramSttTool.request, + body: ( + params: SttV2Params & { + _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + } + ) => ({ + provider: 'deepgram', + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language || 'auto', + timestamps: params.timestamps || 'none', + diarization: params.diarization || false, + translateToEnglish: params.translateToEnglish || false, + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + }), + }, +} diff --git a/apps/sim/tools/stt/elevenlabs.ts b/apps/sim/tools/stt/elevenlabs.ts index b10124a575..88b89a6efd 100644 --- a/apps/sim/tools/stt/elevenlabs.ts +++ b/apps/sim/tools/stt/elevenlabs.ts @@ -1,4 +1,4 @@ -import type { SttParams, SttResponse } from '@/tools/stt/types' +import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' export const elevenLabsSttTool: ToolConfig = { @@ -116,3 +116,39 @@ export const elevenLabsSttTool: ToolConfig = { confidence: { type: 'number', description: 'Overall confidence score' }, }, } + +const elevenLabsSttV2Params = { + provider: elevenLabsSttTool.params.provider, + apiKey: elevenLabsSttTool.params.apiKey, + model: elevenLabsSttTool.params.model, + audioFile: elevenLabsSttTool.params.audioFile, + audioFileReference: elevenLabsSttTool.params.audioFileReference, + language: elevenLabsSttTool.params.language, + timestamps: elevenLabsSttTool.params.timestamps, +} satisfies ToolConfig['params'] + +export const elevenLabsSttV2Tool: ToolConfig = { + ...elevenLabsSttTool, + id: 'stt_elevenlabs_v2', + name: 'ElevenLabs STT (File Only)', + params: elevenLabsSttV2Params, + request: { + ...elevenLabsSttTool.request, + body: ( + params: SttV2Params & { + _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + } + ) => ({ + provider: 'elevenlabs', + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language || 'auto', + timestamps: params.timestamps || 'none', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + }), + }, +} diff --git a/apps/sim/tools/stt/gemini.ts b/apps/sim/tools/stt/gemini.ts index 781527bdd4..a5ad196c44 100644 --- a/apps/sim/tools/stt/gemini.ts +++ b/apps/sim/tools/stt/gemini.ts @@ -1,4 +1,4 @@ -import type { SttParams, SttResponse } from '@/tools/stt/types' +import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' export const geminiSttTool: ToolConfig = { @@ -116,3 +116,39 @@ export const geminiSttTool: ToolConfig = { confidence: { type: 'number', description: 'Overall confidence score' }, }, } + +const geminiSttV2Params = { + provider: geminiSttTool.params.provider, + apiKey: geminiSttTool.params.apiKey, + model: geminiSttTool.params.model, + audioFile: geminiSttTool.params.audioFile, + audioFileReference: geminiSttTool.params.audioFileReference, + language: geminiSttTool.params.language, + timestamps: geminiSttTool.params.timestamps, +} satisfies ToolConfig['params'] + +export const geminiSttV2Tool: ToolConfig = { + ...geminiSttTool, + id: 'stt_gemini_v2', + name: 'Gemini STT (File Only)', + params: geminiSttV2Params, + request: { + ...geminiSttTool.request, + body: ( + params: SttV2Params & { + _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + } + ) => ({ + provider: 'gemini', + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language || 'auto', + timestamps: params.timestamps || 'none', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + }), + }, +} diff --git a/apps/sim/tools/stt/index.ts b/apps/sim/tools/stt/index.ts index a3ab7ca4a5..73a46419b8 100644 --- a/apps/sim/tools/stt/index.ts +++ b/apps/sim/tools/stt/index.ts @@ -1,7 +1,18 @@ -import { assemblyaiSttTool } from '@/tools/stt/assemblyai' -import { deepgramSttTool } from '@/tools/stt/deepgram' -import { elevenLabsSttTool } from '@/tools/stt/elevenlabs' -import { geminiSttTool } from '@/tools/stt/gemini' -import { whisperSttTool } from '@/tools/stt/whisper' +import { assemblyaiSttTool, assemblyaiSttV2Tool } from '@/tools/stt/assemblyai' +import { deepgramSttTool, deepgramSttV2Tool } from '@/tools/stt/deepgram' +import { elevenLabsSttTool, elevenLabsSttV2Tool } from '@/tools/stt/elevenlabs' +import { geminiSttTool, geminiSttV2Tool } from '@/tools/stt/gemini' +import { whisperSttTool, whisperSttV2Tool } from '@/tools/stt/whisper' -export { whisperSttTool, deepgramSttTool, elevenLabsSttTool, assemblyaiSttTool, geminiSttTool } +export { + whisperSttTool, + deepgramSttTool, + elevenLabsSttTool, + assemblyaiSttTool, + geminiSttTool, + whisperSttV2Tool, + deepgramSttV2Tool, + elevenLabsSttV2Tool, + assemblyaiSttV2Tool, + geminiSttV2Tool, +} diff --git a/apps/sim/tools/stt/types.ts b/apps/sim/tools/stt/types.ts index 63cf0cd4ca..de35d1deb7 100644 --- a/apps/sim/tools/stt/types.ts +++ b/apps/sim/tools/stt/types.ts @@ -77,6 +77,8 @@ export interface SttParams { summarization?: boolean } +export interface SttV2Params extends Omit {} + export interface TranscriptSegment { text: string start: number diff --git a/apps/sim/tools/stt/whisper.ts b/apps/sim/tools/stt/whisper.ts index 5c03d3cbe2..084a3c624b 100644 --- a/apps/sim/tools/stt/whisper.ts +++ b/apps/sim/tools/stt/whisper.ts @@ -1,4 +1,4 @@ -import type { SttParams, SttResponse } from '@/tools/stt/types' +import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_SEGMENT_OUTPUT_PROPERTIES } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -153,3 +153,47 @@ export const whisperSttTool: ToolConfig = { duration: { type: 'number', description: 'Audio duration in seconds' }, }, } + +const whisperSttV2Params = { + provider: whisperSttTool.params.provider, + apiKey: whisperSttTool.params.apiKey, + model: whisperSttTool.params.model, + audioFile: whisperSttTool.params.audioFile, + audioFileReference: whisperSttTool.params.audioFileReference, + language: whisperSttTool.params.language, + timestamps: whisperSttTool.params.timestamps, + translateToEnglish: whisperSttTool.params.translateToEnglish, + prompt: whisperSttTool.params.prompt, + temperature: whisperSttTool.params.temperature, + responseFormat: whisperSttTool.params.responseFormat, +} satisfies ToolConfig['params'] + +export const whisperSttV2Tool: ToolConfig = { + ...whisperSttTool, + id: 'stt_whisper_v2', + name: 'OpenAI Whisper STT (File Only)', + params: whisperSttV2Params, + request: { + ...whisperSttTool.request, + body: ( + params: SttV2Params & { + _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + } + ) => ({ + provider: 'whisper', + apiKey: params.apiKey, + model: params.model, + audioFile: params.audioFile, + audioFileReference: params.audioFileReference, + language: params.language || 'auto', + timestamps: params.timestamps || 'none', + translateToEnglish: params.translateToEnglish || false, + prompt: (params as any).prompt, + temperature: (params as any).temperature, + responseFormat: (params as any).responseFormat, + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + }), + }, +} diff --git a/apps/sim/tools/textract/index.ts b/apps/sim/tools/textract/index.ts index 5f618a8b4c..c47c5cfc55 100644 --- a/apps/sim/tools/textract/index.ts +++ b/apps/sim/tools/textract/index.ts @@ -1,2 +1,2 @@ -export { textractParserTool } from '@/tools/textract/parser' +export { textractParserTool, textractParserV2Tool } from '@/tools/textract/parser' export * from '@/tools/textract/types' diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index a7b95564c5..9933505dec 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' -import type { TextractParserInput, TextractParserOutput } from '@/tools/textract/types' +import type { + TextractParserInput, + TextractParserOutput, + TextractParserV2Input, +} from '@/tools/textract/types' import type { ToolConfig } from '@/tools/types' const logger = createLogger('TextractParserTool') @@ -41,18 +45,18 @@ export const textractParserTool: ToolConfig = { + ...textractParserTool, + id: 'textract_parser_v2', + name: 'AWS Textract Parser (File Only)', + params: { + accessKeyId: textractParserTool.params.accessKeyId, + secretAccessKey: textractParserTool.params.secretAccessKey, + region: textractParserTool.params.region, + processingMode: textractParserTool.params.processingMode, + file: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Document to be processed (JPEG, PNG, or single-page PDF).', + }, + s3Uri: textractParserTool.params.s3Uri, + featureTypes: textractParserTool.params.featureTypes, + queries: textractParserTool.params.queries, + }, + request: { + ...textractParserTool.request, + body: (params: TextractParserV2Input) => { + const processingMode = params.processingMode || 'sync' + + const requestBody: Record = { + accessKeyId: params.accessKeyId?.trim(), + secretAccessKey: params.secretAccessKey?.trim(), + region: params.region?.trim(), + processingMode, + } + + if (processingMode === 'async') { + requestBody.s3Uri = params.s3Uri?.trim() + } else { + if (!params.file || typeof params.file !== 'object') { + throw new Error('Document file is required for single-page processing') + } + requestBody.file = params.file + } + + if (params.featureTypes && Array.isArray(params.featureTypes)) { + requestBody.featureTypes = params.featureTypes + } + + if (params.queries && Array.isArray(params.queries)) { + requestBody.queries = params.queries + } + + return requestBody + }, + }, +} diff --git a/apps/sim/tools/textract/types.ts b/apps/sim/tools/textract/types.ts index 7adc46f28b..e871bdbea4 100644 --- a/apps/sim/tools/textract/types.ts +++ b/apps/sim/tools/textract/types.ts @@ -1,3 +1,5 @@ +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import type { UserFile } from '@/executor/types' import type { ToolResponse } from '@/tools/types' export type TextractProcessingMode = 'sync' | 'async' @@ -8,11 +10,20 @@ export interface TextractParserInput { region: string processingMode?: TextractProcessingMode filePath?: string + file?: RawFileInput + s3Uri?: string + fileUpload?: RawFileInput + featureTypes?: TextractFeatureType[] + queries?: TextractQuery[] +} + +export interface TextractParserV2Input { + accessKeyId: string + secretAccessKey: string + region: string + processingMode?: TextractProcessingMode + file?: UserFile s3Uri?: string - fileUpload?: { - url?: string - path?: string - } featureTypes?: TextractFeatureType[] queries?: TextractQuery[] } diff --git a/apps/sim/tools/twilio_voice/get_recording.ts b/apps/sim/tools/twilio_voice/get_recording.ts index 7573b0d2e6..f78fcea725 100644 --- a/apps/sim/tools/twilio_voice/get_recording.ts +++ b/apps/sim/tools/twilio_voice/get_recording.ts @@ -1,9 +1,6 @@ -import { createLogger } from '@sim/logger' import type { TwilioGetRecordingOutput, TwilioGetRecordingParams } from '@/tools/twilio_voice/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('TwilioVoiceGetRecordingTool') - export const getRecordingTool: ToolConfig = { id: 'twilio_voice_get_recording', name: 'Twilio Voice Get Recording', @@ -32,109 +29,16 @@ export const getRecordingTool: ToolConfig { - if (!params.accountSid || !params.recordingSid) { - throw new Error('Twilio Account SID and Recording SID are required') - } - if (!params.accountSid.startsWith('AC')) { - throw new Error( - `Invalid Account SID format. Account SID must start with "AC" (you provided: ${params.accountSid.substring(0, 2)}...)` - ) - } - return `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Recordings/${params.recordingSid}.json` - }, - method: 'GET', - headers: (params) => { - if (!params.accountSid || !params.authToken) { - throw new Error('Twilio credentials are required') - } - const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') - return { - Authorization: `Basic ${authToken}`, - } - }, - }, - - transformResponse: async (response, params) => { - const data = await response.json() - - logger.info('Twilio Get Recording Response:', data) - - if (data.error_code) { - return { - success: false, - output: { - success: false, - error: data.message || data.error_message || 'Failed to retrieve recording', - }, - error: data.message || data.error_message || 'Failed to retrieve recording', - } - } - - const baseUrl = 'https://api.twilio.com' - const mediaUrl = data.uri ? `${baseUrl}${data.uri.replace('.json', '')}` : undefined - - let transcriptionText: string | undefined - let transcriptionStatus: string | undefined - let transcriptionPrice: string | undefined - let transcriptionPriceUnit: string | undefined - - try { - const authToken = Buffer.from(`${params?.accountSid}:${params?.authToken}`).toString('base64') - - const transcriptionUrl = `https://api.twilio.com/2010-04-01/Accounts/${params?.accountSid}/Transcriptions.json?RecordingSid=${data.sid}` - logger.info('Checking for transcriptions:', transcriptionUrl) - - const transcriptionResponse = await fetch(transcriptionUrl, { - method: 'GET', - headers: { Authorization: `Basic ${authToken}` }, - }) - - if (transcriptionResponse.ok) { - const transcriptionData = await transcriptionResponse.json() - logger.info('Transcription response:', JSON.stringify(transcriptionData)) - - if (transcriptionData.transcriptions && transcriptionData.transcriptions.length > 0) { - const transcription = transcriptionData.transcriptions[0] - transcriptionText = transcription.transcription_text - transcriptionStatus = transcription.status - transcriptionPrice = transcription.price - transcriptionPriceUnit = transcription.price_unit - logger.info('Transcription found:', { - status: transcriptionStatus, - textLength: transcriptionText?.length, - }) - } else { - logger.info( - 'No transcriptions found. To enable transcription, use in your TwiML.' - ) - } - } - } catch (error) { - logger.warn('Failed to fetch transcription:', error) - } - - return { - success: true, - output: { - success: true, - recordingSid: data.sid, - callSid: data.call_sid, - duration: data.duration ? Number.parseInt(data.duration, 10) : undefined, - status: data.status, - channels: data.channels, - source: data.source, - mediaUrl, - price: data.price, - priceUnit: data.price_unit, - uri: data.uri, - transcriptionText, - transcriptionStatus, - transcriptionPrice, - transcriptionPriceUnit, - }, - error: undefined, - } + url: '/api/tools/twilio/get-recording', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accountSid: params.accountSid, + authToken: params.authToken, + recordingSid: params.recordingSid, + }), }, outputs: { @@ -146,6 +50,7 @@ export const getRecordingTool: ToolConfig = // For file downloads, we get the file directly const contentType = response.headers.get('content-type') || 'application/octet-stream' const contentDisposition = response.headers.get('content-disposition') || '' + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) // Try to extract filename from content-disposition if possible let filename = '' @@ -80,6 +82,12 @@ export const filesTool: ToolConfig = if (filenameMatch?.[1]) { filename = filenameMatch[1] } + if (!filename && params?.filename) { + filename = params.filename + } + if (!filename) { + filename = 'typeform-file' + } // Get file URL from the response URL or construct it from parameters if not available let fileUrl = response.url @@ -102,6 +110,12 @@ export const filesTool: ToolConfig = success: true, output: { fileUrl: fileUrl || '', + file: { + name: filename, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }, contentType, filename, }, @@ -110,6 +124,7 @@ export const filesTool: ToolConfig = outputs: { fileUrl: { type: 'string', description: 'Direct download URL for the uploaded file' }, + file: { type: 'file', description: 'Downloaded file stored in execution files' }, contentType: { type: 'string', description: 'MIME type of the uploaded file' }, filename: { type: 'string', description: 'Original filename of the uploaded file' }, }, diff --git a/apps/sim/tools/typeform/types.ts b/apps/sim/tools/typeform/types.ts index f628ea06a5..a4e012d9c4 100644 --- a/apps/sim/tools/typeform/types.ts +++ b/apps/sim/tools/typeform/types.ts @@ -1,4 +1,4 @@ -import type { ToolResponse } from '@/tools/types' +import type { ToolFileData, ToolResponse } from '@/tools/types' export interface TypeformFilesParams { formId: string @@ -12,6 +12,7 @@ export interface TypeformFilesParams { export interface TypeformFilesResponse extends ToolResponse { output: { fileUrl: string + file: ToolFileData contentType: string filename: string } diff --git a/apps/sim/tools/utils.server.ts b/apps/sim/tools/utils.server.ts new file mode 100644 index 0000000000..dca9880c4a --- /dev/null +++ b/apps/sim/tools/utils.server.ts @@ -0,0 +1,77 @@ +import { createLogger } from '@sim/logger' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { extractErrorMessage } from '@/tools/error-extractors' +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { RequestParams } from '@/tools/utils' + +const logger = createLogger('ToolsUtils') + +/** + * Execute the actual request and transform the response. + * Server-only: uses DNS validation and IP-pinned fetch. + */ +export async function executeRequest( + toolId: string, + tool: ToolConfig, + requestParams: RequestParams +): Promise { + try { + const { url, method, headers, body } = requestParams + const isExternalUrl = url.startsWith('http://') || url.startsWith('https://') + const externalResponse = isExternalUrl + ? (() => { + return validateUrlWithDNS(url, 'url').then((urlValidation) => { + if (!urlValidation.isValid) { + throw new Error(urlValidation.error) + } + return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { + method, + headers, + body, + }) + }) + })() + : fetch(url, { method, headers, body }) + + const resolvedResponse = await externalResponse + + if (!resolvedResponse.ok) { + let errorData: any + try { + errorData = await resolvedResponse.json() + } catch (_e) { + try { + errorData = await resolvedResponse.text() + } catch (_e2) { + errorData = null + } + } + + const error = extractErrorMessage({ + status: resolvedResponse.status, + statusText: resolvedResponse.statusText, + data: errorData, + }) + logger.error(`${toolId} error:`, { error }) + throw new Error(error) + } + + const transformResponse = + tool.transformResponse || + (async (resp: Response) => ({ + success: true, + output: await resp.json(), + })) + + return await transformResponse(resolvedResponse as Response) + } catch (error: any) { + return { + success: false, + output: {}, + error: error.message || 'Unknown error', + } + } +} diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 5eae3eb767..0507eda1c8 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -5,11 +5,11 @@ import type { ToolConfig } from '@/tools/types' import { createCustomToolRequestBody, createParamSchema, - executeRequest, formatRequestParams, getClientEnvVars, validateRequiredParametersAfterMerge, } from '@/tools/utils' +import { executeRequest } from '@/tools/utils.server' vi.mock('@sim/logger', () => loggerMock) diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 12ab817720..1cfc4b42f5 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -3,9 +3,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { AGENT, isCustomTool } from '@/executor/constants' import { getCustomTool } from '@/hooks/queries/custom-tools' import { useEnvironmentStore } from '@/stores/settings/environment' -import { extractErrorMessage } from '@/tools/error-extractors' import { tools } from '@/tools/registry' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { ToolConfig } from '@/tools/types' const logger = createLogger('ToolsUtils') @@ -70,7 +69,7 @@ export function resolveToolId(toolName: string): string { return toolName } -interface RequestParams { +export interface RequestParams { url: string method: string headers: Record @@ -136,57 +135,6 @@ export function formatRequestParams(tool: ToolConfig, params: Record { - try { - const { url, method, headers, body } = requestParams - - const externalResponse = await fetch(url, { method, headers, body }) - - if (!externalResponse.ok) { - let errorData: any - try { - errorData = await externalResponse.json() - } catch (_e) { - try { - errorData = await externalResponse.text() - } catch (_e2) { - errorData = null - } - } - - const error = extractErrorMessage({ - status: externalResponse.status, - statusText: externalResponse.statusText, - data: errorData, - }) - logger.error(`${toolId} error:`, { error }) - throw new Error(error) - } - - const transformResponse = - tool.transformResponse || - (async (resp: Response) => ({ - success: true, - output: await resp.json(), - })) - - return await transformResponse(externalResponse) - } catch (error: any) { - return { - success: false, - output: {}, - error: error.message || 'Unknown error', - } - } -} - /** * Formats a parameter name for user-friendly error messages * Converts parameter names and descriptions to more readable format diff --git a/apps/sim/tools/video/falai.ts b/apps/sim/tools/video/falai.ts index 27782976a0..82cb89e018 100644 --- a/apps/sim/tools/video/falai.ts +++ b/apps/sim/tools/video/falai.ts @@ -125,7 +125,7 @@ export const falaiVideoTool: ToolConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/tools/video/luma.ts b/apps/sim/tools/video/luma.ts index a0d049ba27..5c9ee83da6 100644 --- a/apps/sim/tools/video/luma.ts +++ b/apps/sim/tools/video/luma.ts @@ -124,7 +124,7 @@ export const lumaVideoTool: ToolConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/tools/video/minimax.ts b/apps/sim/tools/video/minimax.ts index 10b986b4c4..941c0d04b8 100644 --- a/apps/sim/tools/video/minimax.ts +++ b/apps/sim/tools/video/minimax.ts @@ -110,7 +110,7 @@ export const minimaxVideoTool: ToolConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/tools/video/runway.ts b/apps/sim/tools/video/runway.ts index 730c66690c..87a87ee142 100644 --- a/apps/sim/tools/video/runway.ts +++ b/apps/sim/tools/video/runway.ts @@ -51,7 +51,7 @@ export const runwayVideoTool: ToolConfig = { description: 'Video resolution (720p output). Note: Gen-4 Turbo outputs at 720p natively', }, visualReference: { - type: 'json', + type: 'file', required: true, visibility: 'user-or-llm', description: @@ -124,7 +124,7 @@ export const runwayVideoTool: ToolConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/tools/video/veo.ts b/apps/sim/tools/video/veo.ts index 1cc91346a9..082c139ca5 100644 --- a/apps/sim/tools/video/veo.ts +++ b/apps/sim/tools/video/veo.ts @@ -117,7 +117,7 @@ export const veoVideoTool: ToolConfig = { outputs: { videoUrl: { type: 'string', description: 'Generated video URL' }, - videoFile: { type: 'json', description: 'Video file object with metadata' }, + videoFile: { type: 'file', description: 'Video file object with metadata' }, duration: { type: 'number', description: 'Video duration in seconds' }, width: { type: 'number', description: 'Video width in pixels' }, height: { type: 'number', description: 'Video height in pixels' }, diff --git a/apps/sim/tools/vision/index.ts b/apps/sim/tools/vision/index.ts index 8b4f0ad590..696f71461b 100644 --- a/apps/sim/tools/vision/index.ts +++ b/apps/sim/tools/vision/index.ts @@ -1,3 +1,3 @@ -import { visionTool } from '@/tools/vision/tool' +import { visionTool, visionToolV2 } from '@/tools/vision/tool' -export { visionTool } +export { visionTool, visionToolV2 } diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index a9c334a19f..6cd8dc357c 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -1,5 +1,5 @@ import type { ToolConfig } from '@/tools/types' -import type { VisionParams, VisionResponse } from '@/tools/vision/types' +import type { VisionParams, VisionResponse, VisionV2Params } from '@/tools/vision/types' export const visionTool: ToolConfig = { id: 'vision_tool', @@ -96,3 +96,29 @@ export const visionTool: ToolConfig = { }, }, } + +export const visionToolV2: ToolConfig = { + ...visionTool, + id: 'vision_tool_v2', + name: 'Vision Tool (File Only)', + params: { + apiKey: visionTool.params.apiKey, + imageFile: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'Image file to analyze', + }, + model: visionTool.params.model, + prompt: visionTool.params.prompt, + }, + request: { + ...visionTool.request, + body: (params: VisionV2Params) => ({ + apiKey: params.apiKey, + imageFile: params.imageFile, + model: params.model || 'gpt-5.2', + prompt: params.prompt || null, + }), + }, +} diff --git a/apps/sim/tools/vision/types.ts b/apps/sim/tools/vision/types.ts index cda8c45598..0666981c5f 100644 --- a/apps/sim/tools/vision/types.ts +++ b/apps/sim/tools/vision/types.ts @@ -9,6 +9,13 @@ export interface VisionParams { prompt?: string } +export interface VisionV2Params { + apiKey: string + imageFile: UserFile + model?: string + prompt?: string +} + export interface VisionResponse extends ToolResponse { output: { content: string diff --git a/apps/sim/tools/zoom/get_meeting_recordings.ts b/apps/sim/tools/zoom/get_meeting_recordings.ts index c89dd86520..79633625e4 100644 --- a/apps/sim/tools/zoom/get_meeting_recordings.ts +++ b/apps/sim/tools/zoom/get_meeting_recordings.ts @@ -40,78 +40,27 @@ export const zoomGetMeetingRecordingsTool: ToolConfig< visibility: 'user-or-llm', description: 'Time to live for download URLs in seconds (max 604800)', }, - }, - - request: { - url: (params) => { - const baseUrl = `https://api.zoom.us/v2/meetings/${encodeURIComponent(params.meetingId)}/recordings` - const queryParams = new URLSearchParams() - - if (params.includeFolderItems != null) { - queryParams.append('include_folder_items', String(params.includeFolderItems)) - } - if (params.ttl) { - queryParams.append('ttl', String(params.ttl)) - } - - const queryString = queryParams.toString() - return queryString ? `${baseUrl}?${queryString}` : baseUrl - }, - method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Missing access token for Zoom API request') - } - return { - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, - } + downloadFiles: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Download recording files into file outputs', }, }, - transformResponse: async (response) => { - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - return { - success: false, - error: errorData.message || `Zoom API error: ${response.status} ${response.statusText}`, - output: { recording: {} as any }, - } - } - - const data = await response.json() - - return { - success: true, - output: { - recording: { - uuid: data.uuid, - id: data.id, - account_id: data.account_id, - host_id: data.host_id, - topic: data.topic, - type: data.type, - start_time: data.start_time, - duration: data.duration, - total_size: data.total_size, - recording_count: data.recording_count, - share_url: data.share_url, - recording_files: (data.recording_files || []).map((file: any) => ({ - id: file.id, - meeting_id: file.meeting_id, - recording_start: file.recording_start, - recording_end: file.recording_end, - file_type: file.file_type, - file_extension: file.file_extension, - file_size: file.file_size, - play_url: file.play_url, - download_url: file.download_url, - status: file.status, - recording_type: file.recording_type, - })), - }, - }, - } + request: { + url: '/api/tools/zoom/get-recordings', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + meetingId: params.meetingId, + includeFolderItems: params.includeFolderItems, + ttl: params.ttl, + downloadFiles: params.downloadFiles, + }), }, outputs: { @@ -120,5 +69,10 @@ export const zoomGetMeetingRecordingsTool: ToolConfig< description: 'The meeting recording with all files', properties: RECORDING_OUTPUT_PROPERTIES, }, + files: { + type: 'file[]', + description: 'Downloaded recording files', + optional: true, + }, }, } diff --git a/apps/sim/tools/zoom/types.ts b/apps/sim/tools/zoom/types.ts index 513b55d5b7..bd9bff06bf 100644 --- a/apps/sim/tools/zoom/types.ts +++ b/apps/sim/tools/zoom/types.ts @@ -1,5 +1,5 @@ // Common types for Zoom tools -import type { OutputProperty, ToolResponse } from '@/tools/types' +import type { OutputProperty, ToolFileData, ToolResponse } from '@/tools/types' /** * Shared output property definitions for Zoom API responses. @@ -556,11 +556,13 @@ export interface ZoomGetMeetingRecordingsParams extends ZoomBaseParams { meetingId: string includeFolderItems?: boolean ttl?: number + downloadFiles?: boolean } export interface ZoomGetMeetingRecordingsResponse extends ToolResponse { output: { recording: ZoomRecording + files?: ToolFileData[] } } From f4a3c94f87ef18793468e91155f5809b06d75e99 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 15:11:32 -0800 Subject: [PATCH 05/39] consolidate more code --- apps/sim/blocks/blocks/fireflies.ts | 28 +---- apps/sim/blocks/blocks/google_slides.ts | 28 +---- apps/sim/blocks/blocks/stt.ts | 45 +++++--- apps/sim/blocks/blocks/vision.ts | 20 ++++ apps/sim/lib/uploads/utils/file-utils.ts | 135 +++++++++++------------ 5 files changed, 124 insertions(+), 132 deletions(-) diff --git a/apps/sim/blocks/blocks/fireflies.ts b/apps/sim/blocks/blocks/fireflies.ts index 16c3a3fdb3..5e12df3693 100644 --- a/apps/sim/blocks/blocks/fireflies.ts +++ b/apps/sim/blocks/blocks/fireflies.ts @@ -1,33 +1,15 @@ import { FirefliesIcon } from '@/components/icons' +import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { FirefliesResponse } from '@/tools/fireflies/types' import { getTrigger } from '@/triggers' -const resolveHttpsUrlFromFileInput = (fileInput: unknown): string | null => { - if (!fileInput || typeof fileInput !== 'object') { - return null - } - - const record = fileInput as Record - const url = - typeof record.url === 'string' - ? record.url.trim() - : typeof record.path === 'string' - ? record.path.trim() - : '' - - if (!url || !url.startsWith('https://')) { - return null - } - - return url -} - export const FirefliesBlock: BlockConfig = { type: 'fireflies', - name: 'Fireflies', + name: 'Fireflies (Legacy)', description: 'Interact with Fireflies.ai meeting transcripts and recordings', + hideFromToolbar: true, authMode: AuthMode.ApiKey, triggerAllowed: true, longDescription: @@ -618,9 +600,9 @@ const firefliesV2Inputs = FirefliesBlock.inputs export const FirefliesV2Block: BlockConfig = { ...FirefliesBlock, type: 'fireflies_v2', - name: 'Fireflies (File Only)', + name: 'Fireflies', description: 'Interact with Fireflies.ai meeting transcripts and recordings', - hideFromToolbar: true, + hideFromToolbar: false, subBlocks: firefliesV2SubBlocks, tools: { ...FirefliesBlock.tools, diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index bd910735d4..4360bc4ff1 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -1,32 +1,14 @@ import { GoogleSlidesIcon } from '@/components/icons' +import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleSlidesResponse } from '@/tools/google_slides/types' -const resolveHttpsUrlFromFileInput = (fileInput: unknown): string | null => { - if (!fileInput || typeof fileInput !== 'object') { - return null - } - - const record = fileInput as Record - const url = - typeof record.url === 'string' - ? record.url.trim() - : typeof record.path === 'string' - ? record.path.trim() - : '' - - if (!url || !url.startsWith('https://')) { - return null - } - - return url -} - export const GoogleSlidesBlock: BlockConfig = { type: 'google_slides', - name: 'Google Slides', + name: 'Google Slides (Legacy)', description: 'Read, write, and create presentations', + hideFromToolbar: true, authMode: AuthMode.OAuth, longDescription: 'Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.', @@ -963,9 +945,9 @@ const googleSlidesV2Inputs = GoogleSlidesBlock.inputs export const GoogleSlidesV2Block: BlockConfig = { ...GoogleSlidesBlock, type: 'google_slides_v2', - name: 'Google Slides (File Only)', + name: 'Google Slides', description: 'Read, write, and create presentations', - hideFromToolbar: true, + hideFromToolbar: false, subBlocks: googleSlidesV2SubBlocks, tools: { ...GoogleSlidesBlock.tools, diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index a7ab181553..8b9a945754 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -388,21 +388,36 @@ export const SttV2Block: BlockConfig = { suffix: '_v2', fallbackToolId: 'stt_whisper_v2', }), - params: (params) => ({ - provider: params.provider, - apiKey: params.apiKey, - model: params.model, - audioFile: params.audioFile, - audioFileReference: params.audioFileReference, - language: params.language, - timestamps: params.timestamps, - diarization: params.diarization, - translateToEnglish: params.translateToEnglish, - sentiment: params.sentiment, - entityDetection: params.entityDetection, - piiRedaction: params.piiRedaction, - summarization: params.summarization, - }), + params: (params) => { + let audioInput = params.audioFile || params.audioFileReference + if (audioInput && typeof audioInput === 'string') { + try { + audioInput = JSON.parse(audioInput) + } catch { + throw new Error('Audio file must be a valid file reference') + } + } + if (audioInput && Array.isArray(audioInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + return { + provider: params.provider, + apiKey: params.apiKey, + model: params.model, + audioFile: audioInput, + audioFileReference: undefined, + language: params.language, + timestamps: params.timestamps, + diarization: params.diarization, + translateToEnglish: params.translateToEnglish, + sentiment: params.sentiment, + entityDetection: params.entityDetection, + piiRedaction: params.piiRedaction, + summarization: params.summarization, + } + }, }, }, inputs: sttV2Inputs, diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 7cc22bb91f..96422c2bee 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -116,6 +116,26 @@ export const VisionV2Block: BlockConfig = { suffix: '_v2', fallbackToolId: 'vision_tool_v2', }), + params: (params) => { + let imageInput = params.imageFile || params.imageFileReference + if (imageInput && typeof imageInput === 'string') { + try { + imageInput = JSON.parse(imageInput) + } catch { + throw new Error('Image file must be a valid file reference') + } + } + if (imageInput && Array.isArray(imageInput)) { + throw new Error( + 'File reference must be a single file, not an array. Use to select one file.' + ) + } + return { + ...params, + imageFile: imageInput, + imageFileReference: undefined, + } + }, }, }, subBlocks: [ diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 559f505bca..8c1ed04d5c 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -451,9 +451,7 @@ function isCompleteUserFile(file: RawFileInput): file is UserFile { typeof file.url === 'string' && typeof file.size === 'number' && typeof file.type === 'string' && - typeof file.key === 'string' && - typeof file.uploadedAt === 'string' && - typeof file.expiresAt === 'string' + typeof file.key === 'string' ) } @@ -461,6 +459,30 @@ function isUrlLike(value: string): boolean { return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') } +/** + * Extracts HTTPS URL from a file input object (UserFile or RawFileInput) + * Returns null if no valid HTTPS URL is found + */ +export function resolveHttpsUrlFromFileInput(fileInput: unknown): string | null { + if (!fileInput || typeof fileInput !== 'object') { + return null + } + + const record = fileInput as Record + const url = + typeof record.url === 'string' + ? record.url.trim() + : typeof record.path === 'string' + ? record.path.trim() + : '' + + if (!url || !url.startsWith('https://')) { + return null + } + + return url +} + function resolveStorageKeyFromRawFile(file: RawFileInput): string | null { if (file.key) { return file.key @@ -484,45 +506,26 @@ function resolveInternalFileUrl(file: RawFileInput): string { if (file.url && isInternalFileUrl(file.url)) { return file.url } - if (file.path && isInternalFileUrl(file.path)) { return file.path } - return '' } /** - * Converts a single raw file object to UserFile format - * @param file - Raw file object (must be a single file, not an array) - * @param requestId - Request ID for logging - * @param logger - Logger instance - * @returns UserFile object - * @throws Error if file is an array or has no storage key + * Core conversion logic from RawFileInput to UserFile */ -export function processSingleFileToUserFile( - file: RawFileInput, - requestId: string, - logger: Logger -): UserFile { - if (Array.isArray(file)) { - const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).` - logger.error(`[${requestId}] ${errorMsg}`) - throw new Error(errorMsg) - } - +function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger): UserFile | null { if (isCompleteUserFile(file)) { return { ...file, - url: resolveInternalFileUrl(file), + url: resolveInternalFileUrl(file) || file.url, } } const storageKey = resolveStorageKeyFromRawFile(file) - if (!storageKey) { - logger.warn(`[${requestId}] File has no storage key: ${file.name || 'unknown'}`) - throw new Error(`File has no storage key: ${file.name || 'unknown'}`) + return null } const userFile: UserFile = { @@ -541,12 +544,32 @@ export function processSingleFileToUserFile( } /** - * Converts raw file objects (from file-upload or variable references) to UserFile format - * Accepts either a single file or an array of files and normalizes to array output - * @param files - Single file or array of raw file objects - * @param requestId - Request ID for logging - * @param logger - Logger instance - * @returns Array of UserFile objects + * Converts a single raw file object to UserFile format + * @throws Error if file is an array or has no storage key + */ +export function processSingleFileToUserFile( + file: RawFileInput, + requestId: string, + logger: Logger +): UserFile { + if (Array.isArray(file)) { + const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).` + logger.error(`[${requestId}] ${errorMsg}`) + throw new Error(errorMsg) + } + + const userFile = convertToUserFile(file, requestId, logger) + if (!userFile) { + const errorMsg = `File has no storage key: ${file.name || 'unknown'}` + logger.warn(`[${requestId}] ${errorMsg}`) + throw new Error(errorMsg) + } + + return userFile +} + +/** + * Converts raw file objects to UserFile format, accepting single or array input */ export function processFilesToUserFiles( files: RawFileInput | RawFileInput[], @@ -557,46 +580,16 @@ export function processFilesToUserFiles( const userFiles: UserFile[] = [] for (const file of filesArray) { - try { - if (Array.isArray(file)) { - logger.warn(`[${requestId}] Skipping nested array in file input`) - continue - } - - if (isCompleteUserFile(file)) { - userFiles.push({ - ...file, - url: resolveInternalFileUrl(file), - }) - continue - } - - const storageKey = resolveStorageKeyFromRawFile(file) - - if (!storageKey) { - logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`) - continue - } - - const userFile: UserFile = { - id: file.id || `file-${Date.now()}`, - name: file.name, - url: resolveInternalFileUrl(file), - size: file.size, - type: file.type || 'application/octet-stream', - key: storageKey, - context: file.context, - base64: file.base64, - } - - logger.info( - `[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})` - ) + if (Array.isArray(file)) { + logger.warn(`[${requestId}] Skipping nested array in file input`) + continue + } + + const userFile = convertToUserFile(file, requestId, logger) + if (userFile) { userFiles.push(userFile) - } catch (error) { - logger.warn( - `[${requestId}] Skipping file: ${error instanceof Error ? error.message : 'Unknown error'}` - ) + } else { + logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`) } } From 5a0becf76fbbfd6c9a09199c54b2bde5ed1f8c76 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 17:04:17 -0800 Subject: [PATCH 06/39] fix integrations --- .../confluence/upload-attachment/route.ts | 3 + .../microsoft_teams/write_channel/route.ts | 12 ++++ apps/sim/app/api/tools/mistral/parse/route.ts | 39 +++++++++-- .../app/api/tools/onedrive/upload/route.ts | 6 ++ apps/sim/app/api/tools/outlook/send/route.ts | 4 +- apps/sim/app/api/tools/reducto/parse/route.ts | 7 +- .../app/api/tools/sharepoint/upload/route.ts | 68 +++++++++++++++++-- apps/sim/app/api/tools/slack/utils.ts | 8 ++- .../app/api/tools/ssh/download-file/route.ts | 10 +++ apps/sim/app/api/tools/stt/route.ts | 31 ++++++--- apps/sim/blocks/blocks/stt.ts | 5 +- apps/sim/tools/dropbox/download.ts | 13 +++- apps/sim/tools/dropbox/upload.ts | 17 +++-- 13 files changed, 187 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index f6be92f3fc..599d70b754 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -92,6 +92,9 @@ export async function POST(request: NextRequest) { formData.append('comment', comment) } + // Add minorEdit field as required by Confluence API + formData.append('minorEdit', 'false') + const response = await fetch(url, { method: 'POST', headers: { diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 3fb575dd4f..5e0f358ea0 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -94,6 +94,18 @@ export async function POST(request: NextRequest) { for (const file of userFiles) { try { + // Microsoft Graph API limits direct uploads to 4MB + const maxSize = 4 * 1024 * 1024 + if (file.size > maxSize) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + logger.error( + `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` + ) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` + ) + } + logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) const buffer = await downloadFileFromStorage(file, requestId, logger) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index bf7c66905e..c8394122e2 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -101,9 +101,19 @@ export async function POST(request: NextRequest) { const base64Payload = base64.startsWith('data:') ? base64 : `data:${mimeType};base64,${base64}` - mistralBody.document = { - type: 'document_url', - document_url: base64Payload, + + // Mistral API uses different document types for images vs documents + const isImage = mimeType.startsWith('image/') + if (isImage) { + mistralBody.document = { + type: 'image_url', + image_url: base64Payload, + } + } else { + mistralBody.document = { + type: 'document_url', + document_url: base64Payload, + } } } else if (filePath) { let fileUrl = filePath @@ -146,9 +156,26 @@ export async function POST(request: NextRequest) { } } - mistralBody.document = { - type: 'document_url', - document_url: fileUrl, + // Detect image URLs by extension for proper Mistral API type + const lowerUrl = fileUrl.toLowerCase() + const isImageUrl = + lowerUrl.endsWith('.png') || + lowerUrl.endsWith('.jpg') || + lowerUrl.endsWith('.jpeg') || + lowerUrl.endsWith('.gif') || + lowerUrl.endsWith('.webp') || + lowerUrl.endsWith('.avif') + + if (isImageUrl) { + mistralBody.document = { + type: 'image_url', + image_url: fileUrl, + } + } else { + mistralBody.document = { + type: 'document_url', + document_url: fileUrl, + } } } diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 87902f8828..2dbad9ef06 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -38,6 +38,7 @@ const OneDriveUploadSchema = z.object({ folderId: z.string().optional().nullable(), mimeType: z.string().nullish(), values: ExcelValuesSchema.optional().nullable(), + conflictBehavior: z.enum(['fail', 'replace', 'rename']).optional().nullable(), }) async function secureFetchGraph( @@ -184,6 +185,11 @@ export async function POST(request: NextRequest) { uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content` } + // Add conflict behavior if specified (defaults to replace by Microsoft Graph API) + if (validatedData.conflictBehavior) { + uploadUrl += `?@microsoft.graph.conflictBehavior=${validatedData.conflictBehavior}` + } + const uploadResponse = await secureFetchGraph( uploadUrl, { diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index 88578bcef6..f90f62518a 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -96,14 +96,14 @@ export async function POST(request: NextRequest) { if (attachments.length > 0) { const totalSize = attachments.reduce((sum, file) => sum + file.size, 0) - const maxSize = 4 * 1024 * 1024 // 4MB + const maxSize = 3 * 1024 * 1024 // 3MB - Microsoft Graph API limit for inline attachments if (totalSize > maxSize) { const sizeMB = (totalSize / (1024 * 1024)).toFixed(2) return NextResponse.json( { success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`, + error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`, }, { status: 400 } ) diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index dc885b1f89..089733043d 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -175,8 +175,13 @@ export async function POST(request: NextRequest) { } if (validatedData.pages && validatedData.pages.length > 0) { + // Reducto API expects page_range as an object with start/end, not an array + const pages = validatedData.pages reductoBody.settings = { - page_range: validatedData.pages, + page_range: { + start: Math.min(...pages), + end: Math.max(...pages), + }, } } diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 43a39ee4c6..05392f0bf2 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -114,7 +114,9 @@ export async function POST(request: NextRequest) { ) if (!driveResponse.ok) { - const errorData = await driveResponse.json().catch(() => ({})) + const errorData = (await driveResponse.json().catch(() => ({}))) as { + error?: { message?: string } + } logger.error(`[${requestId}] Failed to get default drive:`, errorData) return NextResponse.json( { @@ -125,7 +127,7 @@ export async function POST(request: NextRequest) { ) } - const driveData = await driveResponse.json() + const driveData = (await driveResponse.json()) as { id: string } effectiveDriveId = driveData.id logger.info(`[${requestId}] Using default drive: ${effectiveDriveId}`) } @@ -187,20 +189,76 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Failed to upload file ${fileName}:`, errorData) if (uploadResponse.status === 409) { - logger.warn(`[${requestId}] File ${fileName} already exists, attempting to replace`) + // File exists - retry with conflict behavior set to replace + logger.warn(`[${requestId}] File ${fileName} already exists, retrying with replace`) + const replaceUrl = `${uploadUrl}?@microsoft.graph.conflictBehavior=replace` + const replaceResponse = await secureFetchGraph( + replaceUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': userFile.type || 'application/octet-stream', + }, + body: buffer, + }, + 'replaceUrl' + ) + + if (!replaceResponse.ok) { + const replaceErrorData = (await replaceResponse.json().catch(() => ({}))) as { + error?: { message?: string } + } + logger.error(`[${requestId}] Failed to replace file ${fileName}:`, replaceErrorData) + return NextResponse.json( + { + success: false, + error: replaceErrorData.error?.message || `Failed to replace file: ${fileName}`, + }, + { status: replaceResponse.status } + ) + } + + const replaceData = (await replaceResponse.json()) as { + id: string + name: string + webUrl: string + size: number + createdDateTime: string + lastModifiedDateTime: string + } + logger.info(`[${requestId}] File replaced successfully: ${fileName}`) + + uploadedFiles.push({ + id: replaceData.id, + name: replaceData.name, + webUrl: replaceData.webUrl, + size: replaceData.size, + createdDateTime: replaceData.createdDateTime, + lastModifiedDateTime: replaceData.lastModifiedDateTime, + }) continue } return NextResponse.json( { success: false, - error: errorData.error?.message || `Failed to upload file: ${fileName}`, + error: + (errorData as { error?: { message?: string } }).error?.message || + `Failed to upload file: ${fileName}`, }, { status: uploadResponse.status } ) } - const uploadData = await uploadResponse.json() + const uploadData = (await uploadResponse.json()) as { + id: string + name: string + webUrl: string + size: number + createdDateTime: string + lastModifiedDateTime: string + } logger.info(`[${requestId}] File uploaded successfully: ${fileName}`) uploadedFiles.push({ diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index c4128f4eb7..4577d44915 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -156,7 +156,8 @@ export async function completeSlackFileUpload( uploadedFileIds: string[], channel: string, text: string, - accessToken: string + accessToken: string, + threadTs?: string | null ): Promise<{ ok: boolean; files?: any[]; error?: string }> { const response = await fetch('https://slack.com/api/files.completeUploadExternal', { method: 'POST', @@ -168,6 +169,7 @@ export async function completeSlackFileUpload( files: uploadedFileIds.map((id) => ({ id })), channel_id: channel, initial_comment: text, + ...(threadTs && { thread_ts: threadTs }), }), }) @@ -307,8 +309,8 @@ export async function sendSlackMessage( return { success: true, output: formatMessageSuccessResponse(data, text) } } - // Complete file upload - const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken) + // Complete file upload with thread support + const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs) if (!completeData.ok) { logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index 818d0ed410..cd908a1b94 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -80,6 +80,16 @@ export async function POST(request: NextRequest) { }) }) + // Check file size limit (50MB to prevent memory exhaustion) + const maxSize = 50 * 1024 * 1024 + if (stats.size > maxSize) { + const sizeMB = (stats.size / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { error: `File size (${sizeMB}MB) exceeds download limit of 50MB` }, + { status: 400 } + ) + } + // Read file content const content = await new Promise((resolve, reject) => { const chunks: Buffer[] = [] diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 5917db6809..9330d4da43 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -201,7 +201,9 @@ export async function POST(request: NextRequest) { translateToEnglish, model, body.prompt, - body.temperature + body.temperature, + audioMimeType, + audioFileName ) transcript = result.transcript segments = result.segments @@ -214,7 +216,8 @@ export async function POST(request: NextRequest) { language, timestamps, diarization, - model + model, + audioMimeType ) transcript = result.transcript segments = result.segments @@ -304,7 +307,9 @@ async function transcribeWithWhisper( translate?: boolean, model?: string, prompt?: string, - temperature?: number + temperature?: number, + mimeType?: string, + fileName?: string ): Promise<{ transcript: string segments?: TranscriptSegment[] @@ -313,8 +318,11 @@ async function transcribeWithWhisper( }> { const formData = new FormData() - const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' }) - formData.append('file', blob, 'audio.mp3') + // Use actual MIME type and filename if provided + const actualMimeType = mimeType || 'audio/mpeg' + const actualFileName = fileName || 'audio.mp3' + const blob = new Blob([new Uint8Array(audioBuffer)], { type: actualMimeType }) + formData.append('file', blob, actualFileName) formData.append('model', model || 'whisper-1') if (language && language !== 'auto') { @@ -331,10 +339,11 @@ async function transcribeWithWhisper( formData.append('response_format', 'verbose_json') + // OpenAI API uses array notation for timestamp_granularities if (timestamps === 'word') { - formData.append('timestamp_granularities', 'word') + formData.append('timestamp_granularities[]', 'word') } else if (timestamps === 'sentence') { - formData.append('timestamp_granularities', 'segment') + formData.append('timestamp_granularities[]', 'segment') } const endpoint = translate ? 'translations' : 'transcriptions' @@ -377,7 +386,8 @@ async function transcribeWithDeepgram( language?: string, timestamps?: 'none' | 'sentence' | 'word', diarization?: boolean, - model?: string + model?: string, + mimeType?: string ): Promise<{ transcript: string segments?: TranscriptSegment[] @@ -409,7 +419,7 @@ async function transcribeWithDeepgram( method: 'POST', headers: { Authorization: `Token ${apiKey}`, - 'Content-Type': 'audio/mpeg', + 'Content-Type': mimeType || 'audio/mpeg', }, body: new Uint8Array(audioBuffer), }) @@ -565,7 +575,8 @@ async function transcribeWithAssemblyAI( audio_url: upload_url, } - if (model === 'best' || model === 'nano') { + // AssemblyAI only supports 'best', 'slam-1', or 'universal' for speech_model + if (model === 'best') { transcriptRequest.speech_model = model } diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 8b9a945754..344f14458d 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -82,10 +82,7 @@ export const SttBlock: BlockConfig = { title: 'Model', type: 'dropdown', condition: { field: 'provider', value: 'assemblyai' }, - options: [ - { label: 'Best', id: 'best' }, - { label: 'Nano', id: 'nano' }, - ], + options: [{ label: 'Best', id: 'best' }], value: () => 'best', required: true, }, diff --git a/apps/sim/tools/dropbox/download.ts b/apps/sim/tools/dropbox/download.ts index 24292ebda2..8adf286e6f 100644 --- a/apps/sim/tools/dropbox/download.ts +++ b/apps/sim/tools/dropbox/download.ts @@ -1,6 +1,16 @@ import type { DropboxDownloadParams, DropboxDownloadResponse } from '@/tools/dropbox/types' import type { ToolConfig } from '@/tools/types' +/** + * Escapes non-ASCII characters in JSON string for HTTP header safety. + * Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX. + */ +function httpHeaderSafeJson(value: object): string { + return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => { + return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) + }) +} + export const dropboxDownloadTool: ToolConfig = { id: 'dropbox_download', name: 'Dropbox Download File', @@ -30,7 +40,8 @@ export const dropboxDownloadTool: ToolConfig { + return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) + }) +} + export const dropboxUploadTool: ToolConfig = { id: 'dropbox_upload', name: 'Dropbox Upload File', @@ -70,13 +80,12 @@ export const dropboxUploadTool: ToolConfig { - // The body should be the raw binary data - // In this case we're passing the base64 content which will be decoded - return params.fileContent + // Decode base64 to raw binary bytes - Dropbox expects raw binary, not base64 text + return Buffer.from(params.fileContent, 'base64') }, }, From 42767fc4f4da7b262729f9706e00e36916094763 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 17:13:23 -0800 Subject: [PATCH 07/39] fix types --- .../api/tools/github/latest-commit/route.ts | 31 ++++++++++- .../api/tools/google_drive/download/route.ts | 33 +++++++++--- apps/sim/app/api/tools/image/route.ts | 8 +-- .../app/api/tools/onedrive/download/route.ts | 24 +++++++-- .../app/api/tools/onedrive/upload/route.ts | 53 ++++++++++++++++--- .../api/tools/pipedrive/get-files/route.ts | 14 ++++- .../sim/app/api/tools/textract/parse/route.ts | 3 +- .../api/tools/twilio/get-recording/route.ts | 37 +++++++++++-- .../api/tools/zoom/get-recordings/route.ts | 40 ++++++++++++-- apps/sim/blocks/blocks/discord.ts | 2 +- apps/sim/blocks/blocks/google_slides.ts | 8 +-- apps/sim/blocks/blocks/jira.ts | 2 +- apps/sim/blocks/blocks/outlook.ts | 2 +- apps/sim/blocks/blocks/sendgrid.ts | 2 +- apps/sim/blocks/blocks/sftp.ts | 2 +- apps/sim/blocks/blocks/smtp.ts | 2 +- apps/sim/blocks/blocks/telegram.ts | 2 +- 17 files changed, 224 insertions(+), 41 deletions(-) diff --git a/apps/sim/app/api/tools/github/latest-commit/route.ts b/apps/sim/app/api/tools/github/latest-commit/route.ts index 23df8cf90e..39d088dbec 100644 --- a/apps/sim/app/api/tools/github/latest-commit/route.ts +++ b/apps/sim/app/api/tools/github/latest-commit/route.ts @@ -12,6 +12,33 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GitHubLatestCommitAPI') +interface GitHubErrorResponse { + message?: string +} + +interface GitHubCommitResponse { + sha: string + html_url: string + commit: { + message: string + author: { name: string; email: string; date: string } + committer: { name: string; email: string; date: string } + } + author?: { login: string; avatar_url: string; html_url: string } + committer?: { login: string; avatar_url: string; html_url: string } + stats?: { additions: number; deletions: number; total: number } + files?: Array<{ + filename: string + status: string + additions: number + deletions: number + changes: number + patch?: string + raw_url?: string + blob_url?: string + }> +} + const GitHubLatestCommitSchema = z.object({ owner: z.string().min(1, 'Owner is required'), repo: z.string().min(1, 'Repo is required'), @@ -61,7 +88,7 @@ export async function POST(request: NextRequest) { }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = (await response.json().catch(() => ({}))) as GitHubErrorResponse logger.error(`[${requestId}] GitHub API error`, { status: response.status, error: errorData, @@ -72,7 +99,7 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const data = (await response.json()) as GitHubCommitResponse const content = `Latest commit: "${data.commit.message}" by ${data.commit.author.name} on ${data.commit.author.date}. SHA: ${data.sha}` diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index 1341d54d82..2a9730dcad 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -19,6 +19,21 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveDownloadAPI') +/** Google API error response structure */ +interface GoogleApiErrorResponse { + error?: { + message?: string + code?: number + status?: string + } +} + +/** Google Drive revisions list response */ +interface GoogleDriveRevisionsResponse { + revisions?: GoogleDriveRevision[] + nextPageToken?: string +} + const GoogleDriveDownloadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), fileId: z.string().min(1, 'File ID is required'), @@ -76,7 +91,9 @@ export async function POST(request: NextRequest) { ) if (!metadataResponse.ok) { - const errorDetails = await metadataResponse.json().catch(() => ({})) + const errorDetails = (await metadataResponse + .json() + .catch(() => ({}))) as GoogleApiErrorResponse logger.error(`[${requestId}] Failed to get file metadata`, { status: metadataResponse.status, error: errorDetails, @@ -87,7 +104,7 @@ export async function POST(request: NextRequest) { ) } - const metadata: GoogleDriveFile = await metadataResponse.json() + const metadata = (await metadataResponse.json()) as GoogleDriveFile const fileMimeType = metadata.mimeType let fileBuffer: Buffer @@ -119,7 +136,9 @@ export async function POST(request: NextRequest) { ) if (!exportResponse.ok) { - const exportError = await exportResponse.json().catch(() => ({})) + const exportError = (await exportResponse + .json() + .catch(() => ({}))) as GoogleApiErrorResponse logger.error(`[${requestId}] Failed to export file`, { status: exportResponse.status, error: exportError, @@ -154,7 +173,9 @@ export async function POST(request: NextRequest) { ) if (!downloadResponse.ok) { - const downloadError = await downloadResponse.json().catch(() => ({})) + const downloadError = (await downloadResponse + .json() + .catch(() => ({}))) as GoogleApiErrorResponse logger.error(`[${requestId}] Failed to download file`, { status: downloadResponse.status, error: downloadError, @@ -182,8 +203,8 @@ export async function POST(request: NextRequest) { ) if (revisionsResponse.ok) { - const revisionsData = await revisionsResponse.json() - metadata.revisions = revisionsData.revisions as GoogleDriveRevision[] + const revisionsData = (await revisionsResponse.json()) as GoogleDriveRevisionsResponse + metadata.revisions = revisionsData.revisions logger.info(`[${requestId}] Fetched file revisions`, { fileId, revisionCount: metadata.revisions?.length || 0, diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 86192958f0..475a9de5c6 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -68,14 +68,14 @@ export async function GET(request: NextRequest) { const contentType = imageResponse.headers.get('content-type') || 'image/jpeg' - const imageBlob = await imageResponse.blob() + const imageArrayBuffer = await imageResponse.arrayBuffer() - if (imageBlob.size === 0) { - logger.error(`[${requestId}] Empty image blob received`) + if (imageArrayBuffer.byteLength === 0) { + logger.error(`[${requestId}] Empty image received`) return new NextResponse('Empty image received', { status: 404 }) } - return new NextResponse(imageBlob, { + return new NextResponse(imageArrayBuffer, { headers: { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*', diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts index c4ebf5b29c..a50338af55 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -10,6 +10,24 @@ import { generateRequestId } from '@/lib/core/utils/request' export const dynamic = 'force-dynamic' +/** Microsoft Graph API error response structure */ +interface GraphApiError { + error?: { + code?: string + message?: string + } +} + +/** Microsoft Graph API drive item metadata response */ +interface DriveItemMetadata { + id?: string + name?: string + folder?: Record + file?: { + mimeType?: string + } +} + const logger = createLogger('OneDriveDownloadAPI') const OneDriveDownloadSchema = z.object({ @@ -61,7 +79,7 @@ export async function POST(request: NextRequest) { ) if (!metadataResponse.ok) { - const errorDetails = await metadataResponse.json().catch(() => ({})) + const errorDetails = (await metadataResponse.json().catch(() => ({}))) as GraphApiError logger.error(`[${requestId}] Failed to get file metadata`, { status: metadataResponse.status, error: errorDetails, @@ -72,7 +90,7 @@ export async function POST(request: NextRequest) { ) } - const metadata = await metadataResponse.json() + const metadata = (await metadataResponse.json()) as DriveItemMetadata if (metadata.folder && !metadata.file) { logger.error(`[${requestId}] Attempted to download a folder`, { @@ -110,7 +128,7 @@ export async function POST(request: NextRequest) { ) if (!downloadResponse.ok) { - const downloadError = await downloadResponse.json().catch(() => ({})) + const downloadError = (await downloadResponse.json().catch(() => ({}))) as GraphApiError logger.error(`[${requestId}] Failed to download file`, { status: downloadResponse.status, error: downloadError, diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 2dbad9ef06..691812bc7d 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -5,7 +5,6 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, - validateMicrosoftGraphId, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' @@ -41,6 +40,48 @@ const OneDriveUploadSchema = z.object({ conflictBehavior: z.enum(['fail', 'replace', 'rename']).optional().nullable(), }) +/** Microsoft Graph DriveItem response */ +interface OneDriveFileData { + id: string + name: string + size: number + webUrl: string + createdDateTime: string + lastModifiedDateTime: string + file?: { mimeType: string } + parentReference?: { id: string; path: string } + '@microsoft.graph.downloadUrl'?: string +} + +/** Microsoft Graph Excel range response */ +interface ExcelRangeData { + address?: string + addressLocal?: string + values?: unknown[][] +} + +/** Validates Microsoft Graph item IDs (alphanumeric with some special chars) */ +function validateMicrosoftGraphId( + id: string, + paramName: string +): { isValid: boolean; error?: string } { + // Microsoft Graph IDs are typically alphanumeric, may include hyphens and exclamation marks + const validIdPattern = /^[a-zA-Z0-9!-]+$/ + if (!validIdPattern.test(id)) { + return { + isValid: false, + error: `Invalid ${paramName}: contains invalid characters`, + } + } + if (id.length > 256) { + return { + isValid: false, + error: `Invalid ${paramName}: exceeds maximum length`, + } + } + return { isValid: true } +} + async function secureFetchGraph( url: string, options: { @@ -215,7 +256,7 @@ export async function POST(request: NextRequest) { ) } - const fileData = await uploadResponse.json() + const fileData = (await uploadResponse.json()) as OneDriveFileData let excelWriteResult: any | undefined const shouldWriteExcelContent = @@ -241,7 +282,7 @@ export async function POST(request: NextRequest) { ) if (sessionResp.ok) { - const sessionData = await sessionResp.json() + const sessionData = (await sessionResp.json()) as { id?: string } workbookSessionId = sessionData?.id } @@ -262,7 +303,7 @@ export async function POST(request: NextRequest) { 'listUrl' ) if (listResp.ok) { - const listData = await listResp.json() + const listData = (await listResp.json()) as { value?: Array<{ name?: string }> } const firstSheetName = listData?.value?.[0]?.name if (firstSheetName) { sheetName = firstSheetName @@ -348,7 +389,7 @@ export async function POST(request: NextRequest) { details: errorText, } } else { - const writeData = await excelWriteResponse.json() + const writeData = (await excelWriteResponse.json()) as ExcelRangeData const addr = writeData.address || writeData.addressLocal const v = writeData.values || [] excelWriteResult = { @@ -356,7 +397,7 @@ export async function POST(request: NextRequest) { updatedRange: addr, updatedRows: Array.isArray(v) ? v.length : undefined, updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined, - updatedCells: Array.isArray(v) && v[0] ? v.length * (v[0] as any[]).length : undefined, + updatedCells: Array.isArray(v) && v[0] ? v.length * v[0].length : undefined, } } diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts index b2332454ca..93111c94b0 100644 --- a/apps/sim/app/api/tools/pipedrive/get-files/route.ts +++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts @@ -13,6 +13,18 @@ export const dynamic = 'force-dynamic' const logger = createLogger('PipedriveGetFilesAPI') +interface PipedriveFile { + id?: number + name?: string + url?: string +} + +interface PipedriveApiResponse { + success: boolean + data?: PipedriveFile[] + error?: string +} + const PipedriveGetFilesSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), deal_id: z.string().optional().nullable(), @@ -70,7 +82,7 @@ export async function POST(request: NextRequest) { }, }) - const data = await response.json() + const data = (await response.json()) as PipedriveApiResponse if (!data.success) { logger.error(`[${requestId}] Pipedrive API request failed`, { data }) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index eb40ff2f2e..ad19aeb955 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -3,10 +3,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation' import { secureFetchWithPinnedIP, - validateAwsRegion, - validateS3BucketName, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/tools/twilio/get-recording/route.ts b/apps/sim/app/api/tools/twilio/get-recording/route.ts index 5909b1e64f..b5562307e8 100644 --- a/apps/sim/app/api/tools/twilio/get-recording/route.ts +++ b/apps/sim/app/api/tools/twilio/get-recording/route.ts @@ -13,6 +13,36 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TwilioGetRecordingAPI') +interface TwilioRecordingResponse { + sid?: string + call_sid?: string + duration?: string + status?: string + channels?: number + source?: string + price?: string + price_unit?: string + uri?: string + error_code?: number + message?: string + error_message?: string +} + +interface TwilioErrorResponse { + message?: string +} + +interface TwilioTranscription { + transcription_text?: string + status?: string + price?: string + price_unit?: string +} + +interface TwilioTranscriptionsResponse { + transcriptions?: TwilioTranscription[] +} + const TwilioGetRecordingSchema = z.object({ accountSid: z.string().min(1, 'Account SID is required'), authToken: z.string().min(1, 'Auth token is required'), @@ -67,7 +97,7 @@ export async function POST(request: NextRequest) { }) if (!infoResponse.ok) { - const errorData = await infoResponse.json().catch(() => ({})) + const errorData = (await infoResponse.json().catch(() => ({}))) as TwilioErrorResponse logger.error(`[${requestId}] Twilio API error`, { status: infoResponse.status, error: errorData, @@ -78,7 +108,7 @@ export async function POST(request: NextRequest) { ) } - const data = await infoResponse.json() + const data = (await infoResponse.json()) as TwilioRecordingResponse if (data.error_code) { return NextResponse.json({ @@ -126,7 +156,8 @@ export async function POST(request: NextRequest) { ) if (transcriptionResponse.ok) { - const transcriptionData = await transcriptionResponse.json() + const transcriptionData = + (await transcriptionResponse.json()) as TwilioTranscriptionsResponse if (transcriptionData.transcriptions && transcriptionData.transcriptions.length > 0) { const transcription = transcriptionData.transcriptions[0] diff --git a/apps/sim/app/api/tools/zoom/get-recordings/route.ts b/apps/sim/app/api/tools/zoom/get-recordings/route.ts index ed5d086040..2247612fd2 100644 --- a/apps/sim/app/api/tools/zoom/get-recordings/route.ts +++ b/apps/sim/app/api/tools/zoom/get-recordings/route.ts @@ -13,6 +13,40 @@ export const dynamic = 'force-dynamic' const logger = createLogger('ZoomGetRecordingsAPI') +interface ZoomRecordingFile { + id?: string + meeting_id?: string + recording_start?: string + recording_end?: string + file_type?: string + file_extension?: string + file_size?: number + play_url?: string + download_url?: string + status?: string + recording_type?: string +} + +interface ZoomRecordingsResponse { + uuid?: string + id?: string | number + account_id?: string + host_id?: string + topic?: string + type?: number + start_time?: string + duration?: number + total_size?: number + recording_count?: number + share_url?: string + recording_files?: ZoomRecordingFile[] +} + +interface ZoomErrorResponse { + message?: string + code?: number +} + const ZoomGetRecordingsSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), meetingId: z.string().min(1, 'Meeting ID is required'), @@ -72,7 +106,7 @@ export async function POST(request: NextRequest) { }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = (await response.json().catch(() => ({}))) as ZoomErrorResponse logger.error(`[${requestId}] Zoom API error`, { status: response.status, error: errorData, @@ -83,7 +117,7 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const data = (await response.json()) as ZoomRecordingsResponse const files: Array<{ name: string mimeType: string @@ -152,7 +186,7 @@ export async function POST(request: NextRequest) { total_size: data.total_size, recording_count: data.recording_count, share_url: data.share_url, - recording_files: (data.recording_files || []).map((file: any) => ({ + recording_files: (data.recording_files || []).map((file: ZoomRecordingFile) => ({ id: file.id, meeting_id: file.meeting_id, recording_start: file.recording_start, diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 998570b06c..94c27d4482 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -779,7 +779,7 @@ export const DiscordBlock: BlockConfig = { reason: { type: 'string', description: 'Reason for moderation action' }, archived: { type: 'string', description: 'Archive status (true/false)' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + files: { type: 'array', description: 'Files to attach (UserFile array)' }, limit: { type: 'number', description: 'Message limit' }, autoArchiveDuration: { type: 'number', description: 'Thread auto-archive duration in minutes' }, channelType: { type: 'number', description: 'Discord channel type (0=text, 2=voice, etc.)' }, diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 4360bc4ff1..42bd78961b 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -924,10 +924,10 @@ const googleSlidesV2SubBlocks = (GoogleSlidesBlock.subBlocks || []).flatMap((sub { id: 'imageFileReference', title: 'Image', - type: 'short-input', + type: 'short-input' as const, canonicalParamId: 'imageFile', placeholder: 'Reference image from previous blocks', - mode: 'advanced', + mode: 'advanced' as const, required: true, condition: { field: 'operation', value: 'add_image' }, }, @@ -950,9 +950,9 @@ export const GoogleSlidesV2Block: BlockConfig = { hideFromToolbar: false, subBlocks: googleSlidesV2SubBlocks, tools: { - ...GoogleSlidesBlock.tools, + access: GoogleSlidesBlock.tools!.access, config: { - ...GoogleSlidesBlock.tools?.config, + tool: GoogleSlidesBlock.tools!.config!.tool, params: (params) => { const baseParams = GoogleSlidesBlock.tools?.config?.params if (!baseParams) { diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 16f7b9ddf9..c2e64ce1ec 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1025,7 +1025,7 @@ Return ONLY the comment text - no explanations.`, commentId: { type: 'string', description: 'Comment ID for update/delete operations' }, // Attachment operation inputs attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + files: { type: 'array', description: 'Files to attach (UserFile array)' }, attachmentId: { type: 'string', description: 'Attachment ID for delete operation' }, // Worklog operation inputs timeSpentSeconds: { diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index e35f425f52..cfbe253044 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -392,7 +392,7 @@ export const OutlookBlock: BlockConfig = { body: { type: 'string', description: 'Email content' }, contentType: { type: 'string', description: 'Content type (Text or HTML)' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, // Forward operation inputs messageId: { type: 'string', description: 'Message ID to forward' }, comment: { type: 'string', description: 'Optional comment for forwarding' }, diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts index d50beb7070..422f9b57fa 100644 --- a/apps/sim/blocks/blocks/sendgrid.ts +++ b/apps/sim/blocks/blocks/sendgrid.ts @@ -600,7 +600,7 @@ Return ONLY the HTML content.`, mailTemplateId: { type: 'string', description: 'Template ID for sending mail' }, dynamicTemplateData: { type: 'json', description: 'Dynamic template data' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, - attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, // Contact inputs email: { type: 'string', description: 'Contact email' }, firstName: { type: 'string', description: 'Contact first name' }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index c738e45920..3621ee5b43 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -279,7 +279,7 @@ export const SftpBlock: BlockConfig = { privateKey: { type: 'string', description: 'Private key for authentication' }, passphrase: { type: 'string', description: 'Passphrase for encrypted key' }, remotePath: { type: 'string', description: 'Remote path on the SFTP server' }, - files: { type: 'file[]', description: 'Files to upload (UserFile array)' }, + files: { type: 'array', description: 'Files to upload (UserFile array)' }, fileContent: { type: 'string', description: 'Direct content to upload' }, fileName: { type: 'string', description: 'File name for direct content' }, overwrite: { type: 'boolean', description: 'Overwrite existing files' }, diff --git a/apps/sim/blocks/blocks/smtp.ts b/apps/sim/blocks/blocks/smtp.ts index 3f22ae04c6..c292281b6c 100644 --- a/apps/sim/blocks/blocks/smtp.ts +++ b/apps/sim/blocks/blocks/smtp.ts @@ -196,7 +196,7 @@ export const SmtpBlock: BlockConfig = { cc: { type: 'string', description: 'CC recipients (comma-separated)' }, bcc: { type: 'string', description: 'BCC recipients (comma-separated)' }, replyTo: { type: 'string', description: 'Reply-to email address' }, - attachments: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + attachments: { type: 'array', description: 'Files to attach (UserFile array)' }, }, outputs: { diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 40f94c484e..65b18677ab 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -351,7 +351,7 @@ export const TelegramBlock: BlockConfig = { type: 'json', description: 'Files to attach (UI upload)', }, - files: { type: 'file[]', description: 'Files to attach (UserFile array)' }, + files: { type: 'array', description: 'Files to attach (UserFile array)' }, caption: { type: 'string', description: 'Caption for media' }, messageId: { type: 'string', description: 'Message ID to delete' }, }, From 5ecbf6cf4a1fded1924a924b4cdc69b0db169dc3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 17:21:22 -0800 Subject: [PATCH 08/39] consolidate more code --- .../microsoft_teams/write_channel/route.ts | 27 ++----------- .../tools/microsoft_teams/write_chat/route.ts | 39 ++++++++----------- .../app/api/tools/onedrive/upload/route.ts | 31 +++------------ .../app/api/tools/sharepoint/upload/route.ts | 27 ++----------- apps/sim/app/api/tools/slack/utils.ts | 23 +---------- .../core/security/input-validation.server.ts | 22 +++++++++++ 6 files changed, 54 insertions(+), 115 deletions(-) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 5e0f358ea0..b6c0bbd0aa 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -2,10 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -29,22 +26,6 @@ const TeamsWriteChannelSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -async function secureFetchGraph( - url: string, - options: { - method?: string - headers?: Record - body?: string | Buffer | Uint8Array - }, - paramName: string -) { - const urlValidation = await validateUrlWithDNS(url, paramName) - if (!urlValidation.isValid) { - throw new Error(urlValidation.error) - } - return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) -} - export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -123,7 +104,7 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - const uploadResponse = await secureFetchGraph( + const uploadResponse = await secureFetchWithValidation( uploadUrl, { method: 'PUT', @@ -154,7 +135,7 @@ export async function POST(request: NextRequest) { const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - const fileDetailsResponse = await secureFetchGraph( + const fileDetailsResponse = await secureFetchWithValidation( fileDetailsUrl, { method: 'GET', @@ -260,7 +241,7 @@ export async function POST(request: NextRequest) { const teamsUrl = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(validatedData.teamId)}/channels/${encodeURIComponent(validatedData.channelId)}/messages` - const teamsResponse = await secureFetchGraph( + const teamsResponse = await secureFetchWithValidation( teamsUrl, { method: 'POST', diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 6a4e929bad..ec8d43d8a4 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -2,10 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -28,22 +25,6 @@ const TeamsWriteChatSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -async function secureFetchGraph( - url: string, - options: { - method?: string - headers?: Record - body?: string | Buffer | Uint8Array - }, - paramName: string -) { - const urlValidation = await validateUrlWithDNS(url, paramName) - if (!urlValidation.isValid) { - throw new Error(urlValidation.error) - } - return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) -} - export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -92,6 +73,18 @@ export async function POST(request: NextRequest) { for (const file of userFiles) { try { + // Microsoft Graph API limits direct uploads to 4MB + const maxSize = 4 * 1024 * 1024 + if (file.size > maxSize) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + logger.error( + `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` + ) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` + ) + } + logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) const buffer = await downloadFileFromStorage(file, requestId, logger) @@ -109,7 +102,7 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - const uploadResponse = await secureFetchGraph( + const uploadResponse = await secureFetchWithValidation( uploadUrl, { method: 'PUT', @@ -140,7 +133,7 @@ export async function POST(request: NextRequest) { const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - const fileDetailsResponse = await secureFetchGraph( + const fileDetailsResponse = await secureFetchWithValidation( fileDetailsUrl, { method: 'GET', @@ -245,7 +238,7 @@ export async function POST(request: NextRequest) { const teamsUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(validatedData.chatId)}/messages` - const teamsResponse = await secureFetchGraph( + const teamsResponse = await secureFetchWithValidation( teamsUrl, { method: 'POST', diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 691812bc7d..63d50ae737 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -3,10 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { @@ -82,22 +79,6 @@ function validateMicrosoftGraphId( return { isValid: true } } -async function secureFetchGraph( - url: string, - options: { - method?: string - headers?: Record - body?: string | Buffer | Uint8Array - }, - paramName: string -) { - const urlValidation = await validateUrlWithDNS(url, paramName) - if (!urlValidation.isValid) { - throw new Error(urlValidation.error) - } - return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) -} - export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -231,7 +212,7 @@ export async function POST(request: NextRequest) { uploadUrl += `?@microsoft.graph.conflictBehavior=${validatedData.conflictBehavior}` } - const uploadResponse = await secureFetchGraph( + const uploadResponse = await secureFetchWithValidation( uploadUrl, { method: 'PUT', @@ -268,7 +249,7 @@ export async function POST(request: NextRequest) { const sessionUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( fileData.id )}/workbook/createSession` - const sessionResp = await secureFetchGraph( + const sessionResp = await secureFetchWithValidation( sessionUrl, { method: 'POST', @@ -291,7 +272,7 @@ export async function POST(request: NextRequest) { const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( fileData.id )}/workbook/worksheets?$select=name&$orderby=position&$top=1` - const listResp = await secureFetchGraph( + const listResp = await secureFetchWithValidation( listUrl, { method: 'GET', @@ -362,7 +343,7 @@ export async function POST(request: NextRequest) { )}')/range(address='${encodeURIComponent(computedRangeAddress)}')` ) - const excelWriteResponse = await secureFetchGraph( + const excelWriteResponse = await secureFetchWithValidation( url.toString(), { method: 'PATCH', @@ -406,7 +387,7 @@ export async function POST(request: NextRequest) { const closeUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( fileData.id )}/workbook/closeSession` - const closeResp = await secureFetchGraph( + const closeResp = await secureFetchWithValidation( closeUrl, { method: 'POST', diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 05392f0bf2..4f8f37e128 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -2,10 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -24,22 +21,6 @@ const SharepointUploadSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -async function secureFetchGraph( - url: string, - options: { - method?: string - headers?: Record - body?: string | Buffer | Uint8Array - }, - paramName: string -) { - const urlValidation = await validateUrlWithDNS(url, paramName) - if (!urlValidation.isValid) { - throw new Error(urlValidation.error) - } - return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) -} - export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -101,7 +82,7 @@ export async function POST(request: NextRequest) { if (!effectiveDriveId) { logger.info(`[${requestId}] No driveId provided, fetching default drive for site`) const driveUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive` - const driveResponse = await secureFetchGraph( + const driveResponse = await secureFetchWithValidation( driveUrl, { method: 'GET', @@ -171,7 +152,7 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) - const uploadResponse = await secureFetchGraph( + const uploadResponse = await secureFetchWithValidation( uploadUrl, { method: 'PUT', @@ -192,7 +173,7 @@ export async function POST(request: NextRequest) { // File exists - retry with conflict behavior set to replace logger.warn(`[${requestId}] File ${fileName} already exists, retrying with replace`) const replaceUrl = `${uploadUrl}?@microsoft.graph.conflictBehavior=replace` - const replaceResponse = await secureFetchGraph( + const replaceResponse = await secureFetchWithValidation( replaceUrl, { method: 'PUT', diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index 4577d44915..a14ae74a83 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -1,28 +1,9 @@ import type { Logger } from '@sim/logger' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { ToolFileData } from '@/tools/types' -async function secureFetchExternal( - url: string, - options: { - method?: string - headers?: Record - body?: string | Buffer | Uint8Array - }, - paramName: string -) { - const urlValidation = await validateUrlWithDNS(url, paramName) - if (!urlValidation.isValid) { - throw new Error(urlValidation.error) - } - return secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, options) -} - /** * Sends a message to a Slack channel using chat.postMessage */ @@ -128,7 +109,7 @@ export async function uploadFilesToSlack( logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`) - const uploadResponse = await secureFetchExternal( + const uploadResponse = await secureFetchWithValidation( urlData.upload_url, { method: 'POST', diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index a9a46b6d2e..e8c0ec8614 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -288,3 +288,25 @@ export async function secureFetchWithPinnedIP( req.end() }) } + +/** + * Validates a URL and performs a secure fetch with DNS pinning in one call. + * Combines validateUrlWithDNS and secureFetchWithPinnedIP for convenience. + * + * @param url - The URL to fetch + * @param options - Fetch options (method, headers, body, etc.) + * @param paramName - Name of the parameter for error messages (default: 'url') + * @returns SecureFetchResponse + * @throws Error if URL validation fails + */ +export async function secureFetchWithValidation( + url: string, + options: SecureFetchOptions = {}, + paramName = 'url' +): Promise { + const validation = await validateUrlWithDNS(url, paramName) + if (!validation.isValid) { + throw new Error(validation.error) + } + return secureFetchWithPinnedIP(url, validation.resolvedIP!, options) +} From a65f3b8e6be6ea2d340006bee0b150e9af19c567 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 17:26:57 -0800 Subject: [PATCH 09/39] fix tests --- apps/sim/blocks/blocks.test.ts | 12 ++- .../executor/handlers/api/api-handler.test.ts | 26 +++++- apps/sim/tools/utils.test.ts | 79 ++++++++++++------- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index eb59b46c28..395e1128a6 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -442,7 +442,17 @@ describe('Blocks Module', () => { }) it('should have valid output types', () => { - const validPrimitiveTypes = ['string', 'number', 'boolean', 'json', 'array', 'files', 'any'] + const validPrimitiveTypes = [ + 'string', + 'number', + 'boolean', + 'json', + 'array', + 'files', + 'file', + 'file[]', + 'any', + ] const blocks = getAllBlocks() for (const block of blocks) { for (const [key, outputConfig] of Object.entries(block.outputs)) { diff --git a/apps/sim/executor/handlers/api/api-handler.test.ts b/apps/sim/executor/handlers/api/api-handler.test.ts index 3af7fac6fd..0f7f0186ae 100644 --- a/apps/sim/executor/handlers/api/api-handler.test.ts +++ b/apps/sim/executor/handlers/api/api-handler.test.ts @@ -1,6 +1,7 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { BlockType } from '@/executor/constants' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import type { ExecutionContext } from '@/executor/types' @@ -9,8 +10,13 @@ import { executeTool } from '@/tools' import type { ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: vi.fn(), +})) + const mockGetTool = vi.mocked(getTool) const mockExecuteTool = executeTool as Mock +const mockValidateUrlWithDNS = vi.mocked(validateUrlWithDNS) describe('ApiBlockHandler', () => { let handler: ApiBlockHandler @@ -63,6 +69,12 @@ describe('ApiBlockHandler', () => { // Reset mocks using vi vi.clearAllMocks() + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'example.com', + }) + // Set up mockGetTool to return the mockApiTool mockGetTool.mockImplementation((toolId) => { if (toolId === 'http_request') { @@ -130,8 +142,13 @@ describe('ApiBlockHandler', () => { it('should throw error for invalid URL format (no protocol)', async () => { const inputs = { url: 'example.com/api' } + mockValidateUrlWithDNS.mockResolvedValueOnce({ + isValid: false, + error: 'url must be a valid URL', + }) + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Invalid URL: "example.com/api" - URL must include protocol (try "https://example.com/api")' + 'url must be a valid URL' ) expect(mockExecuteTool).not.toHaveBeenCalled() }) @@ -139,8 +156,13 @@ describe('ApiBlockHandler', () => { it('should throw error for generally invalid URL format', async () => { const inputs = { url: 'htp:/invalid-url' } + mockValidateUrlWithDNS.mockResolvedValueOnce({ + isValid: false, + error: 'url must use https:// protocol', + }) + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - /^Invalid URL: "htp:\/invalid-url" - URL must include protocol/ + 'url must use https:// protocol' ) expect(mockExecuteTool).not.toHaveBeenCalled() }) diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 0507eda1c8..43a5531da9 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -1,5 +1,9 @@ -import { createMockFetch, loggerMock } from '@sim/testing' +import { createMockResponse, loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' import { @@ -12,6 +16,10 @@ import { import { executeRequest } from '@/tools/utils.server' vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: vi.fn(), + secureFetchWithPinnedIP: vi.fn(), +})) vi.mock('@/stores/settings/environment', () => { const mockStore = { @@ -406,11 +414,18 @@ describe('validateRequiredParametersAfterMerge', () => { describe('executeRequest', () => { let mockTool: ToolConfig - let mockFetch: ReturnType + const mockValidateUrlWithDNS = vi.mocked(validateUrlWithDNS) + const mockSecureFetchWithPinnedIP = vi.mocked(secureFetchWithPinnedIP) beforeEach(() => { - mockFetch = createMockFetch({ json: { result: 'success' }, status: 200 }) - global.fetch = mockFetch + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'api.example.com', + }) + mockSecureFetchWithPinnedIP.mockResolvedValue( + createMockResponse({ json: { result: 'success' }, status: 200 }) + ) mockTool = { id: 'test-tool', @@ -441,11 +456,15 @@ describe('executeRequest', () => { headers: {}, }) - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com', { - method: 'GET', - headers: {}, - body: undefined, - }) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://api.example.com', + '93.184.216.34', + { + method: 'GET', + headers: {}, + body: undefined, + } + ) expect(mockTool.transformResponse).toHaveBeenCalled() expect(result).toEqual({ success: true, @@ -455,8 +474,6 @@ describe('executeRequest', () => { it.concurrent('should use default transform response if not provided', async () => { mockTool.transformResponse = undefined - const localMockFetch = createMockFetch({ json: { result: 'success' }, status: 200 }) - global.fetch = localMockFetch const result = await executeRequest('test-tool', mockTool, { url: 'https://api.example.com', @@ -471,13 +488,14 @@ describe('executeRequest', () => { }) it('should handle error responses', async () => { - const errorFetch = createMockFetch({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: { message: 'Invalid input' }, - }) - global.fetch = errorFetch + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: { message: 'Invalid input' }, + }) + ) const result = await executeRequest('test-tool', mockTool, { url: 'https://api.example.com', @@ -493,8 +511,7 @@ describe('executeRequest', () => { }) it.concurrent('should handle network errors', async () => { - const errorFetch = vi.fn().mockRejectedValueOnce(new Error('Network error')) - global.fetch = errorFetch + mockSecureFetchWithPinnedIP.mockRejectedValueOnce(new Error('Network error')) const result = await executeRequest('test-tool', mockTool, { url: 'https://api.example.com', @@ -510,15 +527,16 @@ describe('executeRequest', () => { }) it('should handle JSON parse errors in error response', async () => { - const errorFetch = vi.fn().mockResolvedValueOnce({ + const errorResponse = createMockResponse({ ok: false, status: 500, statusText: 'Server Error', - json: async () => { - throw new Error('Invalid JSON') - }, }) - global.fetch = errorFetch + errorResponse.json = vi.fn(async () => { + throw new Error('Invalid JSON') + }) + errorResponse.text = vi.fn(async () => '') + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse) const result = await executeRequest('test-tool', mockTool, { url: 'https://api.example.com', @@ -548,11 +566,12 @@ describe('executeRequest', () => { }, } - const xmlFetch = createMockFetch({ - status: 200, - text: 'Mock XML response', - }) - global.fetch = xmlFetch + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + createMockResponse({ + status: 200, + text: 'Mock XML response', + }) + ) const result = await executeRequest('test-tool', toolWithTransform, { url: 'https://api.example.com', From 3ceabbb816ed747f7211e6843c07099aed52822f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 20:12:31 -0800 Subject: [PATCH 10/39] fix more bugbot comments --- .../microsoft_teams/write_channel/route.ts | 144 ++------------- .../tools/microsoft_teams/write_chat/route.ts | 144 ++------------- apps/sim/app/api/tools/pulse/parse/route.ts | 139 +++------------ apps/sim/app/api/tools/reducto/parse/route.ts | 141 +++------------ apps/sim/app/api/tools/stt/route.ts | 4 +- .../lib/uploads/utils/file-utils.server.ts | 114 ++++++++++++ apps/sim/tools/microsoft_teams/utils.ts | 165 +++++++++++++++++- 7 files changed, 348 insertions(+), 503 deletions(-) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index b6c0bbd0aa..52aba74736 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -5,14 +5,12 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import type { - GraphApiErrorResponse, - GraphChatMessage, - GraphDriveItem, -} from '@/tools/microsoft_teams/types' -import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' +import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' +import { + resolveMentionsForChannel, + type TeamsMention, + uploadFilesForTeamsMessage, +} from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -60,130 +58,12 @@ export async function POST(request: NextRequest) { fileCount: validatedData.files?.length || 0, }) - const attachments: any[] = [] - const filesOutput: Array<{ - name: string - mimeType: string - data: string - size: number - }> = [] - if (validatedData.files && validatedData.files.length > 0) { - const rawFiles = validatedData.files - logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) - - const userFiles = processFilesToUserFiles(rawFiles, requestId, logger) - - for (const file of userFiles) { - try { - // Microsoft Graph API limits direct uploads to 4MB - const maxSize = 4 * 1024 * 1024 - if (file.size > maxSize) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - logger.error( - `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` - ) - throw new Error( - `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` - ) - } - - logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - filesOutput.push({ - name: file.name, - mimeType: file.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) - - const uploadUrl = - 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + - encodeURIComponent(file.name) + - ':/content' - - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - - const uploadResponse = await secureFetchWithValidation( - uploadUrl, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', - }, - body: buffer, - }, - 'uploadUrl' - ) - - if (!uploadResponse.ok) { - const errorData = (await uploadResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Teams upload failed:`, errorData) - throw new Error( - `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const uploadedFile = (await uploadResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] File uploaded to Teams successfully`, { - id: uploadedFile.id, - webUrl: uploadedFile.webUrl, - }) - - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - - const fileDetailsResponse = await secureFetchWithValidation( - fileDetailsUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - }, - }, - 'fileDetailsUrl' - ) - - if (!fileDetailsResponse.ok) { - const errorData = (await fileDetailsResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Failed to get file details:`, errorData) - throw new Error( - `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] Got file details`, { - webDavUrl: fileDetails.webDavUrl, - eTag: fileDetails.eTag, - }) - - const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id - - attachments.push({ - id: attachmentId, - contentType: 'reference', - contentUrl: fileDetails.webDavUrl, - name: file.name, - }) - - logger.info(`[${requestId}] Created attachment reference for ${file.name}`) - } catch (error) { - logger.error(`[${requestId}] Failed to process file ${file.name}:`, error) - throw new Error( - `Failed to process file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } - - logger.info( - `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` - ) - } + const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ + rawFiles: validatedData.files || [], + accessToken: validatedData.accessToken, + requestId, + logger, + }) let messageContent = validatedData.content let contentType: 'text' | 'html' = 'text' diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index ec8d43d8a4..4d12a4e9c1 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -5,14 +5,12 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import type { - GraphApiErrorResponse, - GraphChatMessage, - GraphDriveItem, -} from '@/tools/microsoft_teams/types' -import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' +import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' +import { + resolveMentionsForChat, + type TeamsMention, + uploadFilesForTeamsMessage, +} from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -58,130 +56,12 @@ export async function POST(request: NextRequest) { fileCount: validatedData.files?.length || 0, }) - const attachments: any[] = [] - const filesOutput: Array<{ - name: string - mimeType: string - data: string - size: number - }> = [] - if (validatedData.files && validatedData.files.length > 0) { - const rawFiles = validatedData.files - logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to Teams`) - - const userFiles = processFilesToUserFiles(rawFiles, requestId, logger) - - for (const file of userFiles) { - try { - // Microsoft Graph API limits direct uploads to 4MB - const maxSize = 4 * 1024 * 1024 - if (file.size > maxSize) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - logger.error( - `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` - ) - throw new Error( - `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` - ) - } - - logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - filesOutput.push({ - name: file.name, - mimeType: file.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) - - const uploadUrl = - 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + - encodeURIComponent(file.name) + - ':/content' - - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - - const uploadResponse = await secureFetchWithValidation( - uploadUrl, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', - }, - body: buffer, - }, - 'uploadUrl' - ) - - if (!uploadResponse.ok) { - const errorData = (await uploadResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Teams upload failed:`, errorData) - throw new Error( - `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const uploadedFile = (await uploadResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] File uploaded to Teams successfully`, { - id: uploadedFile.id, - webUrl: uploadedFile.webUrl, - }) - - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - - const fileDetailsResponse = await secureFetchWithValidation( - fileDetailsUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - }, - }, - 'fileDetailsUrl' - ) - - if (!fileDetailsResponse.ok) { - const errorData = (await fileDetailsResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Failed to get file details:`, errorData) - throw new Error( - `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] Got file details`, { - webDavUrl: fileDetails.webDavUrl, - eTag: fileDetails.eTag, - }) - - const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id - - attachments.push({ - id: attachmentId, - contentType: 'reference', - contentUrl: fileDetails.webDavUrl, - name: file.name, - }) - - logger.info(`[${requestId}] Created attachment reference for ${file.name}`) - } catch (error) { - logger.error(`[${requestId}] Failed to process file ${file.name}:`, error) - throw new Error( - `Failed to process file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } - - logger.info( - `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` - ) - } + const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ + rawFiles: validatedData.files || [], + accessToken: validatedData.accessToken, + requestId, + logger, + }) let messageContent = validatedData.content let contentType: 'text' | 'html' = 'text' diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 906f869d27..39dc9259a4 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -7,15 +7,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { type StorageContext, StorageService } from '@/lib/uploads' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' -import { - inferContextFromKey, - isInternalFileUrl, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-utils' -import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' -import { verifyFileAccess } from '@/app/api/files/authorization' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' @@ -56,120 +50,31 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = PulseParseSchema.parse(body) - const fileInput = validatedData.file - let fileUrl = '' - if (fileInput) { - logger.info(`[${requestId}] Pulse parse request`, { - fileName: fileInput.name, - userId, - }) - - let userFile - try { - userFile = processSingleFileToUserFile(fileInput, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to process file', - }, - { status: 400 } - ) - } + logger.info(`[${requestId}] Pulse parse request`, { + fileName: validatedData.file?.name, + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, + userId, + }) - fileUrl = userFile.url || '' - if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || '' - } - if (!fileUrl && userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) - } - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) - } - } else if (validatedData.filePath) { - logger.info(`[${requestId}] Pulse parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) + const resolution = await resolveFileInputToUrl({ + file: validatedData.file, + filePath: validatedData.filePath, + userId, + requestId, + logger, + }) - fileUrl = validatedData.filePath - const isInternalFilePath = isInternalFileUrl(validatedData.filePath) - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl( - validatedData.filePath, - userId, - requestId, - logger - ) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (validatedData.filePath.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: validatedData.filePath.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') - if (!urlValidation.isValid) { - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } + if (resolution.error) { + return NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ) } + const fileUrl = resolution.fileUrl if (!fileUrl) { - return NextResponse.json( - { - success: false, - error: 'File input is required', - }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } const formData = new FormData() diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index 089733043d..c526c8f2ab 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -7,15 +7,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { type StorageContext, StorageService } from '@/lib/uploads' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' -import { - inferContextFromKey, - isInternalFileUrl, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-utils' -import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' -import { verifyFileAccess } from '@/app/api/files/authorization' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' @@ -52,122 +46,31 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = ReductoParseSchema.parse(body) - const fileInput = validatedData.file - let fileUrl = '' - if (fileInput) { - logger.info(`[${requestId}] Reducto parse request`, { - fileName: fileInput.name, - userId, - }) - - let userFile - try { - userFile = processSingleFileToUserFile(fileInput, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to process file', - }, - { status: 400 } - ) - } - - fileUrl = userFile.url || '' - if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || '' - } - if (!fileUrl && userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) - } + logger.info(`[${requestId}] Reducto parse request`, { + fileName: validatedData.file?.name, + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, + userId, + }) - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) - } - } else if (validatedData.filePath) { - logger.info(`[${requestId}] Reducto parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) + const resolution = await resolveFileInputToUrl({ + file: validatedData.file, + filePath: validatedData.filePath, + userId, + requestId, + logger, + }) - fileUrl = validatedData.filePath - const isInternalFilePath = isInternalFileUrl(validatedData.filePath) - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl( - validatedData.filePath, - userId, - requestId, - logger - ) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (validatedData.filePath.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: validatedData.filePath.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') - if (!urlValidation.isValid) { - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } + if (resolution.error) { + return NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ) } + const fileUrl = resolution.fileUrl if (!fileUrl) { - return NextResponse.json( - { - success: false, - error: 'File input is required', - }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } const reductoBody: Record = { diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 9330d4da43..cab959741a 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -575,8 +575,8 @@ async function transcribeWithAssemblyAI( audio_url: upload_url, } - // AssemblyAI only supports 'best', 'slam-1', or 'universal' for speech_model - if (model === 'best') { + // AssemblyAI supports 'best', 'slam-1', or 'universal' for speech_model + if (model === 'best' || model === 'slam-1' || model === 'universal') { transcriptRequest.speech_model = model } diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 440b2d92c1..b759918d01 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -12,10 +12,124 @@ import { extractStorageKey, inferContextFromKey, isInternalFileUrl, + processSingleFileToUserFile, + type RawFileInput, } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' +/** + * Result type for file input resolution + */ +export interface FileResolutionResult { + fileUrl?: string + error?: { + status: number + message: string + } +} + +/** + * Options for resolving file input to a URL + */ +export interface ResolveFileInputOptions { + file?: RawFileInput + filePath?: string + userId: string + requestId: string + logger: Logger +} + +/** + * Resolves file input (either a file object or filePath string) to a publicly accessible URL. + * Handles: + * - Processing raw file input via processSingleFileToUserFile + * - Resolving internal URLs via resolveInternalFileUrl + * - Generating presigned URLs for storage keys + * - Validating external URLs via validateUrlWithDNS + */ +export async function resolveFileInputToUrl( + options: ResolveFileInputOptions +): Promise { + const { file, filePath, userId, requestId, logger } = options + + if (file) { + let userFile: UserFile + try { + userFile = processSingleFileToUserFile(file, requestId, logger) + } catch (error) { + return { + error: { + status: 400, + message: error instanceof Error ? error.message : 'Failed to process file', + }, + } + } + + let fileUrl = userFile.url || '' + + // Handle internal URLs + if (fileUrl && isInternalFileUrl(fileUrl)) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return { error: resolution.error } + } + fileUrl = resolution.fileUrl || '' + } + + // Generate presigned URL if we have a key but no URL + if (!fileUrl && userFile.key) { + const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) + const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: userFile.key, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + } + + return { fileUrl } + } + + if (filePath) { + let fileUrl = filePath + + if (isInternalFileUrl(filePath)) { + const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger) + if (resolution.error) { + return { error: resolution.error } + } + fileUrl = resolution.fileUrl || fileUrl + } else if (filePath.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: filePath.substring(0, 50), + }) + return { + error: { + status: 400, + message: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + } + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') + if (!urlValidation.isValid) { + return { error: { status: 400, message: urlValidation.error || 'Invalid URL' } } + } + } + + return { fileUrl } + } + + return { error: { status: 400, message: 'File input is required' } } +} + /** * Download a file from a URL (internal or external) * For internal URLs, uses direct storage access (server-side only) diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 5014574717..5e14a0834f 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -1,9 +1,172 @@ +import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' -import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { UserFile } from '@/executor/types' +import type { + GraphApiErrorResponse, + GraphDriveItem, + MicrosoftTeamsAttachment, +} from '@/tools/microsoft_teams/types' import type { ToolFileData } from '@/tools/types' const logger = createLogger('MicrosoftTeamsUtils') +/** Maximum file size for Teams direct upload (4MB) */ +const MAX_TEAMS_FILE_SIZE = 4 * 1024 * 1024 + +/** Output format for uploaded files */ +export interface TeamsFileOutput { + name: string + mimeType: string + data: string + size: number +} + +/** Attachment reference for Teams message */ +export interface TeamsAttachmentRef { + id: string + contentType: 'reference' + contentUrl: string + name: string +} + +/** Result from processing and uploading files for Teams */ +export interface TeamsFileUploadResult { + attachments: TeamsAttachmentRef[] + filesOutput: TeamsFileOutput[] +} + +/** + * Process and upload files to OneDrive for Teams message attachments. + * Handles size validation, downloading from storage, uploading to OneDrive, + * and creating attachment references. + */ +export async function uploadFilesForTeamsMessage(params: { + rawFiles: unknown[] + accessToken: string + requestId: string + logger: Logger +}): Promise { + const { rawFiles, accessToken, requestId, logger: log } = params + const attachments: TeamsAttachmentRef[] = [] + const filesOutput: TeamsFileOutput[] = [] + + if (!rawFiles || rawFiles.length === 0) { + return { attachments, filesOutput } + } + + log.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) + + const userFiles = processFilesToUserFiles(rawFiles, requestId, log) as UserFile[] + + for (const file of userFiles) { + // Check size limit + if (file.size > MAX_TEAMS_FILE_SIZE) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + log.error( + `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` + ) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` + ) + } + + log.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) + + // Download file from storage + const buffer = await downloadFileFromStorage(file, requestId, log) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) + + // Upload to OneDrive + const uploadUrl = + 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + + encodeURIComponent(file.name) + + ':/content' + + log.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`) + + const uploadResponse = await secureFetchWithValidation( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: buffer, + }, + 'uploadUrl' + ) + + if (!uploadResponse.ok) { + const errorData = (await uploadResponse.json().catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Teams upload failed:`, errorData) + throw new Error( + `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` + ) + } + + const uploadedFile = (await uploadResponse.json()) as GraphDriveItem + log.info(`[${requestId}] File uploaded to OneDrive successfully`, { + id: uploadedFile.id, + webUrl: uploadedFile.webUrl, + }) + + // Get file details for attachment reference + const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` + + const fileDetailsResponse = await secureFetchWithValidation( + fileDetailsUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + 'fileDetailsUrl' + ) + + if (!fileDetailsResponse.ok) { + const errorData = (await fileDetailsResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Failed to get file details:`, errorData) + throw new Error(`Failed to get file details: ${errorData.error?.message || 'Unknown error'}`) + } + + const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem + log.info(`[${requestId}] Got file details`, { + webDavUrl: fileDetails.webDavUrl, + eTag: fileDetails.eTag, + }) + + // Create attachment reference + const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id + + attachments.push({ + id: attachmentId, + contentType: 'reference', + contentUrl: fileDetails.webDavUrl!, + name: file.name, + }) + + log.info(`[${requestId}] Created attachment reference for ${file.name}`) + } + + log.info( + `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` + ) + + return { attachments, filesOutput } +} + interface ParsedMention { name: string fullTag: string From 1ff35405fa68be650ab02f25bcac0c0cff4eaf2c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 2 Feb 2026 20:14:46 -0800 Subject: [PATCH 11/39] fix type check --- apps/sim/tools/microsoft_teams/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 5e14a0834f..15b8730b8e 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -1,7 +1,7 @@ import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { UserFile } from '@/executor/types' import type { @@ -44,7 +44,7 @@ export interface TeamsFileUploadResult { * and creating attachment references. */ export async function uploadFilesForTeamsMessage(params: { - rawFiles: unknown[] + rawFiles: RawFileInput[] accessToken: string requestId: string logger: Logger From 1c857cdcdaf5c85a7ce26ca6df3b8694b89ba8bd Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 00:52:12 -0800 Subject: [PATCH 12/39] fix circular impport --- .../microsoft_teams/write_channel/route.ts | 7 +- .../tools/microsoft_teams/write_chat/route.ts | 7 +- .../sim/tools/microsoft_teams/server-utils.ts | 165 ++++++++++++++++++ apps/sim/tools/microsoft_teams/utils.ts | 165 +----------------- 4 files changed, 170 insertions(+), 174 deletions(-) create mode 100644 apps/sim/tools/microsoft_teams/server-utils.ts diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 52aba74736..a477f68d8b 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -5,12 +5,9 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' +import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' -import { - resolveMentionsForChannel, - type TeamsMention, - uploadFilesForTeamsMessage, -} from '@/tools/microsoft_teams/utils' +import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 4d12a4e9c1..67df1e4028 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -5,12 +5,9 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' +import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' -import { - resolveMentionsForChat, - type TeamsMention, - uploadFilesForTeamsMessage, -} from '@/tools/microsoft_teams/utils' +import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/tools/microsoft_teams/server-utils.ts b/apps/sim/tools/microsoft_teams/server-utils.ts new file mode 100644 index 0000000000..55d530f6e8 --- /dev/null +++ b/apps/sim/tools/microsoft_teams/server-utils.ts @@ -0,0 +1,165 @@ +/** + * Server-side utilities for Microsoft Teams integration. + * This file contains functions that require server-side dependencies and should + * only be imported by API routes, NOT by tool definitions (to avoid circular imports). + */ +import type { Logger } from '@sim/logger' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { UserFile } from '@/executor/types' +import type { GraphApiErrorResponse, GraphDriveItem } from '@/tools/microsoft_teams/types' + +/** Maximum file size for Teams direct upload (4MB) */ +const MAX_TEAMS_FILE_SIZE = 4 * 1024 * 1024 + +/** Output format for uploaded files */ +export interface TeamsFileOutput { + name: string + mimeType: string + data: string + size: number +} + +/** Attachment reference for Teams message */ +export interface TeamsAttachmentRef { + id: string + contentType: 'reference' + contentUrl: string + name: string +} + +/** Result from processing and uploading files for Teams */ +export interface TeamsFileUploadResult { + attachments: TeamsAttachmentRef[] + filesOutput: TeamsFileOutput[] +} + +/** + * Process and upload files to OneDrive for Teams message attachments. + * Handles size validation, downloading from storage, uploading to OneDrive, + * and creating attachment references. + */ +export async function uploadFilesForTeamsMessage(params: { + rawFiles: RawFileInput[] + accessToken: string + requestId: string + logger: Logger +}): Promise { + const { rawFiles, accessToken, requestId, logger: log } = params + const attachments: TeamsAttachmentRef[] = [] + const filesOutput: TeamsFileOutput[] = [] + + if (!rawFiles || rawFiles.length === 0) { + return { attachments, filesOutput } + } + + log.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) + + const userFiles = processFilesToUserFiles(rawFiles, requestId, log) as UserFile[] + + for (const file of userFiles) { + // Check size limit + if (file.size > MAX_TEAMS_FILE_SIZE) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + log.error( + `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` + ) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` + ) + } + + log.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) + + // Download file from storage + const buffer = await downloadFileFromStorage(file, requestId, log) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) + + // Upload to OneDrive + const uploadUrl = + 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + + encodeURIComponent(file.name) + + ':/content' + + log.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`) + + const uploadResponse = await secureFetchWithValidation( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: buffer, + }, + 'uploadUrl' + ) + + if (!uploadResponse.ok) { + const errorData = (await uploadResponse.json().catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Teams upload failed:`, errorData) + throw new Error( + `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` + ) + } + + const uploadedFile = (await uploadResponse.json()) as GraphDriveItem + log.info(`[${requestId}] File uploaded to OneDrive successfully`, { + id: uploadedFile.id, + webUrl: uploadedFile.webUrl, + }) + + // Get file details for attachment reference + const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` + + const fileDetailsResponse = await secureFetchWithValidation( + fileDetailsUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + 'fileDetailsUrl' + ) + + if (!fileDetailsResponse.ok) { + const errorData = (await fileDetailsResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Failed to get file details:`, errorData) + throw new Error(`Failed to get file details: ${errorData.error?.message || 'Unknown error'}`) + } + + const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem + log.info(`[${requestId}] Got file details`, { + webDavUrl: fileDetails.webDavUrl, + eTag: fileDetails.eTag, + }) + + // Create attachment reference + const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id + + attachments.push({ + id: attachmentId, + contentType: 'reference', + contentUrl: fileDetails.webDavUrl!, + name: file.name, + }) + + log.info(`[${requestId}] Created attachment reference for ${file.name}`) + } + + log.info( + `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` + ) + + return { attachments, filesOutput } +} diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 15b8730b8e..5014574717 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -1,172 +1,9 @@ -import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import type { UserFile } from '@/executor/types' -import type { - GraphApiErrorResponse, - GraphDriveItem, - MicrosoftTeamsAttachment, -} from '@/tools/microsoft_teams/types' +import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types' import type { ToolFileData } from '@/tools/types' const logger = createLogger('MicrosoftTeamsUtils') -/** Maximum file size for Teams direct upload (4MB) */ -const MAX_TEAMS_FILE_SIZE = 4 * 1024 * 1024 - -/** Output format for uploaded files */ -export interface TeamsFileOutput { - name: string - mimeType: string - data: string - size: number -} - -/** Attachment reference for Teams message */ -export interface TeamsAttachmentRef { - id: string - contentType: 'reference' - contentUrl: string - name: string -} - -/** Result from processing and uploading files for Teams */ -export interface TeamsFileUploadResult { - attachments: TeamsAttachmentRef[] - filesOutput: TeamsFileOutput[] -} - -/** - * Process and upload files to OneDrive for Teams message attachments. - * Handles size validation, downloading from storage, uploading to OneDrive, - * and creating attachment references. - */ -export async function uploadFilesForTeamsMessage(params: { - rawFiles: RawFileInput[] - accessToken: string - requestId: string - logger: Logger -}): Promise { - const { rawFiles, accessToken, requestId, logger: log } = params - const attachments: TeamsAttachmentRef[] = [] - const filesOutput: TeamsFileOutput[] = [] - - if (!rawFiles || rawFiles.length === 0) { - return { attachments, filesOutput } - } - - log.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) - - const userFiles = processFilesToUserFiles(rawFiles, requestId, log) as UserFile[] - - for (const file of userFiles) { - // Check size limit - if (file.size > MAX_TEAMS_FILE_SIZE) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - log.error( - `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` - ) - throw new Error( - `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` - ) - } - - log.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) - - // Download file from storage - const buffer = await downloadFileFromStorage(file, requestId, log) - filesOutput.push({ - name: file.name, - mimeType: file.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) - - // Upload to OneDrive - const uploadUrl = - 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + - encodeURIComponent(file.name) + - ':/content' - - log.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`) - - const uploadResponse = await secureFetchWithValidation( - uploadUrl, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', - }, - body: buffer, - }, - 'uploadUrl' - ) - - if (!uploadResponse.ok) { - const errorData = (await uploadResponse.json().catch(() => ({}))) as GraphApiErrorResponse - log.error(`[${requestId}] Teams upload failed:`, errorData) - throw new Error( - `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const uploadedFile = (await uploadResponse.json()) as GraphDriveItem - log.info(`[${requestId}] File uploaded to OneDrive successfully`, { - id: uploadedFile.id, - webUrl: uploadedFile.webUrl, - }) - - // Get file details for attachment reference - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - - const fileDetailsResponse = await secureFetchWithValidation( - fileDetailsUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - 'fileDetailsUrl' - ) - - if (!fileDetailsResponse.ok) { - const errorData = (await fileDetailsResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - log.error(`[${requestId}] Failed to get file details:`, errorData) - throw new Error(`Failed to get file details: ${errorData.error?.message || 'Unknown error'}`) - } - - const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem - log.info(`[${requestId}] Got file details`, { - webDavUrl: fileDetails.webDavUrl, - eTag: fileDetails.eTag, - }) - - // Create attachment reference - const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id - - attachments.push({ - id: attachmentId, - contentType: 'reference', - contentUrl: fileDetails.webDavUrl!, - name: file.name, - }) - - log.info(`[${requestId}] Created attachment reference for ${file.name}`) - } - - log.info( - `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` - ) - - return { attachments, filesOutput } -} - interface ParsedMention { name: string fullTag: string From 6e642fc705f7450ce98531f7a906fa863fede709 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 00:58:58 -0800 Subject: [PATCH 13/39] address more bugbot comments --- .../download-export-file/route.ts | 1 - .../app/api/tools/onedrive/upload/route.ts | 23 +------------------ .../sim/tools/microsoft_teams/server-utils.ts | 13 +++++++++-- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts index e33e362d7e..01bdfd3f50 100644 --- a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts +++ b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts @@ -15,7 +15,6 @@ const logger = createLogger('GoogleVaultDownloadExportFileAPI') const GoogleVaultDownloadExportFileSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), - matterId: z.string().min(1, 'Matter ID is required'), bucketName: z.string().min(1, 'Bucket name is required'), objectName: z.string().min(1, 'Object name is required'), fileName: z.string().optional().nullable(), diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 63d50ae737..8919b528cd 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' @@ -57,28 +58,6 @@ interface ExcelRangeData { values?: unknown[][] } -/** Validates Microsoft Graph item IDs (alphanumeric with some special chars) */ -function validateMicrosoftGraphId( - id: string, - paramName: string -): { isValid: boolean; error?: string } { - // Microsoft Graph IDs are typically alphanumeric, may include hyphens and exclamation marks - const validIdPattern = /^[a-zA-Z0-9!-]+$/ - if (!validIdPattern.test(id)) { - return { - isValid: false, - error: `Invalid ${paramName}: contains invalid characters`, - } - } - if (id.length > 256) { - return { - isValid: false, - error: `Invalid ${paramName}: exceeds maximum length`, - } - } - return { isValid: true } -} - export async function POST(request: NextRequest) { const requestId = generateRequestId() diff --git a/apps/sim/tools/microsoft_teams/server-utils.ts b/apps/sim/tools/microsoft_teams/server-utils.ts index 55d530f6e8..18372d4216 100644 --- a/apps/sim/tools/microsoft_teams/server-utils.ts +++ b/apps/sim/tools/microsoft_teams/server-utils.ts @@ -117,7 +117,8 @@ export async function uploadFilesForTeamsMessage(params: { }) // Get file details for attachment reference - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` + // Note: webDavUrl requires 'select' without the '$' prefix to be reliably returned + const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?select=id,name,webDavUrl,eTag,size` const fileDetailsResponse = await secureFetchWithValidation( fileDetailsUrl, @@ -144,13 +145,21 @@ export async function uploadFilesForTeamsMessage(params: { eTag: fileDetails.eTag, }) + // Validate webDavUrl is present (required for Teams attachment references) + if (!fileDetails.webDavUrl) { + log.error(`[${requestId}] webDavUrl missing from file details`, { fileId: uploadedFile.id }) + throw new Error( + `Failed to get file URL for attachment "${file.name}". The file was uploaded but Teams attachment reference could not be created.` + ) + } + // Create attachment reference const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id attachments.push({ id: attachmentId, contentType: 'reference', - contentUrl: fileDetails.webDavUrl!, + contentUrl: fileDetails.webDavUrl, name: file.name, }) From cbe0f8aed2db4c011ce22cf127512431550dc5ae Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 01:18:13 -0800 Subject: [PATCH 14/39] fix ocr integrations --- apps/sim/blocks/blocks/pulse.ts | 27 ++++++++++++++++++++++----- apps/sim/blocks/blocks/reducto.ts | 26 +++++++++++++++++++++----- apps/sim/blocks/blocks/textract.ts | 30 ++++++++++++++++++++++++++---- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 38cbb674c3..4edcfe6496 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -132,17 +132,34 @@ export const PulseBlock: BlockConfig = { const pulseV2Inputs = PulseBlock.inputs ? Object.fromEntries(Object.entries(PulseBlock.inputs).filter(([key]) => key !== 'filePath')) : {} -const pulseV2SubBlocks = (PulseBlock.subBlocks || []).filter( - (subBlock) => subBlock.id !== 'filePath' -) +const pulseV2SubBlocks = (PulseBlock.subBlocks || []).flatMap((subBlock) => { + if (subBlock.id === 'filePath') { + return [] // Remove the old filePath subblock + } + if (subBlock.id === 'fileUpload') { + // Insert fileReference right after fileUpload + return [ + subBlock, + { + id: 'fileReference', + title: 'Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'File reference', + mode: 'advanced' as const, + }, + ] + } + return [subBlock] +}) export const PulseV2Block: BlockConfig = { ...PulseBlock, type: 'pulse_v2', - name: 'Pulse (File Only)', + name: 'Pulse', hideFromToolbar: false, longDescription: - 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via upload.', + 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via upload or file references.', subBlocks: pulseV2SubBlocks, tools: { access: ['pulse_parser_v2'], diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 1050b3b132..8688341dd4 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -138,16 +138,32 @@ export const ReductoBlock: BlockConfig = { const reductoV2Inputs = ReductoBlock.inputs ? Object.fromEntries(Object.entries(ReductoBlock.inputs).filter(([key]) => key !== 'filePath')) : {} -const reductoV2SubBlocks = (ReductoBlock.subBlocks || []).filter( - (subBlock) => subBlock.id !== 'filePath' -) +const reductoV2SubBlocks = (ReductoBlock.subBlocks || []).flatMap((subBlock) => { + if (subBlock.id === 'filePath') { + return [] + } + if (subBlock.id === 'fileUpload') { + return [ + subBlock, + { + id: 'fileReference', + title: 'PDF Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'File reference', + mode: 'advanced' as const, + }, + ] + } + return [subBlock] +}) export const ReductoV2Block: BlockConfig = { ...ReductoBlock, type: 'reducto_v2', - name: 'Reducto (File Only)', + name: 'Reducto', hideFromToolbar: false, - longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents.`, + longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents or file references.`, subBlocks: reductoV2SubBlocks, tools: { access: ['reducto_parser_v2'], diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 51e798970e..f3c12c40b3 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -195,14 +195,36 @@ export const TextractBlock: BlockConfig = { const textractV2Inputs = TextractBlock.inputs ? Object.fromEntries(Object.entries(TextractBlock.inputs).filter(([key]) => key !== 'filePath')) : {} -const textractV2SubBlocks = (TextractBlock.subBlocks || []).filter( - (subBlock) => subBlock.id !== 'filePath' -) +const textractV2SubBlocks = (TextractBlock.subBlocks || []).flatMap((subBlock) => { + if (subBlock.id === 'filePath') { + return [] // Remove the old filePath subblock + } + if (subBlock.id === 'fileUpload') { + // Insert fileReference right after fileUpload + return [ + subBlock, + { + id: 'fileReference', + title: 'Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'File reference', + condition: { + field: 'processingMode', + value: 'async', + not: true, + }, + mode: 'advanced' as const, + }, + ] + } + return [subBlock] +}) export const TextractV2Block: BlockConfig = { ...TextractBlock, type: 'textract_v2', - name: 'AWS Textract (File Only)', + name: 'AWS Textract', hideFromToolbar: false, subBlocks: textractV2SubBlocks, tools: { From a6ec6a0e6ccd054a87ff0f3d3f59f7f71d21db2e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 01:22:21 -0800 Subject: [PATCH 15/39] fix typing --- apps/sim/app/api/tools/sharepoint/upload/route.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 4f8f37e128..df8f337128 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' export const dynamic = 'force-dynamic' @@ -232,14 +233,7 @@ export async function POST(request: NextRequest) { ) } - const uploadData = (await uploadResponse.json()) as { - id: string - name: string - webUrl: string - size: number - createdDateTime: string - lastModifiedDateTime: string - } + const uploadData = (await uploadResponse.json()) as MicrosoftGraphDriveItem logger.info(`[${requestId}] File uploaded successfully: ${fileName}`) uploadedFiles.push({ From 0aeaf6faee9cbedee1f214445ce5379750332430 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 01:22:49 -0800 Subject: [PATCH 16/39] remove leftover type --- apps/sim/blocks/blocks.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 395e1128a6..5790ca6d08 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -448,7 +448,6 @@ describe('Blocks Module', () => { 'boolean', 'json', 'array', - 'files', 'file', 'file[]', 'any', From 4169a25e29277af70acb721414ddba223a057ae1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 09:49:07 -0800 Subject: [PATCH 17/39] address bugbot comment --- apps/sim/app/api/tools/mistral/parse/route.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index c8394122e2..f0132228c4 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -156,15 +156,9 @@ export async function POST(request: NextRequest) { } } - // Detect image URLs by extension for proper Mistral API type - const lowerUrl = fileUrl.toLowerCase() - const isImageUrl = - lowerUrl.endsWith('.png') || - lowerUrl.endsWith('.jpg') || - lowerUrl.endsWith('.jpeg') || - lowerUrl.endsWith('.gif') || - lowerUrl.endsWith('.webp') || - lowerUrl.endsWith('.avif') + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'] + const pathname = new URL(fileUrl).pathname.toLowerCase() + const isImageUrl = imageExtensions.some((ext) => pathname.endsWith(ext)) if (isImageUrl) { mistralBody.document = { From 66b954d15d128688d536504f78346a5a6c7b1c6c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 11:00:05 -0800 Subject: [PATCH 18/39] fix file block adv mode --- apps/sim/app/api/tools/mistral/parse/route.ts | 18 +++++++++++++++++- apps/sim/blocks/blocks/file.ts | 15 +++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index f0132228c4..f8b0c11915 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -92,7 +92,23 @@ export async function POST(request: NextRequest) { ) } - const mimeType = userFile.type || 'application/pdf' + let mimeType = userFile.type + if (!mimeType || mimeType === 'application/octet-stream') { + const filename = userFile.name?.toLowerCase() || '' + if (filename.endsWith('.pdf')) { + mimeType = 'application/pdf' + } else if (filename.endsWith('.png')) { + mimeType = 'image/png' + } else if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) { + mimeType = 'image/jpeg' + } else if (filename.endsWith('.gif')) { + mimeType = 'image/gif' + } else if (filename.endsWith('.webp')) { + mimeType = 'image/webp' + } else { + mimeType = 'application/pdf' + } + } let base64 = userFile.base64 if (!base64) { const buffer = await downloadFileFromStorage(userFile, requestId, logger) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 521b74e927..3b0f5ccb9d 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -251,7 +251,8 @@ export const FileV3Block: BlockConfig = { type: 'file_v3', name: 'File', description: 'Read and parse multiple files', - longDescription: 'Upload files or reference files from previous blocks to extract text content.', + longDescription: + 'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.', docsLink: 'https://docs.sim.ai/tools/file', category: 'tools', bgColor: '#40916C', @@ -271,11 +272,11 @@ export const FileV3Block: BlockConfig = { required: true, }, { - id: 'fileRef', - title: 'Files', + id: 'fileUrl', + title: 'File URL', type: 'short-input' as SubBlockType, canonicalParamId: 'fileInput', - placeholder: 'File reference from previous block', + placeholder: 'https://example.com/document.pdf', mode: 'advanced', required: true, }, @@ -285,7 +286,7 @@ export const FileV3Block: BlockConfig = { config: { tool: () => 'file_parser_v3', params: (params) => { - const fileInput = params.fileInput ?? params.file ?? params.filePath + const fileInput = params.fileInput ?? params.file ?? params.fileUrl ?? params.filePath if (!fileInput) { logger.error('No file input provided') throw new Error('File input is required') @@ -337,7 +338,9 @@ export const FileV3Block: BlockConfig = { }, }, inputs: { - fileInput: { type: 'json', description: 'File input (upload or UserFile reference)' }, + fileInput: { type: 'json', description: 'File input (upload or URL)' }, + fileUrl: { type: 'string', description: 'External file URL (advanced mode)' }, + file: { type: 'json', description: 'Uploaded file data (basic mode)' }, fileType: { type: 'string', description: 'File type' }, }, outputs: { From 6e5e8debc506fbc424aca058f74f2c6ae2007d3a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 11:23:54 -0800 Subject: [PATCH 19/39] fix --- apps/sim/blocks/blocks/discord.ts | 9 ++---- apps/sim/blocks/blocks/jira.ts | 6 ++-- apps/sim/blocks/blocks/microsoft_teams.ts | 8 +++--- apps/sim/blocks/blocks/slack.ts | 10 +++---- apps/sim/blocks/blocks/telegram.ts | 10 ++----- apps/sim/blocks/utils.ts | 34 +++++++++++++++++++++++ 6 files changed, 49 insertions(+), 28 deletions(-) diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 94c27d4482..79331eaac5 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -1,6 +1,7 @@ import { DiscordIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { DiscordResponse } from '@/tools/discord/types' export const DiscordBlock: BlockConfig = { @@ -579,17 +580,11 @@ export const DiscordBlock: BlockConfig = { switch (params.operation) { case 'discord_send_message': { - const fileParam = params.attachmentFiles || params.files - const normalizedFiles = fileParam - ? Array.isArray(fileParam) - ? fileParam - : [fileParam] - : undefined return { ...commonParams, channelId: params.channelId, content: params.content, - files: normalizedFiles, + files: normalizeFileInput(params.attachmentFiles || params.files), } } case 'discord_get_messages': diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index c2e64ce1ec..3d67b39026 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1,6 +1,7 @@ import { JiraIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { JiraResponse } from '@/tools/jira/types' import { getTrigger } from '@/triggers' @@ -869,11 +870,10 @@ Return ONLY the comment text - no explanations.`, if (!effectiveIssueKey) { throw new Error('Issue Key is required to add attachments.') } - const fileParam = params.attachmentFiles || params.files - if (!fileParam || (Array.isArray(fileParam) && fileParam.length === 0)) { + const normalizedFiles = normalizeFileInput(params.attachmentFiles || params.files) + if (!normalizedFiles || normalizedFiles.length === 0) { throw new Error('At least one attachment file is required.') } - const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] return { ...baseParams, issueKey: effectiveIssueKey, diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 04cb2d242c..44324e4267 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -1,6 +1,7 @@ import { MicrosoftTeamsIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { MicrosoftTeamsResponse } from '@/tools/microsoft_teams/types' import { getTrigger } from '@/triggers' @@ -344,10 +345,9 @@ export const MicrosoftTeamsBlock: BlockConfig = { } // Add files if provided - const fileParam = attachmentFiles || files - if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) { - const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] - if (normalizedFiles.length > 0) { + if (operation === 'write_chat' || operation === 'write_channel') { + const normalizedFiles = normalizeFileInput(attachmentFiles || files) + if (normalizedFiles) { baseParams.files = normalizedFiles } } diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 0ce640032b..68e0a7a277 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,6 +1,7 @@ import { SlackIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' import { getTrigger } from '@/triggers' @@ -620,12 +621,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, if (threadTs) { baseParams.thread_ts = threadTs } - const fileParam = attachmentFiles || files - if (fileParam) { - const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] - if (normalizedFiles.length > 0) { - baseParams.files = normalizedFiles - } + const normalizedFiles = normalizeFileInput(attachmentFiles || files) + if (normalizedFiles) { + baseParams.files = normalizedFiles } break } diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 65b18677ab..8cdb9ae722 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -1,6 +1,7 @@ import { TelegramIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { TelegramResponse } from '@/tools/telegram/types' import { getTrigger } from '@/triggers' @@ -312,16 +313,9 @@ export const TelegramBlock: BlockConfig = { } } case 'telegram_send_document': { - // Handle file upload - const fileParam = params.attachmentFiles || params.files - const normalizedFiles = fileParam - ? Array.isArray(fileParam) - ? fileParam - : [fileParam] - : undefined return { ...commonParams, - files: normalizedFiles, + files: normalizeFileInput(params.attachmentFiles || params.files), caption: params.caption, } } diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 52ddbf3b93..cb7d5b4185 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -249,3 +249,37 @@ export function createVersionedToolSelector> } } } + +/** + * Normalizes file input from block params. + * Handles the case where template resolution JSON.stringify's arrays/objects + * when they're placed in short-input fields (advanced mode). + * + * @param fileParam - The file parameter which could be: + * - undefined/null (no files) + * - An array of file objects (basic mode or properly resolved) + * - A single file object + * - A JSON string of file(s) (from advanced mode template resolution) + * @returns Normalized array of file objects, or undefined if no files + */ +export function normalizeFileInput(fileParam: unknown): object[] | undefined { + if (!fileParam) return undefined + + if (typeof fileParam === 'string') { + try { + fileParam = JSON.parse(fileParam) + } catch { + return undefined + } + } + + if (Array.isArray(fileParam)) { + return fileParam.length > 0 ? fileParam : undefined + } + + if (typeof fileParam === 'object' && fileParam !== null) { + return [fileParam] + } + + return undefined +} From c230e1aae2b98c4067f88eafbb7bf9c6f413f515 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 11:38:14 -0800 Subject: [PATCH 20/39] normalize file input --- apps/sim/blocks/blocks/a2a.ts | 9 +++ apps/sim/blocks/blocks/confluence.ts | 6 +- apps/sim/blocks/blocks/dropbox.ts | 7 +++ apps/sim/blocks/blocks/file.ts | 76 +++++++++-------------- apps/sim/blocks/blocks/fireflies.ts | 24 ++----- apps/sim/blocks/blocks/gmail.ts | 8 ++- apps/sim/blocks/blocks/google_drive.ts | 7 +++ apps/sim/blocks/blocks/google_slides.ts | 21 +++---- apps/sim/blocks/blocks/linear.ts | 18 ++++-- apps/sim/blocks/blocks/mistral_parse.ts | 22 +++---- apps/sim/blocks/blocks/onedrive.ts | 17 ++++- apps/sim/blocks/blocks/outlook.ts | 9 +++ apps/sim/blocks/blocks/pulse.ts | 20 ++---- apps/sim/blocks/blocks/reducto.ts | 20 ++---- apps/sim/blocks/blocks/s3.ts | 4 +- apps/sim/blocks/blocks/sendgrid.ts | 7 +++ apps/sim/blocks/blocks/sftp.ts | 3 +- apps/sim/blocks/blocks/sharepoint.ts | 7 ++- apps/sim/blocks/blocks/smtp.ts | 3 +- apps/sim/blocks/blocks/spotify.ts | 5 ++ apps/sim/blocks/blocks/stt.ts | 59 +++++++++--------- apps/sim/blocks/blocks/supabase.ts | 12 ++++ apps/sim/blocks/blocks/telegram.ts | 24 +++---- apps/sim/blocks/blocks/textract.ts | 23 ++----- apps/sim/blocks/blocks/video_generator.ts | 3 +- apps/sim/blocks/blocks/vision.ts | 21 ++----- apps/sim/blocks/blocks/wordpress.ts | 3 +- 27 files changed, 220 insertions(+), 218 deletions(-) diff --git a/apps/sim/blocks/blocks/a2a.ts b/apps/sim/blocks/blocks/a2a.ts index 86c98ac9b5..7426ea9177 100644 --- a/apps/sim/blocks/blocks/a2a.ts +++ b/apps/sim/blocks/blocks/a2a.ts @@ -1,5 +1,6 @@ import { A2AIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { ToolResponse } from '@/tools/types' export interface A2AResponse extends ToolResponse { @@ -214,6 +215,14 @@ export const A2ABlock: BlockConfig = { ], config: { tool: (params) => params.operation as string, + params: (params) => { + const { fileUpload, fileReference, ...rest } = params + const normalizedFiles = normalizeFileInput(fileUpload || fileReference || params.files) + return { + ...rest, + ...(normalizedFiles && { files: normalizedFiles }), + } + }, }, }, inputs: { diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 5f9436f5ce..0fb46836c0 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -1,6 +1,7 @@ import { ConfluenceIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { ConfluenceResponse } from '@/tools/confluence/types' export const ConfluenceBlock: BlockConfig = { @@ -651,14 +652,15 @@ export const ConfluenceV2Block: BlockConfig = { if (operation === 'upload_attachment') { const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile - if (!fileInput) { + const normalizedFile = normalizeFileInput(fileInput) + if (!normalizedFile) { throw new Error('File is required for upload attachment operation.') } return { credential, pageId: effectivePageId, operation, - file: fileInput, + file: normalizedFile, fileName: attachmentFileName, comment: attachmentComment, ...rest, diff --git a/apps/sim/blocks/blocks/dropbox.ts b/apps/sim/blocks/blocks/dropbox.ts index b08f6403df..8fbbb8cb39 100644 --- a/apps/sim/blocks/blocks/dropbox.ts +++ b/apps/sim/blocks/blocks/dropbox.ts @@ -1,6 +1,7 @@ import { DropboxIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { DropboxResponse } from '@/tools/dropbox/types' export const DropboxBlock: BlockConfig = { @@ -316,6 +317,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, params.maxResults = Number(params.maxResults) } + // Normalize file input for upload operation + // normalizeFileInput handles JSON stringified values from advanced mode + if (params.fileContent) { + params.fileContent = normalizeFileInput(params.fileContent) + } + switch (params.operation) { case 'dropbox_upload': return 'dropbox_upload' diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 3b0f5ccb9d..be14e6a58a 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { DocumentIcon } from '@/components/icons' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import type { BlockConfig, SubBlockType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { FileParserOutput, FileParserV3Output } from '@/tools/file/types' const logger = createLogger('FileBlock') @@ -200,27 +200,25 @@ export const FileV2Block: BlockConfig = { throw new Error('File is required') } - if (typeof fileInput === 'string') { - return { - filePath: fileInput.trim(), - fileType: params.fileType || 'auto', - workspaceId: params._context?.workspaceId, - } - } - - if (Array.isArray(fileInput) && fileInput.length > 0) { - const filePaths = resolveFilePathsFromInput(fileInput) - return { - filePath: filePaths.length === 1 ? filePaths[0] : filePaths, - fileType: params.fileType || 'auto', + // First, try to normalize as file objects (handles JSON strings from advanced mode) + const normalizedFiles = normalizeFileInput(fileInput) + if (normalizedFiles) { + const filePaths = resolveFilePathsFromInput(normalizedFiles) + if (filePaths.length > 0) { + return { + filePath: filePaths.length === 1 ? filePaths[0] : filePaths, + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + } } } - const resolvedSingle = resolveFilePathsFromInput(fileInput) - if (resolvedSingle.length > 0) { + // If normalization fails, treat as direct URL string + if (typeof fileInput === 'string' && fileInput.trim()) { return { - filePath: resolvedSingle[0], + filePath: fileInput.trim(), fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, } } @@ -292,39 +290,25 @@ export const FileV3Block: BlockConfig = { throw new Error('File input is required') } - if (typeof fileInput === 'string') { - return { - filePath: fileInput.trim(), - fileType: params.fileType || 'auto', - workspaceId: params._context?.workspaceId, - workflowId: params._context?.workflowId, - executionId: params._context?.executionId, - } - } - - if (Array.isArray(fileInput)) { - const filePaths = resolveFilePathsFromInput(fileInput) - if (filePaths.length === 0) { - logger.error('No valid file paths found in file input array') - throw new Error('File input is required') - } - return { - filePath: filePaths.length === 1 ? filePaths[0] : filePaths, - fileType: params.fileType || 'auto', - workspaceId: params._context?.workspaceId, - workflowId: params._context?.workflowId, - executionId: params._context?.executionId, + // First, try to normalize as file objects (handles JSON strings from advanced mode) + const normalizedFiles = normalizeFileInput(fileInput) + if (normalizedFiles) { + const filePaths = resolveFilePathsFromInput(normalizedFiles) + if (filePaths.length > 0) { + return { + filePath: filePaths.length === 1 ? filePaths[0] : filePaths, + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } } } - if (typeof fileInput === 'object') { - const resolvedPaths = resolveFilePathsFromInput(fileInput) - if (resolvedPaths.length === 0) { - logger.error('File input object missing path, url, or key') - throw new Error('File input is required') - } + // If normalization fails, treat as direct URL string + if (typeof fileInput === 'string' && fileInput.trim()) { return { - filePath: resolvedPaths[0], + filePath: fileInput.trim(), fileType: params.fileType || 'auto', workspaceId: params._context?.workspaceId, workflowId: params._context?.workflowId, diff --git a/apps/sim/blocks/blocks/fireflies.ts b/apps/sim/blocks/blocks/fireflies.ts index 5e12df3693..d9d919da51 100644 --- a/apps/sim/blocks/blocks/fireflies.ts +++ b/apps/sim/blocks/blocks/fireflies.ts @@ -2,6 +2,7 @@ import { FirefliesIcon } from '@/components/icons' import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { FirefliesResponse } from '@/tools/fireflies/types' import { getTrigger } from '@/triggers' @@ -619,26 +620,13 @@ export const FirefliesV2Block: BlockConfig = { } if (params.operation === 'fireflies_upload_audio') { - let audioInput = params.audioFile || params.audioFileReference - if (!audioInput) { + const audioFiles = + normalizeFileInput(params.audioFile) || normalizeFileInput(params.audioFileReference) + if (!audioFiles || audioFiles.length === 0) { throw new Error('Audio file is required.') } - if (typeof audioInput === 'string') { - try { - audioInput = JSON.parse(audioInput) - } catch { - throw new Error('Audio file must be a valid file reference.') - } - } - if (Array.isArray(audioInput)) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - if (typeof audioInput !== 'object' || audioInput === null) { - throw new Error('Audio file must be a file reference.') - } - const audioUrl = resolveHttpsUrlFromFileInput(audioInput) + const audioFile = audioFiles[0] + const audioUrl = resolveHttpsUrlFromFileInput(audioFile) if (!audioUrl) { throw new Error('Audio file must include a https URL.') } diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index e0138a88d5..5f8ac25e19 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -1,7 +1,7 @@ import { GmailIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { GmailToolResponse } from '@/tools/gmail/types' import { getTrigger } from '@/triggers' @@ -418,6 +418,8 @@ Return ONLY the search query - no explanations, no extra text.`, labelActionMessageId, labelManagement, manualLabelManagement, + attachmentFiles, + attachments, ...rest } = params @@ -465,9 +467,13 @@ Return ONLY the search query - no explanations, no extra text.`, } } + // Normalize attachments for send/draft operations + const normalizedAttachments = normalizeFileInput(attachmentFiles || attachments) + return { ...rest, credential, + ...(normalizedAttachments && { attachments: normalizedAttachments }), } }, }, diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 23660a2385..8419347318 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -1,6 +1,7 @@ import { GoogleDriveIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { GoogleDriveResponse } from '@/tools/google_drive/types' export const GoogleDriveBlock: BlockConfig = { @@ -782,6 +783,8 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr manualDestinationFolderId, fileSelector, manualFileId, + file, + fileUpload, mimeType, shareType, starred, @@ -789,6 +792,9 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr ...rest } = params + // Normalize file input - handles both basic (file-upload) and advanced (short-input) modes + const normalizedFile = normalizeFileInput(file ?? fileUpload) + // Use folderSelector if provided, otherwise use manualFolderId const effectiveFolderId = (folderSelector || manualFolderId || '').trim() @@ -813,6 +819,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr folderId: effectiveFolderId || undefined, fileId: effectiveFileId || undefined, destinationFolderId: effectiveDestinationFolderId || undefined, + file: normalizedFile, pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, mimeType: mimeType, type: shareType, // Map shareType to type for share tool diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 42bd78961b..31d47ff745 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -2,6 +2,7 @@ import { GoogleSlidesIcon } from '@/components/icons' import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { GoogleSlidesResponse } from '@/tools/google_slides/types' export const GoogleSlidesBlock: BlockConfig = { @@ -960,26 +961,18 @@ export const GoogleSlidesV2Block: BlockConfig = { } if (params.operation === 'add_image') { - let imageInput = params.imageFile || params.imageFileReference || params.imageSource - if (!imageInput) { + const imageInput = params.imageFile || params.imageFileReference || params.imageSource + const normalizedFiles = normalizeFileInput(imageInput) + if (!normalizedFiles || normalizedFiles.length === 0) { throw new Error('Image file is required.') } - if (typeof imageInput === 'string') { - try { - imageInput = JSON.parse(imageInput) - } catch { - throw new Error('Image file must be a valid file reference.') - } - } - if (Array.isArray(imageInput)) { + if (normalizedFiles.length > 1) { throw new Error( 'File reference must be a single file, not an array. Use to select one file.' ) } - if (typeof imageInput !== 'object' || imageInput === null) { - throw new Error('Image file must be a file reference.') - } - const imageUrl = resolveHttpsUrlFromFileInput(imageInput) + const fileObject = normalizedFiles[0] + const imageUrl = resolveHttpsUrlFromFileInput(fileObject) if (!imageUrl) { throw new Error('Image file must include a https URL.') } diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index fefe38bc97..58f3367004 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1,6 +1,7 @@ import { LinearIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { LinearResponse } from '@/tools/linear/types' import { getTrigger } from '@/triggers' @@ -1773,16 +1774,21 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n if (!params.issueId?.trim()) { throw new Error('Issue ID is required.') } - if (Array.isArray(params.file)) { - throw new Error('Attachment file must be a single file.') - } - if (Array.isArray(params.attachmentFileUpload)) { + // Normalize file inputs - handles JSON stringified values from advanced mode + const normalizedUpload = normalizeFileInput(params.attachmentFileUpload) + const normalizedFile = normalizeFileInput(params.file) + // Take the first file from whichever input has data (Linear only accepts single file) + const attachmentFile = normalizedUpload?.[0] || normalizedFile?.[0] + // Check for multiple files + if ( + (normalizedUpload && normalizedUpload.length > 1) || + (normalizedFile && normalizedFile.length > 1) + ) { throw new Error('Attachment file must be a single file.') } - const attachmentFile = params.attachmentFileUpload || params.file const attachmentUrl = params.url?.trim() || - (attachmentFile && !Array.isArray(attachmentFile) ? attachmentFile.url : undefined) + (attachmentFile ? (attachmentFile as { url?: string }).url : undefined) if (!attachmentUrl) { throw new Error('URL or file is required.') } diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index 4330f2b042..472d1ff354 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -1,6 +1,6 @@ import { MistralIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { MistralParserOutput } from '@/tools/mistral/types' export const MistralParseBlock: BlockConfig = { @@ -213,26 +213,18 @@ export const MistralParseV2Block: BlockConfig = { resultType: params.resultType || 'markdown', } - let documentInput = params.fileUpload || params.fileReference || params.document - if (!documentInput) { + const documentInput = normalizeFileInput( + params.fileUpload || params.fileReference || params.document + ) + if (!documentInput || documentInput.length === 0) { throw new Error('PDF document is required') } - if (typeof documentInput === 'string') { - try { - documentInput = JSON.parse(documentInput) - } catch { - throw new Error('PDF document must be a valid file reference') - } - } - if (Array.isArray(documentInput)) { + if (documentInput.length > 1) { throw new Error( 'File reference must be a single file, not an array. Use to select one file.' ) } - if (typeof documentInput !== 'object' || documentInput === null) { - throw new Error('PDF document must be a file reference') - } - parameters.file = documentInput + parameters.file = documentInput[0] let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index 494bfa3b02..605c90ca39 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { MicrosoftOneDriveIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { OneDriveResponse } from '@/tools/onedrive/types' import { normalizeExcelValuesForToolParams } from '@/tools/onedrive/utils' @@ -352,17 +353,31 @@ export const OneDriveBlock: BlockConfig = { } }, params: (params) => { - const { credential, folderId, fileId, mimeType, values, downloadFileName, ...rest } = params + const { + credential, + folderId, + fileId, + mimeType, + values, + downloadFileName, + file, + fileReference, + ...rest + } = params let normalizedValues: ReturnType if (values !== undefined) { normalizedValues = normalizeExcelValuesForToolParams(values) } + // Normalize file input from both basic (file-upload) and advanced (short-input) modes + const normalizedFile = normalizeFileInput(file || fileReference) + return { credential, ...rest, values: normalizedValues, + file: normalizedFile, folderId: folderId || undefined, fileId: fileId || undefined, pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index cfbe253044..b626c20a47 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -1,6 +1,7 @@ import { OutlookIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { OutlookResponse } from '@/tools/outlook/types' import { getTrigger } from '@/triggers' @@ -335,12 +336,20 @@ export const OutlookBlock: BlockConfig = { copyMessageId, copyDestinationFolder, manualCopyDestinationFolder, + attachmentFiles, + attachments, ...rest } = params // Handle both selector and manual folder input const effectiveFolder = (folder || manualFolder || '').trim() + // Normalize file attachments from either basic (file-upload) or advanced (short-input) mode + const normalizedAttachments = normalizeFileInput(attachmentFiles || attachments) + if (normalizedAttachments) { + rest.attachments = normalizedAttachments + } + if (rest.operation === 'read_outlook') { rest.folder = effectiveFolder || 'INBOX' } diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 4edcfe6496..76119d1b1d 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -1,6 +1,6 @@ import { PulseIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { PulseParserOutput } from '@/tools/pulse/types' export const PulseBlock: BlockConfig = { @@ -178,26 +178,16 @@ export const PulseV2Block: BlockConfig = { apiKey: params.apiKey.trim(), } - let documentInput = params.fileUpload || params.document - if (!documentInput) { + const normalizedFiles = normalizeFileInput(params.fileUpload || params.document) + if (!normalizedFiles || normalizedFiles.length === 0) { throw new Error('Document file is required') } - if (typeof documentInput === 'string') { - try { - documentInput = JSON.parse(documentInput) - } catch { - throw new Error('Document file must be a valid file reference') - } - } - if (Array.isArray(documentInput)) { + if (normalizedFiles.length > 1) { throw new Error( 'File reference must be a single file, not an array. Use to select one file.' ) } - if (typeof documentInput !== 'object' || documentInput === null) { - throw new Error('Document file must be a file reference') - } - parameters.file = documentInput + parameters.file = normalizedFiles[0] if (params.pages && params.pages.trim() !== '') { parameters.pages = params.pages.trim() diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 8688341dd4..8e7c13b8e8 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -1,6 +1,6 @@ import { ReductoIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { ReductoParserOutput } from '@/tools/reducto/types' export const ReductoBlock: BlockConfig = { @@ -182,26 +182,16 @@ export const ReductoV2Block: BlockConfig = { apiKey: params.apiKey.trim(), } - let documentInput = params.fileUpload || params.document - if (!documentInput) { + const documentInput = normalizeFileInput(params.fileUpload || params.document) + if (!documentInput || documentInput.length === 0) { throw new Error('PDF document file is required') } - if (typeof documentInput === 'string') { - try { - documentInput = JSON.parse(documentInput) - } catch { - throw new Error('PDF document file must be a valid file reference') - } - } - if (Array.isArray(documentInput)) { + if (documentInput.length > 1) { throw new Error( 'File reference must be a single file, not an array. Use to select one file.' ) } - if (typeof documentInput !== 'object' || documentInput === null) { - throw new Error('PDF document file must be a file reference') - } - parameters.file = documentInput + parameters.file = documentInput[0] let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index f364a78887..5681984fdb 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -1,6 +1,7 @@ import { S3Icon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { S3Response } from '@/tools/s3/types' export const S3Block: BlockConfig = { @@ -271,7 +272,8 @@ export const S3Block: BlockConfig = { throw new Error('Object Key is required for upload') } // Use file from uploadFile if in basic mode, otherwise use file reference - const fileParam = params.uploadFile || params.file + // normalizeFileInput handles JSON stringified values from advanced mode + const fileParam = normalizeFileInput(params.uploadFile || params.file) return { accessKeyId: params.accessKeyId, diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts index 422f9b57fa..c555130261 100644 --- a/apps/sim/blocks/blocks/sendgrid.ts +++ b/apps/sim/blocks/blocks/sendgrid.ts @@ -1,5 +1,6 @@ import { SendgridIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SendMailResult } from '@/tools/sendgrid/types' export const SendGridBlock: BlockConfig = { @@ -561,9 +562,14 @@ Return ONLY the HTML content.`, templateGenerations, listPageSize, templatePageSize, + attachmentFiles, + attachments, ...rest } = params + // Normalize attachments for send_mail operation + const normalizedAttachments = normalizeFileInput(attachmentFiles || attachments) + // Map renamed fields back to tool parameter names return { ...rest, @@ -577,6 +583,7 @@ Return ONLY the HTML content.`, ...(templateGenerations && { generations: templateGenerations }), ...(listPageSize && { pageSize: listPageSize }), ...(templatePageSize && { pageSize: templatePageSize }), + ...(normalizedAttachments && { attachments: normalizedAttachments }), } }, }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 3621ee5b43..c7afdb534f 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -1,6 +1,7 @@ import { SftpIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SftpUploadResult } from '@/tools/sftp/types' export const SftpBlock: BlockConfig = { @@ -222,7 +223,7 @@ export const SftpBlock: BlockConfig = { return { ...connectionConfig, remotePath: params.remotePath, - files: params.files, + files: normalizeFileInput(params.uploadFiles || params.files), overwrite: params.overwrite !== false, permissions: params.permissions, } diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index 5fe1dfb6df..e1a6aac2af 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { MicrosoftSharepointIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SharepointResponse } from '@/tools/sharepoint/types' const logger = createLogger('SharepointBlock') @@ -449,7 +450,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, } // Handle file upload files parameter - const fileParam = uploadFiles || files + const normalizedFiles = normalizeFileInput(uploadFiles || files) const baseParams: Record = { credential, siteId: effectiveSiteId || undefined, @@ -463,8 +464,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, } // Add files if provided - if (fileParam) { - baseParams.files = fileParam + if (normalizedFiles) { + baseParams.files = normalizedFiles } if (columnDefinitions) { diff --git a/apps/sim/blocks/blocks/smtp.ts b/apps/sim/blocks/blocks/smtp.ts index c292281b6c..640cdd6805 100644 --- a/apps/sim/blocks/blocks/smtp.ts +++ b/apps/sim/blocks/blocks/smtp.ts @@ -1,6 +1,7 @@ import { SmtpIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SmtpSendMailResult } from '@/tools/smtp/types' export const SmtpBlock: BlockConfig = { @@ -176,7 +177,7 @@ export const SmtpBlock: BlockConfig = { cc: params.cc, bcc: params.bcc, replyTo: params.replyTo, - attachments: params.attachments, + attachments: normalizeFileInput(params.attachmentFiles || params.attachments), }), }, }, diff --git a/apps/sim/blocks/blocks/spotify.ts b/apps/sim/blocks/blocks/spotify.ts index 417724f6ec..57e75639e9 100644 --- a/apps/sim/blocks/blocks/spotify.ts +++ b/apps/sim/blocks/blocks/spotify.ts @@ -1,6 +1,7 @@ import { SpotifyIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { ToolResponse } from '@/tools/types' export const SpotifyBlock: BlockConfig = { @@ -785,6 +786,10 @@ export const SpotifyBlock: BlockConfig = { if (params.playUris) { params.uris = params.playUris } + // Normalize file input for cover image + if (params.coverImage !== undefined) { + params.coverImage = normalizeFileInput(params.coverImage) + } return params.operation || 'spotify_search' }, }, diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 344f14458d..8896f1b613 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -1,6 +1,6 @@ import { STTIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { SttBlockResponse } from '@/tools/stt/types' export const SttBlock: BlockConfig = { @@ -258,22 +258,28 @@ export const SttBlock: BlockConfig = { return 'stt_whisper' } }, - params: (params) => ({ - provider: params.provider, - apiKey: params.apiKey, - model: params.model, - audioFile: params.audioFile, - audioFileReference: params.audioFileReference, - audioUrl: params.audioUrl, - language: params.language, - timestamps: params.timestamps, - diarization: params.diarization, - translateToEnglish: params.translateToEnglish, - sentiment: params.sentiment, - entityDetection: params.entityDetection, - piiRedaction: params.piiRedaction, - summarization: params.summarization, - }), + params: (params) => { + // Normalize file input from basic (file-upload) or advanced (short-input) mode + const normalizedFiles = normalizeFileInput(params.audioFile || params.audioFileReference) + const audioFile = normalizedFiles?.[0] + + return { + provider: params.provider, + apiKey: params.apiKey, + model: params.model, + audioFile, + audioFileReference: undefined, + audioUrl: params.audioUrl, + language: params.language, + timestamps: params.timestamps, + diarization: params.diarization, + translateToEnglish: params.translateToEnglish, + sentiment: params.sentiment, + entityDetection: params.entityDetection, + piiRedaction: params.piiRedaction, + summarization: params.summarization, + } + }, }, }, @@ -386,24 +392,15 @@ export const SttV2Block: BlockConfig = { fallbackToolId: 'stt_whisper_v2', }), params: (params) => { - let audioInput = params.audioFile || params.audioFileReference - if (audioInput && typeof audioInput === 'string') { - try { - audioInput = JSON.parse(audioInput) - } catch { - throw new Error('Audio file must be a valid file reference') - } - } - if (audioInput && Array.isArray(audioInput)) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } + // Normalize file input from basic (file-upload) or advanced (short-input) mode + const normalizedFiles = normalizeFileInput(params.audioFile || params.audioFileReference) + const audioFile = normalizedFiles?.[0] + return { provider: params.provider, apiKey: params.apiKey, model: params.model, - audioFile: audioInput, + audioFile, audioFileReference: undefined, language: params.language, timestamps: params.timestamps, diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 49c59cb35e..07d3a4bae8 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { SupabaseIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { SupabaseResponse } from '@/tools/supabase/types' const logger = createLogger('SupabaseBlock') @@ -973,9 +974,16 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e allowedMimeTypes, upsert, download, + file, + fileContent, + fileData, ...rest } = params + // Normalize file input for storage_upload operation + // normalizeFileInput handles JSON stringified values from advanced mode + const normalizedFileData = normalizeFileInput(file || fileContent || fileData) + // Parse JSON data if it's a string let parsedData if (data && typeof data === 'string' && data.trim()) { @@ -1102,6 +1110,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e result.isPublic = parsedIsPublic } + if (normalizedFileData !== undefined) { + result.fileData = normalizedFileData + } + return result }, }, diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 8cdb9ae722..bda5b617b6 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -269,46 +269,46 @@ export const TelegramBlock: BlockConfig = { messageId: params.messageId, } case 'telegram_send_photo': { - const photoSource = params.photoFile || params.photo - if (!photoSource) { + const photoSource = normalizeFileInput(params.photoFile || params.photo) + if (!photoSource || photoSource.length === 0) { throw new Error('Photo is required.') } return { ...commonParams, - photo: photoSource, + photo: photoSource[0], caption: params.caption, } } case 'telegram_send_video': { - const videoSource = params.videoFile || params.video - if (!videoSource) { + const videoSource = normalizeFileInput(params.videoFile || params.video) + if (!videoSource || videoSource.length === 0) { throw new Error('Video is required.') } return { ...commonParams, - video: videoSource, + video: videoSource[0], caption: params.caption, } } case 'telegram_send_audio': { - const audioSource = params.audioFile || params.audio - if (!audioSource) { + const audioSource = normalizeFileInput(params.audioFile || params.audio) + if (!audioSource || audioSource.length === 0) { throw new Error('Audio is required.') } return { ...commonParams, - audio: audioSource, + audio: audioSource[0], caption: params.caption, } } case 'telegram_send_animation': { - const animationSource = params.animationFile || params.animation - if (!animationSource) { + const animationSource = normalizeFileInput(params.animationFile || params.animation) + if (!animationSource || animationSource.length === 0) { throw new Error('Animation is required.') } return { ...commonParams, - animation: animationSource, + animation: animationSource[0], caption: params.caption, } } diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index f3c12c40b3..19f32b541e 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -1,6 +1,6 @@ import { TextractIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { TextractParserOutput } from '@/tools/textract/types' export const TextractBlock: BlockConfig = { @@ -260,26 +260,11 @@ export const TextractV2Block: BlockConfig = { } parameters.s3Uri = params.s3Uri.trim() } else { - let documentInput = params.fileUpload || params.document - if (!documentInput) { + const files = normalizeFileInput(params.fileUpload || params.document) + if (!files || files.length === 0) { throw new Error('Document file is required') } - if (typeof documentInput === 'string') { - try { - documentInput = JSON.parse(documentInput) - } catch { - throw new Error('Document file must be a valid file reference') - } - } - if (Array.isArray(documentInput)) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - if (typeof documentInput !== 'object' || documentInput === null) { - throw new Error('Document file must be a file reference') - } - parameters.file = documentInput + parameters.file = files[0] } const featureTypes: string[] = [] diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index 2743cf2c14..cd874c6de7 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -1,5 +1,6 @@ import { VideoIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { VideoBlockResponse } from '@/tools/video/types' export const VideoGeneratorBlock: BlockConfig = { @@ -745,7 +746,7 @@ export const VideoGeneratorV2Block: BlockConfig = { duration: params.duration ? Number(params.duration) : undefined, aspectRatio: params.aspectRatio, resolution: params.resolution, - visualReference: visualRef, + visualReference: normalizeFileInput(visualRef), consistencyMode: params.consistencyMode, stylePreset: params.stylePreset, promptOptimizer: params.promptOptimizer, diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 96422c2bee..244c4126bc 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -1,7 +1,7 @@ import { EyeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' -import { createVersionedToolSelector } from '@/blocks/utils' +import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' import type { VisionResponse } from '@/tools/vision/types' const VISION_MODEL_OPTIONS = [ @@ -117,22 +117,13 @@ export const VisionV2Block: BlockConfig = { fallbackToolId: 'vision_tool_v2', }), params: (params) => { - let imageInput = params.imageFile || params.imageFileReference - if (imageInput && typeof imageInput === 'string') { - try { - imageInput = JSON.parse(imageInput) - } catch { - throw new Error('Image file must be a valid file reference') - } - } - if (imageInput && Array.isArray(imageInput)) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } + // normalizeFileInput handles JSON stringified values from advanced mode + const normalizedFiles = normalizeFileInput(params.imageFile || params.imageFileReference) + // Vision expects a single file, take the first from the normalized array + const imageFile = normalizedFiles?.[0] return { ...params, - imageFile: imageInput, + imageFile, imageFileReference: undefined, } }, diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts index eb19e776a0..bcc7eba500 100644 --- a/apps/sim/blocks/blocks/wordpress.ts +++ b/apps/sim/blocks/blocks/wordpress.ts @@ -1,6 +1,7 @@ import { WordpressIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { WordPressResponse } from '@/tools/wordpress/types' export const WordPressBlock: BlockConfig = { @@ -769,7 +770,7 @@ export const WordPressBlock: BlockConfig = { case 'wordpress_upload_media': return { ...baseParams, - file: params.fileUpload || params.file, + file: normalizeFileInput(params.fileUpload || params.file), filename: params.filename, title: params.mediaTitle, caption: params.caption, From 285490666f284e9de86e144759fff16702145fbb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 11:50:26 -0800 Subject: [PATCH 21/39] fix v2 blocmks for ocr --- apps/sim/blocks/blocks/pulse.ts | 11 +++++++++-- apps/sim/blocks/blocks/reducto.ts | 11 +++++++++-- apps/sim/blocks/blocks/textract.ts | 11 +++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 76119d1b1d..fa8347a054 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -130,7 +130,12 @@ export const PulseBlock: BlockConfig = { } const pulseV2Inputs = PulseBlock.inputs - ? Object.fromEntries(Object.entries(PulseBlock.inputs).filter(([key]) => key !== 'filePath')) + ? { + ...Object.fromEntries( + Object.entries(PulseBlock.inputs).filter(([key]) => key !== 'filePath') + ), + fileReference: { type: 'json', description: 'File reference (advanced mode)' }, + } : {} const pulseV2SubBlocks = (PulseBlock.subBlocks || []).flatMap((subBlock) => { if (subBlock.id === 'filePath') { @@ -178,7 +183,9 @@ export const PulseV2Block: BlockConfig = { apiKey: params.apiKey.trim(), } - const normalizedFiles = normalizeFileInput(params.fileUpload || params.document) + const normalizedFiles = normalizeFileInput( + params.fileUpload || params.fileReference || params.document + ) if (!normalizedFiles || normalizedFiles.length === 0) { throw new Error('Document file is required') } diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 8e7c13b8e8..62a973ee48 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -136,7 +136,12 @@ export const ReductoBlock: BlockConfig = { } const reductoV2Inputs = ReductoBlock.inputs - ? Object.fromEntries(Object.entries(ReductoBlock.inputs).filter(([key]) => key !== 'filePath')) + ? { + ...Object.fromEntries( + Object.entries(ReductoBlock.inputs).filter(([key]) => key !== 'filePath') + ), + fileReference: { type: 'json', description: 'File reference (advanced mode)' }, + } : {} const reductoV2SubBlocks = (ReductoBlock.subBlocks || []).flatMap((subBlock) => { if (subBlock.id === 'filePath') { @@ -182,7 +187,9 @@ export const ReductoV2Block: BlockConfig = { apiKey: params.apiKey.trim(), } - const documentInput = normalizeFileInput(params.fileUpload || params.document) + const documentInput = normalizeFileInput( + params.fileUpload || params.fileReference || params.document + ) if (!documentInput || documentInput.length === 0) { throw new Error('PDF document file is required') } diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 19f32b541e..b59055c21f 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -193,7 +193,12 @@ export const TextractBlock: BlockConfig = { } const textractV2Inputs = TextractBlock.inputs - ? Object.fromEntries(Object.entries(TextractBlock.inputs).filter(([key]) => key !== 'filePath')) + ? { + ...Object.fromEntries( + Object.entries(TextractBlock.inputs).filter(([key]) => key !== 'filePath') + ), + fileReference: { type: 'json', description: 'File reference (advanced mode)' }, + } : {} const textractV2SubBlocks = (TextractBlock.subBlocks || []).flatMap((subBlock) => { if (subBlock.id === 'filePath') { @@ -260,7 +265,9 @@ export const TextractV2Block: BlockConfig = { } parameters.s3Uri = params.s3Uri.trim() } else { - const files = normalizeFileInput(params.fileUpload || params.document) + const files = normalizeFileInput( + params.fileUpload || params.fileReference || params.document + ) if (!files || files.length === 0) { throw new Error('Document file is required') } From 3b747086bf7a106977399be87283f18a46f0cace Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 11:53:57 -0800 Subject: [PATCH 22/39] fix for v2 versions --- apps/sim/tools/pulse/parser.ts | 2 +- apps/sim/tools/reducto/parser.ts | 2 +- apps/sim/tools/textract/parser.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index 5bf872f6c7..a1a19b120d 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -268,7 +268,7 @@ export const pulseParserV2Tool: ToolConfig Date: Tue, 3 Feb 2026 11:55:51 -0800 Subject: [PATCH 23/39] fix more v2 blocks --- apps/sim/tools/jira/add_attachment.ts | 2 +- apps/sim/tools/linear/create_attachment.ts | 2 +- apps/sim/tools/sftp/upload.ts | 2 +- apps/sim/tools/vision/tool.ts | 2 +- apps/sim/tools/wordpress/upload_media.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/tools/jira/add_attachment.ts b/apps/sim/tools/jira/add_attachment.ts index 8055c72304..0fa9946e33 100644 --- a/apps/sim/tools/jira/add_attachment.ts +++ b/apps/sim/tools/jira/add_attachment.ts @@ -35,7 +35,7 @@ export const jiraAddAttachmentTool: ToolConfig = { files: { type: 'file[]', required: false, - visibility: 'user-only', + visibility: 'hidden', description: 'Files to upload', }, fileContent: { diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index 6cd8dc357c..67eac55c4e 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -106,7 +106,7 @@ export const visionToolV2: ToolConfig = { imageFile: { type: 'file', required: true, - visibility: 'user-only', + visibility: 'hidden', description: 'Image file to analyze', }, model: visionTool.params.model, diff --git a/apps/sim/tools/wordpress/upload_media.ts b/apps/sim/tools/wordpress/upload_media.ts index 7115346aaa..50bc57eefe 100644 --- a/apps/sim/tools/wordpress/upload_media.ts +++ b/apps/sim/tools/wordpress/upload_media.ts @@ -27,7 +27,7 @@ export const uploadMediaTool: ToolConfig Date: Tue, 3 Feb 2026 12:09:55 -0800 Subject: [PATCH 24/39] update single file blocks --- apps/sim/blocks/blocks/confluence.ts | 2 +- apps/sim/blocks/blocks/dropbox.ts | 2 +- apps/sim/blocks/blocks/fireflies.ts | 8 +++--- apps/sim/blocks/blocks/google_drive.ts | 2 +- apps/sim/blocks/blocks/linear.ts | 6 ++--- apps/sim/blocks/blocks/onedrive.ts | 2 +- apps/sim/blocks/blocks/s3.ts | 2 +- apps/sim/blocks/blocks/spotify.ts | 2 +- apps/sim/blocks/blocks/stt.ts | 10 ++++--- apps/sim/blocks/blocks/supabase.ts | 4 ++- apps/sim/blocks/blocks/telegram.ts | 32 ++++++++++++++--------- apps/sim/blocks/blocks/textract.ts | 9 ++++--- apps/sim/blocks/blocks/video_generator.ts | 2 +- apps/sim/blocks/blocks/vision.ts | 7 ++--- apps/sim/blocks/blocks/wordpress.ts | 2 +- apps/sim/blocks/utils.ts | 30 +++++++++++++++------ 16 files changed, 75 insertions(+), 47 deletions(-) diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 0fb46836c0..5bdb21e5e0 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -652,7 +652,7 @@ export const ConfluenceV2Block: BlockConfig = { if (operation === 'upload_attachment') { const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile - const normalizedFile = normalizeFileInput(fileInput) + const normalizedFile = normalizeFileInput(fileInput, { single: true }) if (!normalizedFile) { throw new Error('File is required for upload attachment operation.') } diff --git a/apps/sim/blocks/blocks/dropbox.ts b/apps/sim/blocks/blocks/dropbox.ts index 8fbbb8cb39..044625cc61 100644 --- a/apps/sim/blocks/blocks/dropbox.ts +++ b/apps/sim/blocks/blocks/dropbox.ts @@ -320,7 +320,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, // Normalize file input for upload operation // normalizeFileInput handles JSON stringified values from advanced mode if (params.fileContent) { - params.fileContent = normalizeFileInput(params.fileContent) + params.fileContent = normalizeFileInput(params.fileContent, { single: true }) } switch (params.operation) { diff --git a/apps/sim/blocks/blocks/fireflies.ts b/apps/sim/blocks/blocks/fireflies.ts index d9d919da51..b67a256ca6 100644 --- a/apps/sim/blocks/blocks/fireflies.ts +++ b/apps/sim/blocks/blocks/fireflies.ts @@ -620,12 +620,12 @@ export const FirefliesV2Block: BlockConfig = { } if (params.operation === 'fireflies_upload_audio') { - const audioFiles = - normalizeFileInput(params.audioFile) || normalizeFileInput(params.audioFileReference) - if (!audioFiles || audioFiles.length === 0) { + const audioFile = + normalizeFileInput(params.audioFile, { single: true }) || + normalizeFileInput(params.audioFileReference, { single: true }) + if (!audioFile) { throw new Error('Audio file is required.') } - const audioFile = audioFiles[0] const audioUrl = resolveHttpsUrlFromFileInput(audioFile) if (!audioUrl) { throw new Error('Audio file must include a https URL.') diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 8419347318..d14168d5a3 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -793,7 +793,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr } = params // Normalize file input - handles both basic (file-upload) and advanced (short-input) modes - const normalizedFile = normalizeFileInput(file ?? fileUpload) + const normalizedFile = normalizeFileInput(file ?? fileUpload, { single: true }) // Use folderSelector if provided, otherwise use manualFolderId const effectiveFolderId = (folderSelector || manualFolderId || '').trim() diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 58f3367004..d7c245a857 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1775,10 +1775,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n throw new Error('Issue ID is required.') } // Normalize file inputs - handles JSON stringified values from advanced mode - const normalizedUpload = normalizeFileInput(params.attachmentFileUpload) - const normalizedFile = normalizeFileInput(params.file) // Take the first file from whichever input has data (Linear only accepts single file) - const attachmentFile = normalizedUpload?.[0] || normalizedFile?.[0] + const attachmentFile = + normalizeFileInput(params.attachmentFileUpload, { single: true }) || + normalizeFileInput(params.file, { single: true }) // Check for multiple files if ( (normalizedUpload && normalizedUpload.length > 1) || diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index 605c90ca39..e2e3545fbf 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -371,7 +371,7 @@ export const OneDriveBlock: BlockConfig = { } // Normalize file input from both basic (file-upload) and advanced (short-input) modes - const normalizedFile = normalizeFileInput(file || fileReference) + const normalizedFile = normalizeFileInput(file || fileReference, { single: true }) return { credential, diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index 5681984fdb..9c8c537a13 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -273,7 +273,7 @@ export const S3Block: BlockConfig = { } // Use file from uploadFile if in basic mode, otherwise use file reference // normalizeFileInput handles JSON stringified values from advanced mode - const fileParam = normalizeFileInput(params.uploadFile || params.file) + const fileParam = normalizeFileInput(params.uploadFile || params.file, { single: true }) return { accessKeyId: params.accessKeyId, diff --git a/apps/sim/blocks/blocks/spotify.ts b/apps/sim/blocks/blocks/spotify.ts index 57e75639e9..c152b3a56f 100644 --- a/apps/sim/blocks/blocks/spotify.ts +++ b/apps/sim/blocks/blocks/spotify.ts @@ -788,7 +788,7 @@ export const SpotifyBlock: BlockConfig = { } // Normalize file input for cover image if (params.coverImage !== undefined) { - params.coverImage = normalizeFileInput(params.coverImage) + params.coverImage = normalizeFileInput(params.coverImage, { single: true }) } return params.operation || 'spotify_search' }, diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 8896f1b613..4dbf809992 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -260,8 +260,9 @@ export const SttBlock: BlockConfig = { }, params: (params) => { // Normalize file input from basic (file-upload) or advanced (short-input) mode - const normalizedFiles = normalizeFileInput(params.audioFile || params.audioFileReference) - const audioFile = normalizedFiles?.[0] + const audioFile = normalizeFileInput(params.audioFile || params.audioFileReference, { + single: true, + }) return { provider: params.provider, @@ -393,8 +394,9 @@ export const SttV2Block: BlockConfig = { }), params: (params) => { // Normalize file input from basic (file-upload) or advanced (short-input) mode - const normalizedFiles = normalizeFileInput(params.audioFile || params.audioFileReference) - const audioFile = normalizedFiles?.[0] + const audioFile = normalizeFileInput(params.audioFile || params.audioFileReference, { + single: true, + }) return { provider: params.provider, diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 07d3a4bae8..78256c5bec 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -982,7 +982,9 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e // Normalize file input for storage_upload operation // normalizeFileInput handles JSON stringified values from advanced mode - const normalizedFileData = normalizeFileInput(file || fileContent || fileData) + const normalizedFileData = normalizeFileInput(file || fileContent || fileData, { + single: true, + }) // Parse JSON data if it's a string let parsedData diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index bda5b617b6..2be6eb5462 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -269,46 +269,54 @@ export const TelegramBlock: BlockConfig = { messageId: params.messageId, } case 'telegram_send_photo': { - const photoSource = normalizeFileInput(params.photoFile || params.photo) - if (!photoSource || photoSource.length === 0) { + const photoSource = normalizeFileInput(params.photoFile || params.photo, { + single: true, + }) + if (!photoSource) { throw new Error('Photo is required.') } return { ...commonParams, - photo: photoSource[0], + photo: photoSource, caption: params.caption, } } case 'telegram_send_video': { - const videoSource = normalizeFileInput(params.videoFile || params.video) - if (!videoSource || videoSource.length === 0) { + const videoSource = normalizeFileInput(params.videoFile || params.video, { + single: true, + }) + if (!videoSource) { throw new Error('Video is required.') } return { ...commonParams, - video: videoSource[0], + video: videoSource, caption: params.caption, } } case 'telegram_send_audio': { - const audioSource = normalizeFileInput(params.audioFile || params.audio) - if (!audioSource || audioSource.length === 0) { + const audioSource = normalizeFileInput(params.audioFile || params.audio, { + single: true, + }) + if (!audioSource) { throw new Error('Audio is required.') } return { ...commonParams, - audio: audioSource[0], + audio: audioSource, caption: params.caption, } } case 'telegram_send_animation': { - const animationSource = normalizeFileInput(params.animationFile || params.animation) - if (!animationSource || animationSource.length === 0) { + const animationSource = normalizeFileInput(params.animationFile || params.animation, { + single: true, + }) + if (!animationSource) { throw new Error('Animation is required.') } return { ...commonParams, - animation: animationSource[0], + animation: animationSource, caption: params.caption, } } diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index b59055c21f..10f5a1113a 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -265,13 +265,14 @@ export const TextractV2Block: BlockConfig = { } parameters.s3Uri = params.s3Uri.trim() } else { - const files = normalizeFileInput( - params.fileUpload || params.fileReference || params.document + const file = normalizeFileInput( + params.fileUpload || params.fileReference || params.document, + { single: true } ) - if (!files || files.length === 0) { + if (!file) { throw new Error('Document file is required') } - parameters.file = files[0] + parameters.file = file } const featureTypes: string[] = [] diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index cd874c6de7..ae31eb951b 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -746,7 +746,7 @@ export const VideoGeneratorV2Block: BlockConfig = { duration: params.duration ? Number(params.duration) : undefined, aspectRatio: params.aspectRatio, resolution: params.resolution, - visualReference: normalizeFileInput(visualRef), + visualReference: normalizeFileInput(visualRef, { single: true }), consistencyMode: params.consistencyMode, stylePreset: params.stylePreset, promptOptimizer: params.promptOptimizer, diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 244c4126bc..a367b0c58e 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -118,9 +118,10 @@ export const VisionV2Block: BlockConfig = { }), params: (params) => { // normalizeFileInput handles JSON stringified values from advanced mode - const normalizedFiles = normalizeFileInput(params.imageFile || params.imageFileReference) - // Vision expects a single file, take the first from the normalized array - const imageFile = normalizedFiles?.[0] + // Vision expects a single file + const imageFile = normalizeFileInput(params.imageFile || params.imageFileReference, { + single: true, + }) return { ...params, imageFile, diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts index bcc7eba500..e0b206ce5d 100644 --- a/apps/sim/blocks/blocks/wordpress.ts +++ b/apps/sim/blocks/blocks/wordpress.ts @@ -770,7 +770,7 @@ export const WordPressBlock: BlockConfig = { case 'wordpress_upload_media': return { ...baseParams, - file: normalizeFileInput(params.fileUpload || params.file), + file: normalizeFileInput(params.fileUpload || params.file, { single: true }), filename: params.filename, title: params.mediaTitle, caption: params.caption, diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index cb7d5b4185..9c800d50f0 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -251,7 +251,7 @@ export function createVersionedToolSelector> } /** - * Normalizes file input from block params. + * Normalizes file input from block params to a consistent format. * Handles the case where template resolution JSON.stringify's arrays/objects * when they're placed in short-input fields (advanced mode). * @@ -260,9 +260,21 @@ export function createVersionedToolSelector> * - An array of file objects (basic mode or properly resolved) * - A single file object * - A JSON string of file(s) (from advanced mode template resolution) - * @returns Normalized array of file objects, or undefined if no files + * @param options.single - If true, returns only the first file object instead of an array + * @returns Normalized file(s), or undefined if no files */ -export function normalizeFileInput(fileParam: unknown): object[] | undefined { +export function normalizeFileInput( + fileParam: unknown, + options: { single: true } +): object | undefined +export function normalizeFileInput( + fileParam: unknown, + options?: { single?: false } +): object[] | undefined +export function normalizeFileInput( + fileParam: unknown, + options?: { single?: boolean } +): object | object[] | undefined { if (!fileParam) return undefined if (typeof fileParam === 'string') { @@ -273,13 +285,15 @@ export function normalizeFileInput(fileParam: unknown): object[] | undefined { } } + let files: object[] | undefined + if (Array.isArray(fileParam)) { - return fileParam.length > 0 ? fileParam : undefined + files = fileParam.length > 0 ? fileParam : undefined + } else if (typeof fileParam === 'object' && fileParam !== null) { + files = [fileParam] } - if (typeof fileParam === 'object' && fileParam !== null) { - return [fileParam] - } + if (!files) return undefined - return undefined + return options?.single ? files[0] : files } From f256a9fa8cef12e6858846951bb50748fc6a85d5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 12:15:26 -0800 Subject: [PATCH 25/39] make interface simpler --- apps/sim/blocks/blocks/google_slides.ts | 10 ++-------- apps/sim/blocks/blocks/linear.ts | 18 ++++++++---------- apps/sim/blocks/blocks/mistral_parse.ts | 12 ++++-------- apps/sim/blocks/blocks/pulse.ts | 14 +++++--------- apps/sim/blocks/blocks/reducto.ts | 12 ++++-------- apps/sim/blocks/utils.ts | 19 +++++++++++++++---- 6 files changed, 38 insertions(+), 47 deletions(-) diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 31d47ff745..784fc73fc5 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -962,16 +962,10 @@ export const GoogleSlidesV2Block: BlockConfig = { if (params.operation === 'add_image') { const imageInput = params.imageFile || params.imageFileReference || params.imageSource - const normalizedFiles = normalizeFileInput(imageInput) - if (!normalizedFiles || normalizedFiles.length === 0) { + const fileObject = normalizeFileInput(imageInput, { single: true }) + if (!fileObject) { throw new Error('Image file is required.') } - if (normalizedFiles.length > 1) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - const fileObject = normalizedFiles[0] const imageUrl = resolveHttpsUrlFromFileInput(fileObject) if (!imageUrl) { throw new Error('Image file must include a https URL.') diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index d7c245a857..2b8e43587a 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1775,17 +1775,15 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n throw new Error('Issue ID is required.') } // Normalize file inputs - handles JSON stringified values from advanced mode - // Take the first file from whichever input has data (Linear only accepts single file) const attachmentFile = - normalizeFileInput(params.attachmentFileUpload, { single: true }) || - normalizeFileInput(params.file, { single: true }) - // Check for multiple files - if ( - (normalizedUpload && normalizedUpload.length > 1) || - (normalizedFile && normalizedFile.length > 1) - ) { - throw new Error('Attachment file must be a single file.') - } + normalizeFileInput(params.attachmentFileUpload, { + single: true, + errorMessage: 'Attachment file must be a single file.', + }) || + normalizeFileInput(params.file, { + single: true, + errorMessage: 'Attachment file must be a single file.', + }) const attachmentUrl = params.url?.trim() || (attachmentFile ? (attachmentFile as { url?: string }).url : undefined) diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index 472d1ff354..424f65b8a0 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -214,17 +214,13 @@ export const MistralParseV2Block: BlockConfig = { } const documentInput = normalizeFileInput( - params.fileUpload || params.fileReference || params.document + params.fileUpload || params.fileReference || params.document, + { single: true } ) - if (!documentInput || documentInput.length === 0) { + if (!documentInput) { throw new Error('PDF document is required') } - if (documentInput.length > 1) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - parameters.file = documentInput[0] + parameters.file = documentInput let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index fa8347a054..c61f110704 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -183,18 +183,14 @@ export const PulseV2Block: BlockConfig = { apiKey: params.apiKey.trim(), } - const normalizedFiles = normalizeFileInput( - params.fileUpload || params.fileReference || params.document + const normalizedFile = normalizeFileInput( + params.fileUpload || params.fileReference || params.document, + { single: true } ) - if (!normalizedFiles || normalizedFiles.length === 0) { + if (!normalizedFile) { throw new Error('Document file is required') } - if (normalizedFiles.length > 1) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - parameters.file = normalizedFiles[0] + parameters.file = normalizedFile if (params.pages && params.pages.trim() !== '') { parameters.pages = params.pages.trim() diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 62a973ee48..fb9d393703 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -188,17 +188,13 @@ export const ReductoV2Block: BlockConfig = { } const documentInput = normalizeFileInput( - params.fileUpload || params.fileReference || params.document + params.fileUpload || params.fileReference || params.document, + { single: true } ) - if (!documentInput || documentInput.length === 0) { + if (!documentInput) { throw new Error('PDF document file is required') } - if (documentInput.length > 1) { - throw new Error( - 'File reference must be a single file, not an array. Use to select one file.' - ) - } - parameters.file = documentInput[0] + parameters.file = documentInput let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 9c800d50f0..7de0b518af 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -250,6 +250,9 @@ export function createVersionedToolSelector> } } +const DEFAULT_MULTIPLE_FILES_ERROR = + 'File reference must be a single file, not an array. Use to select one file.' + /** * Normalizes file input from block params to a consistent format. * Handles the case where template resolution JSON.stringify's arrays/objects @@ -260,12 +263,13 @@ export function createVersionedToolSelector> * - An array of file objects (basic mode or properly resolved) * - A single file object * - A JSON string of file(s) (from advanced mode template resolution) - * @param options.single - If true, returns only the first file object instead of an array + * @param options.single - If true, returns single file object and throws if multiple provided + * @param options.errorMessage - Custom error message when single is true and multiple files provided * @returns Normalized file(s), or undefined if no files */ export function normalizeFileInput( fileParam: unknown, - options: { single: true } + options: { single: true; errorMessage?: string } ): object | undefined export function normalizeFileInput( fileParam: unknown, @@ -273,7 +277,7 @@ export function normalizeFileInput( ): object[] | undefined export function normalizeFileInput( fileParam: unknown, - options?: { single?: boolean } + options?: { single?: boolean; errorMessage?: string } ): object | object[] | undefined { if (!fileParam) return undefined @@ -295,5 +299,12 @@ export function normalizeFileInput( if (!files) return undefined - return options?.single ? files[0] : files + if (options?.single) { + if (files.length > 1) { + throw new Error(options.errorMessage ?? DEFAULT_MULTIPLE_FILES_ERROR) + } + return files[0] + } + + return files } From fa81609a92b8a13697ad14461ff5381122f88763 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 12:17:55 -0800 Subject: [PATCH 26/39] cleanup fireflies --- apps/sim/blocks/blocks/fireflies.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/fireflies.ts b/apps/sim/blocks/blocks/fireflies.ts index b67a256ca6..568cda788a 100644 --- a/apps/sim/blocks/blocks/fireflies.ts +++ b/apps/sim/blocks/blocks/fireflies.ts @@ -620,9 +620,9 @@ export const FirefliesV2Block: BlockConfig = { } if (params.operation === 'fireflies_upload_audio') { - const audioFile = - normalizeFileInput(params.audioFile, { single: true }) || - normalizeFileInput(params.audioFileReference, { single: true }) + const audioFile = normalizeFileInput(params.audioFile || params.audioFileReference, { + single: true, + }) if (!audioFile) { throw new Error('Audio file is required.') } From dc3d449d99c99bcbfc9b0677d2bbc09bb97b5bc6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 12:24:45 -0800 Subject: [PATCH 27/39] remove file only annotation --- apps/sim/blocks/blocks/stt.ts | 2 +- apps/sim/tools/pulse/parser.ts | 2 +- apps/sim/tools/reducto/parser.ts | 2 +- apps/sim/tools/stt/assemblyai.ts | 2 +- apps/sim/tools/stt/deepgram.ts | 2 +- apps/sim/tools/stt/elevenlabs.ts | 2 +- apps/sim/tools/stt/gemini.ts | 2 +- apps/sim/tools/stt/whisper.ts | 2 +- apps/sim/tools/textract/parser.ts | 2 +- apps/sim/tools/vision/tool.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 4dbf809992..66adef7a91 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -360,7 +360,7 @@ const sttV2SubBlocks = (SttBlock.subBlocks || []).filter((subBlock) => subBlock. export const SttV2Block: BlockConfig = { ...SttBlock, type: 'stt_v2', - name: 'Speech-to-Text (File Only)', + name: 'Speech-to-Text', hideFromToolbar: false, subBlocks: sttV2SubBlocks, tools: { diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index a1a19b120d..805d998ec0 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -257,7 +257,7 @@ export const pulseParserTool: ToolConfig = export const pulseParserV2Tool: ToolConfig = { ...pulseParserTool, id: 'pulse_parser_v2', - name: 'Pulse Document Parser (File Only)', + name: 'Pulse Document Parser', postProcess: undefined, directExecution: undefined, transformResponse: pulseParserTool.transformResponse diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts index 08892b023e..c3810d8421 100644 --- a/apps/sim/tools/reducto/parser.ts +++ b/apps/sim/tools/reducto/parser.ts @@ -185,7 +185,7 @@ export const reductoParserTool: ToolConfig = { ...reductoParserTool, id: 'reducto_parser_v2', - name: 'Reducto PDF Parser (File Only)', + name: 'Reducto PDF Parser', postProcess: undefined, directExecution: undefined, transformResponse: reductoParserTool.transformResponse diff --git a/apps/sim/tools/stt/assemblyai.ts b/apps/sim/tools/stt/assemblyai.ts index 3c8e15173c..74424fd6a8 100644 --- a/apps/sim/tools/stt/assemblyai.ts +++ b/apps/sim/tools/stt/assemblyai.ts @@ -202,7 +202,7 @@ const assemblyaiSttV2Params = { export const assemblyaiSttV2Tool: ToolConfig = { ...assemblyaiSttTool, id: 'stt_assemblyai_v2', - name: 'AssemblyAI STT (File Only)', + name: 'AssemblyAI STT', params: assemblyaiSttV2Params, request: { ...assemblyaiSttTool.request, diff --git a/apps/sim/tools/stt/deepgram.ts b/apps/sim/tools/stt/deepgram.ts index 97465ef157..47fa0f1985 100644 --- a/apps/sim/tools/stt/deepgram.ts +++ b/apps/sim/tools/stt/deepgram.ts @@ -147,7 +147,7 @@ const deepgramSttV2Params = { export const deepgramSttV2Tool: ToolConfig = { ...deepgramSttTool, id: 'stt_deepgram_v2', - name: 'Deepgram STT (File Only)', + name: 'Deepgram STT', params: deepgramSttV2Params, request: { ...deepgramSttTool.request, diff --git a/apps/sim/tools/stt/elevenlabs.ts b/apps/sim/tools/stt/elevenlabs.ts index 88b89a6efd..26e074109d 100644 --- a/apps/sim/tools/stt/elevenlabs.ts +++ b/apps/sim/tools/stt/elevenlabs.ts @@ -130,7 +130,7 @@ const elevenLabsSttV2Params = { export const elevenLabsSttV2Tool: ToolConfig = { ...elevenLabsSttTool, id: 'stt_elevenlabs_v2', - name: 'ElevenLabs STT (File Only)', + name: 'ElevenLabs STT', params: elevenLabsSttV2Params, request: { ...elevenLabsSttTool.request, diff --git a/apps/sim/tools/stt/gemini.ts b/apps/sim/tools/stt/gemini.ts index a5ad196c44..9379c0bc9f 100644 --- a/apps/sim/tools/stt/gemini.ts +++ b/apps/sim/tools/stt/gemini.ts @@ -130,7 +130,7 @@ const geminiSttV2Params = { export const geminiSttV2Tool: ToolConfig = { ...geminiSttTool, id: 'stt_gemini_v2', - name: 'Gemini STT (File Only)', + name: 'Gemini STT', params: geminiSttV2Params, request: { ...geminiSttTool.request, diff --git a/apps/sim/tools/stt/whisper.ts b/apps/sim/tools/stt/whisper.ts index 084a3c624b..33a560adcb 100644 --- a/apps/sim/tools/stt/whisper.ts +++ b/apps/sim/tools/stt/whisper.ts @@ -171,7 +171,7 @@ const whisperSttV2Params = { export const whisperSttV2Tool: ToolConfig = { ...whisperSttTool, id: 'stt_whisper_v2', - name: 'OpenAI Whisper STT (File Only)', + name: 'OpenAI Whisper STT', params: whisperSttV2Params, request: { ...whisperSttTool.request, diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index e21a73113c..d1a82a5f1e 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -297,7 +297,7 @@ export const textractParserTool: ToolConfig = { ...textractParserTool, id: 'textract_parser_v2', - name: 'AWS Textract Parser (File Only)', + name: 'AWS Textract Parser', params: { accessKeyId: textractParserTool.params.accessKeyId, secretAccessKey: textractParserTool.params.secretAccessKey, diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index 67eac55c4e..02dba60f29 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -100,7 +100,7 @@ export const visionTool: ToolConfig = { export const visionToolV2: ToolConfig = { ...visionTool, id: 'vision_tool_v2', - name: 'Vision Tool (File Only)', + name: 'Vision Tool', params: { apiKey: visionTool.params.apiKey, imageFile: { From 4f2b5a5ec6601fd0df74f02525b27ef8ee652d5b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 12:29:55 -0800 Subject: [PATCH 28/39] accept all types --- apps/sim/blocks/blocks/file.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index be14e6a58a..3db0c2d470 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -261,8 +261,7 @@ export const FileV3Block: BlockConfig = { title: 'Files', type: 'file-upload' as SubBlockType, canonicalParamId: 'fileInput', - acceptedTypes: - '.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf', + acceptedTypes: '*', placeholder: 'Upload files to process', multiple: true, mode: 'basic', From a529f06adb6a92a35ce5b913aae17c1eaafcf826 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 3 Feb 2026 12:30:45 -0800 Subject: [PATCH 29/39] added wand to ssh block --- apps/sim/blocks/blocks/ssh.ts | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 32dbffd722..eb7e975d21 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -108,6 +108,28 @@ export const SSHBlock: BlockConfig = { placeholder: 'ls -la /var/www', required: true, condition: { field: 'operation', value: 'ssh_execute_command' }, + wandConfig: { + enabled: true, + prompt: `You are an expert Linux/Unix system administrator. +Generate a shell command or commands based on the user's request for SSH execution on a remote server. + +Current command: {context} + +RULES: +1. Generate ONLY the raw shell command(s) - no markdown, no explanations, no code blocks +2. Use standard Unix/Linux commands that work on most systems +3. For multiple commands, separate with && or ; as appropriate +4. Prefer safe, non-destructive commands when possible +5. Use proper quoting for paths with spaces +6. Consider common shell utilities: ls, cat, grep, find, awk, sed, tar, curl, wget, systemctl, etc. + +Examples: +- "list files" → ls -la +- "find large files" → find . -type f -size +100M +- "check disk space" → df -h +- "show running processes" → ps aux +- "restart nginx" → sudo systemctl restart nginx`, + }, }, { id: 'workingDirectory', @@ -125,6 +147,26 @@ export const SSHBlock: BlockConfig = { placeholder: '#!/bin/bash\necho "Hello World"', required: true, condition: { field: 'operation', value: 'ssh_execute_script' }, + wandConfig: { + enabled: true, + prompt: `You are an expert shell script writer. +Generate a complete shell script based on the user's request for SSH execution on a remote server. + +Current script: {context} + +RULES: +1. Generate ONLY the raw script content - no markdown, no explanations, no code blocks +2. Include appropriate shebang (#!/bin/bash) at the start +3. Use proper error handling where appropriate (set -e, set -o pipefail) +4. Add comments for complex logic +5. Use variables for repeated values +6. Handle edge cases gracefully +7. Make scripts portable across common Linux distributions + +Examples: +- "backup script" → #!/bin/bash\\nset -e\\ntar -czf backup-$(date +%Y%m%d).tar.gz /var/www +- "deploy script" → #!/bin/bash\\nset -e\\ngit pull origin main\\nnpm install\\npm run build\\nsystemctl restart app`, + }, }, { id: 'interpreter', @@ -159,6 +201,25 @@ export const SSHBlock: BlockConfig = { placeholder: 'Content to upload...', required: true, condition: { field: 'operation', value: 'ssh_upload_file' }, + wandConfig: { + enabled: true, + prompt: `You are an expert at generating configuration files and file content for server deployment. +Generate file content based on the user's request for uploading to a remote server via SSH. + +Current content: {context} + +RULES: +1. Generate ONLY the raw file content - no markdown, no explanations, no code blocks +2. Use proper formatting for the file type (JSON, YAML, INI, etc.) +3. Include helpful comments where appropriate for config files +4. Use sensible defaults and best practices +5. Ensure valid syntax for the file format + +Examples: +- "nginx config" → server { listen 80; server_name example.com; ... } +- "json config" → { "key": "value", "port": 3000 } +- "env file" → NODE_ENV=production\\nPORT=3000\\nDATABASE_URL=...`, + }, }, { id: 'fileName', @@ -335,6 +396,25 @@ export const SSHBlock: BlockConfig = { placeholder: 'Content to write...', required: true, condition: { field: 'operation', value: 'ssh_write_file_content' }, + wandConfig: { + enabled: true, + prompt: `You are an expert at generating configuration files and file content for server deployment. +Generate file content based on the user's request for writing to a remote server via SSH. + +Current content: {context} + +RULES: +1. Generate ONLY the raw file content - no markdown, no explanations, no code blocks +2. Use proper formatting for the file type (JSON, YAML, INI, etc.) +3. Include helpful comments where appropriate for config files +4. Use sensible defaults and best practices +5. Ensure valid syntax for the file format + +Examples: +- "nginx config" → server { listen 80; server_name example.com; ... } +- "json config" → { "key": "value", "port": 3000 } +- "env file" → NODE_ENV=production\\nPORT=3000\\nDATABASE_URL=...`, + }, }, { id: 'writeMode', From ed1ca6e8611b2e883fab5be5b694616be47fa877 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 12:53:28 -0800 Subject: [PATCH 30/39] user files should be passed through --- apps/sim/app/api/tools/stt/route.ts | 11 +++- .../sim/executor/utils/file-tool-processor.ts | 55 +++++++++++-------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index cab959741a..1317d8453d 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -6,7 +6,7 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' -import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, resolveInternalFileUrl, @@ -89,7 +89,10 @@ export async function POST(request: NextRequest) { audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name - audioMimeType = file.type + // file.type may be missing if the file came from a block that doesn't preserve it + // Infer from filename extension as fallback + const ext = file.name.split('.').pop()?.toLowerCase() || '' + audioMimeType = file.type || getMimeTypeFromExtension(ext) } else if (body.audioFileReference) { if (Array.isArray(body.audioFileReference) && body.audioFileReference.length !== 1) { return NextResponse.json( @@ -104,7 +107,9 @@ export async function POST(request: NextRequest) { audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name - audioMimeType = file.type + + const ext = file.name.split('.').pop()?.toLowerCase() || '' + audioMimeType = file.type || getMimeTypeFromExtension(ext) } else if (body.audioUrl) { logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`) diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts index dd113f40f0..1f0b10374b 100644 --- a/apps/sim/executor/utils/file-tool-processor.ts +++ b/apps/sim/executor/utils/file-tool-processor.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isUserFile } from '@/lib/core/utils/user-file' import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import type { ExecutionContext, UserFile } from '@/executor/types' @@ -94,31 +95,39 @@ export class FileToolProcessor { } /** - * Convert various file data formats to UserFile by storing in execution filesystem + * Convert various file data formats to UserFile by storing in execution filesystem. + * If the input is already a UserFile, returns it unchanged. */ private static async processFileData( - fileData: ToolFileData, + fileData: ToolFileData | UserFile, context: ExecutionContext ): Promise { + // If already a UserFile (e.g., from tools that handle their own file storage), + // return it directly without re-processing + if (isUserFile(fileData)) { + return fileData as UserFile + } + + const data = fileData as ToolFileData try { let buffer: Buffer | null = null - if (Buffer.isBuffer(fileData.data)) { - buffer = fileData.data + if (Buffer.isBuffer(data.data)) { + buffer = data.data } else if ( - fileData.data && - typeof fileData.data === 'object' && - 'type' in fileData.data && - 'data' in fileData.data + data.data && + typeof data.data === 'object' && + 'type' in data.data && + 'data' in data.data ) { - const serializedBuffer = fileData.data as { type: string; data: number[] } + const serializedBuffer = data.data as { type: string; data: number[] } if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) { buffer = Buffer.from(serializedBuffer.data) } else { - throw new Error(`Invalid serialized buffer format for ${fileData.name}`) + throw new Error(`Invalid serialized buffer format for ${data.name}`) } - } else if (typeof fileData.data === 'string' && fileData.data) { - let base64Data = fileData.data + } else if (typeof data.data === 'string' && data.data) { + let base64Data = data.data if (base64Data.includes('-') || base64Data.includes('_')) { base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/') @@ -127,13 +136,13 @@ export class FileToolProcessor { buffer = Buffer.from(base64Data, 'base64') } - if (!buffer && fileData.url) { - buffer = await downloadFileFromUrl(fileData.url) + if (!buffer && data.url) { + buffer = await downloadFileFromUrl(data.url) } if (buffer) { if (buffer.length === 0) { - throw new Error(`File '${fileData.name}' has zero bytes`) + throw new Error(`File '${data.name}' has zero bytes`) } return await uploadExecutionFile( @@ -143,23 +152,23 @@ export class FileToolProcessor { executionId: context.executionId || '', }, buffer, - fileData.name, - fileData.mimeType, + data.name, + data.mimeType, context.userId ) } - if (!fileData.data) { + if (!data.data) { throw new Error( - `File data for '${fileData.name}' must have either 'data' (Buffer/base64) or 'url' property` + `File data for '${data.name}' must have either 'data' (Buffer/base64) or 'url' property` ) } return uploadFileFromRawData( { - name: fileData.name, - data: fileData.data, - mimeType: fileData.mimeType, + name: data.name, + data: data.data, + mimeType: data.mimeType, }, { workspaceId: context.workspaceId || '', @@ -169,7 +178,7 @@ export class FileToolProcessor { context.userId ) } catch (error) { - logger.error(`Error processing file data for '${fileData.name}':`, error) + logger.error(`Error processing file data for '${data.name}':`, error) throw error } } From b0457bc7c18f6e8d33dfa019aec5d84bd18a65c3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 13:06:16 -0800 Subject: [PATCH 31/39] improve docs --- apps/docs/content/docs/en/execution/files.mdx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/docs/content/docs/en/execution/files.mdx b/apps/docs/content/docs/en/execution/files.mdx index 14c5121d4f..125093952a 100644 --- a/apps/docs/content/docs/en/execution/files.mdx +++ b/apps/docs/content/docs/en/execution/files.mdx @@ -23,6 +23,40 @@ When blocks output files (like Gmail attachments, generated images, or parsed do You can access any of these properties when referencing files from previous blocks. +## The File Block + +The **File block** is the universal entry point for files in your workflows. It accepts files from any source and outputs standardized file objects that work with all integrations. + +**Inputs:** +- **Uploaded files** - Drag and drop or select files directly +- **External URLs** - Any publicly accessible file URL +- **Files from other blocks** - Pass files from Gmail attachments, Slack downloads, etc. + +**Outputs:** +- A list of `UserFile` objects with consistent structure (`name`, `url`, `base64`, `type`, `size`) +- `combinedContent` - Extracted text content from all files (for documents) + +**Example usage:** + +``` +// Get all files from the File block + + +// Get the first file + + +// Get combined text content from parsed documents + +``` + +The File block automatically: +- Detects file types from URLs and extensions +- Extracts text from PDFs, CSVs, and documents +- Generates base64 encoding for binary files +- Creates presigned URLs for secure access + +Use the File block when you need to normalize files from different sources before passing them to other blocks like Vision, STT, or email integrations. + ## Passing Files Between Blocks Reference files from previous blocks using the tag dropdown. Click in any file input field and type `<` to see available outputs. From cfc360404a3391d74da6acff95276a9037cf8c26 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 13:19:58 -0800 Subject: [PATCH 32/39] fix slack to include successful execs --- apps/sim/app/api/tools/slack/utils.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index a14ae74a83..b635c49d8f 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -81,12 +81,6 @@ export async function uploadFilesToSlack( logger.info(`[${requestId}] Uploading file: ${userFile.name}`) const buffer = await downloadFileFromStorage(userFile, requestId, logger) - uploadedFiles.push({ - name: userFile.name, - mimeType: userFile.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { method: 'POST', @@ -125,6 +119,13 @@ export async function uploadFilesToSlack( logger.info(`[${requestId}] File data uploaded successfully`) uploadedFileIds.push(urlData.file_id) + // Only add to uploadedFiles after successful upload to keep arrays in sync + uploadedFiles.push({ + name: userFile.name, + mimeType: userFile.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) } return { fileIds: uploadedFileIds, files: uploadedFiles } From bd5866ed6b06ec7bdbb29f320e85ff40f8210e31 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 15:35:15 -0800 Subject: [PATCH 33/39] fix dropbox upload file --- apps/sim/lib/core/utils/user-file.ts | 17 +++++++++++++++++ apps/sim/tools/dropbox/types.ts | 3 ++- apps/sim/tools/dropbox/upload.ts | 11 ++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/core/utils/user-file.ts b/apps/sim/lib/core/utils/user-file.ts index f2b0340477..0069eb4fba 100644 --- a/apps/sim/lib/core/utils/user-file.ts +++ b/apps/sim/lib/core/utils/user-file.ts @@ -55,3 +55,20 @@ export function filterUserFileForDisplay(data: Record): Record< } return filtered } + +/** + * Extracts base64 content from either a raw base64 string or a UserFile object. + * Useful for tools that accept file input in either format. + * @returns The base64 string, or undefined if not found + */ +export function extractBase64FromFileInput( + input: string | UserFileLike | null | undefined +): string | undefined { + if (typeof input === 'string') { + return input + } + if (input?.base64) { + return input.base64 + } + return undefined +} diff --git a/apps/sim/tools/dropbox/types.ts b/apps/sim/tools/dropbox/types.ts index f789bcfceb..197dd49ec3 100644 --- a/apps/sim/tools/dropbox/types.ts +++ b/apps/sim/tools/dropbox/types.ts @@ -1,3 +1,4 @@ +import type { UserFileLike } from '@/lib/core/utils/user-file' import type { ToolFileData, ToolResponse } from '@/tools/types' // ===== Core Types ===== @@ -70,7 +71,7 @@ export interface DropboxBaseParams { export interface DropboxUploadParams extends DropboxBaseParams { path: string - fileContent: string // Base64 encoded file content + fileContent: string | UserFileLike fileName?: string mode?: 'add' | 'overwrite' autorename?: boolean diff --git a/apps/sim/tools/dropbox/upload.ts b/apps/sim/tools/dropbox/upload.ts index e162679f3f..b43ce68845 100644 --- a/apps/sim/tools/dropbox/upload.ts +++ b/apps/sim/tools/dropbox/upload.ts @@ -1,3 +1,4 @@ +import { extractBase64FromFileInput } from '@/lib/core/utils/user-file' import type { DropboxUploadParams, DropboxUploadResponse } from '@/tools/dropbox/types' import type { ToolConfig } from '@/tools/types' @@ -31,10 +32,10 @@ export const dropboxUploadTool: ToolConfig { + const base64Content = extractBase64FromFileInput(params.fileContent) + if (!base64Content) { + throw new Error('File Content cannot be extracted') + } // Decode base64 to raw binary bytes - Dropbox expects raw binary, not base64 text - return Buffer.from(params.fileContent, 'base64') + return Buffer.from(base64Content, 'base64') }, }, From 2d96ac55db1ebf6f8297126947f042d7e7655bb9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 15:44:39 -0800 Subject: [PATCH 34/39] fix sendgrid --- .../app/api/tools/sendgrid/send-mail/route.ts | 188 ++++++++++++++++++ apps/sim/tools/sendgrid/send_mail.ts | 124 ++++-------- 2 files changed, 222 insertions(+), 90 deletions(-) create mode 100644 apps/sim/app/api/tools/sendgrid/send-mail/route.ts diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts new file mode 100644 index 0000000000..362960b892 --- /dev/null +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -0,0 +1,188 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SendGridSendMailAPI') + +const SendGridSendMailSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + from: z.string().min(1, 'From email is required'), + fromName: z.string().optional().nullable(), + to: z.string().min(1, 'To email is required'), + toName: z.string().optional().nullable(), + subject: z.string().optional().nullable(), + content: z.string().optional().nullable(), + contentType: z.string().optional().nullable(), + cc: z.string().optional().nullable(), + bcc: z.string().optional().nullable(), + replyTo: z.string().optional().nullable(), + replyToName: z.string().optional().nullable(), + templateId: z.string().optional().nullable(), + dynamicTemplateData: z.any().optional().nullable(), + attachments: RawFileInputArraySchema.optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized SendGrid send attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + logger.info(`[${requestId}] Authenticated SendGrid send request via ${authResult.authType}`) + + const body = await request.json() + const validatedData = SendGridSendMailSchema.parse(body) + + logger.info(`[${requestId}] Sending SendGrid email`, { + to: validatedData.to, + subject: validatedData.subject || '(template)', + hasAttachments: !!(validatedData.attachments && validatedData.attachments.length > 0), + attachmentCount: validatedData.attachments?.length || 0, + }) + + // Build personalizations + const personalizations: Record = { + to: [ + { email: validatedData.to, ...(validatedData.toName && { name: validatedData.toName }) }, + ], + } + + if (validatedData.cc) { + personalizations.cc = [{ email: validatedData.cc }] + } + + if (validatedData.bcc) { + personalizations.bcc = [{ email: validatedData.bcc }] + } + + if (validatedData.templateId && validatedData.dynamicTemplateData) { + personalizations.dynamic_template_data = + typeof validatedData.dynamicTemplateData === 'string' + ? JSON.parse(validatedData.dynamicTemplateData) + : validatedData.dynamicTemplateData + } + + // Build mail body + const mailBody: Record = { + personalizations: [personalizations], + from: { + email: validatedData.from, + ...(validatedData.fromName && { name: validatedData.fromName }), + }, + subject: validatedData.subject, + } + + if (validatedData.templateId) { + mailBody.template_id = validatedData.templateId + } else { + mailBody.content = [ + { + type: validatedData.contentType || 'text/plain', + value: validatedData.content, + }, + ] + } + + if (validatedData.replyTo) { + mailBody.reply_to = { + email: validatedData.replyTo, + ...(validatedData.replyToName && { name: validatedData.replyToName }), + } + } + + // Process attachments from UserFile objects + if (validatedData.attachments && validatedData.attachments.length > 0) { + const rawAttachments = validatedData.attachments + logger.info(`[${requestId}] Processing ${rawAttachments.length} attachment(s)`) + + const userFiles = processFilesToUserFiles(rawAttachments, requestId, logger) + + if (userFiles.length > 0) { + const sendGridAttachments = await Promise.all( + userFiles.map(async (file) => { + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + const buffer = await downloadFileFromStorage(file, requestId, logger) + + return { + content: buffer.toString('base64'), + filename: file.name, + type: file.type || 'application/octet-stream', + disposition: 'attachment', + } + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + }) + ) + + mailBody.attachments = sendGridAttachments + } + } + + // Send to SendGrid + const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mailBody), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + const errorMessage = + errorData.errors?.[0]?.message || errorData.message || 'Failed to send email' + logger.error(`[${requestId}] SendGrid API error:`, { status: response.status, errorData }) + return NextResponse.json({ success: false, error: errorMessage }, { status: response.status }) + } + + const messageId = response.headers.get('X-Message-Id') + logger.info(`[${requestId}] Email sent successfully`, { messageId }) + + return NextResponse.json({ + success: true, + output: { + success: true, + messageId: messageId || undefined, + to: validatedData.to, + subject: validatedData.subject || '', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Validation error:`, error.errors) + return NextResponse.json( + { success: false, error: error.errors[0]?.message || 'Validation failed' }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/tools/sendgrid/send_mail.ts b/apps/sim/tools/sendgrid/send_mail.ts index 44f4932281..19b57ae98f 100644 --- a/apps/sim/tools/sendgrid/send_mail.ts +++ b/apps/sim/tools/sendgrid/send_mail.ts @@ -1,9 +1,4 @@ -import type { - SendGridMailBody, - SendGridPersonalization, - SendMailParams, - SendMailResult, -} from '@/tools/sendgrid/types' +import type { SendMailParams, SendMailResult } from '@/tools/sendgrid/types' import type { ToolConfig } from '@/tools/types' export const sendGridSendMailTool: ToolConfig = { @@ -89,7 +84,7 @@ export const sendGridSendMailTool: ToolConfig = type: 'file[]', required: false, visibility: 'user-or-llm', - description: 'Files to attach to the email as an array of attachment objects', + description: 'Files to attach to the email (UserFile objects)', }, templateId: { type: 'string', @@ -106,100 +101,49 @@ export const sendGridSendMailTool: ToolConfig = }, request: { - url: () => 'https://api.sendgrid.com/v3/mail/send', + url: '/api/tools/sendgrid/send-mail', method: 'POST', - headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, + headers: () => ({ 'Content-Type': 'application/json', }), - body: (params) => { - const personalizations: SendGridPersonalization = { - to: [ - { - email: params.to, - ...(params.toName && { name: params.toName }), - }, - ], - } - - if (params.cc) { - personalizations.cc = [{ email: params.cc }] - } - - if (params.bcc) { - personalizations.bcc = [{ email: params.bcc }] - } + body: (params) => ({ + apiKey: params.apiKey, + from: params.from, + fromName: params.fromName, + to: params.to, + toName: params.toName, + subject: params.subject, + content: params.content, + contentType: params.contentType, + cc: params.cc, + bcc: params.bcc, + replyTo: params.replyTo, + replyToName: params.replyToName, + templateId: params.templateId, + dynamicTemplateData: params.dynamicTemplateData, + attachments: params.attachments, + }), + }, - if (params.templateId && params.dynamicTemplateData) { - try { - personalizations.dynamic_template_data = - typeof params.dynamicTemplateData === 'string' - ? JSON.parse(params.dynamicTemplateData) - : params.dynamicTemplateData - } catch (e) { - // If parsing fails, use as-is - } - } + transformResponse: async (response): Promise => { + const data = await response.json() - const mailBody: SendGridMailBody = { - personalizations: [personalizations], - from: { - email: params.from, - ...(params.fromName && { name: params.fromName }), + if (!data.success) { + return { + success: false, + output: { + success: false, + messageId: undefined, + to: '', + subject: '', }, - subject: params.subject, - } - - if (params.templateId) { - mailBody.template_id = params.templateId - } else { - mailBody.content = [ - { - type: params.contentType || 'text/plain', - value: params.content, - }, - ] + error: data.error || 'Failed to send email', } - - if (params.replyTo) { - mailBody.reply_to = { - email: params.replyTo, - ...(params.replyToName && { name: params.replyToName }), - } - } - - if (params.attachments) { - try { - mailBody.attachments = - typeof params.attachments === 'string' - ? JSON.parse(params.attachments) - : params.attachments - } catch (e) { - // If parsing fails, skip attachments - } - } - - return { body: JSON.stringify(mailBody) } - }, - }, - - transformResponse: async (response, params): Promise => { - if (!response.ok) { - const error = await response.json() - throw new Error(error.errors?.[0]?.message || 'Failed to send email') } - // SendGrid returns 202 Accepted with X-Message-Id header - const messageId = response.headers.get('X-Message-Id') - return { success: true, - output: { - success: true, - messageId: messageId || undefined, - to: params?.to || '', - subject: params?.subject || '', - }, + output: data.output, } }, From aa1b158b261927590b8fcb1d20753da151b580f1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 3 Feb 2026 15:50:50 -0800 Subject: [PATCH 35/39] fix dropbox --- .../sim/app/api/tools/dropbox/upload/route.ts | 140 ++++++++++++++++++ apps/sim/blocks/blocks/dropbox.ts | 16 +- apps/sim/tools/dropbox/types.ts | 4 +- apps/sim/tools/dropbox/upload.ts | 77 ++++------ 4 files changed, 183 insertions(+), 54 deletions(-) create mode 100644 apps/sim/app/api/tools/dropbox/upload/route.ts diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts new file mode 100644 index 0000000000..f8544e05d5 --- /dev/null +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -0,0 +1,140 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('DropboxUploadAPI') + +/** + * Escapes non-ASCII characters in JSON string for HTTP header safety. + * Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX. + */ +function httpHeaderSafeJson(value: object): string { + return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => { + return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) + }) +} + +const DropboxUploadSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + path: z.string().min(1, 'Destination path is required'), + file: FileInputSchema.optional().nullable(), + // Legacy field for backwards compatibility + fileContent: z.string().optional().nullable(), + fileName: z.string().optional().nullable(), + mode: z.enum(['add', 'overwrite']).optional().nullable(), + autorename: z.boolean().optional().nullable(), + mute: z.boolean().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Dropbox upload attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + logger.info(`[${requestId}] Authenticated Dropbox upload request via ${authResult.authType}`) + + const body = await request.json() + const validatedData = DropboxUploadSchema.parse(body) + + let fileBuffer: Buffer + let fileName: string + + // Prefer UserFile input, fall back to legacy base64 string + if (validatedData.file) { + // Process UserFile input + const userFiles = processFilesToUserFiles([validatedData.file], requestId, logger) + + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + + const userFile = userFiles[0] + logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + fileName = userFile.name + } else if (validatedData.fileContent) { + // Legacy: base64 string input (backwards compatibility) + logger.info(`[${requestId}] Using legacy base64 content input`) + fileBuffer = Buffer.from(validatedData.fileContent, 'base64') + fileName = validatedData.fileName || 'file' + } else { + return NextResponse.json( + { success: false, error: 'File or file content is required' }, + { status: 400 } + ) + } + + // Determine final path + let finalPath = validatedData.path + if (finalPath.endsWith('/')) { + finalPath = `${finalPath}${fileName}` + } + + logger.info(`[${requestId}] Uploading to Dropbox: ${finalPath} (${fileBuffer.length} bytes)`) + + const dropboxApiArg = { + path: finalPath, + mode: validatedData.mode || 'add', + autorename: validatedData.autorename ?? true, + mute: validatedData.mute ?? false, + } + + const response = await fetch('https://content.dropboxapi.com/2/files/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': httpHeaderSafeJson(dropboxApiArg), + }, + body: fileBuffer, + }) + + const data = await response.json() + + if (!response.ok) { + const errorMessage = data.error_summary || data.error?.message || 'Failed to upload file' + logger.error(`[${requestId}] Dropbox API error:`, { status: response.status, data }) + return NextResponse.json({ success: false, error: errorMessage }, { status: response.status }) + } + + logger.info(`[${requestId}] File uploaded successfully to ${data.path_display}`) + + return NextResponse.json({ + success: true, + output: { + file: data, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Validation error:`, error.errors) + return NextResponse.json( + { success: false, error: error.errors[0]?.message || 'Validation failed' }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/dropbox.ts b/apps/sim/blocks/blocks/dropbox.ts index 044625cc61..8bea2b7eac 100644 --- a/apps/sim/blocks/blocks/dropbox.ts +++ b/apps/sim/blocks/blocks/dropbox.ts @@ -64,7 +64,7 @@ export const DropboxBlock: BlockConfig = { id: 'uploadFile', title: 'File', type: 'file-upload', - canonicalParamId: 'fileContent', + canonicalParamId: 'file', placeholder: 'Upload file to send to Dropbox', mode: 'basic', multiple: false, @@ -72,10 +72,10 @@ export const DropboxBlock: BlockConfig = { condition: { field: 'operation', value: 'dropbox_upload' }, }, { - id: 'fileContent', + id: 'fileRef', title: 'File', type: 'short-input', - canonicalParamId: 'fileContent', + canonicalParamId: 'file', placeholder: 'Reference file from previous blocks', mode: 'advanced', required: true, @@ -319,7 +319,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, // Normalize file input for upload operation // normalizeFileInput handles JSON stringified values from advanced mode - if (params.fileContent) { + if (params.file) { + params.file = normalizeFileInput(params.file, { single: true }) + } + // Legacy: also check fileContent for backwards compatibility + if (params.fileContent && !params.file) { params.fileContent = normalizeFileInput(params.fileContent, { single: true }) } @@ -358,7 +362,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, // Upload inputs uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileContent: { type: 'json', description: 'File reference or UserFile object' }, + file: { type: 'json', description: 'File to upload (UserFile object)' }, + fileRef: { type: 'json', description: 'File reference from previous block' }, + fileContent: { type: 'string', description: 'Legacy: base64 encoded file content' }, fileName: { type: 'string', description: 'Optional filename' }, mode: { type: 'string', description: 'Write mode: add or overwrite' }, mute: { type: 'boolean', description: 'Mute notifications' }, diff --git a/apps/sim/tools/dropbox/types.ts b/apps/sim/tools/dropbox/types.ts index 197dd49ec3..d722e2810c 100644 --- a/apps/sim/tools/dropbox/types.ts +++ b/apps/sim/tools/dropbox/types.ts @@ -71,7 +71,9 @@ export interface DropboxBaseParams { export interface DropboxUploadParams extends DropboxBaseParams { path: string - fileContent: string | UserFileLike + file?: UserFileLike + // Legacy field for backwards compatibility + fileContent?: string fileName?: string mode?: 'add' | 'overwrite' autorename?: boolean diff --git a/apps/sim/tools/dropbox/upload.ts b/apps/sim/tools/dropbox/upload.ts index b43ce68845..1a8914bc43 100644 --- a/apps/sim/tools/dropbox/upload.ts +++ b/apps/sim/tools/dropbox/upload.ts @@ -1,17 +1,6 @@ -import { extractBase64FromFileInput } from '@/lib/core/utils/user-file' import type { DropboxUploadParams, DropboxUploadResponse } from '@/tools/dropbox/types' import type { ToolConfig } from '@/tools/types' -/** - * Escapes non-ASCII characters in JSON string for HTTP header safety. - * Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX. - */ -function httpHeaderSafeJson(value: object): string { - return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => { - return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) - }) -} - export const dropboxUploadTool: ToolConfig = { id: 'dropbox_upload', name: 'Dropbox Upload File', @@ -31,11 +20,18 @@ export const dropboxUploadTool: ToolConfig