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

export const dynamic = 'force-dynamic'

const logger = createLogger('SimFileManageAPI')

const EXT_TO_MIME: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.json': 'application/json',
'.csv': 'text/csv',
'.xml': 'application/xml',
'.yaml': 'application/x-yaml',
'.yml': 'application/x-yaml',
}

function inferContentType(fileName: string, explicitType?: string): string {
if (explicitType) return explicitType
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
return EXT_TO_MIME[ext] || 'text/plain'
}

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 fileId = body.fileId as string | undefined
const content = body.content as string | undefined
const contentType = body.contentType as string | undefined
const append = Boolean(body.append)

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

if (fileName && !fileId) {
const existing = await getWorkspaceFileByName(workspaceId, fileName)

if (existing) {
let finalContent: string
if (append) {
const existingBuffer = await downloadWorkspaceFile(existing)
finalContent = existingBuffer.toString('utf-8') + content
} else {
finalContent = content ?? ''
}

const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)

logger.info('File overwritten by name', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
append,
})

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

const mimeType = inferContentType(fileName, contentType)
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
)

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: result.url },
})
}

if (fileId) {
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json(
{ success: false, error: `File with ID "${fileId}" not found` },
{ status: 404 }
)
}

let finalContent: string
if (append) {
const existingBuffer = await downloadWorkspaceFile(fileRecord)
const existingContent = existingBuffer.toString('utf-8')
finalContent = existingContent + content
} else {
finalContent = content ?? ''
}

const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, fileId, userId, fileBuffer)

logger.info('Sim file written', {
fileId,
name: fileRecord.name,
size: fileBuffer.length,
append,
})

return NextResponse.json({
success: true,
data: { id: fileId, name: fileRecord.name, size: fileBuffer.length },
})
}

return NextResponse.json(
{
success: false,
error: 'Either fileName (to create) or fileId (to update) is required',
},
{ status: 400 }
)
}

default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}. Supported: write` },
{ status: 400 }
)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Sim file operation failed', { operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
106 changes: 93 additions & 13 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,24 @@ 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, or write workspace resource files. Writing by name creates the file if it does not exist, or overwrites it if it does. Use append mode to add content to existing files.',
docsLink: 'https://docs.sim.ai/tools/file',
category: 'tools',
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' },
],
value: () => 'file_parser_v3',
},
{
id: 'file',
title: 'Files',
Expand All @@ -265,7 +275,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 @@ -274,22 +285,70 @@ 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) — overwrites if exists',
condition: { field: 'operation', value: 'file_write' },
},
{
id: 'fileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
placeholder: 'Existing file ID to update',
condition: { 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: 'append',
title: 'Append',
type: 'switch' as SubBlockType,
condition: { 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',
},
],
tools: {
access: ['file_parser_v3'],
access: ['file_parser_v3', 'file_write'],
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,
fileId: params.fileId,
content: params.content,
contentType: params.contentType,
append: Boolean(params.append),
workspaceId: params._context?.workspaceId,
}
}

Comment thread
TheodoreSpeaks marked this conversation as resolved.
const fileInput = params.fileInput
if (!fileInput) {
logger.error('No file input provided')
throw new Error('File input is required')
}

// First, try to normalize as file objects (handles JSON strings from advanced mode)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const normalizedFiles = normalizeFileInput(fileInput)
if (normalizedFiles) {
const filePaths = resolveFilePathsFromInput(normalizedFiles)
Expand All @@ -304,7 +363,6 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
}
}

// If normalization fails, treat as direct URL string
if (typeof fileInput === 'string' && fileInput.trim()) {
return {
filePath: fileInput.trim(),
Expand All @@ -321,17 +379,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 or write)' },
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)' },
fileId: { type: 'string', description: 'ID of an existing file to update (write)' },
content: { type: 'string', description: 'File content to write' },
contentType: { type: 'string', description: 'MIME content type for write' },
append: { type: 'string', description: 'Whether to append content (write)' },
},
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)',
},
},
}
46 changes: 46 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 @@ -278,6 +278,52 @@ export async function fileExistsInWorkspace(
}
}

/**
* Look up a single active workspace file by its original name.
* Returns the record if found, or null otherwise.
*/
export async function getWorkspaceFileByName(
workspaceId: string,
fileName: string
): Promise<WorkspaceFileRecord | null> {
try {
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,
}
} catch (error) {
logger.error(`Failed to get workspace file by name "${fileName}":`, error)
return null
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}

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