mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-03 11:14:58 -05:00
Compare commits
21 Commits
fix/stale-
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aeaf6faee | ||
|
|
a6ec6a0e6c | ||
|
|
cbe0f8aed2 | ||
|
|
6e642fc705 | ||
|
|
1c857cdcda | ||
|
|
7570e509ff | ||
|
|
1ff35405fa | ||
|
|
3ceabbb816 | ||
|
|
a65f3b8e6b | ||
|
|
5ecbf6cf4a | ||
|
|
42767fc4f4 | ||
|
|
5a0becf76f | ||
|
|
f4a3c94f87 | ||
|
|
9ec0c8f3f5 | ||
|
|
a9b7d75d87 | ||
|
|
0449804ffb | ||
|
|
c286f3ed24 | ||
|
|
b738550815 | ||
|
|
39ca1f61c7 | ||
|
|
1da3407f41 | ||
|
|
bea0a685ae |
134
apps/docs/content/docs/en/execution/files.mdx
Normal file
134
apps/docs/content/docs/en/execution/files.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Passing Files
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
|
||||
Sim makes it easy to work with files throughout your workflows. Blocks can receive files, process them, and pass them to other blocks seamlessly.
|
||||
|
||||
## File Objects
|
||||
|
||||
When blocks output files (like Gmail attachments, generated images, or parsed documents), they return a standardized file object:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "report.pdf",
|
||||
"url": "https://...",
|
||||
"base64": "JVBERi0xLjQK...",
|
||||
"type": "application/pdf",
|
||||
"size": 245678
|
||||
}
|
||||
```
|
||||
|
||||
You can access any of these properties when referencing files from previous blocks.
|
||||
|
||||
## Passing Files Between Blocks
|
||||
|
||||
Reference files from previous blocks using the tag dropdown. Click in any file input field and type `<` to see available outputs.
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
```
|
||||
// Single file from a block
|
||||
<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.
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "basics", "api", "logging", "costs"]
|
||||
"pages": ["index", "basics", "files", "api", "logging", "costs"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
@@ -1119,7 +1119,7 @@ async function handlePushNotificationSet(
|
||||
)
|
||||
}
|
||||
|
||||
const urlValidation = validateExternalUrl(
|
||||
const urlValidation = await validateUrlWithDNS(
|
||||
params.pushNotificationConfig.url,
|
||||
'Push notification URL'
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
const logger = createLogger('CleanupStaleExecutions')
|
||||
|
||||
const STALE_THRESHOLD_MINUTES = 30
|
||||
const MAX_INT32 = 2_147_483_647
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,13 +46,14 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
|
||||
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
|
||||
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
|
||||
|
||||
await db
|
||||
.update(workflowExecutionLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
endedAt: new Date(),
|
||||
totalDurationMs: staleDurationMs,
|
||||
totalDurationMs,
|
||||
executionData: sql`jsonb_set(
|
||||
COALESCE(execution_data, '{}'::jsonb),
|
||||
ARRAY['error'],
|
||||
|
||||
@@ -6,7 +6,11 @@ import { createLogger } from '@sim/logger'
|
||||
import binaryExtensionsList from 'binary-extensions'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
|
||||
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
|
||||
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
|
||||
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
getMimeTypeFromExtension,
|
||||
getViewerUrl,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
@@ -215,7 +220,7 @@ async function parseFileSingle(
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath.includes('/api/files/serve/')) {
|
||||
if (isInternalFileUrl(filePath)) {
|
||||
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
|
||||
}
|
||||
|
||||
@@ -246,7 +251,7 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
|
||||
return { isValid: false, error: 'Invalid path: tilde character not allowed' }
|
||||
}
|
||||
|
||||
if (filePath.startsWith('/') && !filePath.startsWith('/api/files/serve/')) {
|
||||
if (filePath.startsWith('/') && !isInternalFileUrl(filePath)) {
|
||||
return { isValid: false, error: 'Path outside allowed directory' }
|
||||
}
|
||||
|
||||
@@ -420,7 +425,7 @@ async function handleExternalUrl(
|
||||
|
||||
return parseResult
|
||||
} catch (error) {
|
||||
logger.error(`Error handling external URL ${url}:`, error)
|
||||
logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error fetching URL: ${(error as Error).message}`,
|
||||
|
||||
@@ -284,7 +284,7 @@ async function handleToolsCall(
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
|
||||
],
|
||||
isError: !executeResult.success,
|
||||
isError: executeResult.success === false,
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(id, result))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { z } from 'zod'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -501,6 +502,18 @@ 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}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -95,6 +96,14 @@ export async function POST(request: NextRequest) {
|
||||
if (validatedData.files && validatedData.files.length > 0) {
|
||||
for (const file of validatedData.files) {
|
||||
if (file.type === 'url') {
|
||||
const urlValidation = await validateUrlWithDNS(file.data, 'fileUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: urlValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const filePart: FilePart = {
|
||||
kind: 'file',
|
||||
file: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createA2AClient } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
||||
|
||||
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
|
||||
const urlValidation = await validateUrlWithDNS(validatedData.webhookUrl, 'Webhook URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -92,6 +92,9 @@ export async function POST(request: NextRequest) {
|
||||
formData.append('comment', comment)
|
||||
}
|
||||
|
||||
// Add minorEdit field as required by Confluence API
|
||||
formData.append('minorEdit', 'false')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateNumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -15,7 +16,7 @@ const DiscordSendMessageSchema = z.object({
|
||||
botToken: z.string().min(1, 'Bot token is required'),
|
||||
channelId: z.string().min(1, 'Channel ID is required'),
|
||||
content: z.string().optional().nullable(),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -101,6 +102,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Processing ${validatedData.files.length} file(s)`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger)
|
||||
const filesOutput: Array<{
|
||||
name: string
|
||||
mimeType: string
|
||||
data: string
|
||||
size: number
|
||||
}> = []
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
logger.warn(`[${requestId}] No valid files to upload, falling back to text-only`)
|
||||
@@ -137,6 +144,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`)
|
||||
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
filesOutput.push({
|
||||
name: userFile.name,
|
||||
mimeType: userFile.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
|
||||
const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type })
|
||||
formData.append(`files[${i}]`, blob, userFile.name)
|
||||
@@ -173,6 +186,7 @@ export async function POST(request: NextRequest) {
|
||||
message: data.content,
|
||||
data: data,
|
||||
fileCount: userFiles.length,
|
||||
files: filesOutput,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
195
apps/sim/app/api/tools/github/latest-commit/route.ts
Normal file
195
apps/sim/app/api/tools/github/latest-commit/route.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
@@ -28,7 +29,7 @@ const GmailDraftSchema = z.object({
|
||||
replyToMessageId: z.string().optional().nullable(),
|
||||
cc: z.string().optional().nullable(),
|
||||
bcc: z.string().optional().nullable(),
|
||||
attachments: z.array(z.any()).optional().nullable(),
|
||||
attachments: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
@@ -28,7 +29,7 @@ const GmailSendSchema = z.object({
|
||||
replyToMessageId: z.string().optional().nullable(),
|
||||
cc: z.string().optional().nullable(),
|
||||
bcc: z.string().optional().nullable(),
|
||||
attachments: z.array(z.any()).optional().nullable(),
|
||||
attachments: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
252
apps/sim/app/api/tools/google_drive/download/route.ts
Normal file
252
apps/sim/app/api/tools/google_drive/download/route.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files'
|
||||
const GoogleDriveUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
file: z.any().optional().nullable(),
|
||||
file: RawFileInputSchema.optional().nullable(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
folderId: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateImageUrl } from '@/lib/core/security/input-validation'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('ImageProxyAPI')
|
||||
@@ -26,7 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
return new NextResponse('Missing URL parameter', { status: 400 })
|
||||
}
|
||||
|
||||
const urlValidation = validateImageUrl(imageUrl)
|
||||
const urlValidation = await validateUrlWithDNS(imageUrl, 'imageUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Blocked image proxy request`, {
|
||||
url: imageUrl.substring(0, 100),
|
||||
@@ -38,7 +41,8 @@ export async function GET(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Proxying image request for: ${imageUrl}`)
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
const imageResponse = await secureFetchWithPinnedIP(imageUrl, urlValidation.resolvedIP!, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
@@ -64,14 +68,14 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'
|
||||
|
||||
const imageBlob = await imageResponse.blob()
|
||||
const imageArrayBuffer = await imageResponse.arrayBuffer()
|
||||
|
||||
if (imageBlob.size === 0) {
|
||||
logger.error(`[${requestId}] Empty image blob received`)
|
||||
if (imageArrayBuffer.byteLength === 0) {
|
||||
logger.error(`[${requestId}] Empty image received`)
|
||||
return new NextResponse('Empty image received', { status: 404 })
|
||||
}
|
||||
|
||||
return new NextResponse(imageBlob, {
|
||||
return new NextResponse(imageArrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
|
||||
121
apps/sim/app/api/tools/jira/add-attachment/route.ts
Normal file
121
apps/sim/app/api/tools/jira/add-attachment/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
const logger = createLogger('JiraAddAttachmentAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const JiraAddAttachmentSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
issueKey: z.string().min(1, 'Issue key is required'),
|
||||
files: RawFileInputArraySchema,
|
||||
cloudId: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = `jira-attach-${Date.now()}`
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = JiraAddAttachmentSchema.parse(body)
|
||||
|
||||
const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger)
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No valid files provided for upload' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const cloudId =
|
||||
validatedData.cloudId ||
|
||||
(await getJiraCloudId(validatedData.domain, validatedData.accessToken))
|
||||
|
||||
const formData = new FormData()
|
||||
const filesOutput: Array<{ name: string; mimeType: string; data: string; size: number }> = []
|
||||
|
||||
for (const file of userFiles) {
|
||||
const buffer = await downloadFileFromStorage(file, requestId, logger)
|
||||
filesOutput.push({
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
const blob = new Blob([new Uint8Array(buffer)], {
|
||||
type: file.type || 'application/octet-stream',
|
||||
})
|
||||
formData.append('file', blob, file.name)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${validatedData.issueKey}/attachments`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'X-Atlassian-Token': 'no-check',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`[${requestId}] Jira attachment upload failed`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to upload attachments: ${response.statusText}`,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const attachments = await response.json()
|
||||
const attachmentIds = Array.isArray(attachments)
|
||||
? attachments.map((attachment) => attachment.id).filter(Boolean)
|
||||
: []
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: validatedData.issueKey,
|
||||
attachmentIds,
|
||||
files: filesOutput,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Jira attachment upload error`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils'
|
||||
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
|
||||
import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,7 +18,7 @@ const TeamsWriteChannelSchema = z.object({
|
||||
teamId: z.string().min(1, 'Team ID is required'),
|
||||
channelId: z.string().min(1, 'Channel ID is required'),
|
||||
content: z.string().min(1, 'Message content is required'),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -53,93 +55,12 @@ export async function POST(request: NextRequest) {
|
||||
fileCount: validatedData.files?.length || 0,
|
||||
})
|
||||
|
||||
const attachments: any[] = []
|
||||
if (validatedData.files && validatedData.files.length > 0) {
|
||||
const rawFiles = validatedData.files
|
||||
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
|
||||
|
||||
for (const file of userFiles) {
|
||||
try {
|
||||
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`
|
||||
)
|
||||
}
|
||||
const { attachments, filesOutput } = await uploadFilesForTeamsMessage({
|
||||
rawFiles: validatedData.files || [],
|
||||
accessToken: validatedData.accessToken,
|
||||
requestId,
|
||||
logger,
|
||||
})
|
||||
|
||||
let messageContent = validatedData.content
|
||||
let contentType: 'text' | 'html' = 'text'
|
||||
@@ -197,17 +118,21 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const teamsUrl = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(validatedData.teamId)}/channels/${encodeURIComponent(validatedData.channelId)}/messages`
|
||||
|
||||
const teamsResponse = await fetch(teamsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
const teamsResponse = await secureFetchWithValidation(
|
||||
teamsUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(messageBody),
|
||||
},
|
||||
body: JSON.stringify(messageBody),
|
||||
})
|
||||
'teamsUrl'
|
||||
)
|
||||
|
||||
if (!teamsResponse.ok) {
|
||||
const errorData = await teamsResponse.json().catch(() => ({}))
|
||||
const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse
|
||||
logger.error(`[${requestId}] Microsoft Teams API error:`, errorData)
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -218,7 +143,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = await teamsResponse.json()
|
||||
const responseData = (await teamsResponse.json()) as GraphChatMessage
|
||||
logger.info(`[${requestId}] Teams channel message sent successfully`, {
|
||||
messageId: responseData.id,
|
||||
attachmentCount: attachments.length,
|
||||
@@ -237,6 +162,7 @@ export async function POST(request: NextRequest) {
|
||||
url: responseData.webUrl || '',
|
||||
attachmentCount: attachments.length,
|
||||
},
|
||||
files: filesOutput,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils'
|
||||
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
|
||||
import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -15,7 +17,7 @@ const TeamsWriteChatSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
chatId: z.string().min(1, 'Chat ID is required'),
|
||||
content: z.string().min(1, 'Message content is required'),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -51,93 +53,12 @@ export async function POST(request: NextRequest) {
|
||||
fileCount: validatedData.files?.length || 0,
|
||||
})
|
||||
|
||||
const attachments: any[] = []
|
||||
if (validatedData.files && validatedData.files.length > 0) {
|
||||
const rawFiles = validatedData.files
|
||||
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to Teams`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
|
||||
|
||||
for (const file of userFiles) {
|
||||
try {
|
||||
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`
|
||||
)
|
||||
}
|
||||
const { attachments, filesOutput } = await uploadFilesForTeamsMessage({
|
||||
rawFiles: validatedData.files || [],
|
||||
accessToken: validatedData.accessToken,
|
||||
requestId,
|
||||
logger,
|
||||
})
|
||||
|
||||
let messageContent = validatedData.content
|
||||
let contentType: 'text' | 'html' = 'text'
|
||||
@@ -194,17 +115,21 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const teamsUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(validatedData.chatId)}/messages`
|
||||
|
||||
const teamsResponse = await fetch(teamsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
const teamsResponse = await secureFetchWithValidation(
|
||||
teamsUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(messageBody),
|
||||
},
|
||||
body: JSON.stringify(messageBody),
|
||||
})
|
||||
'teamsUrl'
|
||||
)
|
||||
|
||||
if (!teamsResponse.ok) {
|
||||
const errorData = await teamsResponse.json().catch(() => ({}))
|
||||
const errorData = (await teamsResponse.json().catch(() => ({}))) as GraphApiErrorResponse
|
||||
logger.error(`[${requestId}] Microsoft Teams API error:`, errorData)
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -215,7 +140,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = await teamsResponse.json()
|
||||
const responseData = (await teamsResponse.json()) as GraphChatMessage
|
||||
logger.info(`[${requestId}] Teams message sent successfully`, {
|
||||
messageId: responseData.id,
|
||||
attachmentCount: attachments.length,
|
||||
@@ -233,6 +158,7 @@ export async function POST(request: NextRequest) {
|
||||
url: responseData.webUrl || '',
|
||||
attachmentCount: attachments.length,
|
||||
},
|
||||
files: filesOutput,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,15 +2,17 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
downloadFileFromStorage,
|
||||
resolveInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,7 +20,9 @@ const logger = createLogger('MistralParseAPI')
|
||||
|
||||
const MistralParseSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
filePath: z.string().min(1, 'File path is required').optional(),
|
||||
fileData: FileInputSchema.optional(),
|
||||
file: FileInputSchema.optional(),
|
||||
resultType: z.string().optional(),
|
||||
pages: z.array(z.number()).optional(),
|
||||
includeImageBase64: z.boolean().optional(),
|
||||
@@ -49,66 +53,130 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = MistralParseSchema.parse(body)
|
||||
|
||||
const fileData = validatedData.file || validatedData.fileData
|
||||
const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath
|
||||
|
||||
if (!fileData && (!filePath || filePath.trim() === '')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File input is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Mistral parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
hasFileData: Boolean(fileData),
|
||||
filePath,
|
||||
isWorkspaceFile: filePath ? isInternalFileUrl(filePath) : false,
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
const mistralBody: any = {
|
||||
model: 'mistral-ocr-latest',
|
||||
}
|
||||
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
if (fileData && typeof fileData === 'object') {
|
||||
const rawFile = fileData
|
||||
let userFile
|
||||
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(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
error: error instanceof Error ? error.message : 'Failed to process file',
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
}
|
||||
|
||||
const mistralBody: any = {
|
||||
model: 'mistral-ocr-latest',
|
||||
document: {
|
||||
type: 'document_url',
|
||||
document_url: fileUrl,
|
||||
},
|
||||
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',
|
||||
document_url: fileUrl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedData.pages) {
|
||||
@@ -124,15 +192,34 @@ export async function POST(request: NextRequest) {
|
||||
mistralBody.image_min_size = validatedData.imageMinSize
|
||||
}
|
||||
|
||||
const mistralResponse = await fetch('https://api.mistral.ai/v1/ocr', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${validatedData.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(mistralBody),
|
||||
})
|
||||
const mistralEndpoint = 'https://api.mistral.ai/v1/ocr'
|
||||
const mistralValidation = await validateUrlWithDNS(mistralEndpoint, 'Mistral API URL')
|
||||
if (!mistralValidation.isValid) {
|
||||
logger.error(`[${requestId}] Mistral API URL validation failed`, {
|
||||
error: mistralValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to reach Mistral API',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const mistralResponse = await secureFetchWithPinnedIP(
|
||||
mistralEndpoint,
|
||||
mistralValidation.resolvedIP!,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${validatedData.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(mistralBody),
|
||||
}
|
||||
)
|
||||
|
||||
if (!mistralResponse.ok) {
|
||||
const errorText = await mistralResponse.text()
|
||||
|
||||
177
apps/sim/app/api/tools/onedrive/download/route.ts
Normal file
177
apps/sim/app/api/tools/onedrive/download/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import * as XLSX from 'xlsx'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import {
|
||||
getExtensionFromMimeType,
|
||||
processSingleFileToUserFile,
|
||||
@@ -29,12 +31,33 @@ const ExcelValuesSchema = z.union([
|
||||
const OneDriveUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
file: z.any().optional(),
|
||||
file: RawFileInputSchema.optional(),
|
||||
folderId: z.string().optional().nullable(),
|
||||
mimeType: z.string().nullish(),
|
||||
values: ExcelValuesSchema.optional().nullable(),
|
||||
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) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
@@ -88,25 +111,9 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
let fileToProcess
|
||||
if (Array.isArray(rawFile)) {
|
||||
if (rawFile.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No file provided',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
fileToProcess = rawFile[0]
|
||||
} else {
|
||||
fileToProcess = rawFile
|
||||
}
|
||||
|
||||
let userFile
|
||||
try {
|
||||
userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
|
||||
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -179,14 +186,23 @@ export async function POST(request: NextRequest) {
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': mimeType,
|
||||
// Add conflict behavior if specified (defaults to replace by Microsoft Graph API)
|
||||
if (validatedData.conflictBehavior) {
|
||||
uploadUrl += `?@microsoft.graph.conflictBehavior=${validatedData.conflictBehavior}`
|
||||
}
|
||||
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
uploadUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
body: fileBuffer,
|
||||
},
|
||||
body: new Uint8Array(fileBuffer),
|
||||
})
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorText = await uploadResponse.text()
|
||||
@@ -200,7 +216,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const fileData = await uploadResponse.json()
|
||||
const fileData = (await uploadResponse.json()) as OneDriveFileData
|
||||
|
||||
let excelWriteResult: any | undefined
|
||||
const shouldWriteExcelContent =
|
||||
@@ -209,8 +225,11 @@ export async function POST(request: NextRequest) {
|
||||
if (shouldWriteExcelContent) {
|
||||
try {
|
||||
let workbookSessionId: string | undefined
|
||||
const sessionResp = await fetch(
|
||||
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
|
||||
const sessionUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/createSession`
|
||||
const sessionResp = await secureFetchWithValidation(
|
||||
sessionUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -218,11 +237,12 @@ export async function POST(request: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ persistChanges: true }),
|
||||
}
|
||||
},
|
||||
'sessionUrl'
|
||||
)
|
||||
|
||||
if (sessionResp.ok) {
|
||||
const sessionData = await sessionResp.json()
|
||||
const sessionData = (await sessionResp.json()) as { id?: string }
|
||||
workbookSessionId = sessionData?.id
|
||||
}
|
||||
|
||||
@@ -231,14 +251,19 @@ export async function POST(request: NextRequest) {
|
||||
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/worksheets?$select=name&$orderby=position&$top=1`
|
||||
const listResp = await fetch(listUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
const listResp = await secureFetchWithValidation(
|
||||
listUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
'listUrl'
|
||||
)
|
||||
if (listResp.ok) {
|
||||
const listData = await listResp.json()
|
||||
const listData = (await listResp.json()) as { value?: Array<{ name?: string }> }
|
||||
const firstSheetName = listData?.value?.[0]?.name
|
||||
if (firstSheetName) {
|
||||
sheetName = firstSheetName
|
||||
@@ -297,15 +322,19 @@ export async function POST(request: NextRequest) {
|
||||
)}')/range(address='${encodeURIComponent(computedRangeAddress)}')`
|
||||
)
|
||||
|
||||
const excelWriteResponse = await fetch(url.toString(), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
const excelWriteResponse = await secureFetchWithValidation(
|
||||
url.toString(),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ values: processedValues }),
|
||||
},
|
||||
body: JSON.stringify({ values: processedValues }),
|
||||
})
|
||||
'excelWriteUrl'
|
||||
)
|
||||
|
||||
if (!excelWriteResponse || !excelWriteResponse.ok) {
|
||||
const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response'
|
||||
@@ -320,7 +349,7 @@ export async function POST(request: NextRequest) {
|
||||
details: errorText,
|
||||
}
|
||||
} else {
|
||||
const writeData = await excelWriteResponse.json()
|
||||
const writeData = (await excelWriteResponse.json()) as ExcelRangeData
|
||||
const addr = writeData.address || writeData.addressLocal
|
||||
const v = writeData.values || []
|
||||
excelWriteResult = {
|
||||
@@ -328,21 +357,25 @@ export async function POST(request: NextRequest) {
|
||||
updatedRange: addr,
|
||||
updatedRows: Array.isArray(v) ? v.length : undefined,
|
||||
updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined,
|
||||
updatedCells: Array.isArray(v) && v[0] ? v.length * (v[0] as any[]).length : undefined,
|
||||
updatedCells: Array.isArray(v) && v[0] ? v.length * v[0].length : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (workbookSessionId) {
|
||||
try {
|
||||
const closeResp = await fetch(
|
||||
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/closeSession`,
|
||||
const closeUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/closeSession`
|
||||
const closeResp = await secureFetchWithValidation(
|
||||
closeUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'workbook-session-id': workbookSessionId,
|
||||
},
|
||||
}
|
||||
},
|
||||
'closeSessionUrl'
|
||||
)
|
||||
if (!closeResp.ok) {
|
||||
const closeText = await closeResp.text()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -18,7 +19,7 @@ const OutlookDraftSchema = z.object({
|
||||
contentType: z.enum(['text', 'html']).optional().nullable(),
|
||||
cc: z.string().optional().nullable(),
|
||||
bcc: z.string().optional().nullable(),
|
||||
attachments: z.array(z.any()).optional().nullable(),
|
||||
attachments: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -20,7 +21,7 @@ const OutlookSendSchema = z.object({
|
||||
bcc: z.string().optional().nullable(),
|
||||
replyToMessageId: z.string().optional().nullable(),
|
||||
conversationId: z.string().optional().nullable(),
|
||||
attachments: z.array(z.any()).optional().nullable(),
|
||||
attachments: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -95,14 +96,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (attachments.length > 0) {
|
||||
const totalSize = attachments.reduce((sum, file) => sum + file.size, 0)
|
||||
const maxSize = 4 * 1024 * 1024 // 4MB
|
||||
const maxSize = 3 * 1024 * 1024 // 3MB - Microsoft Graph API limit for inline attachments
|
||||
|
||||
if (totalSize > maxSize) {
|
||||
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`,
|
||||
error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
|
||||
165
apps/sim/app/api/tools/pipedrive/get-files/route.ts
Normal file
165
apps/sim/app/api/tools/pipedrive/get-files/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,14 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,7 +17,8 @@ const logger = createLogger('PulseParseAPI')
|
||||
|
||||
const PulseParseSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
filePath: z.string().optional(),
|
||||
file: RawFileInputSchema.optional(),
|
||||
pages: z.string().optional(),
|
||||
extractFigure: z.boolean().optional(),
|
||||
figureDescription: z.boolean().optional(),
|
||||
@@ -51,50 +51,30 @@ export async function POST(request: NextRequest) {
|
||||
const validatedData = PulseParseSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Pulse parse request`, {
|
||||
fileName: validatedData.file?.name,
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false,
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
const resolution = await resolveFileInputToUrl({
|
||||
file: validatedData.file,
|
||||
filePath: validatedData.filePath,
|
||||
userId,
|
||||
requestId,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
if (resolution.error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: resolution.error.message },
|
||||
{ status: resolution.error.status }
|
||||
)
|
||||
}
|
||||
|
||||
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
|
||||
userId,
|
||||
key: storageKey,
|
||||
context,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
const fileUrl = resolution.fileUrl
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
@@ -119,13 +99,36 @@ export async function POST(request: NextRequest) {
|
||||
formData.append('chunk_size', String(validatedData.chunkSize))
|
||||
}
|
||||
|
||||
const pulseResponse = await fetch('https://api.runpulse.com/extract', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': validatedData.apiKey,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
const pulseEndpoint = 'https://api.runpulse.com/extract'
|
||||
const pulseValidation = await validateUrlWithDNS(pulseEndpoint, 'Pulse API URL')
|
||||
if (!pulseValidation.isValid) {
|
||||
logger.error(`[${requestId}] Pulse API URL validation failed`, {
|
||||
error: pulseValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to reach Pulse API',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const pulsePayload = new Response(formData)
|
||||
const contentType = pulsePayload.headers.get('content-type') || 'multipart/form-data'
|
||||
const bodyBuffer = Buffer.from(await pulsePayload.arrayBuffer())
|
||||
const pulseResponse = await secureFetchWithPinnedIP(
|
||||
pulseEndpoint,
|
||||
pulseValidation.resolvedIP!,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': validatedData.apiKey,
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
body: bodyBuffer,
|
||||
}
|
||||
)
|
||||
|
||||
if (!pulseResponse.ok) {
|
||||
const errorText = await pulseResponse.text()
|
||||
|
||||
@@ -2,15 +2,14 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,7 +17,8 @@ const logger = createLogger('ReductoParseAPI')
|
||||
|
||||
const ReductoParseSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
filePath: z.string().optional(),
|
||||
file: RawFileInputSchema.optional(),
|
||||
pages: z.array(z.number()).optional(),
|
||||
tableOutputFormat: z.enum(['html', 'md']).optional(),
|
||||
})
|
||||
@@ -47,56 +47,30 @@ export async function POST(request: NextRequest) {
|
||||
const validatedData = ReductoParseSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Reducto parse request`, {
|
||||
fileName: validatedData.file?.name,
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false,
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
const resolution = await resolveFileInputToUrl({
|
||||
file: validatedData.file,
|
||||
filePath: validatedData.filePath,
|
||||
userId,
|
||||
requestId,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
if (resolution.error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: resolution.error.message },
|
||||
{ status: resolution.error.status }
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
const fileUrl = resolution.fileUrl
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const reductoBody: Record<string, unknown> = {
|
||||
@@ -104,8 +78,13 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (validatedData.pages && validatedData.pages.length > 0) {
|
||||
// Reducto API expects page_range as an object with start/end, not an array
|
||||
const pages = validatedData.pages
|
||||
reductoBody.settings = {
|
||||
page_range: validatedData.pages,
|
||||
page_range: {
|
||||
start: Math.min(...pages),
|
||||
end: Math.max(...pages),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +94,34 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const reductoResponse = await fetch('https://platform.reducto.ai/parse', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${validatedData.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(reductoBody),
|
||||
})
|
||||
const reductoEndpoint = 'https://platform.reducto.ai/parse'
|
||||
const reductoValidation = await validateUrlWithDNS(reductoEndpoint, 'Reducto API URL')
|
||||
if (!reductoValidation.isValid) {
|
||||
logger.error(`[${requestId}] Reducto API URL validation failed`, {
|
||||
error: reductoValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to reach Reducto API',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const reductoResponse = await secureFetchWithPinnedIP(
|
||||
reductoEndpoint,
|
||||
reductoValidation.resolvedIP!,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${validatedData.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(reductoBody),
|
||||
}
|
||||
)
|
||||
|
||||
if (!reductoResponse.ok) {
|
||||
const errorText = await reductoResponse.text()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -17,7 +18,7 @@ const S3PutObjectSchema = z.object({
|
||||
region: z.string().min(1, 'Region is required'),
|
||||
bucketName: z.string().min(1, 'Bucket name is required'),
|
||||
objectKey: z.string().min(1, 'Object key is required'),
|
||||
file: z.any().optional().nullable(),
|
||||
file: RawFileInputSchema.optional().nullable(),
|
||||
content: z.string().optional().nullable(),
|
||||
contentType: z.string().optional().nullable(),
|
||||
acl: z.string().optional().nullable(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -111,6 +112,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const buffer = Buffer.concat(chunks)
|
||||
const fileName = path.basename(remotePath)
|
||||
const extension = getFileExtension(fileName)
|
||||
const mimeType = getMimeTypeFromExtension(extension)
|
||||
|
||||
let content: string
|
||||
if (params.encoding === 'base64') {
|
||||
@@ -124,6 +127,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fileName,
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType,
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
},
|
||||
content,
|
||||
size: buffer.length,
|
||||
encoding: params.encoding,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
@@ -26,14 +27,7 @@ const UploadSchema = z.object({
|
||||
privateKey: z.string().nullish(),
|
||||
passphrase: z.string().nullish(),
|
||||
remotePath: z.string().min(1, 'Remote path is required'),
|
||||
files: z
|
||||
.union([z.array(z.any()), z.string(), z.number(), z.null(), z.undefined()])
|
||||
.transform((val) => {
|
||||
if (Array.isArray(val)) return val
|
||||
if (val === null || val === undefined || val === '') return undefined
|
||||
return undefined
|
||||
})
|
||||
.nullish(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
fileContent: z.string().nullish(),
|
||||
fileName: z.string().nullish(),
|
||||
overwrite: z.boolean().default(true),
|
||||
|
||||
@@ -2,9 +2,12 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -16,7 +19,7 @@ const SharepointUploadSchema = z.object({
|
||||
driveId: z.string().optional().nullable(),
|
||||
folderPath: z.string().optional().nullable(),
|
||||
fileName: z.string().optional().nullable(),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -79,18 +82,23 @@ export async function POST(request: NextRequest) {
|
||||
let effectiveDriveId = validatedData.driveId
|
||||
if (!effectiveDriveId) {
|
||||
logger.info(`[${requestId}] No driveId provided, fetching default drive for site`)
|
||||
const driveResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive`,
|
||||
const driveUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive`
|
||||
const driveResponse = await secureFetchWithValidation(
|
||||
driveUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
},
|
||||
'driveUrl'
|
||||
)
|
||||
|
||||
if (!driveResponse.ok) {
|
||||
const errorData = await driveResponse.json().catch(() => ({}))
|
||||
const errorData = (await driveResponse.json().catch(() => ({}))) as {
|
||||
error?: { message?: string }
|
||||
}
|
||||
logger.error(`[${requestId}] Failed to get default drive:`, errorData)
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -101,7 +109,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const driveData = await driveResponse.json()
|
||||
const driveData = (await driveResponse.json()) as { id: string }
|
||||
effectiveDriveId = driveData.id
|
||||
logger.info(`[${requestId}] Using default drive: ${effectiveDriveId}`)
|
||||
}
|
||||
@@ -145,34 +153,87 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Uploading to: ${uploadUrl}`)
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': userFile.type || 'application/octet-stream',
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
uploadUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': userFile.type || 'application/octet-stream',
|
||||
},
|
||||
body: buffer,
|
||||
},
|
||||
body: new Uint8Array(buffer),
|
||||
})
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorData = await uploadResponse.json().catch(() => ({}))
|
||||
logger.error(`[${requestId}] Failed to upload file ${fileName}:`, errorData)
|
||||
|
||||
if (uploadResponse.status === 409) {
|
||||
logger.warn(`[${requestId}] File ${fileName} already exists, attempting to replace`)
|
||||
// File exists - retry with conflict behavior set to replace
|
||||
logger.warn(`[${requestId}] File ${fileName} already exists, retrying with replace`)
|
||||
const replaceUrl = `${uploadUrl}?@microsoft.graph.conflictBehavior=replace`
|
||||
const replaceResponse = await 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
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorData.error?.message || `Failed to upload file: ${fileName}`,
|
||||
error:
|
||||
(errorData as { error?: { message?: string } }).error?.message ||
|
||||
`Failed to upload file: ${fileName}`,
|
||||
},
|
||||
{ status: uploadResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const uploadData = await uploadResponse.json()
|
||||
const uploadData = (await uploadResponse.json()) as MicrosoftGraphDriveItem
|
||||
logger.info(`[${requestId}] File uploaded successfully: ${fileName}`)
|
||||
|
||||
uploadedFiles.push({
|
||||
|
||||
170
apps/sim/app/api/tools/slack/download/route.ts
Normal file
170
apps/sim/app/api/tools/slack/download/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackDownloadAPI')
|
||||
|
||||
const SlackDownloadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileId: z.string().min(1, 'File ID is required'),
|
||||
fileName: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack download attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated Slack download request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = SlackDownloadSchema.parse(body)
|
||||
|
||||
const { accessToken, fileId, fileName } = validatedData
|
||||
|
||||
logger.info(`[${requestId}] Getting file info from Slack`, { fileId })
|
||||
|
||||
const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!infoResponse.ok) {
|
||||
const errorDetails = await infoResponse.json().catch(() => ({}))
|
||||
logger.error(`[${requestId}] Failed to get file info from Slack`, {
|
||||
status: infoResponse.status,
|
||||
statusText: infoResponse.statusText,
|
||||
error: errorDetails,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorDetails.error || 'Failed to get file info',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await infoResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API returned error`, { error: data.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Slack API error',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const file = data.file
|
||||
const resolvedFileName = fileName || file.name || 'download'
|
||||
const mimeType = file.mimetype || 'application/octet-stream'
|
||||
const urlPrivate = file.url_private
|
||||
|
||||
if (!urlPrivate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File does not have a download URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(urlPrivate, 'urlPrivate')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Downloading file from Slack`, {
|
||||
fileId,
|
||||
fileName: resolvedFileName,
|
||||
mimeType,
|
||||
})
|
||||
|
||||
const downloadResponse = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to download file content`, {
|
||||
status: downloadResponse.status,
|
||||
statusText: downloadResponse.statusText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to download file content',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await downloadResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
logger.info(`[${requestId}] File downloaded successfully`, {
|
||||
fileId,
|
||||
name: resolvedFileName,
|
||||
size: fileBuffer.length,
|
||||
mimeType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: resolvedFileName,
|
||||
mimeType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading Slack file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { sendSlackMessage } from '../utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,7 +17,7 @@ const SlackSendMessageSchema = z
|
||||
userId: z.string().optional().nullable(),
|
||||
text: z.string().min(1, 'Message text is required'),
|
||||
thread_ts: z.string().optional().nullable(),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
.refine((data) => data.channel || data.userId, {
|
||||
message: 'Either channel or userId is required',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { ToolFileData } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Sends a message to a Slack channel using chat.postMessage
|
||||
@@ -70,14 +72,21 @@ export async function uploadFilesToSlack(
|
||||
accessToken: string,
|
||||
requestId: string,
|
||||
logger: Logger
|
||||
): Promise<string[]> {
|
||||
): Promise<{ fileIds: string[]; files: ToolFileData[] }> {
|
||||
const userFiles = processFilesToUserFiles(files, requestId, logger)
|
||||
const uploadedFileIds: string[] = []
|
||||
const uploadedFiles: ToolFileData[] = []
|
||||
|
||||
for (const userFile of userFiles) {
|
||||
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
|
||||
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
uploadedFiles.push({
|
||||
name: userFile.name,
|
||||
mimeType: userFile.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
|
||||
const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', {
|
||||
method: 'POST',
|
||||
@@ -100,10 +109,14 @@ export async function uploadFilesToSlack(
|
||||
|
||||
logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`)
|
||||
|
||||
const uploadResponse = await fetch(urlData.upload_url, {
|
||||
method: 'POST',
|
||||
body: new Uint8Array(buffer),
|
||||
})
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
urlData.upload_url,
|
||||
{
|
||||
method: 'POST',
|
||||
body: buffer,
|
||||
},
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`)
|
||||
@@ -114,7 +127,7 @@ export async function uploadFilesToSlack(
|
||||
uploadedFileIds.push(urlData.file_id)
|
||||
}
|
||||
|
||||
return uploadedFileIds
|
||||
return { fileIds: uploadedFileIds, files: uploadedFiles }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +137,8 @@ export async function completeSlackFileUpload(
|
||||
uploadedFileIds: string[],
|
||||
channel: string,
|
||||
text: string,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
threadTs?: string | null
|
||||
): Promise<{ ok: boolean; files?: any[]; error?: string }> {
|
||||
const response = await fetch('https://slack.com/api/files.completeUploadExternal', {
|
||||
method: 'POST',
|
||||
@@ -136,6 +150,7 @@ export async function completeSlackFileUpload(
|
||||
files: uploadedFileIds.map((id) => ({ id })),
|
||||
channel_id: channel,
|
||||
initial_comment: text,
|
||||
...(threadTs && { thread_ts: threadTs }),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -217,7 +232,13 @@ export async function sendSlackMessage(
|
||||
logger: Logger
|
||||
): Promise<{
|
||||
success: boolean
|
||||
output?: { message: any; ts: string; channel: string; fileCount?: number }
|
||||
output?: {
|
||||
message: any
|
||||
ts: string
|
||||
channel: string
|
||||
fileCount?: number
|
||||
files?: ToolFileData[]
|
||||
}
|
||||
error?: string
|
||||
}> {
|
||||
const { accessToken, text, threadTs, files } = params
|
||||
@@ -249,10 +270,15 @@ export async function sendSlackMessage(
|
||||
|
||||
// Process files
|
||||
logger.info(`[${requestId}] Processing ${files.length} file(s)`)
|
||||
const uploadedFileIds = await uploadFilesToSlack(files, accessToken, requestId, logger)
|
||||
const { fileIds, files: uploadedFiles } = await uploadFilesToSlack(
|
||||
files,
|
||||
accessToken,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
|
||||
// No valid files uploaded - send text-only
|
||||
if (uploadedFileIds.length === 0) {
|
||||
if (fileIds.length === 0) {
|
||||
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
|
||||
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs)
|
||||
@@ -264,8 +290,8 @@ export async function sendSlackMessage(
|
||||
return { success: true, output: formatMessageSuccessResponse(data, text) }
|
||||
}
|
||||
|
||||
// Complete file upload
|
||||
const completeData = await completeSlackFileUpload(uploadedFileIds, channel, text, accessToken)
|
||||
// Complete file upload with thread support
|
||||
const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs)
|
||||
|
||||
if (!completeData.ok) {
|
||||
logger.error(`[${requestId}] Failed to complete upload:`, completeData.error)
|
||||
@@ -282,7 +308,8 @@ export async function sendSlackMessage(
|
||||
message: fileMessage,
|
||||
ts: fileMessage.ts,
|
||||
channel,
|
||||
fileCount: uploadedFileIds.length,
|
||||
fileCount: fileIds.length,
|
||||
files: uploadedFiles,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import nodemailer from 'nodemailer'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -28,7 +29,7 @@ const SmtpSendSchema = z.object({
|
||||
cc: z.string().optional().nullable(),
|
||||
bcc: z.string().optional().nullable(),
|
||||
replyTo: z.string().optional().nullable(),
|
||||
attachments: z.array(z.any()).optional().nullable(),
|
||||
attachments: RawFileInputArraySchema.optional().nullable(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHDownloadFileAPI')
|
||||
@@ -79,6 +80,16 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
})
|
||||
|
||||
// Check file size limit (50MB to prevent memory exhaustion)
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
if (stats.size > maxSize) {
|
||||
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{ error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
@@ -96,6 +107,8 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const fileName = path.basename(remotePath)
|
||||
const extension = getFileExtension(fileName)
|
||||
const mimeType = getMimeTypeFromExtension(extension)
|
||||
|
||||
// Encode content as base64 for binary safety
|
||||
const base64Content = content.toString('base64')
|
||||
@@ -104,6 +117,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
downloaded: true,
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType,
|
||||
data: base64Content,
|
||||
size: stats.size,
|
||||
},
|
||||
content: base64Content,
|
||||
fileName: fileName,
|
||||
remotePath: remotePath,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
||||
|
||||
@@ -123,6 +124,10 @@ export async function POST(request: NextRequest) {
|
||||
const variablesObject = processVariables(params.variables)
|
||||
|
||||
const startUrl = normalizeUrl(rawStartUrl)
|
||||
const urlValidation = await validateUrlWithDNS(startUrl, 'startUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json({ error: urlValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Starting Stagehand agent process', {
|
||||
rawStartUrl,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
||||
|
||||
const logger = createLogger('StagehandExtractAPI')
|
||||
@@ -51,6 +52,10 @@ export async function POST(request: NextRequest) {
|
||||
const params = validationResult.data
|
||||
const { url: rawUrl, instruction, selector, provider, apiKey, schema } = params
|
||||
const url = normalizeUrl(rawUrl)
|
||||
const urlValidation = await validateUrlWithDNS(url, 'url')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json({ error: urlValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Starting Stagehand extraction process', {
|
||||
rawUrl,
|
||||
|
||||
@@ -2,7 +2,15 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
downloadFileFromStorage,
|
||||
resolveInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import type { TranscriptSegment } from '@/tools/stt/types'
|
||||
|
||||
@@ -45,6 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const body: SttRequestBody = await request.json()
|
||||
const {
|
||||
provider,
|
||||
@@ -72,6 +81,9 @@ export async function POST(request: NextRequest) {
|
||||
let audioMimeType: string
|
||||
|
||||
if (body.audioFile) {
|
||||
if (Array.isArray(body.audioFile) && body.audioFile.length !== 1) {
|
||||
return NextResponse.json({ error: 'audioFile must be a single file' }, { status: 400 })
|
||||
}
|
||||
const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile
|
||||
logger.info(`[${requestId}] Processing uploaded file: ${file.name}`)
|
||||
|
||||
@@ -79,6 +91,12 @@ export async function POST(request: NextRequest) {
|
||||
audioFileName = file.name
|
||||
audioMimeType = file.type
|
||||
} else if (body.audioFileReference) {
|
||||
if (Array.isArray(body.audioFileReference) && body.audioFileReference.length !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'audioFileReference must be a single file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const file = Array.isArray(body.audioFileReference)
|
||||
? body.audioFileReference[0]
|
||||
: body.audioFileReference
|
||||
@@ -90,14 +108,48 @@ export async function POST(request: NextRequest) {
|
||||
} else if (body.audioUrl) {
|
||||
logger.info(`[${requestId}] Downloading from URL: ${body.audioUrl}`)
|
||||
|
||||
const response = await fetch(body.audioUrl)
|
||||
let audioUrl = body.audioUrl.trim()
|
||||
if (audioUrl.startsWith('/') && !isInternalFileUrl(audioUrl)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (isInternalFileUrl(audioUrl)) {
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required for internal file access' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const resolution = await resolveInternalFileUrl(audioUrl, userId, requestId, logger)
|
||||
if (resolution.error) {
|
||||
return NextResponse.json(
|
||||
{ error: resolution.error.message },
|
||||
{ status: resolution.error.status }
|
||||
)
|
||||
}
|
||||
audioUrl = resolution.fileUrl || audioUrl
|
||||
}
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(audioUrl, 'audioUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json({ error: urlValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio from URL: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
audioBuffer = Buffer.from(arrayBuffer)
|
||||
audioFileName = body.audioUrl.split('/').pop() || 'audio_file'
|
||||
audioFileName = audioUrl.split('/').pop() || 'audio_file'
|
||||
audioMimeType = response.headers.get('content-type') || 'audio/mpeg'
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
@@ -149,7 +201,9 @@ export async function POST(request: NextRequest) {
|
||||
translateToEnglish,
|
||||
model,
|
||||
body.prompt,
|
||||
body.temperature
|
||||
body.temperature,
|
||||
audioMimeType,
|
||||
audioFileName
|
||||
)
|
||||
transcript = result.transcript
|
||||
segments = result.segments
|
||||
@@ -162,7 +216,8 @@ export async function POST(request: NextRequest) {
|
||||
language,
|
||||
timestamps,
|
||||
diarization,
|
||||
model
|
||||
model,
|
||||
audioMimeType
|
||||
)
|
||||
transcript = result.transcript
|
||||
segments = result.segments
|
||||
@@ -252,7 +307,9 @@ async function transcribeWithWhisper(
|
||||
translate?: boolean,
|
||||
model?: string,
|
||||
prompt?: string,
|
||||
temperature?: number
|
||||
temperature?: number,
|
||||
mimeType?: string,
|
||||
fileName?: string
|
||||
): Promise<{
|
||||
transcript: string
|
||||
segments?: TranscriptSegment[]
|
||||
@@ -261,8 +318,11 @@ async function transcribeWithWhisper(
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
|
||||
const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' })
|
||||
formData.append('file', blob, 'audio.mp3')
|
||||
// Use actual MIME type and filename if provided
|
||||
const actualMimeType = mimeType || 'audio/mpeg'
|
||||
const actualFileName = fileName || 'audio.mp3'
|
||||
const blob = new Blob([new Uint8Array(audioBuffer)], { type: actualMimeType })
|
||||
formData.append('file', blob, actualFileName)
|
||||
formData.append('model', model || 'whisper-1')
|
||||
|
||||
if (language && language !== 'auto') {
|
||||
@@ -279,10 +339,11 @@ async function transcribeWithWhisper(
|
||||
|
||||
formData.append('response_format', 'verbose_json')
|
||||
|
||||
// OpenAI API uses array notation for timestamp_granularities
|
||||
if (timestamps === 'word') {
|
||||
formData.append('timestamp_granularities', 'word')
|
||||
formData.append('timestamp_granularities[]', 'word')
|
||||
} else if (timestamps === 'sentence') {
|
||||
formData.append('timestamp_granularities', 'segment')
|
||||
formData.append('timestamp_granularities[]', 'segment')
|
||||
}
|
||||
|
||||
const endpoint = translate ? 'translations' : 'transcriptions'
|
||||
@@ -325,7 +386,8 @@ async function transcribeWithDeepgram(
|
||||
language?: string,
|
||||
timestamps?: 'none' | 'sentence' | 'word',
|
||||
diarization?: boolean,
|
||||
model?: string
|
||||
model?: string,
|
||||
mimeType?: string
|
||||
): Promise<{
|
||||
transcript: string
|
||||
segments?: TranscriptSegment[]
|
||||
@@ -357,7 +419,7 @@ async function transcribeWithDeepgram(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${apiKey}`,
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Content-Type': mimeType || 'audio/mpeg',
|
||||
},
|
||||
body: new Uint8Array(audioBuffer),
|
||||
})
|
||||
@@ -513,7 +575,8 @@ async function transcribeWithAssemblyAI(
|
||||
audio_url: upload_url,
|
||||
}
|
||||
|
||||
if (model === 'best' || model === 'nano') {
|
||||
// AssemblyAI supports 'best', 'slam-1', or 'universal' for speech_model
|
||||
if (model === 'best' || model === 'slam-1' || model === 'universal') {
|
||||
transcriptRequest.speech_model = model
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
@@ -16,7 +17,7 @@ const SupabaseStorageUploadSchema = z.object({
|
||||
bucket: z.string().min(1, 'Bucket name is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
path: z.string().optional().nullable(),
|
||||
fileData: z.any(),
|
||||
fileData: FileInputSchema,
|
||||
contentType: z.string().optional().nullable(),
|
||||
upsert: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
|
||||
@@ -14,7 +15,7 @@ const logger = createLogger('TelegramSendDocumentAPI')
|
||||
const TelegramSendDocumentSchema = z.object({
|
||||
botToken: z.string().min(1, 'Bot token is required'),
|
||||
chatId: z.string().min(1, 'Chat ID is required'),
|
||||
files: z.array(z.any()).optional().nullable(),
|
||||
files: RawFileInputArraySchema.optional().nullable(),
|
||||
caption: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -93,6 +94,14 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Uploading document: ${userFile.name}`)
|
||||
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
const filesOutput = [
|
||||
{
|
||||
name: userFile.name,
|
||||
mimeType: userFile.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
},
|
||||
]
|
||||
|
||||
logger.info(`[${requestId}] Downloaded file: ${buffer.length} bytes`)
|
||||
|
||||
@@ -135,6 +144,7 @@ export async function POST(request: NextRequest) {
|
||||
output: {
|
||||
message: 'Document sent successfully',
|
||||
data: data.result,
|
||||
files: filesOutput,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,19 +3,18 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation'
|
||||
import {
|
||||
validateAwsRegion,
|
||||
validateExternalUrl,
|
||||
validateS3BucketName,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
downloadFileFromStorage,
|
||||
resolveInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
|
||||
@@ -35,6 +34,7 @@ const TextractParseSchema = z
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
|
||||
filePath: z.string().optional(),
|
||||
file: RawFileInputSchema.optional(),
|
||||
s3Uri: z.string().optional(),
|
||||
featureTypes: z
|
||||
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
|
||||
@@ -50,6 +50,20 @@ const TextractParseSchema = z
|
||||
path: ['region'],
|
||||
})
|
||||
}
|
||||
if (data.processingMode === 'async' && !data.s3Uri) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'S3 URI is required for multi-page processing (s3://bucket/key)',
|
||||
path: ['s3Uri'],
|
||||
})
|
||||
}
|
||||
if (data.processingMode !== 'async' && !data.file && !data.filePath) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'File input is required for single-page processing',
|
||||
path: ['filePath'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function getSignatureKey(
|
||||
@@ -111,7 +125,14 @@ function signAwsRequest(
|
||||
}
|
||||
|
||||
async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> {
|
||||
const response = await fetch(url)
|
||||
const urlValidation = await validateUrlWithDNS(url, 'Document URL')
|
||||
if (!urlValidation.isValid) {
|
||||
throw new Error(urlValidation.error || 'Invalid document URL')
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`)
|
||||
}
|
||||
@@ -318,8 +339,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Textract parse request`, {
|
||||
processingMode,
|
||||
filePath: validatedData.filePath?.substring(0, 50),
|
||||
s3Uri: validatedData.s3Uri?.substring(0, 50),
|
||||
hasFile: Boolean(validatedData.file),
|
||||
hasS3Uri: Boolean(validatedData.s3Uri),
|
||||
featureTypes,
|
||||
userId,
|
||||
})
|
||||
@@ -414,90 +435,89 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
if (!validatedData.filePath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File path is required for single-page processing',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
let bytes = ''
|
||||
let contentType = 'application/octet-stream'
|
||||
let isPdf = false
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
|
||||
|
||||
if (isInternalFilePath) {
|
||||
if (validatedData.file) {
|
||||
let userFile
|
||||
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(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
userFile = processSingleFileToUserFile(validatedData.file, requestId, logger)
|
||||
} 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`, {
|
||||
userId,
|
||||
path: validatedData.filePath.substring(0, 50),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
} else {
|
||||
const urlValidation = validateExternalUrl(fileUrl, 'Document URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked`, {
|
||||
userId,
|
||||
url: fileUrl.substring(0, 100),
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
error: error instanceof Error ? error.message : 'Failed to process file',
|
||||
},
|
||||
{ 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
|
||||
|
||||
const isInternalFilePath = isInternalFileUrl(fileUrl)
|
||||
|
||||
if (isInternalFilePath) {
|
||||
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
|
||||
if (resolution.error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: resolution.error.message,
|
||||
},
|
||||
{ status: resolution.error.status }
|
||||
)
|
||||
}
|
||||
fileUrl = resolution.fileUrl || fileUrl
|
||||
} else if (fileUrl.startsWith('/')) {
|
||||
logger.warn(`[${requestId}] Invalid internal path`, {
|
||||
userId,
|
||||
path: fileUrl.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, 'Document URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked`, {
|
||||
userId,
|
||||
url: fileUrl.substring(0, 100),
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const fetched = await fetchDocumentBytes(fileUrl)
|
||||
bytes = fetched.bytes
|
||||
contentType = fetched.contentType
|
||||
isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File input is required for single-page processing',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { bytes, contentType } = await fetchDocumentBytes(fileUrl)
|
||||
|
||||
// Track if this is a PDF for better error messaging
|
||||
const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
|
||||
|
||||
const uri = '/'
|
||||
|
||||
let textractBody: Record<string, unknown>
|
||||
|
||||
250
apps/sim/app/api/tools/twilio/get-recording/route.ts
Normal file
250
apps/sim/app/api/tools/twilio/get-recording/route.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
downloadFileFromStorage,
|
||||
resolveInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils.server'
|
||||
import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -13,8 +23,8 @@ const logger = createLogger('VisionAnalyzeAPI')
|
||||
const VisionAnalyzeSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
imageUrl: z.string().optional().nullable(),
|
||||
imageFile: z.any().optional().nullable(),
|
||||
model: z.string().optional().default('gpt-4o'),
|
||||
imageFile: RawFileInputSchema.optional().nullable(),
|
||||
model: z.string().optional().default('gpt-5.2'),
|
||||
prompt: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -39,6 +49,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const userId = authResult.userId
|
||||
const body = await request.json()
|
||||
const validatedData = VisionAnalyzeSchema.parse(body)
|
||||
|
||||
@@ -77,18 +88,72 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
|
||||
const base64 = buffer.toString('base64')
|
||||
let base64 = userFile.base64
|
||||
let bufferLength = 0
|
||||
if (!base64) {
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
|
||||
base64 = buffer.toString('base64')
|
||||
bufferLength = buffer.length
|
||||
}
|
||||
const mimeType = userFile.type || 'image/jpeg'
|
||||
imageSource = `data:${mimeType};base64,${base64}`
|
||||
logger.info(`[${requestId}] Converted image to base64 (${buffer.length} bytes)`)
|
||||
if (bufferLength > 0) {
|
||||
logger.info(`[${requestId}] Converted image to base64 (${bufferLength} bytes)`)
|
||||
}
|
||||
}
|
||||
|
||||
let imageUrlValidation: Awaited<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 prompt = validatedData.prompt || defaultPrompt
|
||||
|
||||
const isClaude = validatedData.model.startsWith('claude-3')
|
||||
const isClaude = validatedData.model.startsWith('claude-')
|
||||
const isGemini = validatedData.model.startsWith('gemini-')
|
||||
const apiUrl = isClaude
|
||||
? 'https://api.anthropic.com/v1/messages'
|
||||
: 'https://api.openai.com/v1/chat/completions'
|
||||
@@ -106,6 +171,72 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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 (imageSource.startsWith('data:')) {
|
||||
const base64Match = imageSource.match(/^data:([^;]+);base64,(.+)$/)
|
||||
@@ -172,7 +303,7 @@ export async function POST(request: NextRequest) {
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 1000,
|
||||
max_completion_tokens: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import {
|
||||
getFileExtension,
|
||||
getMimeTypeFromExtension,
|
||||
@@ -19,7 +20,7 @@ const WORDPRESS_COM_API_BASE = 'https://public-api.wordpress.com/wp/v2/sites'
|
||||
const WordPressUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
siteId: z.string().min(1, 'Site ID is required'),
|
||||
file: z.any().optional().nullable(),
|
||||
file: RawFileInputSchema.optional().nullable(),
|
||||
filename: z.string().optional().nullable(),
|
||||
title: z.string().optional().nullable(),
|
||||
caption: z.string().optional().nullable(),
|
||||
|
||||
216
apps/sim/app/api/tools/zoom/get-recordings/route.ts
Normal file
216
apps/sim/app/api/tools/zoom/get-recordings/route.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
@@ -88,6 +89,14 @@ 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
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
|
||||
@@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
}
|
||||
|
||||
updateData.billingBlocked = body.billingBlocked
|
||||
// Clear the reason when unblocking
|
||||
if (body.billingBlocked === false) {
|
||||
updateData.billingBlockedReason = null
|
||||
}
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { db, workflow as workflowTable } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
@@ -8,6 +6,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
|
||||
@@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { startBlockId, sourceSnapshot, input } = validation.data
|
||||
const executionId = uuidv4()
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
// Run preprocessing checks (billing, rate limits, usage limits)
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType: 'manual',
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
|
||||
}
|
||||
@@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowId,
|
||||
startBlockId,
|
||||
executedBlocksCount: sourceSnapshot.executedBlocks.length,
|
||||
billingActorUserId: preprocessResult.actorUserId,
|
||||
})
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
|
||||
|
||||
@@ -807,7 +807,7 @@ export function Chat() {
|
||||
|
||||
const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map(
|
||||
(fieldName) => {
|
||||
const defaultType = fieldName === 'files' ? 'files' : 'string'
|
||||
const defaultType = fieldName === 'files' ? 'file[]' : 'string'
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
@@ -179,7 +179,7 @@ export function A2aDeploy({
|
||||
newFields.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'files',
|
||||
type: 'files',
|
||||
type: 'file[]',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
|
||||
interface Field {
|
||||
id: string
|
||||
name: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
|
||||
value?: string
|
||||
description?: string
|
||||
collapsed?: boolean
|
||||
@@ -57,7 +57,7 @@ const TYPE_OPTIONS: ComboboxOption[] = [
|
||||
{ label: 'Boolean', value: 'boolean' },
|
||||
{ label: 'Object', value: 'object' },
|
||||
{ label: 'Array', value: 'array' },
|
||||
{ label: 'Files', value: 'files' },
|
||||
{ label: 'Files', value: 'file[]' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -448,7 +448,7 @@ export function FieldFormat({
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'files') {
|
||||
if (field.type === 'file[]') {
|
||||
const lineCount = fieldValue.split('\n').length
|
||||
const gutterWidth = calculateGutterWidth(lineCount)
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ const getOutputTypeForPath = (
|
||||
const chatModeTypes: Record<string, string> = {
|
||||
input: 'string',
|
||||
conversationId: 'string',
|
||||
files: 'files',
|
||||
files: 'file[]',
|
||||
}
|
||||
return chatModeTypes[outputPath] || 'any'
|
||||
}
|
||||
@@ -1563,16 +1563,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
blockTagGroups.sort((a, b) => a.distance - b.distance)
|
||||
finalBlockTagGroups.push(...blockTagGroups)
|
||||
|
||||
const contextualTags: string[] = []
|
||||
if (loopBlockGroup) {
|
||||
contextualTags.push(...loopBlockGroup.tags)
|
||||
}
|
||||
if (parallelBlockGroup) {
|
||||
contextualTags.push(...parallelBlockGroup.tags)
|
||||
}
|
||||
const groupTags = finalBlockTagGroups.flatMap((group) => group.tags)
|
||||
const tags = [...groupTags, ...variableTags]
|
||||
|
||||
return {
|
||||
tags: [...allBlockTags, ...variableTags, ...contextualTags],
|
||||
tags,
|
||||
variableInfoMap,
|
||||
blockTagGroups: finalBlockTagGroups,
|
||||
}
|
||||
@@ -1746,7 +1741,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
mergedSubBlocks
|
||||
)
|
||||
|
||||
if (fieldType === 'files' || fieldType === 'file[]' || fieldType === 'array') {
|
||||
if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') {
|
||||
const blockName = parts[0]
|
||||
const remainingPath = parts.slice(2).join('.')
|
||||
processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}`
|
||||
|
||||
@@ -50,6 +50,12 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
/** Stable empty object to avoid creating new references */
|
||||
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.
|
||||
*
|
||||
@@ -89,31 +95,23 @@ export function Editor() {
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
const title = currentBlock?.name || 'Editor'
|
||||
|
||||
// Check if selected block is a subflow (loop or parallel)
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Check if selected block is a workflow block
|
||||
const isWorkflowBlock =
|
||||
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
|
||||
|
||||
// Get workspace ID from params
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get user permissions
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Get active workflow ID
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
// Get block properties (advanced/trigger modes)
|
||||
const { advancedMode, triggerMode } = useEditorBlockProperties(
|
||||
currentBlockId,
|
||||
currentWorkflow.isSnapshotView
|
||||
@@ -145,10 +143,9 @@ export function Editor() {
|
||||
[subBlocksForCanonical]
|
||||
)
|
||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||
const advancedValuesPresent = hasAdvancedValues(
|
||||
subBlocksForCanonical,
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
const advancedValuesPresent = useMemo(
|
||||
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
|
||||
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
|
||||
)
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
@@ -156,11 +153,9 @@ export function Editor() {
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
|
||||
if (subBlock.mode !== 'advanced') continue
|
||||
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
|
||||
|
||||
// Check condition - skip if condition not met for current values
|
||||
if (
|
||||
subBlock.condition &&
|
||||
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
|
||||
@@ -173,7 +168,6 @@ export function Editor() {
|
||||
return false
|
||||
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
blockConfig || ({} as any),
|
||||
currentBlockId || '',
|
||||
@@ -206,31 +200,34 @@ export function Editor() {
|
||||
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
|
||||
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
|
||||
|
||||
// Get block connections
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
|
||||
|
||||
// Connections resize hook
|
||||
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
|
||||
subBlocksRef,
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const {
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
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.
|
||||
@@ -251,7 +248,6 @@ export function Editor() {
|
||||
if (trimmedName && trimmedName !== currentBlock?.name) {
|
||||
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
|
||||
if (!result.success) {
|
||||
// Keep rename mode open on error so user can correct the name
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -266,14 +262,6 @@ export function Editor() {
|
||||
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(() => {
|
||||
if (shouldFocusRename && currentBlock) {
|
||||
handleStartRename()
|
||||
@@ -284,17 +272,13 @@ export function Editor() {
|
||||
/**
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
const handleOpenDocs = useCallback(() => {
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
|
||||
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
|
||||
|
||||
// Get child workflow ID for workflow blocks
|
||||
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 } =
|
||||
useWorkflowState(childWorkflowId)
|
||||
|
||||
@@ -307,7 +291,6 @@ export function Editor() {
|
||||
}
|
||||
}, [childWorkflowId, workspaceId])
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
return (
|
||||
@@ -328,7 +311,7 @@ export function Editor() {
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
ref={nameInputRefCallback}
|
||||
type='text'
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
@@ -399,23 +382,21 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)} */}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -495,13 +476,7 @@ export function Editor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -566,13 +541,7 @@ export function Editor() {
|
||||
/>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -581,13 +550,7 @@ export function Editor() {
|
||||
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||
<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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
@@ -600,13 +563,7 @@ export function Editor() {
|
||||
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -630,13 +587,7 @@ export function Editor() {
|
||||
/>
|
||||
{index < advancedOnlySubBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,7 @@ export function useBlockOutputFields({
|
||||
baseOutputs = {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'files', description: 'Uploaded files' },
|
||||
files: { type: 'file[]', description: 'Uploaded files' },
|
||||
}
|
||||
} else {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
|
||||
@@ -417,11 +417,11 @@ async function executeWebhookJobInternal(
|
||||
if (triggerBlock?.subBlocks?.inputFormat?.value) {
|
||||
const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
|
||||
}>
|
||||
logger.debug(`[${requestId}] Processing generic webhook files from inputFormat`)
|
||||
|
||||
const fileFields = inputFormat.filter((field) => field.type === 'files')
|
||||
const fileFields = inputFormat.filter((field) => field.type === 'file[]')
|
||||
|
||||
if (fileFields.length > 0 && typeof input === 'object' && input !== null) {
|
||||
const executionContext = {
|
||||
|
||||
@@ -442,7 +442,16 @@ describe('Blocks Module', () => {
|
||||
})
|
||||
|
||||
it('should have valid output types', () => {
|
||||
const validPrimitiveTypes = ['string', 'number', 'boolean', 'json', 'array', 'files', 'any']
|
||||
const validPrimitiveTypes = [
|
||||
'string',
|
||||
'number',
|
||||
'boolean',
|
||||
'json',
|
||||
'array',
|
||||
'file',
|
||||
'file[]',
|
||||
'any',
|
||||
]
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const [key, outputConfig] of Object.entries(block.outputs)) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ChatTriggerBlock: BlockConfig = {
|
||||
outputs: {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'files', description: 'Uploaded files' },
|
||||
files: { type: 'file[]', description: 'Uploaded files' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
|
||||
@@ -578,13 +578,20 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
if (!params.serverId) throw new Error('Server ID is required')
|
||||
|
||||
switch (params.operation) {
|
||||
case 'discord_send_message':
|
||||
case 'discord_send_message': {
|
||||
const fileParam = params.attachmentFiles || params.files
|
||||
const normalizedFiles = fileParam
|
||||
? Array.isArray(fileParam)
|
||||
? fileParam
|
||||
: [fileParam]
|
||||
: undefined
|
||||
return {
|
||||
...commonParams,
|
||||
channelId: params.channelId,
|
||||
content: params.content,
|
||||
files: params.attachmentFiles || params.files,
|
||||
files: normalizedFiles,
|
||||
}
|
||||
}
|
||||
case 'discord_get_messages':
|
||||
return {
|
||||
...commonParams,
|
||||
@@ -789,6 +796,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
},
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Status message' },
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
data: { type: 'json', description: 'Response data' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,12 +60,25 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'fileContent',
|
||||
title: 'File Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Base64 encoded file content or file reference',
|
||||
condition: { field: 'operation', value: 'dropbox_upload' },
|
||||
id: 'uploadFile',
|
||||
title: 'File',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'fileContent',
|
||||
placeholder: 'Upload file to send to Dropbox',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'dropbox_upload' },
|
||||
},
|
||||
{
|
||||
id: 'fileContent',
|
||||
title: 'File',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'fileContent',
|
||||
placeholder: 'Reference file from previous blocks',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'dropbox_upload' },
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
@@ -337,7 +350,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
path: { type: 'string', description: 'Path in Dropbox' },
|
||||
autorename: { type: 'boolean', description: 'Auto-rename on conflict' },
|
||||
// Upload inputs
|
||||
fileContent: { type: 'string', description: 'Base64 encoded file content' },
|
||||
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
|
||||
fileContent: { type: 'json', description: 'File reference or UserFile object' },
|
||||
fileName: { type: 'string', description: 'Optional filename' },
|
||||
mode: { type: 'string', description: 'Write mode: add or overwrite' },
|
||||
mute: { type: 'boolean', description: 'Mute notifications' },
|
||||
@@ -360,7 +374,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
},
|
||||
outputs: {
|
||||
// Upload/Download outputs
|
||||
file: { type: 'json', description: 'File metadata' },
|
||||
file: { type: 'file', description: 'Downloaded file stored in execution files' },
|
||||
content: { type: 'string', description: 'File content (base64)' },
|
||||
temporaryLink: { type: 'string', description: 'Temporary download link' },
|
||||
// List folder outputs
|
||||
|
||||
@@ -73,5 +73,6 @@ export const ElevenLabsBlock: BlockConfig<ElevenLabsBlockResponse> = {
|
||||
|
||||
outputs: {
|
||||
audioUrl: { type: 'string', description: 'Generated audio URL' },
|
||||
audioFile: { type: 'file', description: 'Generated audio file' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { DocumentIcon } from '@/components/icons'
|
||||
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import type { BlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { FileParserOutput } from '@/tools/file/types'
|
||||
import type { FileParserOutput, FileParserV3Output } from '@/tools/file/types'
|
||||
|
||||
const logger = createLogger('FileBlock')
|
||||
|
||||
const resolveFilePathFromInput = (fileInput: unknown): string | null => {
|
||||
if (!fileInput || typeof fileInput !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = fileInput as Record<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> = {
|
||||
type: 'file',
|
||||
name: 'File (Legacy)',
|
||||
@@ -79,24 +116,14 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
|
||||
|
||||
// Handle file upload input
|
||||
if (inputMethod === 'upload') {
|
||||
// Handle case where 'file' is an array (multiple files)
|
||||
if (params.file && Array.isArray(params.file) && params.file.length > 0) {
|
||||
const filePaths = params.file.map((file) => file.path)
|
||||
|
||||
const filePaths = resolveFilePathsFromInput(params.file)
|
||||
if (filePaths.length > 0) {
|
||||
return {
|
||||
filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
|
||||
fileType: params.fileType || 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where 'file' is a single file object
|
||||
if (params.file?.path) {
|
||||
return {
|
||||
filePath: params.file.path,
|
||||
fileType: params.fileType || 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
// If no files, return error
|
||||
logger.error('No files provided for upload method')
|
||||
throw new Error('Please upload a file')
|
||||
@@ -116,7 +143,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
|
||||
},
|
||||
outputs: {
|
||||
files: {
|
||||
type: 'json',
|
||||
type: 'file[]',
|
||||
description: 'Array of parsed file objects with content, metadata, and file properties',
|
||||
},
|
||||
combinedContent: {
|
||||
@@ -124,7 +151,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
|
||||
description: 'All file contents merged into a single text string',
|
||||
},
|
||||
processedFiles: {
|
||||
type: 'files',
|
||||
type: 'file[]',
|
||||
description: 'Array of UserFile objects for downstream use (attachments, uploads, etc.)',
|
||||
},
|
||||
},
|
||||
@@ -133,9 +160,9 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
|
||||
export const FileV2Block: BlockConfig<FileParserOutput> = {
|
||||
...FileBlock,
|
||||
type: 'file_v2',
|
||||
name: 'File',
|
||||
name: 'File (Legacy)',
|
||||
description: 'Read and parse multiple files',
|
||||
hideFromToolbar: false,
|
||||
hideFromToolbar: true,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'file',
|
||||
@@ -182,16 +209,17 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
|
||||
}
|
||||
|
||||
if (Array.isArray(fileInput) && fileInput.length > 0) {
|
||||
const filePaths = fileInput.map((file) => file.path)
|
||||
const filePaths = resolveFilePathsFromInput(fileInput)
|
||||
return {
|
||||
filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
|
||||
fileType: params.fileType || 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInput?.path) {
|
||||
const resolvedSingle = resolveFilePathsFromInput(fileInput)
|
||||
if (resolvedSingle.length > 0) {
|
||||
return {
|
||||
filePath: fileInput.path,
|
||||
filePath: resolvedSingle[0],
|
||||
fileType: params.fileType || 'auto',
|
||||
}
|
||||
}
|
||||
@@ -209,7 +237,7 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
|
||||
},
|
||||
outputs: {
|
||||
files: {
|
||||
type: 'json',
|
||||
type: 'file[]',
|
||||
description: 'Array of parsed file objects with content, metadata, and file properties',
|
||||
},
|
||||
combinedContent: {
|
||||
@@ -218,3 +246,108 @@ 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FirefliesIcon } from '@/components/icons'
|
||||
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { FirefliesResponse } from '@/tools/fireflies/types'
|
||||
@@ -6,8 +7,9 @@ import { getTrigger } from '@/triggers'
|
||||
|
||||
export const FirefliesBlock: BlockConfig<FirefliesResponse> = {
|
||||
type: 'fireflies',
|
||||
name: 'Fireflies',
|
||||
name: 'Fireflies (Legacy)',
|
||||
description: 'Interact with Fireflies.ai meeting transcripts and recordings',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
triggerAllowed: true,
|
||||
longDescription:
|
||||
@@ -587,3 +589,74 @@ Return ONLY the summary text - no quotes, no labels.`,
|
||||
available: ['fireflies_transcription_complete'],
|
||||
},
|
||||
}
|
||||
|
||||
const firefliesV2SubBlocks = (FirefliesBlock.subBlocks || []).filter(
|
||||
(subBlock) => subBlock.id !== 'audioUrl'
|
||||
)
|
||||
const firefliesV2Inputs = FirefliesBlock.inputs
|
||||
? Object.fromEntries(Object.entries(FirefliesBlock.inputs).filter(([key]) => key !== 'audioUrl'))
|
||||
: {}
|
||||
|
||||
export const FirefliesV2Block: BlockConfig<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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
// Tool outputs
|
||||
content: { type: 'string', description: 'Response content' },
|
||||
metadata: { type: 'json', description: 'Email metadata' },
|
||||
attachments: { type: 'json', description: 'Email attachments array' },
|
||||
attachments: { type: 'file[]', description: 'Email attachments array' },
|
||||
// Trigger outputs
|
||||
email_id: { type: 'string', description: 'Gmail message ID' },
|
||||
thread_id: { type: 'string', description: 'Gmail thread ID' },
|
||||
@@ -579,7 +579,7 @@ export const GmailV2Block: BlockConfig<GmailToolResponse> = {
|
||||
date: { type: 'string', description: 'Date' },
|
||||
body: { type: 'string', description: 'Email body text (best-effort)' },
|
||||
results: { type: 'json', description: 'Search/read summary results' },
|
||||
attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' },
|
||||
attachments: { type: 'file[]', description: 'Downloaded attachments (if enabled)' },
|
||||
|
||||
// Draft-specific outputs
|
||||
draftId: {
|
||||
|
||||
@@ -861,7 +861,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
permissionId: { type: 'string', description: 'Permission ID to remove' },
|
||||
},
|
||||
outputs: {
|
||||
file: { type: 'json', description: 'File metadata or downloaded file data' },
|
||||
file: { type: 'file', description: 'Downloaded file stored in execution files' },
|
||||
files: { type: 'json', description: 'List of files' },
|
||||
metadata: { type: 'json', description: 'Complete file metadata (from download)' },
|
||||
content: { type: 'string', description: 'File content as text' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GoogleSheetsIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types'
|
||||
|
||||
// Legacy block - hidden from toolbar
|
||||
@@ -681,34 +682,38 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
'google_sheets_copy_sheet_v2',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'google_sheets_read_v2'
|
||||
case 'write':
|
||||
return 'google_sheets_write_v2'
|
||||
case 'update':
|
||||
return 'google_sheets_update_v2'
|
||||
case 'append':
|
||||
return 'google_sheets_append_v2'
|
||||
case 'clear':
|
||||
return 'google_sheets_clear_v2'
|
||||
case 'get_info':
|
||||
return 'google_sheets_get_spreadsheet_v2'
|
||||
case 'create':
|
||||
return 'google_sheets_create_spreadsheet_v2'
|
||||
case 'batch_get':
|
||||
return 'google_sheets_batch_get_v2'
|
||||
case 'batch_update':
|
||||
return 'google_sheets_batch_update_v2'
|
||||
case 'batch_clear':
|
||||
return 'google_sheets_batch_clear_v2'
|
||||
case 'copy_sheet':
|
||||
return 'google_sheets_copy_sheet_v2'
|
||||
default:
|
||||
throw new Error(`Invalid Google Sheets V2 operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
tool: createVersionedToolSelector({
|
||||
baseToolSelector: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'google_sheets_read'
|
||||
case 'write':
|
||||
return 'google_sheets_write'
|
||||
case 'update':
|
||||
return 'google_sheets_update'
|
||||
case 'append':
|
||||
return 'google_sheets_append'
|
||||
case 'clear':
|
||||
return 'google_sheets_clear'
|
||||
case 'get_info':
|
||||
return 'google_sheets_get_spreadsheet'
|
||||
case 'create':
|
||||
return 'google_sheets_create_spreadsheet'
|
||||
case 'batch_get':
|
||||
return 'google_sheets_batch_get'
|
||||
case 'batch_update':
|
||||
return 'google_sheets_batch_update'
|
||||
case 'batch_clear':
|
||||
return 'google_sheets_batch_clear'
|
||||
case 'copy_sheet':
|
||||
return 'google_sheets_copy_sheet'
|
||||
default:
|
||||
throw new Error(`Invalid Google Sheets operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
suffix: '_v2',
|
||||
fallbackToolId: 'google_sheets_read_v2',
|
||||
}),
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { GoogleSlidesIcon } from '@/components/icons'
|
||||
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { GoogleSlidesResponse } from '@/tools/google_slides/types'
|
||||
|
||||
export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
type: 'google_slides',
|
||||
name: 'Google Slides',
|
||||
name: 'Google Slides (Legacy)',
|
||||
description: 'Read, write, and create presentations',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.OAuth,
|
||||
longDescription:
|
||||
'Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.',
|
||||
@@ -315,12 +317,26 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'imageUrl',
|
||||
title: 'Image URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'Public URL of the image (PNG, JPEG, or GIF)',
|
||||
condition: { field: 'operation', value: 'add_image' },
|
||||
id: 'imageFile',
|
||||
title: 'Image',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'imageSource',
|
||||
placeholder: 'Upload image (PNG, JPEG, or GIF)',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.png,.jpg,.jpeg,.gif',
|
||||
condition: { field: 'operation', value: 'add_image' },
|
||||
},
|
||||
{
|
||||
id: 'imageUrl',
|
||||
title: 'Image',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'imageSource',
|
||||
placeholder: 'Reference image from previous blocks or enter URL',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'add_image' },
|
||||
},
|
||||
{
|
||||
id: 'imageWidth',
|
||||
@@ -809,7 +825,9 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
|
||||
placeholderIdMappings: { type: 'string', description: 'JSON array of placeholder ID mappings' },
|
||||
// Add image operation
|
||||
pageObjectId: { type: 'string', description: 'Slide object ID for image' },
|
||||
imageUrl: { type: 'string', description: 'Image URL' },
|
||||
imageFile: { type: 'json', description: 'Uploaded image (UserFile)' },
|
||||
imageUrl: { type: 'string', description: 'Image URL or reference' },
|
||||
imageSource: { type: 'json', description: 'Image source (file or URL)' },
|
||||
imageWidth: { type: 'number', description: 'Image width in points' },
|
||||
imageHeight: { type: 'number', description: 'Image height in points' },
|
||||
positionX: { type: 'number', description: 'X position in points' },
|
||||
@@ -887,3 +905,99 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
|
||||
text: { type: 'string', description: 'Text that was inserted' },
|
||||
},
|
||||
}
|
||||
|
||||
const googleSlidesV2SubBlocks = (GoogleSlidesBlock.subBlocks || []).flatMap((subBlock) => {
|
||||
if (subBlock.id === 'imageFile') {
|
||||
return [
|
||||
{
|
||||
...subBlock,
|
||||
canonicalParamId: 'imageFile',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (subBlock.id !== 'imageUrl') {
|
||||
return [subBlock]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'imageFileReference',
|
||||
title: 'Image',
|
||||
type: 'short-input' 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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
description:
|
||||
'Single hold object (for create_matters_holds or list_matters_holds with holdId)',
|
||||
},
|
||||
file: { type: 'json', description: 'Downloaded export file (UserFile) from execution files' },
|
||||
file: { type: 'file', description: 'Downloaded export file (UserFile) from execution files' },
|
||||
nextPageToken: {
|
||||
type: 'string',
|
||||
description: 'Token for fetching next page of results (for list operations)',
|
||||
|
||||
@@ -149,7 +149,7 @@ export const ImageGeneratorBlock: BlockConfig<DalleResponse> = {
|
||||
},
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Generation response' },
|
||||
image: { type: 'string', description: 'Generated image URL' },
|
||||
image: { type: 'file', description: 'Generated image file (UserFile)' },
|
||||
metadata: { type: 'json', description: 'Generation metadata' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ImapBlock: BlockConfig = {
|
||||
bodyHtml: { type: 'string', description: 'HTML email body' },
|
||||
mailbox: { type: 'string', description: 'Mailbox/folder where email was received' },
|
||||
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
|
||||
attachments: { type: 'json', description: 'Array of email attachments' },
|
||||
attachments: { type: 'file[]', description: 'Array of email attachments' },
|
||||
timestamp: { type: 'string', description: 'Event timestamp' },
|
||||
},
|
||||
triggers: {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
{ label: 'Update Comment', id: 'update_comment' },
|
||||
{ label: 'Delete Comment', id: 'delete_comment' },
|
||||
{ label: 'Get Attachments', id: 'get_attachments' },
|
||||
{ label: 'Add Attachment', id: 'add_attachment' },
|
||||
{ label: 'Delete Attachment', id: 'delete_attachment' },
|
||||
{ label: 'Add Worklog', id: 'add_worklog' },
|
||||
{ label: 'Get Worklogs', id: 'get_worklogs' },
|
||||
@@ -137,6 +138,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
'update_comment',
|
||||
'delete_comment',
|
||||
'get_attachments',
|
||||
'add_attachment',
|
||||
'add_worklog',
|
||||
'get_worklogs',
|
||||
'update_worklog',
|
||||
@@ -168,6 +170,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
'update_comment',
|
||||
'delete_comment',
|
||||
'get_attachments',
|
||||
'add_attachment',
|
||||
'add_worklog',
|
||||
'get_worklogs',
|
||||
'update_worklog',
|
||||
@@ -407,6 +410,27 @@ Return ONLY the comment text - no explanations.`,
|
||||
condition: { field: 'operation', value: ['update_comment', 'delete_comment'] },
|
||||
},
|
||||
// Attachment fields
|
||||
{
|
||||
id: 'attachmentFiles',
|
||||
title: 'Attachments',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'files',
|
||||
placeholder: 'Upload files',
|
||||
condition: { field: 'operation', value: 'add_attachment' },
|
||||
mode: 'basic',
|
||||
multiple: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'File References',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'files',
|
||||
placeholder: 'File reference from previous block',
|
||||
condition: { field: 'operation', value: 'add_attachment' },
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'attachmentId',
|
||||
title: 'Attachment ID',
|
||||
@@ -576,6 +600,7 @@ Return ONLY the comment text - no explanations.`,
|
||||
'jira_update_comment',
|
||||
'jira_delete_comment',
|
||||
'jira_get_attachments',
|
||||
'jira_add_attachment',
|
||||
'jira_delete_attachment',
|
||||
'jira_add_worklog',
|
||||
'jira_get_worklogs',
|
||||
@@ -623,6 +648,8 @@ Return ONLY the comment text - no explanations.`,
|
||||
return 'jira_delete_comment'
|
||||
case 'get_attachments':
|
||||
return 'jira_get_attachments'
|
||||
case 'add_attachment':
|
||||
return 'jira_add_attachment'
|
||||
case 'delete_attachment':
|
||||
return 'jira_delete_attachment'
|
||||
case 'add_worklog':
|
||||
@@ -838,6 +865,21 @@ Return ONLY the comment text - no explanations.`,
|
||||
issueKey: effectiveIssueKey,
|
||||
}
|
||||
}
|
||||
case 'add_attachment': {
|
||||
if (!effectiveIssueKey) {
|
||||
throw new Error('Issue Key is required to add attachments.')
|
||||
}
|
||||
const fileParam = params.attachmentFiles || params.files
|
||||
if (!fileParam || (Array.isArray(fileParam) && fileParam.length === 0)) {
|
||||
throw new Error('At least one attachment file is required.')
|
||||
}
|
||||
const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam]
|
||||
return {
|
||||
...baseParams,
|
||||
issueKey: effectiveIssueKey,
|
||||
files: normalizedFiles,
|
||||
}
|
||||
}
|
||||
case 'delete_attachment': {
|
||||
return {
|
||||
...baseParams,
|
||||
@@ -982,6 +1024,8 @@ Return ONLY the comment text - no explanations.`,
|
||||
commentBody: { type: 'string', description: 'Text content for comment operations' },
|
||||
commentId: { type: 'string', description: 'Comment ID for update/delete operations' },
|
||||
// Attachment operation inputs
|
||||
attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' },
|
||||
files: { type: 'array', description: 'Files to attach (UserFile array)' },
|
||||
attachmentId: { type: 'string', description: 'Attachment ID for delete operation' },
|
||||
// Worklog operation inputs
|
||||
timeSpentSeconds: {
|
||||
@@ -1052,6 +1096,8 @@ Return ONLY the comment text - no explanations.`,
|
||||
type: 'json',
|
||||
description: 'Array of attachments with id, filename, size, mimeType, created, author',
|
||||
},
|
||||
files: { type: 'file[]', description: 'Uploaded attachment files' },
|
||||
attachmentIds: { type: 'json', description: 'Uploaded attachment IDs' },
|
||||
|
||||
// jira_delete_attachment, jira_delete_comment, jira_delete_issue, jira_delete_worklog, jira_delete_issue_link outputs
|
||||
attachmentId: { type: 'string', description: 'Deleted attachment ID' },
|
||||
|
||||
@@ -668,17 +668,44 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
// Attachment file
|
||||
{
|
||||
id: 'attachmentFileUpload',
|
||||
title: 'Attachment',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'file',
|
||||
placeholder: 'Upload attachment',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_attachment'],
|
||||
},
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
id: 'file',
|
||||
title: 'File Reference',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'file',
|
||||
placeholder: 'File reference from previous block',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_attachment'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Attachment URL
|
||||
{
|
||||
id: 'url',
|
||||
title: 'URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter URL',
|
||||
required: true,
|
||||
required: false,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_attachment'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Attachment title
|
||||
{
|
||||
@@ -1742,16 +1769,31 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
teamId: effectiveTeamId,
|
||||
}
|
||||
|
||||
case 'linear_create_attachment':
|
||||
if (!params.issueId?.trim() || !params.url?.trim()) {
|
||||
throw new Error('Issue ID and URL are required.')
|
||||
case 'linear_create_attachment': {
|
||||
if (!params.issueId?.trim()) {
|
||||
throw new Error('Issue ID is required.')
|
||||
}
|
||||
if (Array.isArray(params.file)) {
|
||||
throw new Error('Attachment file must be a single file.')
|
||||
}
|
||||
if (Array.isArray(params.attachmentFileUpload)) {
|
||||
throw new Error('Attachment file must be a single file.')
|
||||
}
|
||||
const attachmentFile = params.attachmentFileUpload || params.file
|
||||
const attachmentUrl =
|
||||
params.url?.trim() ||
|
||||
(attachmentFile && !Array.isArray(attachmentFile) ? attachmentFile.url : undefined)
|
||||
if (!attachmentUrl) {
|
||||
throw new Error('URL or file is required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
issueId: params.issueId.trim(),
|
||||
url: params.url.trim(),
|
||||
url: attachmentUrl,
|
||||
file: attachmentFile,
|
||||
title: params.attachmentTitle,
|
||||
}
|
||||
}
|
||||
|
||||
case 'linear_list_attachments':
|
||||
if (!params.issueId?.trim()) {
|
||||
@@ -2248,6 +2290,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
endDate: { type: 'string', description: 'End date' },
|
||||
targetDate: { type: 'string', description: 'Target date' },
|
||||
url: { type: 'string', description: 'URL' },
|
||||
attachmentFileUpload: { type: 'json', description: 'File to attach (UI upload)' },
|
||||
file: { type: 'json', description: 'File to attach (UserFile)' },
|
||||
attachmentTitle: { type: 'string', description: 'Attachment title' },
|
||||
attachmentId: { type: 'string', description: 'Attachment identifier' },
|
||||
relationType: { type: 'string', description: 'Relation type' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MicrosoftExcelIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type {
|
||||
MicrosoftExcelResponse,
|
||||
MicrosoftExcelV2Response,
|
||||
@@ -489,16 +490,20 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
tools: {
|
||||
access: ['microsoft_excel_read_v2', 'microsoft_excel_write_v2'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'microsoft_excel_read_v2'
|
||||
case 'write':
|
||||
return 'microsoft_excel_write_v2'
|
||||
default:
|
||||
throw new Error(`Invalid Microsoft Excel V2 operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
tool: createVersionedToolSelector({
|
||||
baseToolSelector: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'microsoft_excel_read'
|
||||
case 'write':
|
||||
return 'microsoft_excel_write'
|
||||
default:
|
||||
throw new Error(`Invalid Microsoft Excel operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
suffix: '_v2',
|
||||
fallbackToolId: 'microsoft_excel_read_v2',
|
||||
}),
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
|
||||
@@ -346,7 +346,10 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
// Add files if provided
|
||||
const fileParam = attachmentFiles || files
|
||||
if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) {
|
||||
baseParams.files = fileParam
|
||||
const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam]
|
||||
if (normalizedFiles.length > 0) {
|
||||
baseParams.files = normalizedFiles
|
||||
}
|
||||
}
|
||||
|
||||
// Add messageId if provided
|
||||
@@ -462,7 +465,8 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
messages: { type: 'json', description: 'Array of message objects' },
|
||||
totalAttachments: { type: 'number', description: 'Total number of attachments' },
|
||||
attachmentTypes: { type: 'json', description: 'Array of attachment content types' },
|
||||
attachments: { type: 'array', description: 'Downloaded message attachments' },
|
||||
attachments: { type: 'file[]', description: 'Downloaded message attachments' },
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
updatedContent: {
|
||||
type: 'boolean',
|
||||
description: 'Whether content was successfully updated/sent',
|
||||
|
||||
@@ -94,7 +94,7 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
|
||||
if (!params.fileUpload) {
|
||||
throw new Error('Please upload a PDF document')
|
||||
}
|
||||
parameters.fileUpload = params.fileUpload
|
||||
parameters.file = params.fileUpload
|
||||
}
|
||||
|
||||
let pagesArray: number[] | undefined
|
||||
@@ -159,14 +159,16 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
|
||||
placeholder: 'Upload a PDF document',
|
||||
mode: 'basic',
|
||||
maxSize: 50,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'filePath',
|
||||
title: 'PDF Document',
|
||||
id: 'fileReference',
|
||||
title: 'File Reference',
|
||||
type: 'short-input' as SubBlockType,
|
||||
canonicalParamId: 'document',
|
||||
placeholder: 'Document URL',
|
||||
placeholder: 'File reference from previous block',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'resultType',
|
||||
@@ -211,15 +213,26 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
|
||||
resultType: params.resultType || 'markdown',
|
||||
}
|
||||
|
||||
const documentInput = params.fileUpload || params.filePath || params.document
|
||||
let documentInput = params.fileUpload || params.fileReference || params.document
|
||||
if (!documentInput) {
|
||||
throw new Error('PDF document is required')
|
||||
}
|
||||
if (typeof documentInput === 'object') {
|
||||
parameters.fileUpload = documentInput
|
||||
} else if (typeof documentInput === 'string') {
|
||||
parameters.filePath = documentInput.trim()
|
||||
if (typeof documentInput === 'string') {
|
||||
try {
|
||||
documentInput = JSON.parse(documentInput)
|
||||
} catch {
|
||||
throw new Error('PDF document must be a valid file reference')
|
||||
}
|
||||
}
|
||||
if (Array.isArray(documentInput)) {
|
||||
throw new Error(
|
||||
'File reference must be a single file, not an array. Use <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
|
||||
if (params.pages && params.pages.trim() !== '') {
|
||||
@@ -254,8 +267,8 @@ export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
|
||||
filePath: { type: 'string', description: 'PDF document URL (advanced mode)' },
|
||||
document: { type: 'json', description: 'Document input (file upload or file reference)' },
|
||||
fileReference: { type: 'json', description: 'File reference (advanced mode)' },
|
||||
fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' },
|
||||
apiKey: { type: 'string', description: 'Mistral API key' },
|
||||
resultType: { type: 'string', description: 'Output format type' },
|
||||
|
||||
@@ -412,6 +412,7 @@ export const NotionV2Block: BlockConfig<any> = {
|
||||
'notion_read_database_v2',
|
||||
'notion_write_v2',
|
||||
'notion_create_page_v2',
|
||||
'notion_update_page_v2',
|
||||
'notion_query_database_v2',
|
||||
'notion_search_v2',
|
||||
'notion_create_database_v2',
|
||||
|
||||
@@ -393,7 +393,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
deleted: { type: 'boolean', description: 'Whether the file was deleted' },
|
||||
fileId: { type: 'string', description: 'The ID of the deleted file' },
|
||||
file: {
|
||||
type: 'json',
|
||||
type: 'file',
|
||||
description: 'The OneDrive file object, including details such as id, name, size, and more.',
|
||||
},
|
||||
files: {
|
||||
|
||||
@@ -440,7 +440,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
sentDateTime: { type: 'string', description: 'Email sent timestamp' },
|
||||
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
|
||||
attachments: {
|
||||
type: 'json',
|
||||
type: 'file[]',
|
||||
description: 'Email attachments (if includeAttachments is enabled)',
|
||||
},
|
||||
isRead: { type: 'boolean', description: 'Whether email is read' },
|
||||
|
||||
@@ -804,6 +804,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
deals: { type: 'json', description: 'Array of deal objects' },
|
||||
deal: { type: 'json', description: 'Single deal object' },
|
||||
files: { type: 'json', description: 'Array of file objects' },
|
||||
downloadedFiles: { type: 'file[]', description: 'Downloaded files from Pipedrive' },
|
||||
messages: { type: 'json', description: 'Array of mail message objects' },
|
||||
pipelines: { type: 'json', description: 'Array of pipeline objects' },
|
||||
projects: { type: 'json', description: 'Array of project objects' },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { PulseIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { PulseParserOutput } from '@/tools/pulse/types'
|
||||
|
||||
export const PulseBlock: BlockConfig<PulseParserOutput> = {
|
||||
type: 'pulse',
|
||||
name: 'Pulse',
|
||||
description: 'Extract text from documents using Pulse OCR',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.',
|
||||
@@ -77,7 +79,7 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
|
||||
throw new Error('Document is required')
|
||||
}
|
||||
if (typeof documentInput === 'object') {
|
||||
parameters.fileUpload = documentInput
|
||||
parameters.file = documentInput
|
||||
} else if (typeof documentInput === 'string') {
|
||||
parameters.filePath = documentInput.trim()
|
||||
}
|
||||
@@ -126,3 +128,95 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ReductoIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { ReductoParserOutput } from '@/tools/reducto/types'
|
||||
|
||||
export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
|
||||
type: 'reducto',
|
||||
name: 'Reducto',
|
||||
description: 'Extract text from PDF documents',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`,
|
||||
docsLink: 'https://docs.sim.ai/tools/reducto',
|
||||
@@ -74,7 +76,7 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
|
||||
}
|
||||
|
||||
if (typeof documentInput === 'object') {
|
||||
parameters.fileUpload = documentInput
|
||||
parameters.file = documentInput
|
||||
} else if (typeof documentInput === 'string') {
|
||||
parameters.filePath = documentInput.trim()
|
||||
}
|
||||
@@ -132,3 +134,110 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@ export const S3Block: BlockConfig<S3Response> = {
|
||||
type: 'string',
|
||||
description: 'S3 URI (s3://bucket/key) for use with other AWS services',
|
||||
},
|
||||
file: { type: 'file', description: 'Downloaded file stored in execution files' },
|
||||
objects: { type: 'json', description: 'List of objects (for list operation)' },
|
||||
deleted: { type: 'boolean', description: 'Deletion status' },
|
||||
metadata: { type: 'json', description: 'Operation metadata' },
|
||||
|
||||
@@ -293,6 +293,7 @@ export const SftpBlock: BlockConfig<SftpUploadResult> = {
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the operation was successful' },
|
||||
uploadedFiles: { type: 'json', description: 'Array of uploaded file details' },
|
||||
file: { type: 'file', description: 'Downloaded file stored in execution files' },
|
||||
fileName: { type: 'string', description: 'Downloaded file name' },
|
||||
content: { type: 'string', description: 'Downloaded file content' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
|
||||
@@ -622,7 +622,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
}
|
||||
const fileParam = attachmentFiles || files
|
||||
if (fileParam) {
|
||||
baseParams.files = fileParam
|
||||
const normalizedFiles = Array.isArray(fileParam) ? fileParam : [fileParam]
|
||||
if (normalizedFiles.length > 0) {
|
||||
baseParams.files = normalizedFiles
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -796,6 +799,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
type: 'number',
|
||||
description: 'Number of files uploaded (when files are attached)',
|
||||
},
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
|
||||
// slack_canvas outputs
|
||||
canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' },
|
||||
@@ -859,7 +863,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
|
||||
// slack_download outputs
|
||||
file: {
|
||||
type: 'json',
|
||||
type: 'file',
|
||||
description: 'Downloaded file stored in execution files',
|
||||
},
|
||||
|
||||
|
||||
@@ -450,10 +450,24 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
|
||||
|
||||
// === PLAYLIST COVER ===
|
||||
{
|
||||
id: 'imageBase64',
|
||||
title: 'Image (Base64)',
|
||||
type: 'long-input',
|
||||
placeholder: 'Base64-encoded JPEG image (max 256KB)',
|
||||
id: 'coverImageFile',
|
||||
title: 'Cover Image',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'coverImage',
|
||||
placeholder: 'Upload cover image (JPEG, max 256KB)',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.jpg,.jpeg',
|
||||
condition: { field: 'operation', value: 'spotify_add_playlist_cover' },
|
||||
},
|
||||
{
|
||||
id: 'coverImageRef',
|
||||
title: 'Cover Image',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'coverImage',
|
||||
placeholder: 'Reference image from previous blocks',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'spotify_add_playlist_cover' },
|
||||
},
|
||||
@@ -804,7 +818,9 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
|
||||
newName: { type: 'string', description: 'New playlist name' },
|
||||
description: { type: 'string', description: 'Playlist description' },
|
||||
public: { type: 'boolean', description: 'Whether playlist is public' },
|
||||
imageBase64: { type: 'string', description: 'Base64-encoded JPEG image' },
|
||||
coverImage: { type: 'json', description: 'Cover image (UserFile)' },
|
||||
coverImageFile: { type: 'json', description: 'Cover image upload (basic mode)' },
|
||||
coverImageRef: { type: 'json', description: 'Cover image reference (advanced mode)' },
|
||||
range_start: { type: 'number', description: 'Start index for reorder' },
|
||||
insert_before: { type: 'number', description: 'Insert before index' },
|
||||
range_length: { type: 'number', description: 'Number of items to move' },
|
||||
|
||||
@@ -507,6 +507,7 @@ export const SSHBlock: BlockConfig<SSHResponse> = {
|
||||
stderr: { type: 'string', description: 'Command standard error' },
|
||||
exitCode: { type: 'number', description: 'Command exit code' },
|
||||
success: { type: 'boolean', description: 'Operation success status' },
|
||||
file: { type: 'file', description: 'Downloaded file stored in execution files' },
|
||||
fileContent: { type: 'string', description: 'Downloaded/read file content' },
|
||||
entries: { type: 'json', description: 'Directory entries' },
|
||||
exists: { type: 'boolean', description: 'File/directory existence' },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { STTIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { SttBlockResponse } from '@/tools/stt/types'
|
||||
|
||||
export const SttBlock: BlockConfig<SttBlockResponse> = {
|
||||
type: 'stt',
|
||||
name: 'Speech-to-Text',
|
||||
description: 'Convert speech to text using AI',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Transcribe audio and video files to text using leading AI providers. Supports multiple languages, timestamps, and speaker diarization.',
|
||||
@@ -80,10 +82,7 @@ export const SttBlock: BlockConfig<SttBlockResponse> = {
|
||||
title: 'Model',
|
||||
type: 'dropdown',
|
||||
condition: { field: 'provider', value: 'assemblyai' },
|
||||
options: [
|
||||
{ label: 'Best', id: 'best' },
|
||||
{ label: 'Nano', id: 'nano' },
|
||||
],
|
||||
options: [{ label: 'Best', id: 'best' }],
|
||||
value: () => 'best',
|
||||
required: true,
|
||||
},
|
||||
@@ -345,3 +344,78 @@ 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,
|
||||
}
|
||||
|
||||
@@ -675,9 +675,9 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
|
||||
{
|
||||
id: 'fileContent',
|
||||
title: 'File Content',
|
||||
type: 'code',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'fileData',
|
||||
placeholder: 'Base64 encoded for binary files, or plain text',
|
||||
placeholder: 'File reference from previous block',
|
||||
condition: { field: 'operation', value: 'storage_upload' },
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
@@ -1173,7 +1173,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
|
||||
description: 'Row count for count operations',
|
||||
},
|
||||
file: {
|
||||
type: 'files',
|
||||
type: 'file',
|
||||
description: 'Downloaded file stored in execution files',
|
||||
},
|
||||
publicUrl: {
|
||||
|
||||
@@ -65,39 +65,91 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'telegram_message' },
|
||||
},
|
||||
{
|
||||
id: 'photoFile',
|
||||
title: 'Photo',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'photo',
|
||||
placeholder: 'Upload photo',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp',
|
||||
condition: { field: 'operation', value: 'telegram_send_photo' },
|
||||
},
|
||||
{
|
||||
id: 'photo',
|
||||
title: 'Photo',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter photo URL or file_id',
|
||||
description: 'Photo to send. Pass a file_id or HTTP URL',
|
||||
canonicalParamId: 'photo',
|
||||
placeholder: 'Reference photo from previous blocks or enter URL/file_id',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'telegram_send_photo' },
|
||||
},
|
||||
{
|
||||
id: 'videoFile',
|
||||
title: 'Video',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'video',
|
||||
placeholder: 'Upload video',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.mp4,.mov,.avi,.mkv,.webm',
|
||||
condition: { field: 'operation', value: 'telegram_send_video' },
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
title: 'Video',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter video URL or file_id',
|
||||
description: 'Video to send. Pass a file_id or HTTP URL',
|
||||
canonicalParamId: 'video',
|
||||
placeholder: 'Reference video from previous blocks or enter URL/file_id',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'telegram_send_video' },
|
||||
},
|
||||
{
|
||||
id: 'audioFile',
|
||||
title: 'Audio',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'audio',
|
||||
placeholder: 'Upload audio',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.mp3,.m4a,.wav,.ogg,.flac',
|
||||
condition: { field: 'operation', value: 'telegram_send_audio' },
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
title: 'Audio',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter audio URL or file_id',
|
||||
description: 'Audio file to send. Pass a file_id or HTTP URL',
|
||||
canonicalParamId: 'audio',
|
||||
placeholder: 'Reference audio from previous blocks or enter URL/file_id',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'telegram_send_audio' },
|
||||
},
|
||||
{
|
||||
id: 'animationFile',
|
||||
title: 'Animation',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'animation',
|
||||
placeholder: 'Upload animation (GIF)',
|
||||
mode: 'basic',
|
||||
multiple: false,
|
||||
required: true,
|
||||
acceptedTypes: '.gif,.mp4',
|
||||
condition: { field: 'operation', value: 'telegram_send_animation' },
|
||||
},
|
||||
{
|
||||
id: 'animation',
|
||||
title: 'Animation',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter animation URL or file_id',
|
||||
description: 'Animation (GIF) to send. Pass a file_id or HTTP URL',
|
||||
canonicalParamId: 'animation',
|
||||
placeholder: 'Reference animation from previous blocks or enter URL/file_id',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'telegram_send_animation' },
|
||||
},
|
||||
@@ -215,48 +267,61 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
|
||||
...commonParams,
|
||||
messageId: params.messageId,
|
||||
}
|
||||
case 'telegram_send_photo':
|
||||
if (!params.photo) {
|
||||
throw new Error('Photo URL or file_id is required.')
|
||||
case 'telegram_send_photo': {
|
||||
const photoSource = params.photoFile || params.photo
|
||||
if (!photoSource) {
|
||||
throw new Error('Photo is required.')
|
||||
}
|
||||
return {
|
||||
...commonParams,
|
||||
photo: params.photo,
|
||||
photo: photoSource,
|
||||
caption: params.caption,
|
||||
}
|
||||
case 'telegram_send_video':
|
||||
if (!params.video) {
|
||||
throw new Error('Video URL or file_id is required.')
|
||||
}
|
||||
case 'telegram_send_video': {
|
||||
const videoSource = params.videoFile || params.video
|
||||
if (!videoSource) {
|
||||
throw new Error('Video is required.')
|
||||
}
|
||||
return {
|
||||
...commonParams,
|
||||
video: params.video,
|
||||
video: videoSource,
|
||||
caption: params.caption,
|
||||
}
|
||||
case 'telegram_send_audio':
|
||||
if (!params.audio) {
|
||||
throw new Error('Audio URL or file_id is required.')
|
||||
}
|
||||
case 'telegram_send_audio': {
|
||||
const audioSource = params.audioFile || params.audio
|
||||
if (!audioSource) {
|
||||
throw new Error('Audio is required.')
|
||||
}
|
||||
return {
|
||||
...commonParams,
|
||||
audio: params.audio,
|
||||
audio: audioSource,
|
||||
caption: params.caption,
|
||||
}
|
||||
case 'telegram_send_animation':
|
||||
if (!params.animation) {
|
||||
throw new Error('Animation URL or file_id is required.')
|
||||
}
|
||||
case 'telegram_send_animation': {
|
||||
const animationSource = params.animationFile || params.animation
|
||||
if (!animationSource) {
|
||||
throw new Error('Animation is required.')
|
||||
}
|
||||
return {
|
||||
...commonParams,
|
||||
animation: params.animation,
|
||||
animation: animationSource,
|
||||
caption: params.caption,
|
||||
}
|
||||
}
|
||||
case 'telegram_send_document': {
|
||||
// Handle file upload
|
||||
const fileParam = params.attachmentFiles || params.files
|
||||
const normalizedFiles = fileParam
|
||||
? Array.isArray(fileParam)
|
||||
? fileParam
|
||||
: [fileParam]
|
||||
: undefined
|
||||
return {
|
||||
...commonParams,
|
||||
files: fileParam,
|
||||
files: normalizedFiles,
|
||||
caption: params.caption,
|
||||
}
|
||||
}
|
||||
@@ -274,10 +339,14 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
|
||||
botToken: { type: 'string', description: 'Telegram bot token' },
|
||||
chatId: { type: 'string', description: 'Chat identifier' },
|
||||
text: { type: 'string', description: 'Message text' },
|
||||
photo: { type: 'string', description: 'Photo URL or file_id' },
|
||||
video: { type: 'string', description: 'Video URL or file_id' },
|
||||
audio: { type: 'string', description: 'Audio URL or file_id' },
|
||||
animation: { type: 'string', description: 'Animation URL or file_id' },
|
||||
photoFile: { type: 'json', description: 'Uploaded photo (UserFile)' },
|
||||
photo: { type: 'json', description: 'Photo reference or URL/file_id' },
|
||||
videoFile: { type: 'json', description: 'Uploaded video (UserFile)' },
|
||||
video: { type: 'json', description: 'Video reference or URL/file_id' },
|
||||
audioFile: { type: 'json', description: 'Uploaded audio (UserFile)' },
|
||||
audio: { type: 'json', description: 'Audio reference or URL/file_id' },
|
||||
animationFile: { type: 'json', description: 'Uploaded animation (UserFile)' },
|
||||
animation: { type: 'json', description: 'Animation reference or URL/file_id' },
|
||||
attachmentFiles: {
|
||||
type: 'json',
|
||||
description: 'Files to attach (UI upload)',
|
||||
@@ -295,6 +364,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
|
||||
},
|
||||
message: { type: 'string', description: 'Success or error message' },
|
||||
data: { type: 'json', description: 'Response data' },
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
// Specific result fields
|
||||
messageId: { type: 'number', description: 'Sent message ID' },
|
||||
chatId: { type: 'number', description: 'Chat ID where message was sent' },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { TextractIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { TextractParserOutput } from '@/tools/textract/types'
|
||||
|
||||
export const TextractBlock: BlockConfig<TextractParserOutput> = {
|
||||
type: 'textract',
|
||||
name: 'AWS Textract',
|
||||
description: 'Extract text, tables, and forms from documents',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription: `Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.`,
|
||||
docsLink: 'https://docs.sim.ai/tools/textract',
|
||||
@@ -140,7 +142,7 @@ export const TextractBlock: BlockConfig<TextractParserOutput> = {
|
||||
throw new Error('Document is required')
|
||||
}
|
||||
if (typeof documentInput === 'object') {
|
||||
parameters.fileUpload = documentInput
|
||||
parameters.file = documentInput
|
||||
} else if (typeof documentInput === 'string') {
|
||||
parameters.filePath = documentInput.trim()
|
||||
}
|
||||
@@ -189,3 +191,110 @@ 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,
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ export const TtsBlock: BlockConfig<TtsBlockResponse> = {
|
||||
|
||||
outputs: {
|
||||
audioUrl: { type: 'string', description: 'URL to the generated audio file' },
|
||||
audioFile: { type: 'json', description: 'Generated audio file object (UserFile)' },
|
||||
audioFile: { type: 'file', description: 'Generated audio file object (UserFile)' },
|
||||
duration: {
|
||||
type: 'number',
|
||||
description: 'Audio duration in seconds',
|
||||
|
||||
@@ -420,7 +420,7 @@ export const VideoGeneratorBlock: BlockConfig<VideoBlockResponse> = {
|
||||
|
||||
outputs: {
|
||||
videoUrl: { type: 'string', description: 'Generated video URL' },
|
||||
videoFile: { type: 'json', description: 'Video file object with metadata' },
|
||||
videoFile: { type: 'file', description: 'Video file object with metadata' },
|
||||
duration: { type: 'number', description: 'Video duration in seconds' },
|
||||
width: { type: 'number', description: 'Video width in pixels' },
|
||||
height: { type: 'number', description: 'Video height in pixels' },
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { EyeIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import type { VisionResponse } from '@/tools/vision/types'
|
||||
|
||||
const VISION_MODEL_OPTIONS = [
|
||||
{ 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> = {
|
||||
type: 'vision',
|
||||
name: 'Vision',
|
||||
name: 'Vision (Legacy)',
|
||||
description: 'Analyze images with vision models',
|
||||
hideFromToolbar: true,
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription: 'Integrate Vision into the workflow. Can analyze images with vision models.',
|
||||
docsLink: 'https://docs.sim.ai/tools/vision',
|
||||
@@ -47,12 +65,8 @@ export const VisionBlock: BlockConfig<VisionResponse> = {
|
||||
id: 'model',
|
||||
title: 'Vision Model',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'gpt-4o', id: 'gpt-4o' },
|
||||
{ label: 'claude-3-opus', id: 'claude-3-opus-20240229' },
|
||||
{ label: 'claude-3-sonnet', id: 'claude-3-sonnet-20240229' },
|
||||
],
|
||||
value: () => 'gpt-4o',
|
||||
options: VISION_MODEL_OPTIONS,
|
||||
value: () => 'gpt-5.2',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
@@ -87,3 +101,92 @@ export const VisionBlock: BlockConfig<VisionResponse> = {
|
||||
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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
|
||||
import { EnrichBlock } from '@/blocks/blocks/enrich'
|
||||
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
|
||||
import { ExaBlock } from '@/blocks/blocks/exa'
|
||||
import { FileBlock, FileV2Block } from '@/blocks/blocks/file'
|
||||
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
|
||||
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
|
||||
import { FirefliesBlock } from '@/blocks/blocks/fireflies'
|
||||
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
|
||||
import { FunctionBlock } from '@/blocks/blocks/function'
|
||||
import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook'
|
||||
import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github'
|
||||
@@ -45,7 +45,7 @@ import { GoogleFormsBlock } from '@/blocks/blocks/google_forms'
|
||||
import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
|
||||
import { GoogleMapsBlock } from '@/blocks/blocks/google_maps'
|
||||
import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets'
|
||||
import { GoogleSlidesBlock } from '@/blocks/blocks/google_slides'
|
||||
import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides'
|
||||
import { GoogleVaultBlock } from '@/blocks/blocks/google_vault'
|
||||
import { GrafanaBlock } from '@/blocks/blocks/grafana'
|
||||
import { GrainBlock } from '@/blocks/blocks/grain'
|
||||
@@ -95,11 +95,11 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive'
|
||||
import { PolymarketBlock } from '@/blocks/blocks/polymarket'
|
||||
import { PostgreSQLBlock } from '@/blocks/blocks/postgresql'
|
||||
import { PostHogBlock } from '@/blocks/blocks/posthog'
|
||||
import { PulseBlock } from '@/blocks/blocks/pulse'
|
||||
import { PulseBlock, PulseV2Block } from '@/blocks/blocks/pulse'
|
||||
import { QdrantBlock } from '@/blocks/blocks/qdrant'
|
||||
import { RDSBlock } from '@/blocks/blocks/rds'
|
||||
import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ReductoBlock } from '@/blocks/blocks/reducto'
|
||||
import { ReductoBlock, ReductoV2Block } from '@/blocks/blocks/reducto'
|
||||
import { ResendBlock } from '@/blocks/blocks/resend'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
|
||||
@@ -125,11 +125,11 @@ import { StagehandBlock } from '@/blocks/blocks/stagehand'
|
||||
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
|
||||
import { StarterBlock } from '@/blocks/blocks/starter'
|
||||
import { StripeBlock } from '@/blocks/blocks/stripe'
|
||||
import { SttBlock } from '@/blocks/blocks/stt'
|
||||
import { SttBlock, SttV2Block } from '@/blocks/blocks/stt'
|
||||
import { SupabaseBlock } from '@/blocks/blocks/supabase'
|
||||
import { TavilyBlock } from '@/blocks/blocks/tavily'
|
||||
import { TelegramBlock } from '@/blocks/blocks/telegram'
|
||||
import { TextractBlock } from '@/blocks/blocks/textract'
|
||||
import { TextractBlock, TextractV2Block } from '@/blocks/blocks/textract'
|
||||
import { ThinkingBlock } from '@/blocks/blocks/thinking'
|
||||
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
|
||||
import { TranslateBlock } from '@/blocks/blocks/translate'
|
||||
@@ -140,7 +140,7 @@ import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
|
||||
import { TypeformBlock } from '@/blocks/blocks/typeform'
|
||||
import { VariablesBlock } from '@/blocks/blocks/variables'
|
||||
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
|
||||
import { VisionBlock } from '@/blocks/blocks/vision'
|
||||
import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision'
|
||||
import { WaitBlock } from '@/blocks/blocks/wait'
|
||||
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
|
||||
import { WebflowBlock } from '@/blocks/blocks/webflow'
|
||||
@@ -194,8 +194,10 @@ export const registry: Record<string, BlockConfig> = {
|
||||
exa: ExaBlock,
|
||||
file: FileBlock,
|
||||
file_v2: FileV2Block,
|
||||
file_v3: FileV3Block,
|
||||
firecrawl: FirecrawlBlock,
|
||||
fireflies: FirefliesBlock,
|
||||
fireflies_v2: FirefliesV2Block,
|
||||
function: FunctionBlock,
|
||||
generic_webhook: GenericWebhookBlock,
|
||||
github: GitHubBlock,
|
||||
@@ -214,6 +216,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
google_sheets: GoogleSheetsBlock,
|
||||
google_sheets_v2: GoogleSheetsV2Block,
|
||||
google_slides: GoogleSlidesBlock,
|
||||
google_slides_v2: GoogleSlidesV2Block,
|
||||
google_vault: GoogleVaultBlock,
|
||||
grafana: GrafanaBlock,
|
||||
grain: GrainBlock,
|
||||
@@ -269,10 +272,12 @@ export const registry: Record<string, BlockConfig> = {
|
||||
postgresql: PostgreSQLBlock,
|
||||
posthog: PostHogBlock,
|
||||
pulse: PulseBlock,
|
||||
pulse_v2: PulseV2Block,
|
||||
qdrant: QdrantBlock,
|
||||
rds: RDSBlock,
|
||||
reddit: RedditBlock,
|
||||
reducto: ReductoBlock,
|
||||
reducto_v2: ReductoV2Block,
|
||||
resend: ResendBlock,
|
||||
response: ResponseBlock,
|
||||
router: RouterBlock,
|
||||
@@ -300,10 +305,12 @@ export const registry: Record<string, BlockConfig> = {
|
||||
starter: StarterBlock,
|
||||
stripe: StripeBlock,
|
||||
stt: SttBlock,
|
||||
stt_v2: SttV2Block,
|
||||
supabase: SupabaseBlock,
|
||||
tavily: TavilyBlock,
|
||||
telegram: TelegramBlock,
|
||||
textract: TextractBlock,
|
||||
textract_v2: TextractV2Block,
|
||||
thinking: ThinkingBlock,
|
||||
tinybird: TinybirdBlock,
|
||||
translate: TranslateBlock,
|
||||
@@ -316,6 +323,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
video_generator: VideoGeneratorBlock,
|
||||
video_generator_v2: VideoGeneratorV2Block,
|
||||
vision: VisionBlock,
|
||||
vision_v2: VisionV2Block,
|
||||
wait: WaitBlock,
|
||||
wealthbox: WealthboxBlock,
|
||||
webflow: WebflowBlock,
|
||||
|
||||
@@ -9,7 +9,8 @@ export type PrimitiveValueType =
|
||||
| 'boolean'
|
||||
| 'json'
|
||||
| 'array'
|
||||
| 'files'
|
||||
| 'file'
|
||||
| 'file[]'
|
||||
| 'any'
|
||||
|
||||
export type BlockCategory = 'blocks' | 'tools' | 'triggers'
|
||||
|
||||
@@ -137,7 +137,7 @@ export class BlockExecutor {
|
||||
normalizedOutput = this.normalizeOutput(output)
|
||||
}
|
||||
|
||||
if (ctx.includeFileBase64 && containsUserFileWithMetadata(normalizedOutput)) {
|
||||
if (containsUserFileWithMetadata(normalizedOutput)) {
|
||||
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
|
||||
requestId: ctx.metadata.requestId,
|
||||
executionId: ctx.executionId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@sim/testing/mocks/executor'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
@@ -9,8 +10,13 @@ import { executeTool } from '@/tools'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => ({
|
||||
validateUrlWithDNS: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetTool = vi.mocked(getTool)
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
const mockValidateUrlWithDNS = vi.mocked(validateUrlWithDNS)
|
||||
|
||||
describe('ApiBlockHandler', () => {
|
||||
let handler: ApiBlockHandler
|
||||
@@ -63,6 +69,12 @@ describe('ApiBlockHandler', () => {
|
||||
// Reset mocks using vi
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: '93.184.216.34',
|
||||
originalHostname: 'example.com',
|
||||
})
|
||||
|
||||
// Set up mockGetTool to return the mockApiTool
|
||||
mockGetTool.mockImplementation((toolId) => {
|
||||
if (toolId === 'http_request') {
|
||||
@@ -130,8 +142,13 @@ describe('ApiBlockHandler', () => {
|
||||
it('should throw error for invalid URL format (no protocol)', async () => {
|
||||
const inputs = { url: 'example.com/api' }
|
||||
|
||||
mockValidateUrlWithDNS.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'url must be a valid URL',
|
||||
})
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
'Invalid URL: "example.com/api" - URL must include protocol (try "https://example.com/api")'
|
||||
'url must be a valid URL'
|
||||
)
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -139,8 +156,13 @@ describe('ApiBlockHandler', () => {
|
||||
it('should throw error for generally invalid URL format', async () => {
|
||||
const inputs = { url: 'htp:/invalid-url' }
|
||||
|
||||
mockValidateUrlWithDNS.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'url must use https:// protocol',
|
||||
})
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
/^Invalid URL: "htp:\/invalid-url" - URL must include protocol/
|
||||
'url must use https:// protocol'
|
||||
)
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { BlockType, HTTP } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
@@ -41,16 +42,9 @@ export class ApiBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!urlToValidate.match(/^https?:\/\//i)) {
|
||||
throw new Error(
|
||||
`Invalid URL: "${urlToValidate}" - URL must include protocol (try "https://${urlToValidate}")`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(urlToValidate)
|
||||
} catch (e: any) {
|
||||
throw new Error(`Invalid URL format: "${urlToValidate}" - ${e.message}`)
|
||||
const urlValidation = await validateUrlWithDNS(urlToValidate, 'url')
|
||||
if (!urlValidation.isValid) {
|
||||
throw new Error(urlValidation.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user