Files
sim/apps/sim/app/api/tools/file/manage/route.ts
T
Waleed 8d7bbbc670 chore(utils): migrate to shared random/ID utilities and add enforcement linting (#4623)
* chore(utils): migrate to shared random/ID utilities and add enforcement linting

- Replace all Math.random(), crypto.randomUUID(), crypto.randomBytes(), nanoid, and uuid usages with shared @sim/utils/random and @sim/utils/id helpers across 72 files
- Add new @sim/utils exports: deepClone, omit, filterUndefined (object), truncate (string), backoffWithJitter, parseRetryAfter (retry), getErrorMessage (errors)
- Sweep all getErrorMessage, sleep, deepClone callsites across 500+ files to use shared utilities
- Add Biome noRestrictedImports rule to catch nanoid, uuid, and crypto named imports at lint time
- Add scripts/check-utils-enforcement.ts to catch Math.random and crypto.* global property access
- Add check:utils script to package.json

* chore(utils): replace deepClone wrapper with structuredClone built-in

deepClone() was a one-line wrapper around structuredClone(), which is
universally available in Node 17+ and all modern browsers. Removing the
abstraction reduces indirection and means contributors don't need to
learn a project-specific name for a well-known built-in.

- Remove deepClone from packages/utils/src/object.ts and index.ts
- Replace all 17 call sites with structuredClone() directly
- Update check:utils script suggestion text
- Update CLAUDE.md and global.md docs

* fix(utils): add missing biome noRestrictedImports rule and correct truncate docs

- Add noRestrictedImports to biome.json under style — bans nanoid and uuid
  package imports at lint time (crypto.randomUUID/randomBytes are caught by
  the check:utils grep script which handles global property access)
- Correct truncate() TSDoc and parameter name: sliceLength makes it clear
  that total output length is sliceLength + suffix.length, matching the
  behavior all callers were already written to expect

* fix(utils): add missing getErrorMessage imports at 4 call sites

The sweep agents added getErrorMessage calls without the corresponding
import in 4 files, causing test failures. Added the missing imports.

* fix(utils): fix build errors from getErrorMessage sweep and retry.ts Turbopack issue

- Fix retry.ts cross-file import: Turbopack cannot resolve './random.js' for
  internal package imports; inline the jitter crypto call directly
- Add missing getErrorMessage imports to 32 files where the sweep added calls
  without the corresponding import (caught by type-check and test runs)
- Remove accidental getErrorMessage import from crowdstrike/query/route.ts
  which has its own domain-specific getErrorMessage for parsing CrowdStrike's
  JSON error format
- Fix use-sub-block-value.ts type error from structuredClone narrowing:
  add 'as T' cast at emitValue callsite (safe — valueCopy is always a
  structural copy of newValue)

* fix(tools): use toError in crowdstrike catch block instead of local getErrorMessage

The catch block was calling the local getErrorMessage function which
parses CrowdStrike API JSON responses, not JavaScript Error objects.
Use toError(error).message to correctly extract the message from a
caught value in this context.
2026-05-15 17:31:27 -07:00

360 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { fileManageContract } from '@/lib/api/contracts/tools/file'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
resolveWorkspaceFileReference,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('FileManageAPI')
const workspaceFileToUserFile = (file: Awaited<ReturnType<typeof getWorkspaceFile>>) => {
if (!file) return null
return {
id: file.id,
name: file.name,
url: ensureAbsoluteUrl(file.path),
size: file.size,
type: file.type,
key: file.key,
context: 'workspace',
}
}
const fileInputToUserFile = (fileInput: unknown) => {
if (!fileInput || typeof fileInput !== 'object' || Array.isArray(fileInput)) return null
const record = fileInput as Record<string, unknown>
const id =
typeof record.id === 'string'
? record.id.trim()
: typeof record.fileId === 'string'
? record.fileId.trim()
: ''
// Objects with ids are resolved through workspace metadata. This fallback is for
// picker/upload values that only carry storage fields.
if (id) return null
const key = typeof record.key === 'string' ? record.key.trim() : ''
const path = typeof record.path === 'string' ? record.path.trim() : ''
const url = typeof record.url === 'string' ? record.url.trim() : ''
const fileUrl =
url || path || (key ? `/api/files/serve/${encodeURIComponent(key)}?context=workspace` : '')
if (!fileUrl && !key) return null
return {
id: key || fileUrl,
name:
typeof record.name === 'string' && record.name.trim() ? record.name.trim() : 'workspace-file',
url: fileUrl ? ensureAbsoluteUrl(fileUrl) : '',
size: typeof record.size === 'number' ? record.size : 0,
type:
typeof record.type === 'string' && record.type.trim()
? record.type.trim()
: 'application/octet-stream',
key,
context: 'workspace',
}
}
const normalizeFileIdList = (value: unknown): string[] => {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
try {
return normalizeFileIdList(JSON.parse(trimmed))
} catch {
return [trimmed]
}
}
if (!Array.isArray(value)) return []
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((id) => id.length > 0)
}
const extractUserFilesFromInput = (fileInput: unknown) => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []
return inputs
.map((input) => fileInputToUserFile(input))
.filter((file): file is NonNullable<ReturnType<typeof fileInputToUserFile>> => Boolean(file))
}
const extractFileIdsFromInput = (fileInput: unknown): string[] => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []
return inputs
.flatMap((input) => {
if (typeof input === 'string') return normalizeFileIdList(input)
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>
if (typeof record.id === 'string') return normalizeFileIdList(record.id)
if (typeof record.fileId === 'string') return normalizeFileIdList(record.fileId)
}
return []
})
.filter((id) => id.length > 0)
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}
const parsed = await parseRequest(fileManageContract, request, {})
if (!parsed.success) return parsed.response
const { query, body } = parsed.data
const userId = auth.userId || query.userId
if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}
const workspaceId = body.workspaceId || query.workspaceId
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}
try {
await assertActiveWorkspaceAccess(workspaceId, userId)
switch (body.operation) {
case 'get': {
const { fileId, fileInput } = body
const selectedFileId =
fileId ||
(fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput)
? (() => {
const obj = fileInput as Record<string, unknown>
return typeof obj.id === 'string'
? obj.id
: typeof obj.fileId === 'string'
? obj.fileId
: ''
})()
: '')
if (!selectedFileId) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const file = await getWorkspaceFile(workspaceId, selectedFileId)
if (!file) {
return NextResponse.json(
{ success: false, error: `File not found: "${selectedFileId}"` },
{ status: 404 }
)
}
logger.info('File retrieved', {
fileId: file.id,
name: file.name,
})
return NextResponse.json({
success: true,
data: {
file: workspaceFileToUserFile(file),
},
})
}
case 'read': {
const { fileId, fileInput } = body
const selectedFileIds = Array.isArray(fileId)
? fileId.map((id) => id.trim()).filter(Boolean)
: fileId
? normalizeFileIdList(fileId)
: extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const files = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !files[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}
const userFiles = files
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)
logger.info('Files retrieved', {
count: userFiles.length,
fileIds: userFiles.map((file) => file.id),
})
return NextResponse.json({
success: true,
data: {
file: userFiles[0],
files: userFiles,
},
})
}
case 'write': {
const { fileName, content, contentType } = body
const { folderSegments, leafName } = splitWorkspaceFilePath(fileName)
const folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: folderSegments,
})
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
leafName,
mimeType,
{ folderId }
)
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 'move': {
const { fileId, targetFolder } = body
const pathSegments = targetFolder.trim()
? targetFolder
.trim()
.split('/')
.map((s) => s.trim())
.filter(Boolean)
: []
const targetFolderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments,
})
const moveResult = await performMoveWorkspaceFileItems({
workspaceId,
userId,
fileIds: [fileId],
targetFolderId,
})
if (!moveResult.success) {
return NextResponse.json(
{ success: false, error: moveResult.error },
{
status:
moveResult.errorCode === 'conflict'
? 409
: moveResult.errorCode === 'not_found'
? 404
: 400,
}
)
}
logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' })
return NextResponse.json({
success: true,
data: { fileId, targetFolder: targetFolder || '(root)' },
})
}
case 'append': {
const { fileName, content } = body
const existing = await resolveWorkspaceFileReference(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
{ status: 404 }
)
}
const lockKey = `file-append:${workspaceId}:${existing.id}`
const lockValue = `${Date.now()}-${generateShortId()}`
const acquired = await acquireLock(lockKey, lockValue, 30)
if (!acquired) {
return NextResponse.json(
{ success: false, error: 'File is busy, please retry' },
{ status: 409 }
)
}
try {
const existingBuffer = await fetchWorkspaceFileBuffer(existing)
const finalContent = existingBuffer.toString('utf-8') + content
const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
logger.info('File appended', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: existing.id,
name: existing.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(existing.path),
},
})
} finally {
await releaseLock(lockKey, lockValue)
}
}
}
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error('File operation failed', { operation: body.operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
})