Compare commits

..

46 Commits

Author SHA1 Message Date
Waleed
2bb68335ee v0.5.79: longer MCP tools timeout, optimize loop/parallel regeneration, enrich.so integration 2026-01-31 21:57:56 -08:00
Waleed
8528fbe2d2 v0.5.78: billing fixes, mcp timeout increase, reactquery migrations, updated tool param visibilities, DSPy and Google Maps integrations 2026-01-31 13:48:22 -08:00
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
224 changed files with 2702 additions and 6965 deletions

View File

@@ -1,134 +0,0 @@
---
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
<gmail.attachments[0]>
// Pass the whole file object
<file_parser.files[0]>
// Access specific properties
<gmail.attachments[0].name>
<gmail.attachments[0].base64>
```
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:
<Tabs items={['Base64', 'URL']}>
<Tab value="Base64">
```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"
}
}'
```
</Tab>
<Tab value="URL">
```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"
}
}'
```
</Tab>
</Tabs>
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
<Callout type="info">
Files are automatically available to downstream blocks. The execution engine handles all file transfer and format conversion.
</Callout>
## 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.

View File

@@ -1,3 +1,3 @@
{ {
"pages": ["index", "basics", "files", "api", "logging", "costs"] "pages": ["index", "basics", "api", "logging", "costs"]
} }

View File

@@ -16,7 +16,7 @@ import {
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { SSE_HEADERS } from '@/lib/core/utils/sse' import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { markExecutionCancelled } from '@/lib/execution/cancellation' import { markExecutionCancelled } from '@/lib/execution/cancellation'
@@ -1119,7 +1119,7 @@ async function handlePushNotificationSet(
) )
} }
const urlValidation = await validateUrlWithDNS( const urlValidation = validateExternalUrl(
params.pushNotificationConfig.url, params.pushNotificationConfig.url,
'Push notification URL' 'Push notification URL'
) )

View File

@@ -8,7 +8,6 @@ import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions') const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30 const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -46,14 +45,13 @@ export async function GET(request: NextRequest) {
try { try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime() const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000) const staleDurationMinutes = Math.round(staleDurationMs / 60000)
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
await db await db
.update(workflowExecutionLogs) .update(workflowExecutionLogs)
.set({ .set({
status: 'failed', status: 'failed',
endedAt: new Date(), endedAt: new Date(),
totalDurationMs, totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set( executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb), COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'], ARRAY['error'],

View File

@@ -6,11 +6,7 @@ import { createLogger } from '@sim/logger'
import binaryExtensionsList from 'binary-extensions' import binaryExtensionsList from 'binary-extensions'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
@@ -23,7 +19,6 @@ import {
getMimeTypeFromExtension, getMimeTypeFromExtension,
getViewerUrl, getViewerUrl,
inferContextFromKey, inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils' } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyFileAccess } from '@/app/api/files/authorization' import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -220,7 +215,7 @@ async function parseFileSingle(
} }
} }
if (isInternalFileUrl(filePath)) { if (filePath.includes('/api/files/serve/')) {
return handleCloudFile(filePath, fileType, undefined, userId, executionContext) return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
} }
@@ -251,7 +246,7 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
return { isValid: false, error: 'Invalid path: tilde character not allowed' } return { isValid: false, error: 'Invalid path: tilde character not allowed' }
} }
if (filePath.startsWith('/') && !isInternalFileUrl(filePath)) { if (filePath.startsWith('/') && !filePath.startsWith('/api/files/serve/')) {
return { isValid: false, error: 'Path outside allowed directory' } return { isValid: false, error: 'Path outside allowed directory' }
} }
@@ -425,7 +420,7 @@ async function handleExternalUrl(
return parseResult return parseResult
} catch (error) { } catch (error) {
logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error) logger.error(`Error handling external URL ${url}:`, error)
return { return {
success: false, success: false,
error: `Error fetching URL: ${(error as Error).message}`, error: `Error fetching URL: ${(error as Error).message}`,

View File

@@ -284,7 +284,7 @@ async function handleToolsCall(
content: [ content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) }, { type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
], ],
isError: executeResult.success === false, isError: !executeResult.success,
} }
return NextResponse.json(createResponse(id, result)) return NextResponse.json(createResponse(id, result))

View File

@@ -20,7 +20,6 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing' import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,18 +501,6 @@ export async function PUT(
} }
} }
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}
logger.info(`Organization invitation ${status}`, { logger.info(`Organization invitation ${status}`, {
organizationId, organizationId,
invitationId, invitationId,

View File

@@ -4,7 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils' import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -96,14 +95,6 @@ export async function POST(request: NextRequest) {
if (validatedData.files && validatedData.files.length > 0) { if (validatedData.files && validatedData.files.length > 0) {
for (const file of validatedData.files) { for (const file of validatedData.files) {
if (file.type === 'url') { 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 = { const filePart: FilePart = {
kind: 'file', kind: 'file',
file: { file: {

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils' import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
const body = await request.json() const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body) const validatedData = A2ASetPushNotificationSchema.parse(body)
const urlValidation = await validateUrlWithDNS(validatedData.webhookUrl, 'Webhook URL') const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
if (!urlValidation.isValid) { if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error }) logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
return NextResponse.json( return NextResponse.json(

View File

@@ -92,9 +92,6 @@ export async function POST(request: NextRequest) {
formData.append('comment', comment) formData.append('comment', comment)
} }
// Add minorEdit field as required by Confluence API
formData.append('minorEdit', 'false')
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -4,7 +4,6 @@ import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateNumericId } from '@/lib/core/security/input-validation' import { validateNumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -16,7 +15,7 @@ const DiscordSendMessageSchema = z.object({
botToken: z.string().min(1, 'Bot token is required'), botToken: z.string().min(1, 'Bot token is required'),
channelId: z.string().min(1, 'Channel ID is required'), channelId: z.string().min(1, 'Channel ID is required'),
content: z.string().optional().nullable(), content: z.string().optional().nullable(),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -102,12 +101,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Processing ${validatedData.files.length} file(s)`) logger.info(`[${requestId}] Processing ${validatedData.files.length} file(s)`)
const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger) const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger)
const filesOutput: Array<{
name: string
mimeType: string
data: string
size: number
}> = []
if (userFiles.length === 0) { if (userFiles.length === 0) {
logger.warn(`[${requestId}] No valid files to upload, falling back to text-only`) logger.warn(`[${requestId}] No valid files to upload, falling back to text-only`)
@@ -144,12 +137,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`) logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`)
const buffer = await downloadFileFromStorage(userFile, requestId, logger) 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 }) const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type })
formData.append(`files[${i}]`, blob, userFile.name) formData.append(`files[${i}]`, blob, userFile.name)
@@ -186,7 +173,6 @@ export async function POST(request: NextRequest) {
message: data.content, message: data.content,
data: data, data: data,
fileCount: userFiles.length, fileCount: userFiles.length,
files: filesOutput,
}, },
}) })
} catch (error) { } catch (error) {

View File

@@ -1,195 +0,0 @@
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')
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'),
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(() => ({}))) as GitHubErrorResponse
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()) as GitHubCommitResponse
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<string, any> = {
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 }
)
}
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { import {
@@ -29,7 +28,7 @@ const GmailDraftSchema = z.object({
replyToMessageId: z.string().optional().nullable(), replyToMessageId: z.string().optional().nullable(),
cc: z.string().optional().nullable(), cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(), bcc: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(), attachments: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { import {
@@ -29,7 +28,7 @@ const GmailSendSchema = z.object({
replyToMessageId: z.string().optional().nullable(), replyToMessageId: z.string().optional().nullable(),
cc: z.string().optional().nullable(), cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(), bcc: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(), attachments: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

View File

@@ -1,252 +0,0 @@
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')
/** 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'),
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(() => ({}))) as GoogleApiErrorResponse
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()) as GoogleDriveFile
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(() => ({}))) as GoogleApiErrorResponse
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(() => ({}))) as GoogleApiErrorResponse
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()) as GoogleDriveRevisionsResponse
metadata.revisions = revisionsData.revisions
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 }
)
}
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { import {
@@ -21,7 +20,7 @@ const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files'
const GoogleDriveUploadSchema = z.object({ const GoogleDriveUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
fileName: z.string().min(1, 'File name is required'), fileName: z.string().min(1, 'File name is required'),
file: RawFileInputSchema.optional().nullable(), file: z.any().optional().nullable(),
mimeType: z.string().optional().nullable(), mimeType: z.string().optional().nullable(),
folderId: z.string().optional().nullable(), folderId: z.string().optional().nullable(),
}) })

View File

@@ -1,131 +0,0 @@
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'),
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 }
)
}
}

View File

@@ -1,10 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import { validateImageUrl } from '@/lib/core/security/input-validation'
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('ImageProxyAPI') const logger = createLogger('ImageProxyAPI')
@@ -29,7 +26,7 @@ export async function GET(request: NextRequest) {
return new NextResponse('Missing URL parameter', { status: 400 }) return new NextResponse('Missing URL parameter', { status: 400 })
} }
const urlValidation = await validateUrlWithDNS(imageUrl, 'imageUrl') const urlValidation = validateImageUrl(imageUrl)
if (!urlValidation.isValid) { if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Blocked image proxy request`, { logger.warn(`[${requestId}] Blocked image proxy request`, {
url: imageUrl.substring(0, 100), url: imageUrl.substring(0, 100),
@@ -41,8 +38,7 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Proxying image request for: ${imageUrl}`) logger.info(`[${requestId}] Proxying image request for: ${imageUrl}`)
try { try {
const imageResponse = await secureFetchWithPinnedIP(imageUrl, urlValidation.resolvedIP!, { const imageResponse = await fetch(imageUrl, {
method: 'GET',
headers: { headers: {
'User-Agent': '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', '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',
@@ -68,14 +64,14 @@ export async function GET(request: NextRequest) {
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg' const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'
const imageArrayBuffer = await imageResponse.arrayBuffer() const imageBlob = await imageResponse.blob()
if (imageArrayBuffer.byteLength === 0) { if (imageBlob.size === 0) {
logger.error(`[${requestId}] Empty image received`) logger.error(`[${requestId}] Empty image blob received`)
return new NextResponse('Empty image received', { status: 404 }) return new NextResponse('Empty image received', { status: 404 })
} }
return new NextResponse(imageArrayBuffer, { return new NextResponse(imageBlob, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',

View File

@@ -1,121 +0,0 @@
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 }
)
}
}

View File

@@ -2,11 +2,9 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -18,7 +16,7 @@ const TeamsWriteChannelSchema = z.object({
teamId: z.string().min(1, 'Team ID is required'), teamId: z.string().min(1, 'Team ID is required'),
channelId: z.string().min(1, 'Channel ID is required'), channelId: z.string().min(1, 'Channel ID is required'),
content: z.string().min(1, 'Message content is required'), content: z.string().min(1, 'Message content is required'),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -55,13 +53,94 @@ export async function POST(request: NextRequest) {
fileCount: validatedData.files?.length || 0, fileCount: validatedData.files?.length || 0,
}) })
const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ const attachments: any[] = []
rawFiles: validatedData.files || [], if (validatedData.files && validatedData.files.length > 0) {
accessToken: validatedData.accessToken, const rawFiles = validatedData.files
requestId, logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`)
logger,
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
for (const file of userFiles) {
try {
logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`)
const buffer = await downloadFileFromStorage(file, requestId, logger)
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 fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
'Content-Type': file.type || 'application/octet-stream',
},
body: new Uint8Array(buffer),
}) })
if (!uploadResponse.ok) {
const errorData = await uploadResponse.json().catch(() => ({}))
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()
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 fetch(fileDetailsUrl, {
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
},
})
if (!fileDetailsResponse.ok) {
const errorData = await fileDetailsResponse.json().catch(() => ({}))
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()
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`
)
}
let messageContent = validatedData.content let messageContent = validatedData.content
let contentType: 'text' | 'html' = 'text' let contentType: 'text' | 'html' = 'text'
const mentionEntities: TeamsMention[] = [] const mentionEntities: TeamsMention[] = []
@@ -118,21 +197,17 @@ export async function POST(request: NextRequest) {
const teamsUrl = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(validatedData.teamId)}/channels/${encodeURIComponent(validatedData.channelId)}/messages` const teamsUrl = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(validatedData.teamId)}/channels/${encodeURIComponent(validatedData.channelId)}/messages`
const teamsResponse = await secureFetchWithValidation( const teamsResponse = await fetch(teamsUrl, {
teamsUrl,
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
}, },
body: JSON.stringify(messageBody), body: JSON.stringify(messageBody),
}, })
'teamsUrl'
)
if (!teamsResponse.ok) { if (!teamsResponse.ok) {
const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse const errorData = await teamsResponse.json().catch(() => ({}))
logger.error(`[${requestId}] Microsoft Teams API error:`, errorData) logger.error(`[${requestId}] Microsoft Teams API error:`, errorData)
return NextResponse.json( return NextResponse.json(
{ {
@@ -143,7 +218,7 @@ export async function POST(request: NextRequest) {
) )
} }
const responseData = (await teamsResponse.json()) as GraphChatMessage const responseData = await teamsResponse.json()
logger.info(`[${requestId}] Teams channel message sent successfully`, { logger.info(`[${requestId}] Teams channel message sent successfully`, {
messageId: responseData.id, messageId: responseData.id,
attachmentCount: attachments.length, attachmentCount: attachments.length,
@@ -162,7 +237,6 @@ export async function POST(request: NextRequest) {
url: responseData.webUrl || '', url: responseData.webUrl || '',
attachmentCount: attachments.length, attachmentCount: attachments.length,
}, },
files: filesOutput,
}, },
}) })
} catch (error) { } catch (error) {

View File

@@ -2,11 +2,9 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,7 +15,7 @@ const TeamsWriteChatSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
chatId: z.string().min(1, 'Chat ID is required'), chatId: z.string().min(1, 'Chat ID is required'),
content: z.string().min(1, 'Message content is required'), content: z.string().min(1, 'Message content is required'),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -53,13 +51,94 @@ export async function POST(request: NextRequest) {
fileCount: validatedData.files?.length || 0, fileCount: validatedData.files?.length || 0,
}) })
const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ const attachments: any[] = []
rawFiles: validatedData.files || [], if (validatedData.files && validatedData.files.length > 0) {
accessToken: validatedData.accessToken, const rawFiles = validatedData.files
requestId, logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to Teams`)
logger,
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
for (const file of userFiles) {
try {
logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`)
const buffer = await downloadFileFromStorage(file, requestId, logger)
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 fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
'Content-Type': file.type || 'application/octet-stream',
},
body: new Uint8Array(buffer),
}) })
if (!uploadResponse.ok) {
const errorData = await uploadResponse.json().catch(() => ({}))
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()
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 fetch(fileDetailsUrl, {
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
},
})
if (!fileDetailsResponse.ok) {
const errorData = await fileDetailsResponse.json().catch(() => ({}))
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()
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`
)
}
let messageContent = validatedData.content let messageContent = validatedData.content
let contentType: 'text' | 'html' = 'text' let contentType: 'text' | 'html' = 'text'
const mentionEntities: TeamsMention[] = [] const mentionEntities: TeamsMention[] = []
@@ -115,21 +194,17 @@ export async function POST(request: NextRequest) {
const teamsUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(validatedData.chatId)}/messages` const teamsUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(validatedData.chatId)}/messages`
const teamsResponse = await secureFetchWithValidation( const teamsResponse = await fetch(teamsUrl, {
teamsUrl,
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
}, },
body: JSON.stringify(messageBody), body: JSON.stringify(messageBody),
}, })
'teamsUrl'
)
if (!teamsResponse.ok) { if (!teamsResponse.ok) {
const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse const errorData = await teamsResponse.json().catch(() => ({}))
logger.error(`[${requestId}] Microsoft Teams API error:`, errorData) logger.error(`[${requestId}] Microsoft Teams API error:`, errorData)
return NextResponse.json( return NextResponse.json(
{ {
@@ -140,7 +215,7 @@ export async function POST(request: NextRequest) {
) )
} }
const responseData = (await teamsResponse.json()) as GraphChatMessage const responseData = await teamsResponse.json()
logger.info(`[${requestId}] Teams message sent successfully`, { logger.info(`[${requestId}] Teams message sent successfully`, {
messageId: responseData.id, messageId: responseData.id,
attachmentCount: attachments.length, attachmentCount: attachments.length,
@@ -158,7 +233,6 @@ export async function POST(request: NextRequest) {
url: responseData.webUrl || '', url: responseData.webUrl || '',
attachmentCount: attachments.length, attachmentCount: attachments.length,
}, },
files: filesOutput,
}, },
}) })
} catch (error) { } catch (error) {

View File

@@ -2,17 +2,15 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getBaseUrl } from '@/lib/core/utils/urls'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { StorageService } from '@/lib/uploads'
import { import {
downloadFileFromStorage, extractStorageKey,
resolveInternalFileUrl, inferContextFromKey,
} from '@/lib/uploads/utils/file-utils.server' isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -20,9 +18,7 @@ const logger = createLogger('MistralParseAPI')
const MistralParseSchema = z.object({ const MistralParseSchema = z.object({
apiKey: z.string().min(1, 'API key is required'), apiKey: z.string().min(1, 'API key is required'),
filePath: z.string().min(1, 'File path is required').optional(), filePath: z.string().min(1, 'File path is required'),
fileData: FileInputSchema.optional(),
file: FileInputSchema.optional(),
resultType: z.string().optional(), resultType: z.string().optional(),
pages: z.array(z.number()).optional(), pages: z.array(z.number()).optional(),
includeImageBase64: z.boolean().optional(), includeImageBase64: z.boolean().optional(),
@@ -53,130 +49,66 @@ export async function POST(request: NextRequest) {
const body = await request.json() const body = await request.json()
const validatedData = MistralParseSchema.parse(body) const validatedData = MistralParseSchema.parse(body)
const fileData = validatedData.file || validatedData.fileData logger.info(`[${requestId}] Mistral parse request`, {
const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath filePath: validatedData.filePath,
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId,
})
if (!fileData && (!filePath || filePath.trim() === '')) { let fileUrl = validatedData.filePath
if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(
storageKey,
userId,
undefined, // customConfig
context, // context
false // isLocal
)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: 'File input is required', error: 'File not found',
}, },
{ status: 400 } { status: 404 }
) )
} }
logger.info(`[${requestId}] Mistral parse request`, { fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
hasFileData: Boolean(fileData), logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
filePath, } catch (error) {
isWorkspaceFile: filePath ? isInternalFileUrl(filePath) : false, logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
userId, return NextResponse.json(
}) {
success: false,
error: 'Failed to generate file access URL',
},
{ status: 500 }
)
}
} else if (validatedData.filePath?.startsWith('/')) {
const baseUrl = getBaseUrl()
fileUrl = `${baseUrl}${validatedData.filePath}`
}
const mistralBody: any = { const mistralBody: any = {
model: 'mistral-ocr-latest', model: 'mistral-ocr-latest',
} document: {
if (fileData && typeof fileData === 'object') {
const rawFile = fileData
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
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}`
// 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
const isInternalFilePath = isInternalFileUrl(filePath)
if (isInternalFilePath) {
const resolution = await resolveInternalFileUrl(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 (filePath.startsWith('/')) {
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 }
)
}
}
// 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', type: 'document_url',
document_url: fileUrl, document_url: fileUrl,
} },
}
} }
if (validatedData.pages) { if (validatedData.pages) {
@@ -192,25 +124,7 @@ export async function POST(request: NextRequest) {
mistralBody.image_min_size = validatedData.imageMinSize mistralBody.image_min_size = validatedData.imageMinSize
} }
const mistralEndpoint = 'https://api.mistral.ai/v1/ocr' const mistralResponse = await fetch('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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -218,8 +132,7 @@ export async function POST(request: NextRequest) {
Authorization: `Bearer ${validatedData.apiKey}`, Authorization: `Bearer ${validatedData.apiKey}`,
}, },
body: JSON.stringify(mistralBody), body: JSON.stringify(mistralBody),
} })
)
if (!mistralResponse.ok) { if (!mistralResponse.ok) {
const errorText = await mistralResponse.text() const errorText = await mistralResponse.text()

View File

@@ -1,177 +0,0 @@
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'
/** 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<string, unknown>
file?: {
mimeType?: string
}
}
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(() => ({}))) as GraphApiError
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()) as DriveItemMetadata
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(() => ({}))) as GraphApiError
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 }
)
}
}

View File

@@ -4,9 +4,7 @@ import * as XLSX from 'xlsx'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' 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 { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { import {
getExtensionFromMimeType, getExtensionFromMimeType,
processSingleFileToUserFile, processSingleFileToUserFile,
@@ -31,33 +29,12 @@ const ExcelValuesSchema = z.union([
const OneDriveUploadSchema = z.object({ const OneDriveUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
fileName: z.string().min(1, 'File name is required'), fileName: z.string().min(1, 'File name is required'),
file: RawFileInputSchema.optional(), file: z.any().optional(),
folderId: z.string().optional().nullable(), folderId: z.string().optional().nullable(),
mimeType: z.string().nullish(), mimeType: z.string().nullish(),
values: ExcelValuesSchema.optional().nullable(), values: ExcelValuesSchema.optional().nullable(),
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[][]
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
@@ -111,9 +88,25 @@ 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 let userFile
try { try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger) userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -186,23 +179,14 @@ export async function POST(request: NextRequest) {
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content` uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
} }
// Add conflict behavior if specified (defaults to replace by Microsoft Graph API) const uploadResponse = await fetch(uploadUrl, {
if (validatedData.conflictBehavior) {
uploadUrl += `?@microsoft.graph.conflictBehavior=${validatedData.conflictBehavior}`
}
const uploadResponse = await secureFetchWithValidation(
uploadUrl,
{
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
'Content-Type': mimeType, 'Content-Type': mimeType,
}, },
body: fileBuffer, body: new Uint8Array(fileBuffer),
}, })
'uploadUrl'
)
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
const errorText = await uploadResponse.text() const errorText = await uploadResponse.text()
@@ -216,7 +200,7 @@ export async function POST(request: NextRequest) {
) )
} }
const fileData = (await uploadResponse.json()) as OneDriveFileData const fileData = await uploadResponse.json()
let excelWriteResult: any | undefined let excelWriteResult: any | undefined
const shouldWriteExcelContent = const shouldWriteExcelContent =
@@ -225,11 +209,8 @@ export async function POST(request: NextRequest) {
if (shouldWriteExcelContent) { if (shouldWriteExcelContent) {
try { try {
let workbookSessionId: string | undefined let workbookSessionId: string | undefined
const sessionUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( const sessionResp = await fetch(
fileData.id `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
)}/workbook/createSession`
const sessionResp = await secureFetchWithValidation(
sessionUrl,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -237,12 +218,11 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ persistChanges: true }), body: JSON.stringify({ persistChanges: true }),
}, }
'sessionUrl'
) )
if (sessionResp.ok) { if (sessionResp.ok) {
const sessionData = (await sessionResp.json()) as { id?: string } const sessionData = await sessionResp.json()
workbookSessionId = sessionData?.id workbookSessionId = sessionData?.id
} }
@@ -251,19 +231,14 @@ export async function POST(request: NextRequest) {
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
fileData.id fileData.id
)}/workbook/worksheets?$select=name&$orderby=position&$top=1` )}/workbook/worksheets?$select=name&$orderby=position&$top=1`
const listResp = await secureFetchWithValidation( const listResp = await fetch(listUrl, {
listUrl,
{
method: 'GET',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
}, },
}, })
'listUrl'
)
if (listResp.ok) { if (listResp.ok) {
const listData = (await listResp.json()) as { value?: Array<{ name?: string }> } const listData = await listResp.json()
const firstSheetName = listData?.value?.[0]?.name const firstSheetName = listData?.value?.[0]?.name
if (firstSheetName) { if (firstSheetName) {
sheetName = firstSheetName sheetName = firstSheetName
@@ -322,9 +297,7 @@ export async function POST(request: NextRequest) {
)}')/range(address='${encodeURIComponent(computedRangeAddress)}')` )}')/range(address='${encodeURIComponent(computedRangeAddress)}')`
) )
const excelWriteResponse = await secureFetchWithValidation( const excelWriteResponse = await fetch(url.toString(), {
url.toString(),
{
method: 'PATCH', method: 'PATCH',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
@@ -332,9 +305,7 @@ export async function POST(request: NextRequest) {
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
}, },
body: JSON.stringify({ values: processedValues }), body: JSON.stringify({ values: processedValues }),
}, })
'excelWriteUrl'
)
if (!excelWriteResponse || !excelWriteResponse.ok) { if (!excelWriteResponse || !excelWriteResponse.ok) {
const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response' const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response'
@@ -349,7 +320,7 @@ export async function POST(request: NextRequest) {
details: errorText, details: errorText,
} }
} else { } else {
const writeData = (await excelWriteResponse.json()) as ExcelRangeData const writeData = await excelWriteResponse.json()
const addr = writeData.address || writeData.addressLocal const addr = writeData.address || writeData.addressLocal
const v = writeData.values || [] const v = writeData.values || []
excelWriteResult = { excelWriteResult = {
@@ -357,25 +328,21 @@ export async function POST(request: NextRequest) {
updatedRange: addr, updatedRange: addr,
updatedRows: Array.isArray(v) ? v.length : undefined, updatedRows: Array.isArray(v) ? v.length : undefined,
updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined, updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined,
updatedCells: Array.isArray(v) && v[0] ? v.length * v[0].length : undefined, updatedCells: Array.isArray(v) && v[0] ? v.length * (v[0] as any[]).length : undefined,
} }
} }
if (workbookSessionId) { if (workbookSessionId) {
try { try {
const closeUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( const closeResp = await fetch(
fileData.id `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/closeSession`,
)}/workbook/closeSession`
const closeResp = await secureFetchWithValidation(
closeUrl,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
'workbook-session-id': workbookSessionId, 'workbook-session-id': workbookSessionId,
}, },
}, }
'closeSessionUrl'
) )
if (!closeResp.ok) { if (!closeResp.ok) {
const closeText = await closeResp.text() const closeText = await closeResp.text()

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -19,7 +18,7 @@ const OutlookDraftSchema = z.object({
contentType: z.enum(['text', 'html']).optional().nullable(), contentType: z.enum(['text', 'html']).optional().nullable(),
cc: z.string().optional().nullable(), cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(), bcc: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(), attachments: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -21,7 +20,7 @@ const OutlookSendSchema = z.object({
bcc: z.string().optional().nullable(), bcc: z.string().optional().nullable(),
replyToMessageId: z.string().optional().nullable(), replyToMessageId: z.string().optional().nullable(),
conversationId: z.string().optional().nullable(), conversationId: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(), attachments: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -96,14 +95,14 @@ export async function POST(request: NextRequest) {
if (attachments.length > 0) { if (attachments.length > 0) {
const totalSize = attachments.reduce((sum, file) => sum + file.size, 0) const totalSize = attachments.reduce((sum, file) => sum + file.size, 0)
const maxSize = 3 * 1024 * 1024 // 3MB - Microsoft Graph API limit for inline attachments const maxSize = 4 * 1024 * 1024 // 4MB
if (totalSize > maxSize) { if (totalSize > maxSize) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2) const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`, error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`,
}, },
{ status: 400 } { status: 400 }
) )

View File

@@ -1,165 +0,0 @@
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')
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(),
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()) as PipedriveApiResponse
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 }
)
}
}

View File

@@ -2,14 +2,15 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getBaseUrl } from '@/lib/core/utils/urls'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { StorageService } from '@/lib/uploads'
import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,8 +18,7 @@ const logger = createLogger('PulseParseAPI')
const PulseParseSchema = z.object({ const PulseParseSchema = z.object({
apiKey: z.string().min(1, 'API key is required'), apiKey: z.string().min(1, 'API key is required'),
filePath: z.string().optional(), filePath: z.string().min(1, 'File path is required'),
file: RawFileInputSchema.optional(),
pages: z.string().optional(), pages: z.string().optional(),
extractFigure: z.boolean().optional(), extractFigure: z.boolean().optional(),
figureDescription: z.boolean().optional(), figureDescription: z.boolean().optional(),
@@ -51,30 +51,50 @@ export async function POST(request: NextRequest) {
const validatedData = PulseParseSchema.parse(body) const validatedData = PulseParseSchema.parse(body)
logger.info(`[${requestId}] Pulse parse request`, { logger.info(`[${requestId}] Pulse parse request`, {
fileName: validatedData.file?.name,
filePath: validatedData.filePath, filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId, userId,
}) })
const resolution = await resolveFileInputToUrl({ let fileUrl = validatedData.filePath
file: validatedData.file,
filePath: validatedData.filePath,
userId,
requestId,
logger,
})
if (resolution.error) { if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.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( return NextResponse.json(
{ success: false, error: resolution.error.message }, {
{ status: resolution.error.status } success: false,
error: 'File not found',
},
{ status: 404 }
) )
} }
const fileUrl = resolution.fileUrl fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
if (!fileUrl) { logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } 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 (validatedData.filePath?.startsWith('/')) {
const baseUrl = getBaseUrl()
fileUrl = `${baseUrl}${validatedData.filePath}`
} }
const formData = new FormData() const formData = new FormData()
@@ -99,36 +119,13 @@ export async function POST(request: NextRequest) {
formData.append('chunk_size', String(validatedData.chunkSize)) formData.append('chunk_size', String(validatedData.chunkSize))
} }
const pulseEndpoint = 'https://api.runpulse.com/extract' const pulseResponse = await fetch('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', method: 'POST',
headers: { headers: {
'x-api-key': validatedData.apiKey, 'x-api-key': validatedData.apiKey,
'Content-Type': contentType,
}, },
body: bodyBuffer, body: formData,
} })
)
if (!pulseResponse.ok) { if (!pulseResponse.ok) {
const errorText = await pulseResponse.text() const errorText = await pulseResponse.text()

View File

@@ -2,14 +2,15 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getBaseUrl } from '@/lib/core/utils/urls'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { StorageService } from '@/lib/uploads'
import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,8 +18,7 @@ const logger = createLogger('ReductoParseAPI')
const ReductoParseSchema = z.object({ const ReductoParseSchema = z.object({
apiKey: z.string().min(1, 'API key is required'), apiKey: z.string().min(1, 'API key is required'),
filePath: z.string().optional(), filePath: z.string().min(1, 'File path is required'),
file: RawFileInputSchema.optional(),
pages: z.array(z.number()).optional(), pages: z.array(z.number()).optional(),
tableOutputFormat: z.enum(['html', 'md']).optional(), tableOutputFormat: z.enum(['html', 'md']).optional(),
}) })
@@ -47,30 +47,56 @@ export async function POST(request: NextRequest) {
const validatedData = ReductoParseSchema.parse(body) const validatedData = ReductoParseSchema.parse(body)
logger.info(`[${requestId}] Reducto parse request`, { logger.info(`[${requestId}] Reducto parse request`, {
fileName: validatedData.file?.name,
filePath: validatedData.filePath, filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
userId, userId,
}) })
const resolution = await resolveFileInputToUrl({ let fileUrl = validatedData.filePath
file: validatedData.file,
filePath: validatedData.filePath,
userId,
requestId,
logger,
})
if (resolution.error) { if (isInternalFileUrl(validatedData.filePath)) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(
storageKey,
userId,
undefined, // customConfig
context, // context
false // isLocal
)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return NextResponse.json( return NextResponse.json(
{ success: false, error: resolution.error.message }, {
{ status: resolution.error.status } success: false,
error: 'File not found',
},
{ status: 404 }
) )
} }
const fileUrl = resolution.fileUrl fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
if (!fileUrl) { logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } 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 (validatedData.filePath?.startsWith('/')) {
const baseUrl = getBaseUrl()
fileUrl = `${baseUrl}${validatedData.filePath}`
} }
const reductoBody: Record<string, unknown> = { const reductoBody: Record<string, unknown> = {
@@ -78,13 +104,8 @@ export async function POST(request: NextRequest) {
} }
if (validatedData.pages && validatedData.pages.length > 0) { 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 = { reductoBody.settings = {
page_range: { page_range: validatedData.pages,
start: Math.min(...pages),
end: Math.max(...pages),
},
} }
} }
@@ -94,25 +115,7 @@ export async function POST(request: NextRequest) {
} }
} }
const reductoEndpoint = 'https://platform.reducto.ai/parse' const reductoResponse = await fetch('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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -120,8 +123,7 @@ export async function POST(request: NextRequest) {
Authorization: `Bearer ${validatedData.apiKey}`, Authorization: `Bearer ${validatedData.apiKey}`,
}, },
body: JSON.stringify(reductoBody), body: JSON.stringify(reductoBody),
} })
)
if (!reductoResponse.ok) { if (!reductoResponse.ok) {
const errorText = await reductoResponse.text() const errorText = await reductoResponse.text()

View File

@@ -4,7 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -18,7 +17,7 @@ const S3PutObjectSchema = z.object({
region: z.string().min(1, 'Region is required'), region: z.string().min(1, 'Region is required'),
bucketName: z.string().min(1, 'Bucket name is required'), bucketName: z.string().min(1, 'Bucket name is required'),
objectKey: z.string().min(1, 'Object key is required'), objectKey: z.string().min(1, 'Object key is required'),
file: RawFileInputSchema.optional().nullable(), file: z.any().optional().nullable(),
content: z.string().optional().nullable(), content: z.string().optional().nullable(),
contentType: z.string().optional().nullable(), contentType: z.string().optional().nullable(),
acl: z.string().optional().nullable(), acl: z.string().optional().nullable(),

View File

@@ -4,7 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' 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' import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -112,8 +111,6 @@ export async function POST(request: NextRequest) {
const buffer = Buffer.concat(chunks) const buffer = Buffer.concat(chunks)
const fileName = path.basename(remotePath) const fileName = path.basename(remotePath)
const extension = getFileExtension(fileName)
const mimeType = getMimeTypeFromExtension(extension)
let content: string let content: string
if (params.encoding === 'base64') { if (params.encoding === 'base64') {
@@ -127,12 +124,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
fileName, fileName,
file: {
name: fileName,
mimeType,
data: buffer.toString('base64'),
size: buffer.length,
},
content, content,
size: buffer.length, size: buffer.length,
encoding: params.encoding, encoding: params.encoding,

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { import {
@@ -27,7 +26,14 @@ const UploadSchema = z.object({
privateKey: z.string().nullish(), privateKey: z.string().nullish(),
passphrase: z.string().nullish(), passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'), remotePath: z.string().min(1, 'Remote path is required'),
files: RawFileInputArraySchema.optional().nullable(), 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(),
fileContent: z.string().nullish(), fileContent: z.string().nullish(),
fileName: z.string().nullish(), fileName: z.string().nullish(),
overwrite: z.boolean().default(true), overwrite: z.boolean().default(true),

View File

@@ -2,12 +2,9 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -19,7 +16,7 @@ const SharepointUploadSchema = z.object({
driveId: z.string().optional().nullable(), driveId: z.string().optional().nullable(),
folderPath: z.string().optional().nullable(), folderPath: z.string().optional().nullable(),
fileName: z.string().optional().nullable(), fileName: z.string().optional().nullable(),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -82,23 +79,18 @@ export async function POST(request: NextRequest) {
let effectiveDriveId = validatedData.driveId let effectiveDriveId = validatedData.driveId
if (!effectiveDriveId) { if (!effectiveDriveId) {
logger.info(`[${requestId}] No driveId provided, fetching default drive for site`) 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 fetch(
const driveResponse = await secureFetchWithValidation( `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive`,
driveUrl,
{ {
method: 'GET',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
Accept: 'application/json', Accept: 'application/json',
}, },
}, }
'driveUrl'
) )
if (!driveResponse.ok) { if (!driveResponse.ok) {
const errorData = (await driveResponse.json().catch(() => ({}))) as { const errorData = await driveResponse.json().catch(() => ({}))
error?: { message?: string }
}
logger.error(`[${requestId}] Failed to get default drive:`, errorData) logger.error(`[${requestId}] Failed to get default drive:`, errorData)
return NextResponse.json( return NextResponse.json(
{ {
@@ -109,7 +101,7 @@ export async function POST(request: NextRequest) {
) )
} }
const driveData = (await driveResponse.json()) as { id: string } const driveData = await driveResponse.json()
effectiveDriveId = driveData.id effectiveDriveId = driveData.id
logger.info(`[${requestId}] Using default drive: ${effectiveDriveId}`) logger.info(`[${requestId}] Using default drive: ${effectiveDriveId}`)
} }
@@ -153,87 +145,34 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) logger.info(`[${requestId}] Uploading to: ${uploadUrl}`)
const uploadResponse = await secureFetchWithValidation( const uploadResponse = await fetch(uploadUrl, {
uploadUrl,
{
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: `Bearer ${validatedData.accessToken}`, Authorization: `Bearer ${validatedData.accessToken}`,
'Content-Type': userFile.type || 'application/octet-stream', 'Content-Type': userFile.type || 'application/octet-stream',
}, },
body: buffer, body: new Uint8Array(buffer),
}, })
'uploadUrl'
)
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
const errorData = await uploadResponse.json().catch(() => ({})) const errorData = await uploadResponse.json().catch(() => ({}))
logger.error(`[${requestId}] Failed to upload file ${fileName}:`, errorData) logger.error(`[${requestId}] Failed to upload file ${fileName}:`, errorData)
if (uploadResponse.status === 409) { if (uploadResponse.status === 409) {
// File exists - retry with conflict behavior set to replace logger.warn(`[${requestId}] File ${fileName} already exists, attempting to replace`)
logger.warn(`[${requestId}] File ${fileName} already exists, retrying with replace`)
const replaceUrl = `${uploadUrl}?@microsoft.graph.conflictBehavior=replace`
const replaceResponse = await secureFetchWithValidation(
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 continue
} }
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error: errorData.error?.message || `Failed to upload file: ${fileName}`,
(errorData as { error?: { message?: string } }).error?.message ||
`Failed to upload file: ${fileName}`,
}, },
{ status: uploadResponse.status } { status: uploadResponse.status }
) )
} }
const uploadData = (await uploadResponse.json()) as MicrosoftGraphDriveItem const uploadData = await uploadResponse.json()
logger.info(`[${requestId}] File uploaded successfully: ${fileName}`) logger.info(`[${requestId}] File uploaded successfully: ${fileName}`)
uploadedFiles.push({ uploadedFiles.push({

View File

@@ -1,170 +0,0 @@
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 }
)
}
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { sendSlackMessage } from '../utils' import { sendSlackMessage } from '../utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,7 +16,7 @@ const SlackSendMessageSchema = z
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
text: z.string().min(1, 'Message text is required'), text: z.string().min(1, 'Message text is required'),
thread_ts: z.string().optional().nullable(), thread_ts: z.string().optional().nullable(),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
}) })
.refine((data) => data.channel || data.userId, { .refine((data) => data.channel || data.userId, {
message: 'Either channel or userId is required', message: 'Either channel or userId is required',

View File

@@ -1,8 +1,6 @@
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' 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 * Sends a message to a Slack channel using chat.postMessage
@@ -72,21 +70,14 @@ export async function uploadFilesToSlack(
accessToken: string, accessToken: string,
requestId: string, requestId: string,
logger: Logger logger: Logger
): Promise<{ fileIds: string[]; files: ToolFileData[] }> { ): Promise<string[]> {
const userFiles = processFilesToUserFiles(files, requestId, logger) const userFiles = processFilesToUserFiles(files, requestId, logger)
const uploadedFileIds: string[] = [] const uploadedFileIds: string[] = []
const uploadedFiles: ToolFileData[] = []
for (const userFile of userFiles) { for (const userFile of userFiles) {
logger.info(`[${requestId}] Uploading file: ${userFile.name}`) logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
const buffer = await downloadFileFromStorage(userFile, requestId, logger) 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', { const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', {
method: 'POST', method: 'POST',
@@ -109,14 +100,10 @@ export async function uploadFilesToSlack(
logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`) logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`)
const uploadResponse = await secureFetchWithValidation( const uploadResponse = await fetch(urlData.upload_url, {
urlData.upload_url,
{
method: 'POST', method: 'POST',
body: buffer, body: new Uint8Array(buffer),
}, })
'uploadUrl'
)
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`) logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`)
@@ -127,7 +114,7 @@ export async function uploadFilesToSlack(
uploadedFileIds.push(urlData.file_id) uploadedFileIds.push(urlData.file_id)
} }
return { fileIds: uploadedFileIds, files: uploadedFiles } return uploadedFileIds
} }
/** /**
@@ -137,8 +124,7 @@ export async function completeSlackFileUpload(
uploadedFileIds: string[], uploadedFileIds: string[],
channel: string, channel: string,
text: string, text: string,
accessToken: string, accessToken: string
threadTs?: string | null
): Promise<{ ok: boolean; files?: any[]; error?: string }> { ): Promise<{ ok: boolean; files?: any[]; error?: string }> {
const response = await fetch('https://slack.com/api/files.completeUploadExternal', { const response = await fetch('https://slack.com/api/files.completeUploadExternal', {
method: 'POST', method: 'POST',
@@ -150,7 +136,6 @@ export async function completeSlackFileUpload(
files: uploadedFileIds.map((id) => ({ id })), files: uploadedFileIds.map((id) => ({ id })),
channel_id: channel, channel_id: channel,
initial_comment: text, initial_comment: text,
...(threadTs && { thread_ts: threadTs }),
}), }),
}) })
@@ -232,13 +217,7 @@ export async function sendSlackMessage(
logger: Logger logger: Logger
): Promise<{ ): Promise<{
success: boolean success: boolean
output?: { output?: { message: any; ts: string; channel: string; fileCount?: number }
message: any
ts: string
channel: string
fileCount?: number
files?: ToolFileData[]
}
error?: string error?: string
}> { }> {
const { accessToken, text, threadTs, files } = params const { accessToken, text, threadTs, files } = params
@@ -270,15 +249,10 @@ export async function sendSlackMessage(
// Process files // Process files
logger.info(`[${requestId}] Processing ${files.length} file(s)`) logger.info(`[${requestId}] Processing ${files.length} file(s)`)
const { fileIds, files: uploadedFiles } = await uploadFilesToSlack( const uploadedFileIds = await uploadFilesToSlack(files, accessToken, requestId, logger)
files,
accessToken,
requestId,
logger
)
// No valid files uploaded - send text-only // No valid files uploaded - send text-only
if (fileIds.length === 0) { if (uploadedFileIds.length === 0) {
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`) logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
const data = await postSlackMessage(accessToken, channel, text, threadTs) const data = await postSlackMessage(accessToken, channel, text, threadTs)
@@ -290,8 +264,8 @@ export async function sendSlackMessage(
return { success: true, output: formatMessageSuccessResponse(data, text) } return { success: true, output: formatMessageSuccessResponse(data, text) }
} }
// Complete file upload with thread support // Complete file upload
const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs) const completeData = await completeSlackFileUpload(uploadedFileIds, channel, text, accessToken)
if (!completeData.ok) { if (!completeData.ok) {
logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) logger.error(`[${requestId}] Failed to complete upload:`, completeData.error)
@@ -308,8 +282,7 @@ export async function sendSlackMessage(
message: fileMessage, message: fileMessage,
ts: fileMessage.ts, ts: fileMessage.ts,
channel, channel,
fileCount: fileIds.length, fileCount: uploadedFileIds.length,
files: uploadedFiles,
}, },
} }
} }

View File

@@ -4,7 +4,6 @@ import nodemailer from 'nodemailer'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -29,7 +28,7 @@ const SmtpSendSchema = z.object({
cc: z.string().optional().nullable(), cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(), bcc: z.string().optional().nullable(),
replyTo: z.string().optional().nullable(), replyTo: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(), attachments: z.array(z.any()).optional().nullable(),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

View File

@@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2' import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHDownloadFileAPI') const logger = createLogger('SSHDownloadFileAPI')
@@ -80,16 +79,6 @@ 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 // Read file content
const content = await new Promise<Buffer>((resolve, reject) => { const content = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
@@ -107,8 +96,6 @@ export async function POST(request: NextRequest) {
}) })
const fileName = path.basename(remotePath) const fileName = path.basename(remotePath)
const extension = getFileExtension(fileName)
const mimeType = getMimeTypeFromExtension(extension)
// Encode content as base64 for binary safety // Encode content as base64 for binary safety
const base64Content = content.toString('base64') const base64Content = content.toString('base64')
@@ -117,12 +104,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
downloaded: true, downloaded: true,
file: {
name: fileName,
mimeType,
data: base64Content,
size: stats.size,
},
content: base64Content, content: base64Content,
fileName: fileName, fileName: fileName,
remotePath: remotePath, remotePath: remotePath,

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env' 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 { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -124,10 +123,6 @@ export async function POST(request: NextRequest) {
const variablesObject = processVariables(params.variables) const variablesObject = processVariables(params.variables)
const startUrl = normalizeUrl(rawStartUrl) 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', { logger.info('Starting Stagehand agent process', {
rawStartUrl, rawStartUrl,

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
const logger = createLogger('StagehandExtractAPI') const logger = createLogger('StagehandExtractAPI')
@@ -52,10 +51,6 @@ export async function POST(request: NextRequest) {
const params = validationResult.data const params = validationResult.data
const { url: rawUrl, instruction, selector, provider, apiKey, schema } = params const { url: rawUrl, instruction, selector, provider, apiKey, schema } = params
const url = normalizeUrl(rawUrl) 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', { logger.info('Starting Stagehand extraction process', {
rawUrl, rawUrl,

View File

@@ -2,15 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
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 { UserFile } from '@/executor/types'
import type { TranscriptSegment } from '@/tools/stt/types' import type { TranscriptSegment } from '@/tools/stt/types'
@@ -53,7 +45,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const userId = authResult.userId
const body: SttRequestBody = await request.json() const body: SttRequestBody = await request.json()
const { const {
provider, provider,
@@ -81,9 +72,6 @@ export async function POST(request: NextRequest) {
let audioMimeType: string let audioMimeType: string
if (body.audioFile) { 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 const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile
logger.info(`[${requestId}] Processing uploaded file: ${file.name}`) logger.info(`[${requestId}] Processing uploaded file: ${file.name}`)
@@ -91,12 +79,6 @@ export async function POST(request: NextRequest) {
audioFileName = file.name audioFileName = file.name
audioMimeType = file.type audioMimeType = file.type
} else if (body.audioFileReference) { } 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) const file = Array.isArray(body.audioFileReference)
? body.audioFileReference[0] ? body.audioFileReference[0]
: body.audioFileReference : body.audioFileReference
@@ -108,48 +90,14 @@ export async function POST(request: NextRequest) {
} else if (body.audioUrl) { } else if (body.audioUrl) {
logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`) logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`)
let audioUrl = body.audioUrl.trim() const response = await fetch(body.audioUrl)
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 secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, {
method: 'GET',
})
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to download audio from URL: ${response.statusText}`) throw new Error(`Failed to download audio from URL: ${response.statusText}`)
} }
const arrayBuffer = await response.arrayBuffer() const arrayBuffer = await response.arrayBuffer()
audioBuffer = Buffer.from(arrayBuffer) audioBuffer = Buffer.from(arrayBuffer)
audioFileName = audioUrl.split('/').pop() || 'audio_file' audioFileName = body.audioUrl.split('/').pop() || 'audio_file'
audioMimeType = response.headers.get('content-type') || 'audio/mpeg' audioMimeType = response.headers.get('content-type') || 'audio/mpeg'
} else { } else {
return NextResponse.json( return NextResponse.json(
@@ -201,9 +149,7 @@ export async function POST(request: NextRequest) {
translateToEnglish, translateToEnglish,
model, model,
body.prompt, body.prompt,
body.temperature, body.temperature
audioMimeType,
audioFileName
) )
transcript = result.transcript transcript = result.transcript
segments = result.segments segments = result.segments
@@ -216,8 +162,7 @@ export async function POST(request: NextRequest) {
language, language,
timestamps, timestamps,
diarization, diarization,
model, model
audioMimeType
) )
transcript = result.transcript transcript = result.transcript
segments = result.segments segments = result.segments
@@ -307,9 +252,7 @@ async function transcribeWithWhisper(
translate?: boolean, translate?: boolean,
model?: string, model?: string,
prompt?: string, prompt?: string,
temperature?: number, temperature?: number
mimeType?: string,
fileName?: string
): Promise<{ ): Promise<{
transcript: string transcript: string
segments?: TranscriptSegment[] segments?: TranscriptSegment[]
@@ -318,11 +261,8 @@ async function transcribeWithWhisper(
}> { }> {
const formData = new FormData() const formData = new FormData()
// Use actual MIME type and filename if provided const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' })
const actualMimeType = mimeType || 'audio/mpeg' formData.append('file', blob, 'audio.mp3')
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') formData.append('model', model || 'whisper-1')
if (language && language !== 'auto') { if (language && language !== 'auto') {
@@ -339,11 +279,10 @@ async function transcribeWithWhisper(
formData.append('response_format', 'verbose_json') formData.append('response_format', 'verbose_json')
// OpenAI API uses array notation for timestamp_granularities
if (timestamps === 'word') { if (timestamps === 'word') {
formData.append('timestamp_granularities[]', 'word') formData.append('timestamp_granularities', 'word')
} else if (timestamps === 'sentence') { } else if (timestamps === 'sentence') {
formData.append('timestamp_granularities[]', 'segment') formData.append('timestamp_granularities', 'segment')
} }
const endpoint = translate ? 'translations' : 'transcriptions' const endpoint = translate ? 'translations' : 'transcriptions'
@@ -386,8 +325,7 @@ async function transcribeWithDeepgram(
language?: string, language?: string,
timestamps?: 'none' | 'sentence' | 'word', timestamps?: 'none' | 'sentence' | 'word',
diarization?: boolean, diarization?: boolean,
model?: string, model?: string
mimeType?: string
): Promise<{ ): Promise<{
transcript: string transcript: string
segments?: TranscriptSegment[] segments?: TranscriptSegment[]
@@ -419,7 +357,7 @@ async function transcribeWithDeepgram(
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Token ${apiKey}`, Authorization: `Token ${apiKey}`,
'Content-Type': mimeType || 'audio/mpeg', 'Content-Type': 'audio/mpeg',
}, },
body: new Uint8Array(audioBuffer), body: new Uint8Array(audioBuffer),
}) })
@@ -575,8 +513,7 @@ async function transcribeWithAssemblyAI(
audio_url: upload_url, audio_url: upload_url,
} }
// AssemblyAI supports 'best', 'slam-1', or 'universal' for speech_model if (model === 'best' || model === 'nano') {
if (model === 'best' || model === 'slam-1' || model === 'universal') {
transcriptRequest.speech_model = model transcriptRequest.speech_model = model
} }

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -17,7 +16,7 @@ const SupabaseStorageUploadSchema = z.object({
bucket: z.string().min(1, 'Bucket name is required'), bucket: z.string().min(1, 'Bucket name is required'),
fileName: z.string().min(1, 'File name is required'), fileName: z.string().min(1, 'File name is required'),
path: z.string().optional().nullable(), path: z.string().optional().nullable(),
fileData: FileInputSchema, fileData: z.any(),
contentType: z.string().optional().nullable(), contentType: z.string().optional().nullable(),
upsert: z.boolean().optional().default(false), upsert: z.boolean().optional().default(false),
}) })

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { convertMarkdownToHTML } from '@/tools/telegram/utils' import { convertMarkdownToHTML } from '@/tools/telegram/utils'
@@ -15,7 +14,7 @@ const logger = createLogger('TelegramSendDocumentAPI')
const TelegramSendDocumentSchema = z.object({ const TelegramSendDocumentSchema = z.object({
botToken: z.string().min(1, 'Bot token is required'), botToken: z.string().min(1, 'Bot token is required'),
chatId: z.string().min(1, 'Chat ID is required'), chatId: z.string().min(1, 'Chat ID is required'),
files: RawFileInputArraySchema.optional().nullable(), files: z.array(z.any()).optional().nullable(),
caption: z.string().optional().nullable(), caption: z.string().optional().nullable(),
}) })
@@ -94,14 +93,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Uploading document: ${userFile.name}`) logger.info(`[${requestId}] Uploading document: ${userFile.name}`)
const buffer = await downloadFileFromStorage(userFile, requestId, logger) 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`) logger.info(`[${requestId}] Downloaded file: ${buffer.length} bytes`)
@@ -144,7 +135,6 @@ export async function POST(request: NextRequest) {
output: { output: {
message: 'Document sent successfully', message: 'Document sent successfully',
data: data.result, data: data.result,
files: filesOutput,
}, },
}) })
} catch (error) { } catch (error) {

View File

@@ -3,18 +3,19 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation'
import { import {
secureFetchWithPinnedIP, validateAwsRegion,
validateUrlWithDNS, validateExternalUrl,
} from '@/lib/core/security/input-validation.server' validateS3BucketName,
} from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { StorageService } from '@/lib/uploads'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { import {
downloadFileFromStorage, extractStorageKey,
resolveInternalFileUrl, inferContextFromKey,
} from '@/lib/uploads/utils/file-utils.server' isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
@@ -34,7 +35,6 @@ const TextractParseSchema = z
region: z.string().min(1, 'AWS region is required'), region: z.string().min(1, 'AWS region is required'),
processingMode: z.enum(['sync', 'async']).optional().default('sync'), processingMode: z.enum(['sync', 'async']).optional().default('sync'),
filePath: z.string().optional(), filePath: z.string().optional(),
file: RawFileInputSchema.optional(),
s3Uri: z.string().optional(), s3Uri: z.string().optional(),
featureTypes: z featureTypes: z
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'])) .array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
@@ -50,20 +50,6 @@ const TextractParseSchema = z
path: ['region'], 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( function getSignatureKey(
@@ -125,14 +111,7 @@ function signAwsRequest(
} }
async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> { async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> {
const urlValidation = await validateUrlWithDNS(url, 'Document URL') const response = await fetch(url)
if (!urlValidation.isValid) {
throw new Error(urlValidation.error || 'Invalid document URL')
}
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
method: 'GET',
})
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch document: ${response.statusText}`) throw new Error(`Failed to fetch document: ${response.statusText}`)
} }
@@ -339,8 +318,8 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Textract parse request`, { logger.info(`[${requestId}] Textract parse request`, {
processingMode, processingMode,
hasFile: Boolean(validatedData.file), filePath: validatedData.filePath?.substring(0, 50),
hasS3Uri: Boolean(validatedData.s3Uri), s3Uri: validatedData.s3Uri?.substring(0, 50),
featureTypes, featureTypes,
userId, userId,
}) })
@@ -435,49 +414,59 @@ export async function POST(request: NextRequest) {
}) })
} }
let bytes = '' if (!validatedData.filePath) {
let contentType = 'application/octet-stream'
let isPdf = false
if (validatedData.file) {
let userFile
try {
userFile = processSingleFileToUserFile(validatedData.file, requestId, logger)
} catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to process file', error: 'File path is required for single-page processing',
}, },
{ status: 400 } { status: 400 }
) )
} }
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 let fileUrl = validatedData.filePath
const isInternalFilePath = isInternalFileUrl(fileUrl) const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
if (isInternalFilePath) { if (isInternalFilePath) {
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) try {
if (resolution.error) { const storageKey = extractStorageKey(validatedData.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( return NextResponse.json(
{ {
success: false, success: false,
error: resolution.error.message, error: 'File not found',
}, },
{ status: resolution.error.status } { status: 404 }
) )
} }
fileUrl = resolution.fileUrl || fileUrl
} else if (fileUrl.startsWith('/')) { 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 (validatedData.filePath?.startsWith('/')) {
// Reject arbitrary absolute paths that don't contain /api/files/serve/
logger.warn(`[${requestId}] Invalid internal path`, { logger.warn(`[${requestId}] Invalid internal path`, {
userId, userId,
path: fileUrl.substring(0, 50), path: validatedData.filePath.substring(0, 50),
}) })
return NextResponse.json( return NextResponse.json(
{ {
@@ -487,7 +476,7 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
) )
} else { } else {
const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') const urlValidation = validateExternalUrl(fileUrl, 'Document URL')
if (!urlValidation.isValid) { if (!urlValidation.isValid) {
logger.warn(`[${requestId}] SSRF attempt blocked`, { logger.warn(`[${requestId}] SSRF attempt blocked`, {
userId, userId,
@@ -504,19 +493,10 @@ export async function POST(request: NextRequest) {
} }
} }
const fetched = await fetchDocumentBytes(fileUrl) const { bytes, contentType } = await fetchDocumentBytes(fileUrl)
bytes = fetched.bytes
contentType = fetched.contentType // Track if this is a PDF for better error messaging
isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
} else {
return NextResponse.json(
{
success: false,
error: 'File input is required for single-page processing',
},
{ status: 400 }
)
}
const uri = '/' const uri = '/'

View File

@@ -1,250 +0,0 @@
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')
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'),
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(() => ({}))) as TwilioErrorResponse
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()) as TwilioRecordingResponse
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()) as TwilioTranscriptionsResponse
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 }
)
}
}

View File

@@ -1,20 +1,10 @@
import { GoogleGenAI } from '@google/genai'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import {
downloadFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -23,8 +13,8 @@ const logger = createLogger('VisionAnalyzeAPI')
const VisionAnalyzeSchema = z.object({ const VisionAnalyzeSchema = z.object({
apiKey: z.string().min(1, 'API key is required'), apiKey: z.string().min(1, 'API key is required'),
imageUrl: z.string().optional().nullable(), imageUrl: z.string().optional().nullable(),
imageFile: RawFileInputSchema.optional().nullable(), imageFile: z.any().optional().nullable(),
model: z.string().optional().default('gpt-5.2'), model: z.string().optional().default('gpt-4o'),
prompt: z.string().optional().nullable(), prompt: z.string().optional().nullable(),
}) })
@@ -49,7 +39,6 @@ export async function POST(request: NextRequest) {
userId: authResult.userId, userId: authResult.userId,
}) })
const userId = authResult.userId
const body = await request.json() const body = await request.json()
const validatedData = VisionAnalyzeSchema.parse(body) const validatedData = VisionAnalyzeSchema.parse(body)
@@ -88,72 +77,18 @@ export async function POST(request: NextRequest) {
) )
} }
let base64 = userFile.base64
let bufferLength = 0
if (!base64) {
const buffer = await downloadFileFromStorage(userFile, requestId, logger) const buffer = await downloadFileFromStorage(userFile, requestId, logger)
base64 = buffer.toString('base64')
bufferLength = buffer.length const base64 = buffer.toString('base64')
}
const mimeType = userFile.type || 'image/jpeg' const mimeType = userFile.type || 'image/jpeg'
imageSource = `data:${mimeType};base64,${base64}` imageSource = `data:${mimeType};base64,${base64}`
if (bufferLength > 0) { logger.info(`[${requestId}] Converted image to base64 (${buffer.length} bytes)`)
logger.info(`[${requestId}] Converted image to base64 (${bufferLength} bytes)`)
}
}
let imageUrlValidation: Awaited<ReturnType<typeof validateUrlWithDNS>> | 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.' const defaultPrompt = 'Please analyze this image and describe what you see in detail.'
const prompt = validatedData.prompt || defaultPrompt const prompt = validatedData.prompt || defaultPrompt
const isClaude = validatedData.model.startsWith('claude-') const isClaude = validatedData.model.startsWith('claude-3')
const isGemini = validatedData.model.startsWith('gemini-')
const apiUrl = isClaude const apiUrl = isClaude
? 'https://api.anthropic.com/v1/messages' ? 'https://api.anthropic.com/v1/messages'
: 'https://api.openai.com/v1/chat/completions' : 'https://api.openai.com/v1/chat/completions'
@@ -171,72 +106,6 @@ export async function POST(request: NextRequest) {
let requestBody: any let requestBody: any
if (isGemini) {
let base64Payload = imageSource
if (!base64Payload.startsWith('data:')) {
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' },
{ 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 (isClaude) {
if (imageSource.startsWith('data:')) { if (imageSource.startsWith('data:')) {
const base64Match = imageSource.match(/^data:([^;]+);base64,(.+)$/) const base64Match = imageSource.match(/^data:([^;]+);base64,(.+)$/)
@@ -303,7 +172,7 @@ export async function POST(request: NextRequest) {
], ],
}, },
], ],
max_completion_tokens: 1000, max_tokens: 1000,
} }
} }

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid' import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { import {
getFileExtension, getFileExtension,
getMimeTypeFromExtension, getMimeTypeFromExtension,
@@ -20,7 +19,7 @@ const WORDPRESS_COM_API_BASE = 'https://public-api.wordpress.com/wp/v2/sites'
const WordPressUploadSchema = z.object({ const WordPressUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
siteId: z.string().min(1, 'Site ID is required'), siteId: z.string().min(1, 'Site ID is required'),
file: RawFileInputSchema.optional().nullable(), file: z.any().optional().nullable(),
filename: z.string().optional().nullable(), filename: z.string().optional().nullable(),
title: z.string().optional().nullable(), title: z.string().optional().nullable(),
caption: z.string().optional().nullable(), caption: z.string().optional().nullable(),

View File

@@ -1,216 +0,0 @@
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')
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'),
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(() => ({}))) as ZoomErrorResponse
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()) as ZoomRecordingsResponse
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: ZoomRecordingFile) => ({
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 }
)
}
}

View File

@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI') const logger = createLogger('SubscriptionTransferAPI')
@@ -89,14 +88,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
) )
} }
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}
await db await db
.update(subscription) .update(subscription)
.set({ referenceId: organizationId }) .set({ referenceId: organizationId })

View File

@@ -203,10 +203,6 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
} }
updateData.billingBlocked = body.billingBlocked updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked') updated.push('billingBlocked')
} }

View File

@@ -1,4 +1,6 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
@@ -6,7 +8,6 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse' import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation' import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session' import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events' import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -74,31 +75,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4() const executionId = uuidv4()
// Run preprocessing checks (billing, rate limits, usage limits) const [workflowRecord] = await db
const preprocessResult = await preprocessExecution({ .select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
workflowId, .from(workflowTable)
userId, .where(eq(workflowTable.id, workflowId))
triggerType: 'manual', .limit(1)
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})
if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}
const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) { if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 }) return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
} }
@@ -110,7 +92,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId, workflowId,
startBlockId, startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length, executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
}) })
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

View File

@@ -807,7 +807,7 @@ export function Chat() {
const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map( const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map(
(fieldName) => { (fieldName) => {
const defaultType = fieldName === 'files' ? 'file[]' : 'string' const defaultType = fieldName === 'files' ? 'files' : 'string'
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),

View File

@@ -179,7 +179,7 @@ export function A2aDeploy({
newFields.push({ newFields.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: 'files', name: 'files',
type: 'file[]', type: 'files',
value: '', value: '',
collapsed: false, collapsed: false,
}) })

View File

@@ -26,7 +26,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
interface Field { interface Field {
id: string id: string
name: string name: string
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
value?: string value?: string
description?: string description?: string
collapsed?: boolean collapsed?: boolean
@@ -57,7 +57,7 @@ const TYPE_OPTIONS: ComboboxOption[] = [
{ label: 'Boolean', value: 'boolean' }, { label: 'Boolean', value: 'boolean' },
{ label: 'Object', value: 'object' }, { label: 'Object', value: 'object' },
{ label: 'Array', value: 'array' }, { label: 'Array', value: 'array' },
{ label: 'Files', value: 'file[]' }, { label: 'Files', value: 'files' },
] ]
/** /**
@@ -448,7 +448,7 @@ export function FieldFormat({
) )
} }
if (field.type === 'file[]') { if (field.type === 'files') {
const lineCount = fieldValue.split('\n').length const lineCount = fieldValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount) const gutterWidth = calculateGutterWidth(lineCount)

View File

@@ -225,7 +225,7 @@ const getOutputTypeForPath = (
const chatModeTypes: Record<string, string> = { const chatModeTypes: Record<string, string> = {
input: 'string', input: 'string',
conversationId: 'string', conversationId: 'string',
files: 'file[]', files: 'files',
} }
return chatModeTypes[outputPath] || 'any' return chatModeTypes[outputPath] || 'any'
} }
@@ -1563,11 +1563,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockTagGroups.sort((a, b) => a.distance - b.distance) blockTagGroups.sort((a, b) => a.distance - b.distance)
finalBlockTagGroups.push(...blockTagGroups) finalBlockTagGroups.push(...blockTagGroups)
const groupTags = finalBlockTagGroups.flatMap((group) => group.tags) const contextualTags: string[] = []
const tags = [...groupTags, ...variableTags] if (loopBlockGroup) {
contextualTags.push(...loopBlockGroup.tags)
}
if (parallelBlockGroup) {
contextualTags.push(...parallelBlockGroup.tags)
}
return { return {
tags, tags: [...allBlockTags, ...variableTags, ...contextualTags],
variableInfoMap, variableInfoMap,
blockTagGroups: finalBlockTagGroups, blockTagGroups: finalBlockTagGroups,
} }
@@ -1741,7 +1746,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
mergedSubBlocks mergedSubBlocks
) )
if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') { if (fieldType === 'files' || fieldType === 'file[]' || fieldType === 'array') {
const blockName = parts[0] const blockName = parts[0]
const remainingPath = parts.slice(2).join('.') const remainingPath = parts.slice(2).join('.')
processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}` processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}`

View File

@@ -50,12 +50,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** Stable empty object to avoid creating new references */ /** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any> const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Shared style for dashed divider lines */
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/** /**
* Icon component for rendering block icons. * Icon component for rendering block icons.
* *
@@ -95,23 +89,31 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor' const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow = const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel') currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock = const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input') currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
// Get user permissions
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get block properties (advanced/trigger modes)
const { advancedMode, triggerMode } = useEditorBlockProperties( const { advancedMode, triggerMode } = useEditorBlockProperties(
currentBlockId, currentBlockId,
currentWorkflow.isSnapshotView currentWorkflow.isSnapshotView
@@ -143,9 +145,10 @@ export function Editor() {
[subBlocksForCanonical] [subBlocksForCanonical]
) )
const canonicalModeOverrides = currentBlock?.data?.canonicalModes const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = useMemo( const advancedValuesPresent = hasAdvancedValues(
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex), subBlocksForCanonical,
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex] blockSubBlockValues,
canonicalIndex
) )
const displayAdvancedOptions = userPermissions.canEdit const displayAdvancedOptions = userPermissions.canEdit
? advancedMode ? advancedMode
@@ -153,9 +156,11 @@ export function Editor() {
const hasAdvancedOnlyFields = useMemo(() => { const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) { for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if ( if (
subBlock.condition && subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues) !evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
@@ -168,6 +173,7 @@ export function Editor() {
return false return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues]) }, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout( const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any), blockConfig || ({} as any),
currentBlockId || '', currentBlockId || '',
@@ -200,34 +206,31 @@ export function Editor() {
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly } return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId]) }, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '') const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
// Connections resize hook
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({ const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
subBlocksRef, subBlocksRef,
}) })
// Collaborative actions
const { const {
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName, collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => { const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId) collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode]) }, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('') const [editedName, setEditedName] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
/**
* Ref callback that auto-selects the input text when mounted.
*/
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
if (element) {
element.select()
}
}, [])
/** /**
* Handles starting the rename process. * Handles starting the rename process.
@@ -248,6 +251,7 @@ export function Editor() {
if (trimmedName && trimmedName !== currentBlock?.name) { if (trimmedName && trimmedName !== currentBlock?.name) {
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName) const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
if (!result.success) { if (!result.success) {
// Keep rename mode open on error so user can correct the name
return return
} }
} }
@@ -262,6 +266,14 @@ export function Editor() {
setEditedName('') setEditedName('')
}, []) }, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => { useEffect(() => {
if (shouldFocusRename && currentBlock) { if (shouldFocusRename && currentBlock) {
handleStartRename() handleStartRename()
@@ -272,13 +284,17 @@ export function Editor() {
/** /**
* Handles opening documentation link in a new secure tab. * Handles opening documentation link in a new secure tab.
*/ */
const handleOpenDocs = useCallback(() => { const handleOpenDocs = () => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer') if (docsLink) {
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink]) window.open(docsLink, '_blank', 'noopener,noreferrer')
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId) useWorkflowState(childWorkflowId)
@@ -291,6 +307,7 @@ export function Editor() {
} }
}, [childWorkflowId, workspaceId]) }, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35 const isConnectionsAtMinHeight = connectionsHeight <= 35
return ( return (
@@ -311,7 +328,7 @@ export function Editor() {
)} )}
{isRenaming ? ( {isRenaming ? (
<input <input
ref={nameInputRefCallback} ref={nameInputRef}
type='text' type='text'
value={editedName} value={editedName}
onChange={(e) => setEditedName(e.target.value)} onChange={(e) => setEditedName(e.target.value)}
@@ -382,6 +399,7 @@ export function Editor() {
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} */} )} */}
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
@@ -397,6 +415,7 @@ export function Editor() {
<p>Open docs</p> <p>Open docs</p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)}
</div> </div>
</div> </div>
@@ -476,7 +495,13 @@ export function Editor() {
</div> </div>
</div> </div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} /> <div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
</> </>
)} )}
@@ -541,7 +566,13 @@ export function Editor() {
/> />
{showDivider && ( {showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} /> <div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
</div> </div>
@@ -550,7 +581,13 @@ export function Editor() {
{hasAdvancedOnlyFields && userPermissions.canEdit && ( {hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'> <div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} /> <div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button <button
type='button' type='button'
onClick={handleToggleAdvancedMode} onClick={handleToggleAdvancedMode}
@@ -563,7 +600,13 @@ export function Editor() {
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`} className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/> />
</button> </button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} /> <div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
@@ -587,7 +630,13 @@ export function Editor() {
/> />
{index < advancedOnlySubBlocks.length - 1 && ( {index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} /> <div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -188,7 +188,7 @@ export function useBlockOutputFields({
baseOutputs = { baseOutputs = {
input: { type: 'string', description: 'User message' }, input: { type: 'string', description: 'User message' },
conversationId: { type: 'string', description: 'Conversation ID' }, conversationId: { type: 'string', description: 'Conversation ID' },
files: { type: 'file[]', description: 'Uploaded files' }, files: { type: 'files', description: 'Uploaded files' },
} }
} else { } else {
const inputFormatValue = mergedSubBlocks?.inputFormat?.value const inputFormatValue = mergedSubBlocks?.inputFormat?.value

View File

@@ -417,11 +417,11 @@ async function executeWebhookJobInternal(
if (triggerBlock?.subBlocks?.inputFormat?.value) { if (triggerBlock?.subBlocks?.inputFormat?.value) {
const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{ const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{
name: string name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
}> }>
logger.debug(`[${requestId}] Processing generic webhook files from inputFormat`) logger.debug(`[${requestId}] Processing generic webhook files from inputFormat`)
const fileFields = inputFormat.filter((field) => field.type === 'file[]') const fileFields = inputFormat.filter((field) => field.type === 'files')
if (fileFields.length > 0 && typeof input === 'object' && input !== null) { if (fileFields.length > 0 && typeof input === 'object' && input !== null) {
const executionContext = { const executionContext = {

View File

@@ -442,16 +442,7 @@ describe('Blocks Module', () => {
}) })
it('should have valid output types', () => { it('should have valid output types', () => {
const validPrimitiveTypes = [ const validPrimitiveTypes = ['string', 'number', 'boolean', 'json', 'array', 'files', 'any']
'string',
'number',
'boolean',
'json',
'array',
'file',
'file[]',
'any',
]
const blocks = getAllBlocks() const blocks = getAllBlocks()
for (const block of blocks) { for (const block of blocks) {
for (const [key, outputConfig] of Object.entries(block.outputs)) { for (const [key, outputConfig] of Object.entries(block.outputs)) {

View File

@@ -26,7 +26,7 @@ export const ChatTriggerBlock: BlockConfig = {
outputs: { outputs: {
input: { type: 'string', description: 'User message' }, input: { type: 'string', description: 'User message' },
conversationId: { type: 'string', description: 'Conversation ID' }, conversationId: { type: 'string', description: 'Conversation ID' },
files: { type: 'file[]', description: 'Uploaded files' }, files: { type: 'files', description: 'Uploaded files' },
}, },
triggers: { triggers: {
enabled: true, enabled: true,

View File

@@ -578,19 +578,12 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
if (!params.serverId) throw new Error('Server ID is required') if (!params.serverId) throw new Error('Server ID is required')
switch (params.operation) { 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 { return {
...commonParams, ...commonParams,
channelId: params.channelId, channelId: params.channelId,
content: params.content, content: params.content,
files: normalizedFiles, files: params.attachmentFiles || params.files,
}
} }
case 'discord_get_messages': case 'discord_get_messages':
return { return {
@@ -796,7 +789,6 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
}, },
outputs: { outputs: {
message: { type: 'string', description: 'Status message' }, message: { type: 'string', description: 'Status message' },
files: { type: 'file[]', description: 'Files attached to the message' },
data: { type: 'json', description: 'Response data' }, data: { type: 'json', description: 'Response data' },
}, },
} }

View File

@@ -59,26 +59,13 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
condition: { field: 'operation', value: 'dropbox_upload' }, condition: { field: 'operation', value: 'dropbox_upload' },
required: true, required: true,
}, },
{
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', id: 'fileContent',
title: 'File', title: 'File Content',
type: 'short-input', type: 'long-input',
canonicalParamId: 'fileContent', placeholder: 'Base64 encoded file content or file reference',
placeholder: 'Reference file from previous blocks',
mode: 'advanced',
required: true,
condition: { field: 'operation', value: 'dropbox_upload' }, condition: { field: 'operation', value: 'dropbox_upload' },
required: true,
}, },
{ {
id: 'mode', id: 'mode',
@@ -350,8 +337,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
path: { type: 'string', description: 'Path in Dropbox' }, path: { type: 'string', description: 'Path in Dropbox' },
autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, autorename: { type: 'boolean', description: 'Auto-rename on conflict' },
// Upload inputs // Upload inputs
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, fileContent: { type: 'string', description: 'Base64 encoded file content' },
fileContent: { type: 'json', description: 'File reference or UserFile object' },
fileName: { type: 'string', description: 'Optional filename' }, fileName: { type: 'string', description: 'Optional filename' },
mode: { type: 'string', description: 'Write mode: add or overwrite' }, mode: { type: 'string', description: 'Write mode: add or overwrite' },
mute: { type: 'boolean', description: 'Mute notifications' }, mute: { type: 'boolean', description: 'Mute notifications' },
@@ -374,7 +360,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
outputs: { outputs: {
// Upload/Download outputs // Upload/Download outputs
file: { type: 'file', description: 'Downloaded file stored in execution files' }, file: { type: 'json', description: 'File metadata' },
content: { type: 'string', description: 'File content (base64)' }, content: { type: 'string', description: 'File content (base64)' },
temporaryLink: { type: 'string', description: 'Temporary download link' }, temporaryLink: { type: 'string', description: 'Temporary download link' },
// List folder outputs // List folder outputs

View File

@@ -73,6 +73,5 @@ export const ElevenLabsBlock: BlockConfig<ElevenLabsBlockResponse> = {
outputs: { outputs: {
audioUrl: { type: 'string', description: 'Generated audio URL' }, audioUrl: { type: 'string', description: 'Generated audio URL' },
audioFile: { type: 'file', description: 'Generated audio file' },
}, },
} }

View File

@@ -1,48 +1,11 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { DocumentIcon } from '@/components/icons' import { DocumentIcon } from '@/components/icons'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import type { BlockConfig, SubBlockType } from '@/blocks/types' import type { BlockConfig, SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils' import { createVersionedToolSelector } from '@/blocks/utils'
import type { FileParserOutput, FileParserV3Output } from '@/tools/file/types' import type { FileParserOutput } from '@/tools/file/types'
const logger = createLogger('FileBlock') const logger = createLogger('FileBlock')
const resolveFilePathFromInput = (fileInput: unknown): string | null => {
if (!fileInput || typeof fileInput !== 'object') {
return null
}
const record = fileInput as Record<string, unknown>
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<FileParserOutput> = { export const FileBlock: BlockConfig<FileParserOutput> = {
type: 'file', type: 'file',
name: 'File (Legacy)', name: 'File (Legacy)',
@@ -116,14 +79,24 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
// Handle file upload input // Handle file upload input
if (inputMethod === 'upload') { if (inputMethod === 'upload') {
const filePaths = resolveFilePathsFromInput(params.file) // Handle case where 'file' is an array (multiple files)
if (filePaths.length > 0) { if (params.file && Array.isArray(params.file) && params.file.length > 0) {
const filePaths = params.file.map((file) => file.path)
return { return {
filePath: filePaths.length === 1 ? filePaths[0] : filePaths, filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
fileType: params.fileType || 'auto', 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 // If no files, return error
logger.error('No files provided for upload method') logger.error('No files provided for upload method')
throw new Error('Please upload a file') throw new Error('Please upload a file')
@@ -143,7 +116,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
}, },
outputs: { outputs: {
files: { files: {
type: 'file[]', type: 'json',
description: 'Array of parsed file objects with content, metadata, and file properties', description: 'Array of parsed file objects with content, metadata, and file properties',
}, },
combinedContent: { combinedContent: {
@@ -151,7 +124,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
description: 'All file contents merged into a single text string', description: 'All file contents merged into a single text string',
}, },
processedFiles: { processedFiles: {
type: 'file[]', type: 'files',
description: 'Array of UserFile objects for downstream use (attachments, uploads, etc.)', description: 'Array of UserFile objects for downstream use (attachments, uploads, etc.)',
}, },
}, },
@@ -160,9 +133,9 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
export const FileV2Block: BlockConfig<FileParserOutput> = { export const FileV2Block: BlockConfig<FileParserOutput> = {
...FileBlock, ...FileBlock,
type: 'file_v2', type: 'file_v2',
name: 'File (Legacy)', name: 'File',
description: 'Read and parse multiple files', description: 'Read and parse multiple files',
hideFromToolbar: true, hideFromToolbar: false,
subBlocks: [ subBlocks: [
{ {
id: 'file', id: 'file',
@@ -209,17 +182,16 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
} }
if (Array.isArray(fileInput) && fileInput.length > 0) { if (Array.isArray(fileInput) && fileInput.length > 0) {
const filePaths = resolveFilePathsFromInput(fileInput) const filePaths = fileInput.map((file) => file.path)
return { return {
filePath: filePaths.length === 1 ? filePaths[0] : filePaths, filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
fileType: params.fileType || 'auto', fileType: params.fileType || 'auto',
} }
} }
const resolvedSingle = resolveFilePathsFromInput(fileInput) if (fileInput?.path) {
if (resolvedSingle.length > 0) {
return { return {
filePath: resolvedSingle[0], filePath: fileInput.path,
fileType: params.fileType || 'auto', fileType: params.fileType || 'auto',
} }
} }
@@ -237,7 +209,7 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
}, },
outputs: { outputs: {
files: { files: {
type: 'file[]', type: 'json',
description: 'Array of parsed file objects with content, metadata, and file properties', description: 'Array of parsed file objects with content, metadata, and file properties',
}, },
combinedContent: { combinedContent: {
@@ -246,108 +218,3 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
}, },
}, },
} }
export const FileV3Block: BlockConfig<FileParserV3Output> = {
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 = 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,
}
}
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')
}
return {
filePath: resolvedPaths[0],
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',
},
},
}

View File

@@ -1,5 +1,4 @@
import { FirefliesIcon } from '@/components/icons' import { FirefliesIcon } from '@/components/icons'
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import type { FirefliesResponse } from '@/tools/fireflies/types' import type { FirefliesResponse } from '@/tools/fireflies/types'
@@ -7,9 +6,8 @@ import { getTrigger } from '@/triggers'
export const FirefliesBlock: BlockConfig<FirefliesResponse> = { export const FirefliesBlock: BlockConfig<FirefliesResponse> = {
type: 'fireflies', type: 'fireflies',
name: 'Fireflies (Legacy)', name: 'Fireflies',
description: 'Interact with Fireflies.ai meeting transcripts and recordings', description: 'Interact with Fireflies.ai meeting transcripts and recordings',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
triggerAllowed: true, triggerAllowed: true,
longDescription: longDescription:
@@ -589,74 +587,3 @@ Return ONLY the summary text - no quotes, no labels.`,
available: ['fireflies_transcription_complete'], 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<FirefliesResponse> = {
...FirefliesBlock,
type: 'fireflies_v2',
name: 'Fireflies',
description: 'Interact with Fireflies.ai meeting transcripts and recordings',
hideFromToolbar: false,
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 <block.files[0]> 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' },
},
}

View File

@@ -516,7 +516,7 @@ Return ONLY the search query - no explanations, no extra text.`,
// Tool outputs // Tool outputs
content: { type: 'string', description: 'Response content' }, content: { type: 'string', description: 'Response content' },
metadata: { type: 'json', description: 'Email metadata' }, metadata: { type: 'json', description: 'Email metadata' },
attachments: { type: 'file[]', description: 'Email attachments array' }, attachments: { type: 'json', description: 'Email attachments array' },
// Trigger outputs // Trigger outputs
email_id: { type: 'string', description: 'Gmail message ID' }, email_id: { type: 'string', description: 'Gmail message ID' },
thread_id: { type: 'string', description: 'Gmail thread ID' }, thread_id: { type: 'string', description: 'Gmail thread ID' },
@@ -579,7 +579,7 @@ export const GmailV2Block: BlockConfig<GmailToolResponse> = {
date: { type: 'string', description: 'Date' }, date: { type: 'string', description: 'Date' },
body: { type: 'string', description: 'Email body text (best-effort)' }, body: { type: 'string', description: 'Email body text (best-effort)' },
results: { type: 'json', description: 'Search/read summary results' }, results: { type: 'json', description: 'Search/read summary results' },
attachments: { type: 'file[]', description: 'Downloaded attachments (if enabled)' }, attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' },
// Draft-specific outputs // Draft-specific outputs
draftId: { draftId: {

View File

@@ -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' }, permissionId: { type: 'string', description: 'Permission ID to remove' },
}, },
outputs: { outputs: {
file: { type: 'file', description: 'Downloaded file stored in execution files' }, file: { type: 'json', description: 'File metadata or downloaded file data' },
files: { type: 'json', description: 'List of files' }, files: { type: 'json', description: 'List of files' },
metadata: { type: 'json', description: 'Complete file metadata (from download)' }, metadata: { type: 'json', description: 'Complete file metadata (from download)' },
content: { type: 'string', description: 'File content as text' }, content: { type: 'string', description: 'File content as text' },

View File

@@ -1,7 +1,6 @@
import { GoogleSheetsIcon } from '@/components/icons' import { GoogleSheetsIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types' import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types'
// Legacy block - hidden from toolbar // Legacy block - hidden from toolbar
@@ -682,38 +681,34 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
'google_sheets_copy_sheet_v2', 'google_sheets_copy_sheet_v2',
], ],
config: { config: {
tool: createVersionedToolSelector({ tool: (params) => {
baseToolSelector: (params) => {
switch (params.operation) { switch (params.operation) {
case 'read': case 'read':
return 'google_sheets_read' return 'google_sheets_read_v2'
case 'write': case 'write':
return 'google_sheets_write' return 'google_sheets_write_v2'
case 'update': case 'update':
return 'google_sheets_update' return 'google_sheets_update_v2'
case 'append': case 'append':
return 'google_sheets_append' return 'google_sheets_append_v2'
case 'clear': case 'clear':
return 'google_sheets_clear' return 'google_sheets_clear_v2'
case 'get_info': case 'get_info':
return 'google_sheets_get_spreadsheet' return 'google_sheets_get_spreadsheet_v2'
case 'create': case 'create':
return 'google_sheets_create_spreadsheet' return 'google_sheets_create_spreadsheet_v2'
case 'batch_get': case 'batch_get':
return 'google_sheets_batch_get' return 'google_sheets_batch_get_v2'
case 'batch_update': case 'batch_update':
return 'google_sheets_batch_update' return 'google_sheets_batch_update_v2'
case 'batch_clear': case 'batch_clear':
return 'google_sheets_batch_clear' return 'google_sheets_batch_clear_v2'
case 'copy_sheet': case 'copy_sheet':
return 'google_sheets_copy_sheet' return 'google_sheets_copy_sheet_v2'
default: default:
throw new Error(`Invalid Google Sheets operation: ${params.operation}`) throw new Error(`Invalid Google Sheets V2 operation: ${params.operation}`)
} }
}, },
suffix: '_v2',
fallbackToolId: 'google_sheets_read_v2',
}),
params: (params) => { params: (params) => {
const { const {
credential, credential,

View File

@@ -1,14 +1,12 @@
import { GoogleSlidesIcon } from '@/components/icons' import { GoogleSlidesIcon } from '@/components/icons'
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import type { GoogleSlidesResponse } from '@/tools/google_slides/types' import type { GoogleSlidesResponse } from '@/tools/google_slides/types'
export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = { export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
type: 'google_slides', type: 'google_slides',
name: 'Google Slides (Legacy)', name: 'Google Slides',
description: 'Read, write, and create presentations', description: 'Read, write, and create presentations',
hideFromToolbar: true,
authMode: AuthMode.OAuth, authMode: AuthMode.OAuth,
longDescription: 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.', '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.',
@@ -316,27 +314,13 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
condition: { field: 'operation', value: 'add_image' }, condition: { field: 'operation', value: 'add_image' },
required: true, 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', id: 'imageUrl',
title: 'Image', title: 'Image URL',
type: 'short-input', type: 'short-input',
canonicalParamId: 'imageSource', placeholder: 'Public URL of the image (PNG, JPEG, or GIF)',
placeholder: 'Reference image from previous blocks or enter URL',
mode: 'advanced',
required: true,
condition: { field: 'operation', value: 'add_image' }, condition: { field: 'operation', value: 'add_image' },
required: true,
}, },
{ {
id: 'imageWidth', id: 'imageWidth',
@@ -825,9 +809,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
placeholderIdMappings: { type: 'string', description: 'JSON array of placeholder ID mappings' }, placeholderIdMappings: { type: 'string', description: 'JSON array of placeholder ID mappings' },
// Add image operation // Add image operation
pageObjectId: { type: 'string', description: 'Slide object ID for image' }, pageObjectId: { type: 'string', description: 'Slide object ID for image' },
imageFile: { type: 'json', description: 'Uploaded image (UserFile)' }, imageUrl: { type: 'string', description: 'Image URL' },
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' }, imageWidth: { type: 'number', description: 'Image width in points' },
imageHeight: { type: 'number', description: 'Image height in points' }, imageHeight: { type: 'number', description: 'Image height in points' },
positionX: { type: 'number', description: 'X position in points' }, positionX: { type: 'number', description: 'X position in points' },
@@ -905,99 +887,3 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
text: { type: 'string', description: 'Text that was inserted' }, 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' as const,
canonicalParamId: 'imageFile',
placeholder: 'Reference image from previous blocks',
mode: 'advanced' as const,
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<GoogleSlidesResponse> = {
...GoogleSlidesBlock,
type: 'google_slides_v2',
name: 'Google Slides',
description: 'Read, write, and create presentations',
hideFromToolbar: false,
subBlocks: googleSlidesV2SubBlocks,
tools: {
access: GoogleSlidesBlock.tools!.access,
config: {
tool: GoogleSlidesBlock.tools!.config!.tool,
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 <block.files[0]> 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' },
},
}

View File

@@ -526,7 +526,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
description: description:
'Single hold object (for create_matters_holds or list_matters_holds with holdId)', 'Single hold object (for create_matters_holds or list_matters_holds with holdId)',
}, },
file: { type: 'file', description: 'Downloaded export file (UserFile) from execution files' }, file: { type: 'json', description: 'Downloaded export file (UserFile) from execution files' },
nextPageToken: { nextPageToken: {
type: 'string', type: 'string',
description: 'Token for fetching next page of results (for list operations)', description: 'Token for fetching next page of results (for list operations)',

View File

@@ -149,7 +149,7 @@ export const ImageGeneratorBlock: BlockConfig<DalleResponse> = {
}, },
outputs: { outputs: {
content: { type: 'string', description: 'Generation response' }, content: { type: 'string', description: 'Generation response' },
image: { type: 'file', description: 'Generated image file (UserFile)' }, image: { type: 'string', description: 'Generated image URL' },
metadata: { type: 'json', description: 'Generation metadata' }, metadata: { type: 'json', description: 'Generation metadata' },
}, },
} }

View File

@@ -44,7 +44,7 @@ export const ImapBlock: BlockConfig = {
bodyHtml: { type: 'string', description: 'HTML email body' }, bodyHtml: { type: 'string', description: 'HTML email body' },
mailbox: { type: 'string', description: 'Mailbox/folder where email was received' }, mailbox: { type: 'string', description: 'Mailbox/folder where email was received' },
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }, hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
attachments: { type: 'file[]', description: 'Array of email attachments' }, attachments: { type: 'json', description: 'Array of email attachments' },
timestamp: { type: 'string', description: 'Event timestamp' }, timestamp: { type: 'string', description: 'Event timestamp' },
}, },
triggers: { triggers: {

View File

@@ -34,7 +34,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
{ label: 'Update Comment', id: 'update_comment' }, { label: 'Update Comment', id: 'update_comment' },
{ label: 'Delete Comment', id: 'delete_comment' }, { label: 'Delete Comment', id: 'delete_comment' },
{ label: 'Get Attachments', id: 'get_attachments' }, { label: 'Get Attachments', id: 'get_attachments' },
{ label: 'Add Attachment', id: 'add_attachment' },
{ label: 'Delete Attachment', id: 'delete_attachment' }, { label: 'Delete Attachment', id: 'delete_attachment' },
{ label: 'Add Worklog', id: 'add_worklog' }, { label: 'Add Worklog', id: 'add_worklog' },
{ label: 'Get Worklogs', id: 'get_worklogs' }, { label: 'Get Worklogs', id: 'get_worklogs' },
@@ -138,7 +137,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'update_comment', 'update_comment',
'delete_comment', 'delete_comment',
'get_attachments', 'get_attachments',
'add_attachment',
'add_worklog', 'add_worklog',
'get_worklogs', 'get_worklogs',
'update_worklog', 'update_worklog',
@@ -170,7 +168,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'update_comment', 'update_comment',
'delete_comment', 'delete_comment',
'get_attachments', 'get_attachments',
'add_attachment',
'add_worklog', 'add_worklog',
'get_worklogs', 'get_worklogs',
'update_worklog', 'update_worklog',
@@ -410,27 +407,6 @@ Return ONLY the comment text - no explanations.`,
condition: { field: 'operation', value: ['update_comment', 'delete_comment'] }, condition: { field: 'operation', value: ['update_comment', 'delete_comment'] },
}, },
// Attachment fields // 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', id: 'attachmentId',
title: 'Attachment ID', title: 'Attachment ID',
@@ -600,7 +576,6 @@ Return ONLY the comment text - no explanations.`,
'jira_update_comment', 'jira_update_comment',
'jira_delete_comment', 'jira_delete_comment',
'jira_get_attachments', 'jira_get_attachments',
'jira_add_attachment',
'jira_delete_attachment', 'jira_delete_attachment',
'jira_add_worklog', 'jira_add_worklog',
'jira_get_worklogs', 'jira_get_worklogs',
@@ -648,8 +623,6 @@ Return ONLY the comment text - no explanations.`,
return 'jira_delete_comment' return 'jira_delete_comment'
case 'get_attachments': case 'get_attachments':
return 'jira_get_attachments' return 'jira_get_attachments'
case 'add_attachment':
return 'jira_add_attachment'
case 'delete_attachment': case 'delete_attachment':
return 'jira_delete_attachment' return 'jira_delete_attachment'
case 'add_worklog': case 'add_worklog':
@@ -865,21 +838,6 @@ Return ONLY the comment text - no explanations.`,
issueKey: effectiveIssueKey, 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': { case 'delete_attachment': {
return { return {
...baseParams, ...baseParams,
@@ -1024,8 +982,6 @@ Return ONLY the comment text - no explanations.`,
commentBody: { type: 'string', description: 'Text content for comment operations' }, commentBody: { type: 'string', description: 'Text content for comment operations' },
commentId: { type: 'string', description: 'Comment ID for update/delete operations' }, commentId: { type: 'string', description: 'Comment ID for update/delete operations' },
// Attachment operation inputs // 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' }, attachmentId: { type: 'string', description: 'Attachment ID for delete operation' },
// Worklog operation inputs // Worklog operation inputs
timeSpentSeconds: { timeSpentSeconds: {
@@ -1096,8 +1052,6 @@ Return ONLY the comment text - no explanations.`,
type: 'json', type: 'json',
description: 'Array of attachments with id, filename, size, mimeType, created, author', 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 // jira_delete_attachment, jira_delete_comment, jira_delete_issue, jira_delete_worklog, jira_delete_issue_link outputs
attachmentId: { type: 'string', description: 'Deleted attachment ID' }, attachmentId: { type: 'string', description: 'Deleted attachment ID' },

View File

@@ -668,44 +668,17 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
generationType: 'timestamp', 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 // Attachment URL
{ {
id: 'url', id: 'url',
title: 'URL', title: 'URL',
type: 'short-input', type: 'short-input',
placeholder: 'Enter URL', placeholder: 'Enter URL',
required: false, required: true,
condition: { condition: {
field: 'operation', field: 'operation',
value: ['linear_create_attachment'], value: ['linear_create_attachment'],
}, },
mode: 'advanced',
}, },
// Attachment title // Attachment title
{ {
@@ -1769,31 +1742,16 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
teamId: effectiveTeamId, teamId: effectiveTeamId,
} }
case 'linear_create_attachment': { case 'linear_create_attachment':
if (!params.issueId?.trim()) { if (!params.issueId?.trim() || !params.url?.trim()) {
throw new Error('Issue ID is required.') throw new Error('Issue ID and URL are 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 { return {
...baseParams, ...baseParams,
issueId: params.issueId.trim(), issueId: params.issueId.trim(),
url: attachmentUrl, url: params.url.trim(),
file: attachmentFile,
title: params.attachmentTitle, title: params.attachmentTitle,
} }
}
case 'linear_list_attachments': case 'linear_list_attachments':
if (!params.issueId?.trim()) { if (!params.issueId?.trim()) {
@@ -2290,8 +2248,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
endDate: { type: 'string', description: 'End date' }, endDate: { type: 'string', description: 'End date' },
targetDate: { type: 'string', description: 'Target date' }, targetDate: { type: 'string', description: 'Target date' },
url: { type: 'string', description: 'URL' }, 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' }, attachmentTitle: { type: 'string', description: 'Attachment title' },
attachmentId: { type: 'string', description: 'Attachment identifier' }, attachmentId: { type: 'string', description: 'Attachment identifier' },
relationType: { type: 'string', description: 'Relation type' }, relationType: { type: 'string', description: 'Relation type' },

View File

@@ -1,7 +1,6 @@
import { MicrosoftExcelIcon } from '@/components/icons' import { MicrosoftExcelIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { import type {
MicrosoftExcelResponse, MicrosoftExcelResponse,
MicrosoftExcelV2Response, MicrosoftExcelV2Response,
@@ -490,20 +489,16 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
tools: { tools: {
access: ['microsoft_excel_read_v2', 'microsoft_excel_write_v2'], access: ['microsoft_excel_read_v2', 'microsoft_excel_write_v2'],
config: { config: {
tool: createVersionedToolSelector({ tool: (params) => {
baseToolSelector: (params) => {
switch (params.operation) { switch (params.operation) {
case 'read': case 'read':
return 'microsoft_excel_read' return 'microsoft_excel_read_v2'
case 'write': case 'write':
return 'microsoft_excel_write' return 'microsoft_excel_write_v2'
default: default:
throw new Error(`Invalid Microsoft Excel operation: ${params.operation}`) throw new Error(`Invalid Microsoft Excel V2 operation: ${params.operation}`)
} }
}, },
suffix: '_v2',
fallbackToolId: 'microsoft_excel_read_v2',
}),
params: (params) => { params: (params) => {
const { const {
credential, credential,

View File

@@ -346,10 +346,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
// Add files if provided // Add files if provided
const fileParam = attachmentFiles || files const fileParam = attachmentFiles || files
if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) { if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) {
const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] baseParams.files = fileParam
if (normalizedFiles.length > 0) {
baseParams.files = normalizedFiles
}
} }
// Add messageId if provided // Add messageId if provided
@@ -465,8 +462,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
messages: { type: 'json', description: 'Array of message objects' }, messages: { type: 'json', description: 'Array of message objects' },
totalAttachments: { type: 'number', description: 'Total number of attachments' }, totalAttachments: { type: 'number', description: 'Total number of attachments' },
attachmentTypes: { type: 'json', description: 'Array of attachment content types' }, attachmentTypes: { type: 'json', description: 'Array of attachment content types' },
attachments: { type: 'file[]', description: 'Downloaded message attachments' }, attachments: { type: 'array', description: 'Downloaded message attachments' },
files: { type: 'file[]', description: 'Files attached to the message' },
updatedContent: { updatedContent: {
type: 'boolean', type: 'boolean',
description: 'Whether content was successfully updated/sent', description: 'Whether content was successfully updated/sent',

View File

@@ -94,7 +94,7 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
if (!params.fileUpload) { if (!params.fileUpload) {
throw new Error('Please upload a PDF document') throw new Error('Please upload a PDF document')
} }
parameters.file = params.fileUpload parameters.fileUpload = params.fileUpload
} }
let pagesArray: number[] | undefined let pagesArray: number[] | undefined
@@ -159,16 +159,14 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
placeholder: 'Upload a PDF document', placeholder: 'Upload a PDF document',
mode: 'basic', mode: 'basic',
maxSize: 50, maxSize: 50,
required: true,
}, },
{ {
id: 'fileReference', id: 'filePath',
title: 'File Reference', title: 'PDF Document',
type: 'short-input' as SubBlockType, type: 'short-input' as SubBlockType,
canonicalParamId: 'document', canonicalParamId: 'document',
placeholder: 'File reference from previous block', placeholder: 'Document URL',
mode: 'advanced', mode: 'advanced',
required: true,
}, },
{ {
id: 'resultType', id: 'resultType',
@@ -213,26 +211,15 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
resultType: params.resultType || 'markdown', resultType: params.resultType || 'markdown',
} }
let documentInput = params.fileUpload || params.fileReference || params.document const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) { if (!documentInput) {
throw new Error('PDF document is required') throw new Error('PDF document is required')
} }
if (typeof documentInput === 'string') { if (typeof documentInput === 'object') {
try { parameters.fileUpload = documentInput
documentInput = JSON.parse(documentInput) } else if (typeof documentInput === 'string') {
} catch { parameters.filePath = documentInput.trim()
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 <block.attachments[0]> 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 let pagesArray: number[] | undefined
if (params.pages && params.pages.trim() !== '') { if (params.pages && params.pages.trim() !== '') {
@@ -267,8 +254,8 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
}, },
}, },
inputs: { inputs: {
document: { type: 'json', description: 'Document input (file upload or file reference)' }, document: { type: 'json', description: 'Document input (file upload or URL reference)' },
fileReference: { type: 'json', description: 'File reference (advanced mode)' }, filePath: { type: 'string', description: 'PDF document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' }, fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' },
apiKey: { type: 'string', description: 'Mistral API key' }, apiKey: { type: 'string', description: 'Mistral API key' },
resultType: { type: 'string', description: 'Output format type' }, resultType: { type: 'string', description: 'Output format type' },

View File

@@ -412,7 +412,6 @@ export const NotionV2Block: BlockConfig<any> = {
'notion_read_database_v2', 'notion_read_database_v2',
'notion_write_v2', 'notion_write_v2',
'notion_create_page_v2', 'notion_create_page_v2',
'notion_update_page_v2',
'notion_query_database_v2', 'notion_query_database_v2',
'notion_search_v2', 'notion_search_v2',
'notion_create_database_v2', 'notion_create_database_v2',

View File

@@ -393,7 +393,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
deleted: { type: 'boolean', description: 'Whether the file was deleted' }, deleted: { type: 'boolean', description: 'Whether the file was deleted' },
fileId: { type: 'string', description: 'The ID of the deleted file' }, fileId: { type: 'string', description: 'The ID of the deleted file' },
file: { file: {
type: 'file', type: 'json',
description: 'The OneDrive file object, including details such as id, name, size, and more.', description: 'The OneDrive file object, including details such as id, name, size, and more.',
}, },
files: { files: {

View File

@@ -440,7 +440,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
sentDateTime: { type: 'string', description: 'Email sent timestamp' }, sentDateTime: { type: 'string', description: 'Email sent timestamp' },
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }, hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
attachments: { attachments: {
type: 'file[]', type: 'json',
description: 'Email attachments (if includeAttachments is enabled)', description: 'Email attachments (if includeAttachments is enabled)',
}, },
isRead: { type: 'boolean', description: 'Whether email is read' }, isRead: { type: 'boolean', description: 'Whether email is read' },

View File

@@ -804,7 +804,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
deals: { type: 'json', description: 'Array of deal objects' }, deals: { type: 'json', description: 'Array of deal objects' },
deal: { type: 'json', description: 'Single deal object' }, deal: { type: 'json', description: 'Single deal object' },
files: { type: 'json', description: 'Array of file objects' }, 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' }, messages: { type: 'json', description: 'Array of mail message objects' },
pipelines: { type: 'json', description: 'Array of pipeline objects' }, pipelines: { type: 'json', description: 'Array of pipeline objects' },
projects: { type: 'json', description: 'Array of project objects' }, projects: { type: 'json', description: 'Array of project objects' },

View File

@@ -1,13 +1,11 @@
import { PulseIcon } from '@/components/icons' import { PulseIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { PulseParserOutput } from '@/tools/pulse/types' import type { PulseParserOutput } from '@/tools/pulse/types'
export const PulseBlock: BlockConfig<PulseParserOutput> = { export const PulseBlock: BlockConfig<PulseParserOutput> = {
type: 'pulse', type: 'pulse',
name: 'Pulse', name: 'Pulse',
description: 'Extract text from documents using Pulse OCR', description: 'Extract text from documents using Pulse OCR',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: longDescription:
'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.', 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.',
@@ -79,7 +77,7 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
throw new Error('Document is required') throw new Error('Document is required')
} }
if (typeof documentInput === 'object') { if (typeof documentInput === 'object') {
parameters.file = documentInput parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') { } else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim() parameters.filePath = documentInput.trim()
} }
@@ -128,95 +126,3 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
figures: { type: 'json', description: 'Extracted figures if figure extraction was enabled' }, 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 || []).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<PulseParserOutput> = {
...PulseBlock,
type: 'pulse_v2',
name: 'Pulse',
hideFromToolbar: false,
longDescription:
'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'],
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<string, unknown> = {
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 <block.attachments[0]> 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,
}

View File

@@ -1,13 +1,11 @@
import { ReductoIcon } from '@/components/icons' import { ReductoIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { ReductoParserOutput } from '@/tools/reducto/types' import type { ReductoParserOutput } from '@/tools/reducto/types'
export const ReductoBlock: BlockConfig<ReductoParserOutput> = { export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
type: 'reducto', type: 'reducto',
name: 'Reducto', name: 'Reducto',
description: 'Extract text from PDF documents', description: 'Extract text from PDF documents',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`, 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', docsLink: 'https://docs.sim.ai/tools/reducto',
@@ -76,7 +74,7 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
} }
if (typeof documentInput === 'object') { if (typeof documentInput === 'object') {
parameters.file = documentInput parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') { } else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim() parameters.filePath = documentInput.trim()
} }
@@ -134,110 +132,3 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
studio_link: { type: 'string', description: 'Link to Reducto studio interface' }, 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 || []).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<ReductoParserOutput> = {
...ReductoBlock,
type: 'reducto_v2',
name: 'Reducto',
hideFromToolbar: false,
longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents or file references.`,
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<string, unknown> = {
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 <block.attachments[0]> 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,
}

View File

@@ -418,7 +418,6 @@ export const S3Block: BlockConfig<S3Response> = {
type: 'string', type: 'string',
description: 'S3 URI (s3://bucket/key) for use with other AWS services', 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)' }, objects: { type: 'json', description: 'List of objects (for list operation)' },
deleted: { type: 'boolean', description: 'Deletion status' }, deleted: { type: 'boolean', description: 'Deletion status' },
metadata: { type: 'json', description: 'Operation metadata' }, metadata: { type: 'json', description: 'Operation metadata' },

View File

@@ -293,7 +293,6 @@ export const SftpBlock: BlockConfig<SftpUploadResult> = {
outputs: { outputs: {
success: { type: 'boolean', description: 'Whether the operation was successful' }, success: { type: 'boolean', description: 'Whether the operation was successful' },
uploadedFiles: { type: 'json', description: 'Array of uploaded file details' }, 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' }, fileName: { type: 'string', description: 'Downloaded file name' },
content: { type: 'string', description: 'Downloaded file content' }, content: { type: 'string', description: 'Downloaded file content' },
size: { type: 'number', description: 'File size in bytes' }, size: { type: 'number', description: 'File size in bytes' },

View File

@@ -622,10 +622,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
} }
const fileParam = attachmentFiles || files const fileParam = attachmentFiles || files
if (fileParam) { if (fileParam) {
const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam] baseParams.files = fileParam
if (normalizedFiles.length > 0) {
baseParams.files = normalizedFiles
}
} }
break break
} }
@@ -799,7 +796,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
type: 'number', type: 'number',
description: 'Number of files uploaded (when files are attached)', description: 'Number of files uploaded (when files are attached)',
}, },
files: { type: 'file[]', description: 'Files attached to the message' },
// slack_canvas outputs // slack_canvas outputs
canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' }, canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' },
@@ -863,7 +859,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
// slack_download outputs // slack_download outputs
file: { file: {
type: 'file', type: 'json',
description: 'Downloaded file stored in execution files', description: 'Downloaded file stored in execution files',
}, },

View File

@@ -450,24 +450,10 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
// === PLAYLIST COVER === // === PLAYLIST COVER ===
{ {
id: 'coverImageFile', id: 'imageBase64',
title: 'Cover Image', title: 'Image (Base64)',
type: 'file-upload', type: 'long-input',
canonicalParamId: 'coverImage', placeholder: 'Base64-encoded JPEG image (max 256KB)',
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, required: true,
condition: { field: 'operation', value: 'spotify_add_playlist_cover' }, condition: { field: 'operation', value: 'spotify_add_playlist_cover' },
}, },
@@ -818,9 +804,7 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
newName: { type: 'string', description: 'New playlist name' }, newName: { type: 'string', description: 'New playlist name' },
description: { type: 'string', description: 'Playlist description' }, description: { type: 'string', description: 'Playlist description' },
public: { type: 'boolean', description: 'Whether playlist is public' }, public: { type: 'boolean', description: 'Whether playlist is public' },
coverImage: { type: 'json', description: 'Cover image (UserFile)' }, imageBase64: { type: 'string', description: 'Base64-encoded JPEG image' },
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' }, range_start: { type: 'number', description: 'Start index for reorder' },
insert_before: { type: 'number', description: 'Insert before index' }, insert_before: { type: 'number', description: 'Insert before index' },
range_length: { type: 'number', description: 'Number of items to move' }, range_length: { type: 'number', description: 'Number of items to move' },

View File

@@ -507,7 +507,6 @@ export const SSHBlock: BlockConfig<SSHResponse> = {
stderr: { type: 'string', description: 'Command standard error' }, stderr: { type: 'string', description: 'Command standard error' },
exitCode: { type: 'number', description: 'Command exit code' }, exitCode: { type: 'number', description: 'Command exit code' },
success: { type: 'boolean', description: 'Operation success status' }, 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' }, fileContent: { type: 'string', description: 'Downloaded/read file content' },
entries: { type: 'json', description: 'Directory entries' }, entries: { type: 'json', description: 'Directory entries' },
exists: { type: 'boolean', description: 'File/directory existence' }, exists: { type: 'boolean', description: 'File/directory existence' },

View File

@@ -1,13 +1,11 @@
import { STTIcon } from '@/components/icons' import { STTIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { SttBlockResponse } from '@/tools/stt/types' import type { SttBlockResponse } from '@/tools/stt/types'
export const SttBlock: BlockConfig<SttBlockResponse> = { export const SttBlock: BlockConfig<SttBlockResponse> = {
type: 'stt', type: 'stt',
name: 'Speech-to-Text', name: 'Speech-to-Text',
description: 'Convert speech to text using AI', description: 'Convert speech to text using AI',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: longDescription:
'Transcribe audio and video files to text using leading AI providers. Supports multiple languages, timestamps, and speaker diarization.', 'Transcribe audio and video files to text using leading AI providers. Supports multiple languages, timestamps, and speaker diarization.',
@@ -82,7 +80,10 @@ export const SttBlock: BlockConfig<SttBlockResponse> = {
title: 'Model', title: 'Model',
type: 'dropdown', type: 'dropdown',
condition: { field: 'provider', value: 'assemblyai' }, condition: { field: 'provider', value: 'assemblyai' },
options: [{ label: 'Best', id: 'best' }], options: [
{ label: 'Best', id: 'best' },
{ label: 'Nano', id: 'nano' },
],
value: () => 'best', value: () => 'best',
required: true, required: true,
}, },
@@ -344,78 +345,3 @@ export const SttBlock: BlockConfig<SttBlockResponse> = {
}, },
}, },
} }
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<SttBlockResponse> = {
...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) => {
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 <block.files[0]> 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,
}

View File

@@ -675,9 +675,9 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
{ {
id: 'fileContent', id: 'fileContent',
title: 'File Content', title: 'File Content',
type: 'short-input', type: 'code',
canonicalParamId: 'fileData', canonicalParamId: 'fileData',
placeholder: 'File reference from previous block', placeholder: 'Base64 encoded for binary files, or plain text',
condition: { field: 'operation', value: 'storage_upload' }, condition: { field: 'operation', value: 'storage_upload' },
mode: 'advanced', mode: 'advanced',
required: true, required: true,
@@ -1173,7 +1173,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
description: 'Row count for count operations', description: 'Row count for count operations',
}, },
file: { file: {
type: 'file', type: 'files',
description: 'Downloaded file stored in execution files', description: 'Downloaded file stored in execution files',
}, },
publicUrl: { publicUrl: {

View File

@@ -65,91 +65,39 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
required: true, required: true,
condition: { field: 'operation', value: 'telegram_message' }, 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', id: 'photo',
title: 'Photo', title: 'Photo',
type: 'short-input', type: 'short-input',
canonicalParamId: 'photo', placeholder: 'Enter photo URL or file_id',
placeholder: 'Reference photo from previous blocks or enter URL/file_id', description: 'Photo to send. Pass a file_id or HTTP URL',
mode: 'advanced',
required: true, required: true,
condition: { field: 'operation', value: 'telegram_send_photo' }, 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', id: 'video',
title: 'Video', title: 'Video',
type: 'short-input', type: 'short-input',
canonicalParamId: 'video', placeholder: 'Enter video URL or file_id',
placeholder: 'Reference video from previous blocks or enter URL/file_id', description: 'Video to send. Pass a file_id or HTTP URL',
mode: 'advanced',
required: true, required: true,
condition: { field: 'operation', value: 'telegram_send_video' }, 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', id: 'audio',
title: 'Audio', title: 'Audio',
type: 'short-input', type: 'short-input',
canonicalParamId: 'audio', placeholder: 'Enter audio URL or file_id',
placeholder: 'Reference audio from previous blocks or enter URL/file_id', description: 'Audio file to send. Pass a file_id or HTTP URL',
mode: 'advanced',
required: true, required: true,
condition: { field: 'operation', value: 'telegram_send_audio' }, 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', id: 'animation',
title: 'Animation', title: 'Animation',
type: 'short-input', type: 'short-input',
canonicalParamId: 'animation', placeholder: 'Enter animation URL or file_id',
placeholder: 'Reference animation from previous blocks or enter URL/file_id', description: 'Animation (GIF) to send. Pass a file_id or HTTP URL',
mode: 'advanced',
required: true, required: true,
condition: { field: 'operation', value: 'telegram_send_animation' }, condition: { field: 'operation', value: 'telegram_send_animation' },
}, },
@@ -267,61 +215,48 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
...commonParams, ...commonParams,
messageId: params.messageId, messageId: params.messageId,
} }
case 'telegram_send_photo': { case 'telegram_send_photo':
const photoSource = params.photoFile || params.photo if (!params.photo) {
if (!photoSource) { throw new Error('Photo URL or file_id is required.')
throw new Error('Photo is required.')
} }
return { return {
...commonParams, ...commonParams,
photo: photoSource, photo: params.photo,
caption: params.caption, caption: params.caption,
} }
} case 'telegram_send_video':
case 'telegram_send_video': { if (!params.video) {
const videoSource = params.videoFile || params.video throw new Error('Video URL or file_id is required.')
if (!videoSource) {
throw new Error('Video is required.')
} }
return { return {
...commonParams, ...commonParams,
video: videoSource, video: params.video,
caption: params.caption, caption: params.caption,
} }
} case 'telegram_send_audio':
case 'telegram_send_audio': { if (!params.audio) {
const audioSource = params.audioFile || params.audio throw new Error('Audio URL or file_id is required.')
if (!audioSource) {
throw new Error('Audio is required.')
} }
return { return {
...commonParams, ...commonParams,
audio: audioSource, audio: params.audio,
caption: params.caption, caption: params.caption,
} }
} case 'telegram_send_animation':
case 'telegram_send_animation': { if (!params.animation) {
const animationSource = params.animationFile || params.animation throw new Error('Animation URL or file_id is required.')
if (!animationSource) {
throw new Error('Animation is required.')
} }
return { return {
...commonParams, ...commonParams,
animation: animationSource, animation: params.animation,
caption: params.caption, caption: params.caption,
} }
}
case 'telegram_send_document': { case 'telegram_send_document': {
// Handle file upload // Handle file upload
const fileParam = params.attachmentFiles || params.files const fileParam = params.attachmentFiles || params.files
const normalizedFiles = fileParam
? Array.isArray(fileParam)
? fileParam
: [fileParam]
: undefined
return { return {
...commonParams, ...commonParams,
files: normalizedFiles, files: fileParam,
caption: params.caption, caption: params.caption,
} }
} }
@@ -339,14 +274,10 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
botToken: { type: 'string', description: 'Telegram bot token' }, botToken: { type: 'string', description: 'Telegram bot token' },
chatId: { type: 'string', description: 'Chat identifier' }, chatId: { type: 'string', description: 'Chat identifier' },
text: { type: 'string', description: 'Message text' }, text: { type: 'string', description: 'Message text' },
photoFile: { type: 'json', description: 'Uploaded photo (UserFile)' }, photo: { type: 'string', description: 'Photo URL or file_id' },
photo: { type: 'json', description: 'Photo reference or URL/file_id' }, video: { type: 'string', description: 'Video URL or file_id' },
videoFile: { type: 'json', description: 'Uploaded video (UserFile)' }, audio: { type: 'string', description: 'Audio URL or file_id' },
video: { type: 'json', description: 'Video reference or URL/file_id' }, animation: { type: 'string', description: 'Animation URL or 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: { attachmentFiles: {
type: 'json', type: 'json',
description: 'Files to attach (UI upload)', description: 'Files to attach (UI upload)',
@@ -364,7 +295,6 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
}, },
message: { type: 'string', description: 'Success or error message' }, message: { type: 'string', description: 'Success or error message' },
data: { type: 'json', description: 'Response data' }, data: { type: 'json', description: 'Response data' },
files: { type: 'file[]', description: 'Files attached to the message' },
// Specific result fields // Specific result fields
messageId: { type: 'number', description: 'Sent message ID' }, messageId: { type: 'number', description: 'Sent message ID' },
chatId: { type: 'number', description: 'Chat ID where message was sent' }, chatId: { type: 'number', description: 'Chat ID where message was sent' },

View File

@@ -1,13 +1,11 @@
import { TextractIcon } from '@/components/icons' import { TextractIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { TextractParserOutput } from '@/tools/textract/types' import type { TextractParserOutput } from '@/tools/textract/types'
export const TextractBlock: BlockConfig<TextractParserOutput> = { export const TextractBlock: BlockConfig<TextractParserOutput> = {
type: 'textract', type: 'textract',
name: 'AWS Textract', name: 'AWS Textract',
description: 'Extract text, tables, and forms from documents', description: 'Extract text, tables, and forms from documents',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, 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.`, 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', docsLink: 'https://docs.sim.ai/tools/textract',
@@ -142,7 +140,7 @@ export const TextractBlock: BlockConfig<TextractParserOutput> = {
throw new Error('Document is required') throw new Error('Document is required')
} }
if (typeof documentInput === 'object') { if (typeof documentInput === 'object') {
parameters.file = documentInput parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') { } else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim() parameters.filePath = documentInput.trim()
} }
@@ -191,110 +189,3 @@ export const TextractBlock: BlockConfig<TextractParserOutput> = {
}, },
}, },
} }
const textractV2Inputs = TextractBlock.inputs
? Object.fromEntries(Object.entries(TextractBlock.inputs).filter(([key]) => key !== '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<TextractParserOutput> = {
...TextractBlock,
type: 'textract_v2',
name: 'AWS Textract',
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<string, unknown> = {
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 <block.attachments[0]> 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,
}

View File

@@ -578,7 +578,7 @@ export const TtsBlock: BlockConfig<TtsBlockResponse> = {
outputs: { outputs: {
audioUrl: { type: 'string', description: 'URL to the generated audio file' }, audioUrl: { type: 'string', description: 'URL to the generated audio file' },
audioFile: { type: 'file', description: 'Generated audio file object (UserFile)' }, audioFile: { type: 'json', description: 'Generated audio file object (UserFile)' },
duration: { duration: {
type: 'number', type: 'number',
description: 'Audio duration in seconds', description: 'Audio duration in seconds',

View File

@@ -420,7 +420,7 @@ export const VideoGeneratorBlock: BlockConfig<VideoBlockResponse> = {
outputs: { outputs: {
videoUrl: { type: 'string', description: 'Generated video URL' }, videoUrl: { type: 'string', description: 'Generated video URL' },
videoFile: { type: 'file', description: 'Video file object with metadata' }, videoFile: { type: 'json', description: 'Video file object with metadata' },
duration: { type: 'number', description: 'Video duration in seconds' }, duration: { type: 'number', description: 'Video duration in seconds' },
width: { type: 'number', description: 'Video width in pixels' }, width: { type: 'number', description: 'Video width in pixels' },
height: { type: 'number', description: 'Video height in pixels' }, height: { type: 'number', description: 'Video height in pixels' },

View File

@@ -1,30 +1,12 @@
import { EyeIcon } from '@/components/icons' import { EyeIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import type { VisionResponse } from '@/tools/vision/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<VisionResponse> = { export const VisionBlock: BlockConfig<VisionResponse> = {
type: 'vision', type: 'vision',
name: 'Vision (Legacy)', name: 'Vision',
description: 'Analyze images with vision models', description: 'Analyze images with vision models',
hideFromToolbar: true,
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: 'Integrate Vision into the workflow. Can analyze images with vision models.', longDescription: 'Integrate Vision into the workflow. Can analyze images with vision models.',
docsLink: 'https://docs.sim.ai/tools/vision', docsLink: 'https://docs.sim.ai/tools/vision',
@@ -65,8 +47,12 @@ export const VisionBlock: BlockConfig<VisionResponse> = {
id: 'model', id: 'model',
title: 'Vision Model', title: 'Vision Model',
type: 'dropdown', type: 'dropdown',
options: VISION_MODEL_OPTIONS, options: [
value: () => 'gpt-5.2', { 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',
}, },
{ {
id: 'prompt', id: 'prompt',
@@ -101,92 +87,3 @@ export const VisionBlock: BlockConfig<VisionResponse> = {
tokens: { type: 'number', description: 'Token usage' }, tokens: { type: 'number', description: 'Token usage' },
}, },
} }
export const VisionV2Block: BlockConfig<VisionResponse> = {
...VisionBlock,
type: 'vision_v2',
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',
}),
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 <block.files[0]> to select one file.'
)
}
return {
...params,
imageFile: imageInput,
imageFileReference: undefined,
}
},
},
},
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' },
},
}

View File

@@ -29,9 +29,9 @@ import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EnrichBlock } from '@/blocks/blocks/enrich' import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { ExaBlock } from '@/blocks/blocks/exa' import { ExaBlock } from '@/blocks/blocks/exa'
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' import { FileBlock, FileV2Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies' import { FirefliesBlock } from '@/blocks/blocks/fireflies'
import { FunctionBlock } from '@/blocks/blocks/function' import { FunctionBlock } from '@/blocks/blocks/function'
import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook' import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook'
import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github' import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github'
@@ -45,7 +45,7 @@ import { GoogleFormsBlock } from '@/blocks/blocks/google_forms'
import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups' import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
import { GoogleMapsBlock } from '@/blocks/blocks/google_maps' import { GoogleMapsBlock } from '@/blocks/blocks/google_maps'
import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets' import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets'
import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides' import { GoogleSlidesBlock } from '@/blocks/blocks/google_slides'
import { GoogleVaultBlock } from '@/blocks/blocks/google_vault' import { GoogleVaultBlock } from '@/blocks/blocks/google_vault'
import { GrafanaBlock } from '@/blocks/blocks/grafana' import { GrafanaBlock } from '@/blocks/blocks/grafana'
import { GrainBlock } from '@/blocks/blocks/grain' import { GrainBlock } from '@/blocks/blocks/grain'
@@ -95,11 +95,11 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive'
import { PolymarketBlock } from '@/blocks/blocks/polymarket' import { PolymarketBlock } from '@/blocks/blocks/polymarket'
import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql'
import { PostHogBlock } from '@/blocks/blocks/posthog' import { PostHogBlock } from '@/blocks/blocks/posthog'
import { PulseBlock, PulseV2Block } from '@/blocks/blocks/pulse' import { PulseBlock } from '@/blocks/blocks/pulse'
import { QdrantBlock } from '@/blocks/blocks/qdrant' import { QdrantBlock } from '@/blocks/blocks/qdrant'
import { RDSBlock } from '@/blocks/blocks/rds' import { RDSBlock } from '@/blocks/blocks/rds'
import { RedditBlock } from '@/blocks/blocks/reddit' import { RedditBlock } from '@/blocks/blocks/reddit'
import { ReductoBlock, ReductoV2Block } from '@/blocks/blocks/reducto' import { ReductoBlock } from '@/blocks/blocks/reducto'
import { ResendBlock } from '@/blocks/blocks/resend' import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response' import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
@@ -125,11 +125,11 @@ import { StagehandBlock } from '@/blocks/blocks/stagehand'
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
import { StarterBlock } from '@/blocks/blocks/starter' import { StarterBlock } from '@/blocks/blocks/starter'
import { StripeBlock } from '@/blocks/blocks/stripe' import { StripeBlock } from '@/blocks/blocks/stripe'
import { SttBlock, SttV2Block } from '@/blocks/blocks/stt' import { SttBlock } from '@/blocks/blocks/stt'
import { SupabaseBlock } from '@/blocks/blocks/supabase' import { SupabaseBlock } from '@/blocks/blocks/supabase'
import { TavilyBlock } from '@/blocks/blocks/tavily' import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram' import { TelegramBlock } from '@/blocks/blocks/telegram'
import { TextractBlock, TextractV2Block } from '@/blocks/blocks/textract' import { TextractBlock } from '@/blocks/blocks/textract'
import { ThinkingBlock } from '@/blocks/blocks/thinking' import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TinybirdBlock } from '@/blocks/blocks/tinybird' import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate' import { TranslateBlock } from '@/blocks/blocks/translate'
@@ -140,7 +140,7 @@ import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
import { TypeformBlock } from '@/blocks/blocks/typeform' import { TypeformBlock } from '@/blocks/blocks/typeform'
import { VariablesBlock } from '@/blocks/blocks/variables' import { VariablesBlock } from '@/blocks/blocks/variables'
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator' import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision' import { VisionBlock } from '@/blocks/blocks/vision'
import { WaitBlock } from '@/blocks/blocks/wait' import { WaitBlock } from '@/blocks/blocks/wait'
import { WealthboxBlock } from '@/blocks/blocks/wealthbox' import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
import { WebflowBlock } from '@/blocks/blocks/webflow' import { WebflowBlock } from '@/blocks/blocks/webflow'
@@ -194,10 +194,8 @@ export const registry: Record<string, BlockConfig> = {
exa: ExaBlock, exa: ExaBlock,
file: FileBlock, file: FileBlock,
file_v2: FileV2Block, file_v2: FileV2Block,
file_v3: FileV3Block,
firecrawl: FirecrawlBlock, firecrawl: FirecrawlBlock,
fireflies: FirefliesBlock, fireflies: FirefliesBlock,
fireflies_v2: FirefliesV2Block,
function: FunctionBlock, function: FunctionBlock,
generic_webhook: GenericWebhookBlock, generic_webhook: GenericWebhookBlock,
github: GitHubBlock, github: GitHubBlock,
@@ -216,7 +214,6 @@ export const registry: Record<string, BlockConfig> = {
google_sheets: GoogleSheetsBlock, google_sheets: GoogleSheetsBlock,
google_sheets_v2: GoogleSheetsV2Block, google_sheets_v2: GoogleSheetsV2Block,
google_slides: GoogleSlidesBlock, google_slides: GoogleSlidesBlock,
google_slides_v2: GoogleSlidesV2Block,
google_vault: GoogleVaultBlock, google_vault: GoogleVaultBlock,
grafana: GrafanaBlock, grafana: GrafanaBlock,
grain: GrainBlock, grain: GrainBlock,
@@ -272,12 +269,10 @@ export const registry: Record<string, BlockConfig> = {
postgresql: PostgreSQLBlock, postgresql: PostgreSQLBlock,
posthog: PostHogBlock, posthog: PostHogBlock,
pulse: PulseBlock, pulse: PulseBlock,
pulse_v2: PulseV2Block,
qdrant: QdrantBlock, qdrant: QdrantBlock,
rds: RDSBlock, rds: RDSBlock,
reddit: RedditBlock, reddit: RedditBlock,
reducto: ReductoBlock, reducto: ReductoBlock,
reducto_v2: ReductoV2Block,
resend: ResendBlock, resend: ResendBlock,
response: ResponseBlock, response: ResponseBlock,
router: RouterBlock, router: RouterBlock,
@@ -305,12 +300,10 @@ export const registry: Record<string, BlockConfig> = {
starter: StarterBlock, starter: StarterBlock,
stripe: StripeBlock, stripe: StripeBlock,
stt: SttBlock, stt: SttBlock,
stt_v2: SttV2Block,
supabase: SupabaseBlock, supabase: SupabaseBlock,
tavily: TavilyBlock, tavily: TavilyBlock,
telegram: TelegramBlock, telegram: TelegramBlock,
textract: TextractBlock, textract: TextractBlock,
textract_v2: TextractV2Block,
thinking: ThinkingBlock, thinking: ThinkingBlock,
tinybird: TinybirdBlock, tinybird: TinybirdBlock,
translate: TranslateBlock, translate: TranslateBlock,
@@ -323,7 +316,6 @@ export const registry: Record<string, BlockConfig> = {
video_generator: VideoGeneratorBlock, video_generator: VideoGeneratorBlock,
video_generator_v2: VideoGeneratorV2Block, video_generator_v2: VideoGeneratorV2Block,
vision: VisionBlock, vision: VisionBlock,
vision_v2: VisionV2Block,
wait: WaitBlock, wait: WaitBlock,
wealthbox: WealthboxBlock, wealthbox: WealthboxBlock,
webflow: WebflowBlock, webflow: WebflowBlock,

View File

@@ -9,8 +9,7 @@ export type PrimitiveValueType =
| 'boolean' | 'boolean'
| 'json' | 'json'
| 'array' | 'array'
| 'file' | 'files'
| 'file[]'
| 'any' | 'any'
export type BlockCategory = 'blocks' | 'tools' | 'triggers' export type BlockCategory = 'blocks' | 'tools' | 'triggers'

View File

@@ -137,7 +137,7 @@ export class BlockExecutor {
normalizedOutput = this.normalizeOutput(output) normalizedOutput = this.normalizeOutput(output)
} }
if (containsUserFileWithMetadata(normalizedOutput)) { if (ctx.includeFileBase64 && containsUserFileWithMetadata(normalizedOutput)) {
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, { normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
requestId: ctx.metadata.requestId, requestId: ctx.metadata.requestId,
executionId: ctx.executionId, executionId: ctx.executionId,

View File

@@ -1,7 +1,6 @@
import '@sim/testing/mocks/executor' import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' 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 { BlockType } from '@/executor/constants'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import type { ExecutionContext } from '@/executor/types' import type { ExecutionContext } from '@/executor/types'
@@ -10,13 +9,8 @@ import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types' import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils' import { getTool } from '@/tools/utils'
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: vi.fn(),
}))
const mockGetTool = vi.mocked(getTool) const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock const mockExecuteTool = executeTool as Mock
const mockValidateUrlWithDNS = vi.mocked(validateUrlWithDNS)
describe('ApiBlockHandler', () => { describe('ApiBlockHandler', () => {
let handler: ApiBlockHandler let handler: ApiBlockHandler
@@ -69,12 +63,6 @@ describe('ApiBlockHandler', () => {
// Reset mocks using vi // Reset mocks using vi
vi.clearAllMocks() vi.clearAllMocks()
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'example.com',
})
// Set up mockGetTool to return the mockApiTool // Set up mockGetTool to return the mockApiTool
mockGetTool.mockImplementation((toolId) => { mockGetTool.mockImplementation((toolId) => {
if (toolId === 'http_request') { if (toolId === 'http_request') {
@@ -142,13 +130,8 @@ describe('ApiBlockHandler', () => {
it('should throw error for invalid URL format (no protocol)', async () => { it('should throw error for invalid URL format (no protocol)', async () => {
const inputs = { url: 'example.com/api' } 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( await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must be a valid URL' 'Invalid URL: "example.com/api" - URL must include protocol (try "https://example.com/api")'
) )
expect(mockExecuteTool).not.toHaveBeenCalled() expect(mockExecuteTool).not.toHaveBeenCalled()
}) })
@@ -156,13 +139,8 @@ describe('ApiBlockHandler', () => {
it('should throw error for generally invalid URL format', async () => { it('should throw error for generally invalid URL format', async () => {
const inputs = { url: 'htp:/invalid-url' } 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( await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must use https:// protocol' /^Invalid URL: "htp:\/invalid-url" - URL must include protocol/
) )
expect(mockExecuteTool).not.toHaveBeenCalled() expect(mockExecuteTool).not.toHaveBeenCalled()
}) })

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { BlockType, HTTP } from '@/executor/constants' import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
@@ -42,9 +41,16 @@ export class ApiBlockHandler implements BlockHandler {
} }
} }
const urlValidation = await validateUrlWithDNS(urlToValidate, 'url') if (!urlToValidate.match(/^https?:\/\//i)) {
if (!urlValidation.isValid) { throw new Error(
throw new Error(urlValidation.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}`)
} }
} }

Some files were not shown because too many files have changed in this diff Show More