mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
feat(tools): added sftp tool to accompany smtp and ssh tools (#2261)
This commit is contained in:
@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 32 32'
|
||||
width='32px'
|
||||
height='32px'
|
||||
>
|
||||
<path
|
||||
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
@@ -148,6 +149,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
slack: SlackIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sftp: SftpIcon,
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"sendgrid",
|
||||
"sentry",
|
||||
"serper",
|
||||
"sftp",
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
"slack",
|
||||
|
||||
188
apps/docs/content/docs/en/tools/sftp.mdx
Normal file
188
apps/docs/content/docs/en/tools/sftp.mdx
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: SFTP
|
||||
description: Transfer files via SFTP (SSH File Transfer Protocol)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="sftp"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[SFTP (SSH File Transfer Protocol)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) is a secure network protocol that enables you to upload, download, and manage files on remote servers. SFTP operates over SSH, making it ideal for automated, encrypted file transfers and remote file management within modern workflows.
|
||||
|
||||
With SFTP tools integrated into Sim, you can easily automate the movement of files between your AI agents and external systems or servers. This empowers your agents to manage critical data exchanges, backups, document generation, and remote system orchestration—all with robust security.
|
||||
|
||||
**Key functionality available via SFTP tools:**
|
||||
|
||||
- **Upload Files:** Seamlessly transfer files of any type from your workflow to a remote server, with support for both password and SSH private key authentication.
|
||||
- **Download Files:** Retrieve files from remote SFTP servers directly for processing, archiving, or further automation.
|
||||
- **List & Manage Files:** Enumerate directories, delete or create files and folders, and manage file system permissions remotely.
|
||||
- **Flexible Authentication:** Connect using either traditional passwords or SSH keys, with support for passphrases and permissions control.
|
||||
- **Large File Support:** Programmatically manage large file uploads and downloads, with built-in size limits for safety.
|
||||
|
||||
By integrating SFTP into Sim, you can automate secure file operations as part of any workflow, whether it’s data collection, reporting, remote system maintenance, or dynamic content exchange between platforms.
|
||||
|
||||
The sections below describe the key SFTP tools available:
|
||||
|
||||
- **sftp_upload:** Upload one or more files to a remote server.
|
||||
- **sftp_download:** Download files from a remote server to your workflow.
|
||||
- **sftp_list:** List directory contents on a remote SFTP server.
|
||||
- **sftp_delete:** Delete files or directories from a remote server.
|
||||
- **sftp_create:** Create new files on a remote SFTP server.
|
||||
- **sftp_mkdir:** Create new directories remotely.
|
||||
|
||||
See the tool documentation below for detailed input and output parameters for each operation.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `sftp_upload`
|
||||
|
||||
Upload files to a remote SFTP server
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | SFTP server hostname or IP address |
|
||||
| `port` | number | Yes | SFTP server port \(default: 22\) |
|
||||
| `username` | string | Yes | SFTP username |
|
||||
| `password` | string | No | Password for authentication \(if not using private key\) |
|
||||
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
|
||||
| `passphrase` | string | No | Passphrase for encrypted private key |
|
||||
| `remotePath` | string | Yes | Destination directory on the remote server |
|
||||
| `files` | file[] | No | Files to upload |
|
||||
| `fileContent` | string | No | Direct file content to upload \(for text files\) |
|
||||
| `fileName` | string | No | File name when using direct content |
|
||||
| `overwrite` | boolean | No | Whether to overwrite existing files \(default: true\) |
|
||||
| `permissions` | string | No | File permissions \(e.g., 0644\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the upload was successful |
|
||||
| `uploadedFiles` | json | Array of uploaded file details \(name, remotePath, size\) |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
### `sftp_download`
|
||||
|
||||
Download a file from a remote SFTP server
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | SFTP server hostname or IP address |
|
||||
| `port` | number | Yes | SFTP server port \(default: 22\) |
|
||||
| `username` | string | Yes | SFTP username |
|
||||
| `password` | string | No | Password for authentication \(if not using private key\) |
|
||||
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
|
||||
| `passphrase` | string | No | Passphrase for encrypted private key |
|
||||
| `remotePath` | string | Yes | Path to the file on the remote server |
|
||||
| `encoding` | string | No | Output encoding: utf-8 for text, base64 for binary \(default: utf-8\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the download was successful |
|
||||
| `fileName` | string | Name of the downloaded file |
|
||||
| `content` | string | File content \(text or base64 encoded\) |
|
||||
| `size` | number | File size in bytes |
|
||||
| `encoding` | string | Content encoding \(utf-8 or base64\) |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
### `sftp_list`
|
||||
|
||||
List files and directories on a remote SFTP server
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | SFTP server hostname or IP address |
|
||||
| `port` | number | Yes | SFTP server port \(default: 22\) |
|
||||
| `username` | string | Yes | SFTP username |
|
||||
| `password` | string | No | Password for authentication \(if not using private key\) |
|
||||
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
|
||||
| `passphrase` | string | No | Passphrase for encrypted private key |
|
||||
| `remotePath` | string | Yes | Directory path on the remote server |
|
||||
| `detailed` | boolean | No | Include detailed file information \(size, permissions, modified date\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the operation was successful |
|
||||
| `path` | string | Directory path that was listed |
|
||||
| `entries` | json | Array of directory entries with name, type, size, permissions, modifiedAt |
|
||||
| `count` | number | Number of entries in the directory |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
### `sftp_delete`
|
||||
|
||||
Delete a file or directory on a remote SFTP server
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | SFTP server hostname or IP address |
|
||||
| `port` | number | Yes | SFTP server port \(default: 22\) |
|
||||
| `username` | string | Yes | SFTP username |
|
||||
| `password` | string | No | Password for authentication \(if not using private key\) |
|
||||
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
|
||||
| `passphrase` | string | No | Passphrase for encrypted private key |
|
||||
| `remotePath` | string | Yes | Path to the file or directory to delete |
|
||||
| `recursive` | boolean | No | Delete directories recursively |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the deletion was successful |
|
||||
| `deletedPath` | string | Path that was deleted |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
### `sftp_mkdir`
|
||||
|
||||
Create a directory on a remote SFTP server
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | SFTP server hostname or IP address |
|
||||
| `port` | number | Yes | SFTP server port \(default: 22\) |
|
||||
| `username` | string | Yes | SFTP username |
|
||||
| `password` | string | No | Password for authentication \(if not using private key\) |
|
||||
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
|
||||
| `passphrase` | string | No | Passphrase for encrypted private key |
|
||||
| `remotePath` | string | Yes | Path for the new directory |
|
||||
| `recursive` | boolean | No | Create parent directories if they do not exist |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the directory was created successfully |
|
||||
| `createdPath` | string | Path of the created directory |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `sftp`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
188
apps/sim/app/api/tools/sftp/delete/route.ts
Normal file
188
apps/sim/app/api/tools/sftp/delete/route.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
createSftpConnection,
|
||||
getFileType,
|
||||
getSftp,
|
||||
isPathSafe,
|
||||
sanitizePath,
|
||||
sftpIsDirectory,
|
||||
} from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpDeleteAPI')
|
||||
|
||||
const DeleteSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive().default(22),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().nullish(),
|
||||
privateKey: z.string().nullish(),
|
||||
passphrase: z.string().nullish(),
|
||||
remotePath: z.string().min(1, 'Remote path is required'),
|
||||
recursive: z.boolean().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* Recursively deletes a directory and all its contents
|
||||
*/
|
||||
async function deleteRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
|
||||
const entries = await new Promise<Array<{ filename: string; attrs: any }>>((resolve, reject) => {
|
||||
sftp.readdir(dirPath, (err, list) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(list)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === '.' || entry.filename === '..') continue
|
||||
|
||||
const entryPath = `${dirPath}/${entry.filename}`
|
||||
const entryType = getFileType(entry.attrs)
|
||||
|
||||
if (entryType === 'directory') {
|
||||
await deleteRecursive(sftp, entryPath)
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.unlink(entryPath, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.rmdir(dirPath, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized SFTP delete attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP delete request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
|
||||
logger.info(`[${requestId}] Deleting ${remotePath} (recursive: ${params.recursive})`)
|
||||
|
||||
const isDir = await sftpIsDirectory(sftp, remotePath)
|
||||
|
||||
if (isDir) {
|
||||
if (params.recursive) {
|
||||
await deleteRecursive(sftp, remotePath)
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.rmdir(remotePath, (err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('not empty')) {
|
||||
reject(
|
||||
new Error(
|
||||
'Directory is not empty. Use recursive: true to delete non-empty directories.'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.unlink(remotePath, (err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('No such file')) {
|
||||
reject(new Error(`File not found: ${remotePath}`))
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted ${remotePath}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedPath: remotePath,
|
||||
message: `Successfully deleted ${remotePath}`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] SFTP delete failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP delete failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
149
apps/sim/app/api/tools/sftp/download/route.ts
Normal file
149
apps/sim/app/api/tools/sftp/download/route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import path from 'path'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpDownloadAPI')
|
||||
|
||||
const DownloadSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive().default(22),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().nullish(),
|
||||
privateKey: z.string().nullish(),
|
||||
passphrase: z.string().nullish(),
|
||||
remotePath: z.string().min(1, 'Remote path is required'),
|
||||
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized SFTP download attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP download request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const params = DownloadSchema.parse(body)
|
||||
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
|
||||
const stats = await new Promise<{ size: number }>((resolve, reject) => {
|
||||
sftp.stat(remotePath, (err, stats) => {
|
||||
if (err) {
|
||||
if (err.message.includes('No such file')) {
|
||||
reject(new Error(`File not found: ${remotePath}`))
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve(stats)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
if (stats.size > maxSize) {
|
||||
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Downloading file ${remotePath} (${stats.size} bytes)`)
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = sftp.createReadStream(remotePath)
|
||||
|
||||
readStream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
readStream.on('end', () => resolve())
|
||||
readStream.on('error', reject)
|
||||
})
|
||||
|
||||
const buffer = Buffer.concat(chunks)
|
||||
const fileName = path.basename(remotePath)
|
||||
|
||||
let content: string
|
||||
if (params.encoding === 'base64') {
|
||||
content = buffer.toString('base64')
|
||||
} else {
|
||||
content = buffer.toString('utf-8')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Downloaded ${fileName} (${buffer.length} bytes)`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fileName,
|
||||
content,
|
||||
size: buffer.length,
|
||||
encoding: params.encoding,
|
||||
message: `Successfully downloaded ${fileName}`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] SFTP download failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
156
apps/sim/app/api/tools/sftp/list/route.ts
Normal file
156
apps/sim/app/api/tools/sftp/list/route.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
createSftpConnection,
|
||||
getFileType,
|
||||
getSftp,
|
||||
isPathSafe,
|
||||
parsePermissions,
|
||||
sanitizePath,
|
||||
} from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpListAPI')
|
||||
|
||||
const ListSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive().default(22),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().nullish(),
|
||||
privateKey: z.string().nullish(),
|
||||
passphrase: z.string().nullish(),
|
||||
remotePath: z.string().min(1, 'Remote path is required'),
|
||||
detailed: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized SFTP list attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP list request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const params = ListSchema.parse(body)
|
||||
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
|
||||
logger.info(`[${requestId}] Listing directory ${remotePath}`)
|
||||
|
||||
const fileList = await new Promise<Array<{ filename: string; longname: string; attrs: any }>>(
|
||||
(resolve, reject) => {
|
||||
sftp.readdir(remotePath, (err, list) => {
|
||||
if (err) {
|
||||
if (err.message.includes('No such file')) {
|
||||
reject(new Error(`Directory not found: ${remotePath}`))
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve(list)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const entries = fileList
|
||||
.filter((item) => item.filename !== '.' && item.filename !== '..')
|
||||
.map((item) => {
|
||||
const entry: {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
size?: number
|
||||
permissions?: string
|
||||
modifiedAt?: string
|
||||
} = {
|
||||
name: item.filename,
|
||||
type: getFileType(item.attrs),
|
||||
}
|
||||
|
||||
if (params.detailed) {
|
||||
entry.size = item.attrs.size
|
||||
entry.permissions = parsePermissions(item.attrs.mode)
|
||||
if (item.attrs.mtime) {
|
||||
entry.modifiedAt = new Date(item.attrs.mtime * 1000).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Listed ${entries.length} entries in ${remotePath}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
path: remotePath,
|
||||
entries,
|
||||
count: entries.length,
|
||||
message: `Found ${entries.length} entries in ${remotePath}`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] SFTP list failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP list failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
168
apps/sim/app/api/tools/sftp/mkdir/route.ts
Normal file
168
apps/sim/app/api/tools/sftp/mkdir/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
createSftpConnection,
|
||||
getSftp,
|
||||
isPathSafe,
|
||||
sanitizePath,
|
||||
sftpExists,
|
||||
} from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpMkdirAPI')
|
||||
|
||||
const MkdirSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive().default(22),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().nullish(),
|
||||
privateKey: z.string().nullish(),
|
||||
passphrase: z.string().nullish(),
|
||||
remotePath: z.string().min(1, 'Remote path is required'),
|
||||
recursive: z.boolean().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates directory recursively (like mkdir -p)
|
||||
*/
|
||||
async function mkdirRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
|
||||
const parts = dirPath.split('/').filter(Boolean)
|
||||
let currentPath = dirPath.startsWith('/') ? '' : ''
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath = currentPath
|
||||
? `${currentPath}/${part}`
|
||||
: dirPath.startsWith('/')
|
||||
? `/${part}`
|
||||
: part
|
||||
|
||||
const exists = await sftpExists(sftp, currentPath)
|
||||
if (!exists) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.mkdir(currentPath, (err) => {
|
||||
if (err && !err.message.includes('already exists')) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized SFTP mkdir attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP mkdir request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const params = MkdirSchema.parse(body)
|
||||
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Creating directory ${remotePath} (recursive: ${params.recursive})`
|
||||
)
|
||||
|
||||
if (params.recursive) {
|
||||
await mkdirRecursive(sftp, remotePath)
|
||||
} else {
|
||||
const exists = await sftpExists(sftp, remotePath)
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directory already exists: ${remotePath}` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.mkdir(remotePath, (err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('No such file')) {
|
||||
reject(
|
||||
new Error(
|
||||
'Parent directory does not exist. Use recursive: true to create parent directories.'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully created directory ${remotePath}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
createdPath: remotePath,
|
||||
message: `Successfully created directory ${remotePath}`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] SFTP mkdir failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP mkdir failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
242
apps/sim/app/api/tools/sftp/upload/route.ts
Normal file
242
apps/sim/app/api/tools/sftp/upload/route.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import {
|
||||
createSftpConnection,
|
||||
getSftp,
|
||||
isPathSafe,
|
||||
sanitizeFileName,
|
||||
sanitizePath,
|
||||
sftpExists,
|
||||
} from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpUploadAPI')
|
||||
|
||||
const UploadSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive().default(22),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().nullish(),
|
||||
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(),
|
||||
fileContent: z.string().nullish(),
|
||||
fileName: z.string().nullish(),
|
||||
overwrite: z.boolean().default(true),
|
||||
permissions: z.string().nullish(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP upload request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const params = UploadSchema.parse(body)
|
||||
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasFiles = params.files && params.files.length > 0
|
||||
const hasDirectContent = params.fileContent && params.fileName
|
||||
|
||||
if (!hasFiles && !hasDirectContent) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either files or fileContent with fileName must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
const uploadedFiles: Array<{ name: string; remotePath: string; size: number }> = []
|
||||
|
||||
if (hasFiles) {
|
||||
const rawFiles = params.files!
|
||||
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
|
||||
|
||||
const totalSize = userFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
|
||||
if (totalSize > maxSize) {
|
||||
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
for (const file of userFiles) {
|
||||
try {
|
||||
logger.info(
|
||||
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
|
||||
)
|
||||
const buffer = await downloadFileFromStorage(file, requestId, logger)
|
||||
|
||||
const safeFileName = sanitizeFileName(file.name)
|
||||
const fullRemotePath = remotePath.endsWith('/')
|
||||
? `${remotePath}${safeFileName}`
|
||||
: `${remotePath}/${safeFileName}`
|
||||
|
||||
const sanitizedRemotePath = sanitizePath(fullRemotePath)
|
||||
|
||||
if (!params.overwrite) {
|
||||
const exists = await sftpExists(sftp, sanitizedRemotePath)
|
||||
if (exists) {
|
||||
logger.warn(`[${requestId}] File ${sanitizedRemotePath} already exists, skipping`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
|
||||
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
|
||||
})
|
||||
|
||||
writeStream.on('error', reject)
|
||||
writeStream.on('close', () => resolve())
|
||||
writeStream.end(buffer)
|
||||
})
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeFileName,
|
||||
remotePath: sanitizedRemotePath,
|
||||
size: buffer.length,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Uploaded ${safeFileName} to ${sanitizedRemotePath}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error)
|
||||
throw new Error(
|
||||
`Failed to upload file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDirectContent) {
|
||||
const safeFileName = sanitizeFileName(params.fileName!)
|
||||
const fullRemotePath = remotePath.endsWith('/')
|
||||
? `${remotePath}${safeFileName}`
|
||||
: `${remotePath}/${safeFileName}`
|
||||
|
||||
const sanitizedRemotePath = sanitizePath(fullRemotePath)
|
||||
|
||||
if (!params.overwrite) {
|
||||
const exists = await sftpExists(sftp, sanitizedRemotePath)
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File already exists and overwrite is disabled' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let content: Buffer
|
||||
try {
|
||||
content = Buffer.from(params.fileContent!, 'base64')
|
||||
const reEncoded = content.toString('base64')
|
||||
if (reEncoded !== params.fileContent) {
|
||||
content = Buffer.from(params.fileContent!, 'utf-8')
|
||||
}
|
||||
} catch {
|
||||
content = Buffer.from(params.fileContent!, 'utf-8')
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
|
||||
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
|
||||
})
|
||||
|
||||
writeStream.on('error', reject)
|
||||
writeStream.on('close', () => resolve())
|
||||
writeStream.end(content)
|
||||
})
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeFileName,
|
||||
remotePath: sanitizedRemotePath,
|
||||
size: content.length,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Uploaded direct content to ${sanitizedRemotePath}`)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] SFTP upload completed: ${uploadedFiles.length} file(s)`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
uploadedFiles,
|
||||
message: `Successfully uploaded ${uploadedFiles.length} file(s)`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] SFTP upload failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
275
apps/sim/app/api/tools/sftp/utils.ts
Normal file
275
apps/sim/app/api/tools/sftp/utils.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
|
||||
|
||||
const S_IFMT = 0o170000
|
||||
const S_IFDIR = 0o040000
|
||||
const S_IFREG = 0o100000
|
||||
const S_IFLNK = 0o120000
|
||||
|
||||
export interface SftpConnectionConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string | null
|
||||
privateKey?: string | null
|
||||
passphrase?: string | null
|
||||
timeout?: number
|
||||
keepaliveInterval?: number
|
||||
readyTimeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats SSH/SFTP errors with helpful troubleshooting context
|
||||
*/
|
||||
function formatSftpError(err: Error, config: { host: string; port: number }): Error {
|
||||
const errorMessage = err.message.toLowerCase()
|
||||
const { host, port } = config
|
||||
|
||||
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
|
||||
return new Error(
|
||||
`Connection refused to ${host}:${port}. ` +
|
||||
`Please verify: (1) SSH/SFTP server is running, ` +
|
||||
`(2) Port ${port} is correct, ` +
|
||||
`(3) Firewall allows connections.`
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
|
||||
return new Error(
|
||||
`Connection reset by ${host}:${port}. ` +
|
||||
`This usually means: (1) Wrong port number, ` +
|
||||
`(2) Server rejected the connection, ` +
|
||||
`(3) Network/firewall interrupted the connection.`
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
|
||||
return new Error(
|
||||
`Connection timed out to ${host}:${port}. ` +
|
||||
`Please verify: (1) Host is reachable, ` +
|
||||
`(2) No firewall is blocking the connection, ` +
|
||||
`(3) The SFTP server is responding.`
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
|
||||
return new Error(
|
||||
`Could not resolve hostname "${host}". Please verify the hostname or IP address is correct.`
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
|
||||
return new Error(
|
||||
`Authentication failed on ${host}:${port}. ` +
|
||||
`Please verify: (1) Username is correct, ` +
|
||||
`(2) Password or private key is valid, ` +
|
||||
`(3) User has SFTP access on the server.`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('key') &&
|
||||
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
|
||||
) {
|
||||
return new Error(
|
||||
`Invalid private key format. ` +
|
||||
`Please ensure you're using a valid OpenSSH private key ` +
|
||||
`(starts with "-----BEGIN" and ends with "-----END").`
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
|
||||
return new Error(
|
||||
`Host key verification issue for ${host}. ` +
|
||||
`This may be the first connection or the server's key has changed.`
|
||||
)
|
||||
}
|
||||
|
||||
return new Error(`SFTP connection to ${host}:${port} failed: ${err.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSH connection for SFTP using the provided configuration.
|
||||
* Uses ssh2 library defaults which align with OpenSSH standards.
|
||||
*/
|
||||
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
const port = config.port || 22
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasPassword = config.password && config.password.trim() !== ''
|
||||
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
|
||||
|
||||
if (!hasPassword && !hasPrivateKey) {
|
||||
reject(new Error('Authentication required. Please provide either a password or private key.'))
|
||||
return
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
if (config.readyTimeout !== undefined) {
|
||||
connectConfig.readyTimeout = config.readyTimeout
|
||||
}
|
||||
if (config.keepaliveInterval !== undefined) {
|
||||
connectConfig.keepaliveInterval = config.keepaliveInterval
|
||||
}
|
||||
|
||||
if (hasPrivateKey) {
|
||||
connectConfig.privateKey = config.privateKey!
|
||||
if (config.passphrase && config.passphrase.trim() !== '') {
|
||||
connectConfig.passphrase = config.passphrase
|
||||
}
|
||||
} else if (hasPassword) {
|
||||
connectConfig.password = config.password!
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
resolve(client)
|
||||
})
|
||||
|
||||
client.on('error', (err) => {
|
||||
reject(formatSftpError(err, { host, port }))
|
||||
})
|
||||
|
||||
try {
|
||||
client.connect(connectConfig)
|
||||
} catch (err) {
|
||||
reject(formatSftpError(err instanceof Error ? err : new Error(String(err)), { host, port }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets SFTP subsystem from SSH client
|
||||
*/
|
||||
export function getSftp(client: Client): Promise<SFTPWrapper> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
reject(new Error(`Failed to start SFTP session: ${err.message}`))
|
||||
} else {
|
||||
resolve(sftp)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a remote path to prevent path traversal attacks.
|
||||
* Removes null bytes, normalizes path separators, and collapses traversal sequences.
|
||||
* Based on OWASP Path Traversal prevention guidelines.
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
let sanitized = path
|
||||
sanitized = sanitized.replace(/\0/g, '')
|
||||
sanitized = decodeURIComponent(sanitized)
|
||||
sanitized = sanitized.replace(/\\/g, '/')
|
||||
sanitized = sanitized.replace(/\/+/g, '/')
|
||||
sanitized = sanitized.trim()
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a filename to prevent path traversal and injection attacks.
|
||||
* Removes directory traversal sequences, path separators, null bytes, and dangerous patterns.
|
||||
* Based on OWASP Input Validation Cheat Sheet recommendations.
|
||||
*/
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
let sanitized = fileName
|
||||
sanitized = sanitized.replace(/\0/g, '')
|
||||
|
||||
try {
|
||||
sanitized = decodeURIComponent(sanitized)
|
||||
} catch {
|
||||
// Keep original if decode fails (malformed encoding)
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/\.\.[/\\]?/g, '')
|
||||
sanitized = sanitized.replace(/[/\\]/g, '_')
|
||||
sanitized = sanitized.replace(/^\.+/, '')
|
||||
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
return sanitized || 'unnamed_file'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path doesn't contain traversal sequences.
|
||||
* Returns true if the path is safe, false if it contains potential traversal attacks.
|
||||
*/
|
||||
export function isPathSafe(path: string): boolean {
|
||||
const normalizedPath = path.replace(/\\/g, '/')
|
||||
|
||||
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(normalizedPath)
|
||||
if (decoded.includes('../') || decoded.includes('..\\')) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedPath.includes('\0')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses file permissions from mode bits to octal string representation.
|
||||
*/
|
||||
export function parsePermissions(mode: number): string {
|
||||
return `0${(mode & 0o777).toString(8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines file type from SFTP attributes mode bits.
|
||||
*/
|
||||
export function getFileType(attrs: Attributes): 'file' | 'directory' | 'symlink' | 'other' {
|
||||
const fileType = attrs.mode & S_IFMT
|
||||
|
||||
if (fileType === S_IFDIR) return 'directory'
|
||||
if (fileType === S_IFREG) return 'file'
|
||||
if (fileType === S_IFLNK) return 'symlink'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path exists on the SFTP server.
|
||||
*/
|
||||
export function sftpExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
sftp.stat(path, (err) => {
|
||||
resolve(!err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is a directory on the SFTP server.
|
||||
*/
|
||||
export function sftpIsDirectory(sftp: SFTPWrapper, path: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) {
|
||||
resolve(false)
|
||||
} else {
|
||||
resolve(getFileType(stats) === 'directory')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
306
apps/sim/blocks/blocks/sftp.ts
Normal file
306
apps/sim/blocks/blocks/sftp.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { SftpIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { SftpUploadResult } from '@/tools/sftp/types'
|
||||
|
||||
export const SftpBlock: BlockConfig<SftpUploadResult> = {
|
||||
type: 'sftp',
|
||||
name: 'SFTP',
|
||||
description: 'Transfer files via SFTP (SSH File Transfer Protocol)',
|
||||
longDescription:
|
||||
'Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.',
|
||||
docsLink: 'https://docs.sim.ai/tools/sftp',
|
||||
category: 'tools',
|
||||
bgColor: '#2D3748',
|
||||
icon: SftpIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Upload Files', id: 'sftp_upload' },
|
||||
{ label: 'Create File', id: 'sftp_create' },
|
||||
{ label: 'Download File', id: 'sftp_download' },
|
||||
{ label: 'List Directory', id: 'sftp_list' },
|
||||
{ label: 'Delete File/Directory', id: 'sftp_delete' },
|
||||
{ label: 'Create Directory', id: 'sftp_mkdir' },
|
||||
],
|
||||
value: () => 'sftp_upload',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'host',
|
||||
title: 'SFTP Host',
|
||||
type: 'short-input',
|
||||
placeholder: 'sftp.example.com or 192.168.1.100',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'port',
|
||||
title: 'SFTP Port',
|
||||
type: 'short-input',
|
||||
placeholder: '22',
|
||||
value: () => '22',
|
||||
},
|
||||
{
|
||||
id: 'username',
|
||||
title: 'Username',
|
||||
type: 'short-input',
|
||||
placeholder: 'sftp-user',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'authMethod',
|
||||
title: 'Authentication Method',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Password', id: 'password' },
|
||||
{ label: 'Private Key', id: 'privateKey' },
|
||||
],
|
||||
value: () => 'password',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Password',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Your SFTP password',
|
||||
condition: { field: 'authMethod', value: 'password' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'privateKey',
|
||||
title: 'Private Key',
|
||||
type: 'code',
|
||||
placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...',
|
||||
condition: { field: 'authMethod', value: 'privateKey' },
|
||||
},
|
||||
{
|
||||
id: 'passphrase',
|
||||
title: 'Passphrase',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Passphrase for encrypted key (optional)',
|
||||
condition: { field: 'authMethod', value: 'privateKey' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'remotePath',
|
||||
title: 'Remote Path',
|
||||
type: 'short-input',
|
||||
placeholder: '/home/user/uploads',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uploadFiles',
|
||||
title: 'Files to Upload',
|
||||
type: 'file-upload',
|
||||
canonicalParamId: 'files',
|
||||
placeholder: 'Select files to upload',
|
||||
mode: 'basic',
|
||||
multiple: true,
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'sftp_upload' },
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'File Reference',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'files',
|
||||
placeholder: 'Reference file from previous block (e.g., {{block_name.file}})',
|
||||
mode: 'advanced',
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'sftp_upload' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'overwrite',
|
||||
title: 'Overwrite Existing Files',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'permissions',
|
||||
title: 'File Permissions',
|
||||
type: 'short-input',
|
||||
placeholder: '0644',
|
||||
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'fileName',
|
||||
title: 'File Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'filename.txt',
|
||||
condition: { field: 'operation', value: 'sftp_create' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'fileContent',
|
||||
title: 'File Content',
|
||||
type: 'code',
|
||||
placeholder: 'Text content to write to the file',
|
||||
condition: { field: 'operation', value: 'sftp_create' },
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'encoding',
|
||||
title: 'Output Encoding',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'UTF-8 (Text)', id: 'utf-8' },
|
||||
{ label: 'Base64 (Binary)', id: 'base64' },
|
||||
],
|
||||
value: () => 'utf-8',
|
||||
condition: { field: 'operation', value: 'sftp_download' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'detailed',
|
||||
title: 'Show Detailed Info',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
condition: { field: 'operation', value: 'sftp_list' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'recursive',
|
||||
title: 'Recursive Delete',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
condition: { field: 'operation', value: 'sftp_delete' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'mkdirRecursive',
|
||||
title: 'Create Parent Directories',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
condition: { field: 'operation', value: 'sftp_mkdir' },
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: ['sftp_upload', 'sftp_download', 'sftp_list', 'sftp_delete', 'sftp_mkdir'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
const operation = params.operation || 'sftp_upload'
|
||||
if (operation === 'sftp_create') return 'sftp_upload'
|
||||
return operation
|
||||
},
|
||||
params: (params) => {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: params.host,
|
||||
port:
|
||||
typeof params.port === 'string' ? Number.parseInt(params.port, 10) : params.port || 22,
|
||||
username: params.username,
|
||||
}
|
||||
|
||||
if (params.authMethod === 'privateKey') {
|
||||
connectionConfig.privateKey = params.privateKey
|
||||
if (params.passphrase) {
|
||||
connectionConfig.passphrase = params.passphrase
|
||||
}
|
||||
} else {
|
||||
connectionConfig.password = params.password
|
||||
}
|
||||
|
||||
const operation = params.operation || 'sftp_upload'
|
||||
|
||||
switch (operation) {
|
||||
case 'sftp_upload':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
files: params.files,
|
||||
overwrite: params.overwrite !== false,
|
||||
permissions: params.permissions,
|
||||
}
|
||||
case 'sftp_create':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
fileContent: params.fileContent,
|
||||
fileName: params.fileName,
|
||||
overwrite: params.overwrite !== false,
|
||||
permissions: params.permissions,
|
||||
}
|
||||
case 'sftp_download':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
encoding: params.encoding || 'utf-8',
|
||||
}
|
||||
case 'sftp_list':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
detailed: params.detailed || false,
|
||||
}
|
||||
case 'sftp_delete':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
recursive: params.recursive || false,
|
||||
}
|
||||
case 'sftp_mkdir':
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
recursive: params.mkdirRecursive !== false,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
...connectionConfig,
|
||||
remotePath: params.remotePath,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'SFTP operation to perform' },
|
||||
host: { type: 'string', description: 'SFTP server hostname' },
|
||||
port: { type: 'number', description: 'SFTP server port' },
|
||||
username: { type: 'string', description: 'SFTP username' },
|
||||
authMethod: { type: 'string', description: 'Authentication method (password or privateKey)' },
|
||||
password: { type: 'string', description: 'Password for authentication' },
|
||||
privateKey: { type: 'string', description: 'Private key for authentication' },
|
||||
passphrase: { type: 'string', description: 'Passphrase for encrypted key' },
|
||||
remotePath: { type: 'string', description: 'Remote path on the SFTP server' },
|
||||
files: { type: 'array', description: 'Files to upload (UserFile array)' },
|
||||
fileContent: { type: 'string', description: 'Direct content to upload' },
|
||||
fileName: { type: 'string', description: 'File name for direct content' },
|
||||
overwrite: { type: 'boolean', description: 'Overwrite existing files' },
|
||||
permissions: { type: 'string', description: 'File permissions (e.g., 0644)' },
|
||||
encoding: { type: 'string', description: 'Output encoding for download' },
|
||||
detailed: { type: 'boolean', description: 'Show detailed file info' },
|
||||
recursive: { type: 'boolean', description: 'Recursive delete' },
|
||||
mkdirRecursive: { type: 'boolean', description: 'Create parent directories' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the operation was successful' },
|
||||
uploadedFiles: { type: 'json', description: 'Array of uploaded file details' },
|
||||
fileName: { type: 'string', description: 'Downloaded file name' },
|
||||
content: { type: 'string', description: 'Downloaded file content' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
entries: { type: 'json', description: 'Directory listing entries' },
|
||||
count: { type: 'number', description: 'Number of entries' },
|
||||
deletedPath: { type: 'string', description: 'Path that was deleted' },
|
||||
createdPath: { type: 'string', description: 'Directory that was created' },
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
error: { type: 'string', description: 'Error message if operation failed' },
|
||||
},
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const SmtpBlock: BlockConfig<SmtpSendMailResult> = {
|
||||
'Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.',
|
||||
docsLink: 'https://docs.sim.ai/tools/smtp',
|
||||
category: 'tools',
|
||||
bgColor: '#4A5568',
|
||||
bgColor: '#2D3748',
|
||||
icon: SmtpIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ import { SearchBlock } from '@/blocks/blocks/search'
|
||||
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
|
||||
import { SentryBlock } from '@/blocks/blocks/sentry'
|
||||
import { SerperBlock } from '@/blocks/blocks/serper'
|
||||
import { SftpBlock } from '@/blocks/blocks/sftp'
|
||||
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
|
||||
import { ShopifyBlock } from '@/blocks/blocks/shopify'
|
||||
import { SlackBlock } from '@/blocks/blocks/slack'
|
||||
@@ -240,6 +241,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
shopify: ShopifyBlock,
|
||||
slack: SlackBlock,
|
||||
smtp: SmtpBlock,
|
||||
sftp: SftpBlock,
|
||||
ssh: SSHBlock,
|
||||
stagehand: StagehandBlock,
|
||||
stagehand_agent: StagehandAgentBlock,
|
||||
|
||||
@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 32 32'
|
||||
width='32px'
|
||||
height='32px'
|
||||
>
|
||||
<path
|
||||
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -996,6 +996,13 @@ import {
|
||||
updateProjectTool,
|
||||
} from '@/tools/sentry'
|
||||
import { serperSearchTool } from '@/tools/serper'
|
||||
import {
|
||||
sftpDeleteTool,
|
||||
sftpDownloadTool,
|
||||
sftpListTool,
|
||||
sftpMkdirTool,
|
||||
sftpUploadTool,
|
||||
} from '@/tools/sftp'
|
||||
import {
|
||||
sharepointAddListItemTool,
|
||||
sharepointCreateListTool,
|
||||
@@ -1372,6 +1379,11 @@ export const tools: Record<string, ToolConfig> = {
|
||||
sendgrid_delete_template: sendGridDeleteTemplateTool,
|
||||
sendgrid_create_template_version: sendGridCreateTemplateVersionTool,
|
||||
smtp_send_mail: smtpSendMailTool,
|
||||
sftp_upload: sftpUploadTool,
|
||||
sftp_download: sftpDownloadTool,
|
||||
sftp_list: sftpListTool,
|
||||
sftp_delete: sftpDeleteTool,
|
||||
sftp_mkdir: sftpMkdirTool,
|
||||
ssh_execute_command: sshExecuteCommandTool,
|
||||
ssh_execute_script: sshExecuteScriptTool,
|
||||
ssh_check_command_exists: sshCheckCommandExistsTool,
|
||||
|
||||
107
apps/sim/tools/sftp/delete.ts
Normal file
107
apps/sim/tools/sftp/delete.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { SftpDeleteParams, SftpDeleteResult } from '@/tools/sftp/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const sftpDeleteTool: ToolConfig<SftpDeleteParams, SftpDeleteResult> = {
|
||||
id: 'sftp_delete',
|
||||
name: 'SFTP Delete',
|
||||
description: 'Delete a file or directory on a remote SFTP server',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server hostname or IP address',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server port (default: 22)',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Password for authentication (if not using private key)',
|
||||
},
|
||||
privateKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Private key for authentication (OpenSSH format)',
|
||||
},
|
||||
passphrase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Passphrase for encrypted private key',
|
||||
},
|
||||
remotePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Path to the file or directory to delete',
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Delete directories recursively',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/sftp/delete',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
host: params.host,
|
||||
port: Number(params.port) || 22,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
remotePath: params.remotePath,
|
||||
recursive: params.recursive || false,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'SFTP delete failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
deletedPath: data.deletedPath,
|
||||
message: data.message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the deletion was successful' },
|
||||
deletedPath: { type: 'string', description: 'Path that was deleted' },
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
},
|
||||
}
|
||||
113
apps/sim/tools/sftp/download.ts
Normal file
113
apps/sim/tools/sftp/download.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { SftpDownloadParams, SftpDownloadResult } from '@/tools/sftp/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const sftpDownloadTool: ToolConfig<SftpDownloadParams, SftpDownloadResult> = {
|
||||
id: 'sftp_download',
|
||||
name: 'SFTP Download',
|
||||
description: 'Download a file from a remote SFTP server',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server hostname or IP address',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server port (default: 22)',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Password for authentication (if not using private key)',
|
||||
},
|
||||
privateKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Private key for authentication (OpenSSH format)',
|
||||
},
|
||||
passphrase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Passphrase for encrypted private key',
|
||||
},
|
||||
remotePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Path to the file on the remote server',
|
||||
},
|
||||
encoding: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Output encoding: utf-8 for text, base64 for binary (default: utf-8)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/sftp/download',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
host: params.host,
|
||||
port: Number(params.port) || 22,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
remotePath: params.remotePath,
|
||||
encoding: params.encoding || 'utf-8',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'SFTP download failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
fileName: data.fileName,
|
||||
content: data.content,
|
||||
size: data.size,
|
||||
encoding: data.encoding,
|
||||
message: data.message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the download was successful' },
|
||||
fileName: { type: 'string', description: 'Name of the downloaded file' },
|
||||
content: { type: 'string', description: 'File content (text or base64 encoded)' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
encoding: { type: 'string', description: 'Content encoding (utf-8 or base64)' },
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
},
|
||||
}
|
||||
18
apps/sim/tools/sftp/index.ts
Normal file
18
apps/sim/tools/sftp/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { sftpDeleteTool } from './delete'
|
||||
export { sftpDownloadTool } from './download'
|
||||
export { sftpListTool } from './list'
|
||||
export { sftpMkdirTool } from './mkdir'
|
||||
export type {
|
||||
SftpConnectionConfig,
|
||||
SftpDeleteParams,
|
||||
SftpDeleteResult,
|
||||
SftpDownloadParams,
|
||||
SftpDownloadResult,
|
||||
SftpListParams,
|
||||
SftpListResult,
|
||||
SftpMkdirParams,
|
||||
SftpMkdirResult,
|
||||
SftpUploadParams,
|
||||
SftpUploadResult,
|
||||
} from './types'
|
||||
export { sftpUploadTool } from './upload'
|
||||
114
apps/sim/tools/sftp/list.ts
Normal file
114
apps/sim/tools/sftp/list.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { SftpListParams, SftpListResult } from '@/tools/sftp/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const sftpListTool: ToolConfig<SftpListParams, SftpListResult> = {
|
||||
id: 'sftp_list',
|
||||
name: 'SFTP List Directory',
|
||||
description: 'List files and directories on a remote SFTP server',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server hostname or IP address',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server port (default: 22)',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Password for authentication (if not using private key)',
|
||||
},
|
||||
privateKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Private key for authentication (OpenSSH format)',
|
||||
},
|
||||
passphrase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Passphrase for encrypted private key',
|
||||
},
|
||||
remotePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Directory path on the remote server',
|
||||
},
|
||||
detailed: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Include detailed file information (size, permissions, modified date)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/sftp/list',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
host: params.host,
|
||||
port: Number(params.port) || 22,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
remotePath: params.remotePath,
|
||||
detailed: params.detailed || false,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'SFTP list failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
path: data.path,
|
||||
entries: data.entries,
|
||||
count: data.count,
|
||||
message: data.message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the operation was successful' },
|
||||
path: { type: 'string', description: 'Directory path that was listed' },
|
||||
entries: {
|
||||
type: 'json',
|
||||
description: 'Array of directory entries with name, type, size, permissions, modifiedAt',
|
||||
},
|
||||
count: { type: 'number', description: 'Number of entries in the directory' },
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
},
|
||||
}
|
||||
107
apps/sim/tools/sftp/mkdir.ts
Normal file
107
apps/sim/tools/sftp/mkdir.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { SftpMkdirParams, SftpMkdirResult } from '@/tools/sftp/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const sftpMkdirTool: ToolConfig<SftpMkdirParams, SftpMkdirResult> = {
|
||||
id: 'sftp_mkdir',
|
||||
name: 'SFTP Create Directory',
|
||||
description: 'Create a directory on a remote SFTP server',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server hostname or IP address',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server port (default: 22)',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Password for authentication (if not using private key)',
|
||||
},
|
||||
privateKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Private key for authentication (OpenSSH format)',
|
||||
},
|
||||
passphrase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Passphrase for encrypted private key',
|
||||
},
|
||||
remotePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Path for the new directory',
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Create parent directories if they do not exist',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/sftp/mkdir',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
host: params.host,
|
||||
port: Number(params.port) || 22,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
remotePath: params.remotePath,
|
||||
recursive: params.recursive || false,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'SFTP mkdir failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
createdPath: data.createdPath,
|
||||
message: data.message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the directory was created successfully' },
|
||||
createdPath: { type: 'string', description: 'Path of the created directory' },
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
},
|
||||
}
|
||||
99
apps/sim/tools/sftp/types.ts
Normal file
99
apps/sim/tools/sftp/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface SftpConnectionConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
// Upload file params
|
||||
export interface SftpUploadParams extends SftpConnectionConfig {
|
||||
remotePath: string
|
||||
files?: any[] // UserFile array from file-upload component
|
||||
fileContent?: string // Direct content for text files
|
||||
fileName?: string // File name when using direct content
|
||||
overwrite?: boolean
|
||||
permissions?: string
|
||||
}
|
||||
|
||||
export interface SftpUploadResult extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
uploadedFiles?: Array<{
|
||||
name: string
|
||||
remotePath: string
|
||||
size: number
|
||||
}>
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Download file params
|
||||
export interface SftpDownloadParams extends SftpConnectionConfig {
|
||||
remotePath: string
|
||||
encoding?: 'utf-8' | 'base64'
|
||||
}
|
||||
|
||||
export interface SftpDownloadResult extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
fileName?: string
|
||||
content?: string
|
||||
size?: number
|
||||
encoding?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
// List directory params
|
||||
export interface SftpListParams extends SftpConnectionConfig {
|
||||
remotePath: string
|
||||
detailed?: boolean
|
||||
}
|
||||
|
||||
export interface SftpListResult extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
path?: string
|
||||
entries?: Array<{
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
size?: number
|
||||
permissions?: string
|
||||
modifiedAt?: string
|
||||
}>
|
||||
count?: number
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file params
|
||||
export interface SftpDeleteParams extends SftpConnectionConfig {
|
||||
remotePath: string
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface SftpDeleteResult extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
deletedPath?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Mkdir params
|
||||
export interface SftpMkdirParams extends SftpConnectionConfig {
|
||||
remotePath: string
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface SftpMkdirResult extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
createdPath?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
138
apps/sim/tools/sftp/upload.ts
Normal file
138
apps/sim/tools/sftp/upload.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { SftpUploadParams, SftpUploadResult } from '@/tools/sftp/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const sftpUploadTool: ToolConfig<SftpUploadParams, SftpUploadResult> = {
|
||||
id: 'sftp_upload',
|
||||
name: 'SFTP Upload',
|
||||
description: 'Upload files to a remote SFTP server',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server hostname or IP address',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP server port (default: 22)',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SFTP username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Password for authentication (if not using private key)',
|
||||
},
|
||||
privateKey: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Private key for authentication (OpenSSH format)',
|
||||
},
|
||||
passphrase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Passphrase for encrypted private key',
|
||||
},
|
||||
remotePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Destination directory on the remote server',
|
||||
},
|
||||
files: {
|
||||
type: 'file[]',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Files to upload',
|
||||
},
|
||||
fileContent: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Direct file content to upload (for text files)',
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'File name when using direct content',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Whether to overwrite existing files (default: true)',
|
||||
},
|
||||
permissions: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'File permissions (e.g., 0644)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/sftp/upload',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
host: params.host,
|
||||
port: Number(params.port) || 22,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
remotePath: params.remotePath,
|
||||
files: params.files,
|
||||
fileContent: params.fileContent,
|
||||
fileName: params.fileName,
|
||||
overwrite: params.overwrite !== false,
|
||||
permissions: params.permissions,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'SFTP upload failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
uploadedFiles: data.uploadedFiles,
|
||||
message: data.message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the upload was successful' },
|
||||
uploadedFiles: {
|
||||
type: 'json',
|
||||
description: 'Array of uploaded file details (name, remotePath, size)',
|
||||
},
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user