Skip to content
151 changes: 151 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import {
downloadWorkspaceFile,
getWorkspaceFileByName,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('FileManageAPI')

export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}

const { searchParams } = new URL(request.url)
const userId = auth.userId || searchParams.get('userId')

if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}

let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 })
}

const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}

const operation = body.operation as string

try {
switch (operation) {
case 'write': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined
const contentType = body.contentType as string | undefined

if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for write operation' },
{ status: 400 }
)
}

if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for write operation' },
{ status: 400 }
)
}

const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
)
Comment thread
TheodoreSpeaks marked this conversation as resolved.

logger.info('File created', {
fileId: result.id,
name: fileName,
size: fileBuffer.length,
})

return NextResponse.json({
success: true,
data: {
id: result.id,
name: result.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(result.url),
},
})
}

case 'append': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined

if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for append operation' },
{ status: 400 }
)
}

if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for append operation' },
{ status: 400 }
)
}

const existing = await getWorkspaceFileByName(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
{ status: 404 }
)
}

const existingBuffer = await downloadWorkspaceFile(existing)
const finalContent = existingBuffer.toString('utf-8') + content
const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

logger.info('File appended', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
})
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated

return NextResponse.json({
success: true,
data: {
id: existing.id,
name: existing.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(existing.path),
},
})
}

default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}. Supported: write, append` },
{ status: 400 }
)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('File operation failed', { operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
125 changes: 114 additions & 11 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,16 +250,27 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
export const FileV3Block: BlockConfig<FileParserV3Output> = {
type: 'file_v3',
name: 'File',
description: 'Read and parse multiple files',
description: 'Read and write workspace files',
longDescription:
'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.',
'Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.',
docsLink: 'https://docs.sim.ai/tools/file',
category: 'tools',
integrationType: IntegrationType.FileStorage,
tags: ['document-processing'],
bgColor: '#40916C',
icon: DocumentIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown' as SubBlockType,
options: [
{ label: 'Read', id: 'file_parser_v3' },
{ label: 'Write', id: 'file_write' },
{ label: 'Append', id: 'file_append' },
],
value: () => 'file_parser_v3',
},
{
id: 'file',
title: 'Files',
Expand All @@ -270,7 +281,8 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
multiple: true,
mode: 'basic',
maxSize: 100,
required: true,
required: { field: 'operation', value: 'file_parser_v3' },
condition: { field: 'operation', value: 'file_parser_v3' },
},
{
id: 'fileUrl',
Expand All @@ -279,15 +291,84 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
canonicalParamId: 'fileInput',
placeholder: 'https://example.com/document.pdf',
mode: 'advanced',
required: true,
required: { field: 'operation', value: 'file_parser_v3' },
condition: { field: 'operation', value: 'file_parser_v3' },
},
{
id: 'fileName',
title: 'File Name',
type: 'short-input' as SubBlockType,
placeholder: 'File name (e.g., data.csv)',
condition: { field: 'operation', value: 'file_write' },
required: { field: 'operation', value: 'file_write' },
},
{
id: 'content',
title: 'Content',
type: 'long-input' as SubBlockType,
placeholder: 'File content to write...',
condition: { field: 'operation', value: 'file_write' },
required: { field: 'operation', value: 'file_write' },
},
{
id: 'contentType',
title: 'Content Type',
type: 'short-input' as SubBlockType,
placeholder: 'text/plain (auto-detected from extension)',
condition: { field: 'operation', value: 'file_write' },
mode: 'advanced',
},
{
id: 'appendFileName',
title: 'File',
type: 'dropdown' as SubBlockType,
placeholder: 'Select a workspace file...',
condition: { field: 'operation', value: 'file_append' },
required: { field: 'operation', value: 'file_append' },
options: [],
fetchOptions: async () => {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []
const response = await fetch(`/api/workspaces/${workspaceId}/files`)
const data = await response.json()
if (!data.success || !data.files) return []
return data.files.map((f: { name: string }) => ({ label: f.name, id: f.name }))
},
},
{
id: 'appendContent',
title: 'Content',
type: 'long-input' as SubBlockType,
placeholder: 'Content to append...',
condition: { field: 'operation', value: 'file_append' },
required: { field: 'operation', value: 'file_append' },
},
],
tools: {
access: ['file_parser_v3'],
access: ['file_parser_v3', 'file_write', 'file_append'],
config: {
tool: () => 'file_parser_v3',
tool: (params) => params.operation || 'file_parser_v3',
params: (params) => {
// Use canonical 'fileInput' param directly
const operation = params.operation || 'file_parser_v3'

if (operation === 'file_write') {
return {
fileName: params.fileName,
content: params.content,
contentType: params.contentType,
workspaceId: params._context?.workspaceId,
}
}

if (operation === 'file_append') {
return {
fileName: params.appendFileName,
content: params.appendContent,
workspaceId: params._context?.workspaceId,
}
}

Comment thread
TheodoreSpeaks marked this conversation as resolved.
const fileInput = params.fileInput
if (!fileInput) {
logger.error('No file input provided')
Expand Down Expand Up @@ -326,17 +407,39 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
},
},
inputs: {
fileInput: { type: 'json', description: 'File input (canonical param)' },
fileType: { type: 'string', description: 'File type' },
operation: { type: 'string', description: 'Operation to perform (read, write, or append)' },
fileInput: { type: 'json', description: 'File input for read (canonical param)' },
fileType: { type: 'string', description: 'File type for read' },
fileName: { type: 'string', description: 'Name for a new file (write)' },
content: { type: 'string', description: 'File content to write' },
contentType: { type: 'string', description: 'MIME content type for write' },
appendFileName: { type: 'string', description: 'Name of existing file to append to' },
appendContent: { type: 'string', description: 'Content to append to file' },
},
outputs: {
files: {
type: 'file[]',
description: 'Parsed files as UserFile objects',
description: 'Parsed files as UserFile objects (read)',
},
combinedContent: {
type: 'string',
description: 'All file contents merged into a single text string',
description: 'All file contents merged into a single text string (read)',
},
id: {
type: 'string',
description: 'File ID (write)',
},
name: {
type: 'string',
description: 'File name (write)',
},
size: {
type: 'number',
description: 'File size in bytes (write)',
},
url: {
type: 'string',
description: 'URL to access the file (write)',
},
},
}
42 changes: 42 additions & 0 deletions apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,48 @@ export async function fileExistsInWorkspace(
}
}

/**
* Look up a single active workspace file by its original name.
* Returns the record if found, or null if no matching file exists.
* Throws on DB errors so callers can distinguish "not found" from "lookup failed."
*/
export async function getWorkspaceFileByName(
workspaceId: string,
fileName: string
): Promise<WorkspaceFileRecord | null> {
const files = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)

if (files.length === 0) return null

const { getServePathPrefix } = await import('@/lib/uploads')
const pathPrefix = getServePathPrefix()

const file = files[0]
return {
id: file.id,
workspaceId: file.workspaceId || workspaceId,
name: file.originalName,
key: file.key,
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
size: file.size,
type: file.contentType,
uploadedBy: file.userId,
deletedAt: file.deletedAt,
uploadedAt: file.uploadedAt,
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}

/**
* List all files for a workspace
*/
Expand Down
Loading
Loading