diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts new file mode 100644 index 00000000000..ba5b4366803 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -0,0 +1,153 @@ +import path from 'node:path' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import JSZip from 'jszip' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { fileExportContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { StorageContext } from '@/lib/uploads/config' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { verifyFileAccess } from '@/app/api/files/authorization' + +const logger = createLogger('FilesExportAPI') + +const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) +const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) +const VIEW_URL_RE = + /\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi +const MAX_EMBEDDED_IMAGES = 50 + +function isMarkdown(originalName: string, contentType: string): boolean { + if (MARKDOWN_MIME_TYPES.has(contentType)) return true + const ext = originalName.split('.').pop()?.toLowerCase() ?? '' + return MARKDOWN_EXTENSIONS.has(ext) +} + +function safeFilename(name: string): string { + return path + .basename(name) + .replace(/["\\]/g, '_') + .replace(/[\r\n\t]/g, '') +} + +function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { + if (!existing.has(preferred)) return preferred + const ext = path.extname(preferred) + const base = path.basename(preferred, ext) + const short = `${base}_${imageId.slice(0, 8)}${ext}` + if (!existing.has(short)) return short + return `${base}_${imageId}${ext}` +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const parsed = await parseRequest(fileExportContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = authResult.userId + + const record = await getFileMetadataById(id) + if (!record) { + logger.warn('File not found by ID', { id }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const hasAccess = await verifyFileAccess(record.key, userId) + if (!hasAccess) { + logger.warn('Unauthorized file export attempt', { id, userId }) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (!isMarkdown(record.originalName, record.contentType)) { + const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + } + + const mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + }) + let mdContent = mdBuffer.toString('utf-8') + + const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice( + 0, + MAX_EMBEDDED_IMAGES + ) + + logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length }) + + const fetchResults = await Promise.allSettled( + imageIds.map(async (imageId) => { + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return null + const imgHasAccess = await verifyFileAccess(imgRecord.key, userId) + if (!imgHasAccess) return null + const imgBuffer = await downloadFile({ + key: imgRecord.key, + context: imgRecord.context as StorageContext, + }) + return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer } + }) + ) + + const assetMap = new Map() + const usedFilenames = new Set() + + for (let i = 0; i < fetchResults.length; i++) { + const result = fetchResults[i] + if (result.status === 'rejected') { + logger.warn('Failed to fetch asset for export', { + imageId: imageIds[i], + error: toError(result.reason).message, + }) + continue + } + if (!result.value) continue + const { imageId, originalName, buffer } = result.value + const preferred = safeFilename(originalName) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + usedFilenames.add(filename) + assetMap.set(imageId, { filename, buffer }) + } + + for (const [imageId, asset] of assetMap) { + const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const replacement = `./assets/${asset.filename}` + mdContent = mdContent.replace( + new RegExp(`/api/files/view/${escapedId}`, 'g'), + () => replacement + ) + } + + const zip = new JSZip() + zip.file(safeFilename(record.originalName), mdContent) + const assetsFolder = zip.folder('assets')! + for (const { filename, buffer } of assetMap.values()) { + assetsFolder.file(filename, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) + + return new NextResponse(new Uint8Array(zipBuffer), { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${zipName}"`, + 'Content-Length': String(zipBuffer.length), + }, + }) + } +) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 01048b92950..3880e587bcc 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -7,7 +7,7 @@ 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 { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFileByName, updateWorkspaceFileContent, uploadWorkspaceFile, @@ -91,7 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const existingBuffer = await downloadWorkspaceFile(existing) + 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) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 0a7e4b6020c..a2e4c029d1d 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -7,7 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceFile, - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { @@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - const buffer = await downloadWorkspaceFile(fileRecord) + const buffer = await fetchWorkspaceFileBuffer(fileRecord) return new Response(new Uint8Array(buffer), { status: 200, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index a6d54e8983f..7324c915c20 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -8,7 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { validateMermaidSource } from '@/lib/mermaid/validate' -import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -62,7 +62,7 @@ export const GET = withRouteHandler( let buffer: Buffer try { - buffer = await downloadWorkspaceFile(fileRecord) + buffer = await fetchWorkspaceFileBuffer(fileRecord) } catch (err) { logger.error('Failed to download file for compiled check', { fileId, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index 815d8eb4f6f..c30d0e9723f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -52,7 +52,7 @@ export const GET = withRouteHandler( let buffer: Buffer try { - buffer = await downloadWorkspaceFile(fileRecord) + buffer = await fetchWorkspaceFileBuffer(fileRecord) } catch (err) { logger.error('Failed to download file for style extraction', { fileId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index c599a313c2a..b172d8bc5d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -28,10 +28,10 @@ import { } from '@/components/emcn' import { File as FilesIcon } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' +import { triggerFileDownload } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { - downloadWorkspaceFile, formatFileSize, getFileExtension, getMimeTypeFromExtension, @@ -475,7 +475,7 @@ export function Files() { const handleDownload = useCallback(async (file: WorkspaceFileRecord) => { try { - await downloadWorkspaceFile(file) + await triggerFileDownload(file) } catch (err) { logger.error('Failed to download file:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index e60f2bebd21..46f78e1f89e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -19,11 +19,8 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' -import { - downloadWorkspaceFile, - getFileExtension, - getMimeTypeFromExtension, -} from '@/lib/uploads/utils/file-utils' +import { triggerFileDownload } from '@/lib/uploads/client/download' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { workflowBorderColor } from '@/lib/workspaces/colors' import { FileViewer, @@ -422,7 +419,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps) const handleDownload = async () => { if (!file) return try { - await downloadWorkspaceFile(file) + await triggerFileDownload(file) } catch (err) { fileLogger.error('Failed to download file:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index a6d56d389c6..e6c74e8bf0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -26,7 +26,7 @@ const ACTION_LABELS: Record = { } as const function isAutoDismissable(n: Notification): boolean { - return !!n.workflowId + return !!n.workflowId && n.action?.type !== 'unlock-workflow' } function NotificationCountdownRing({ onPause }: { onPause: () => void }) { @@ -99,7 +99,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat break case 'unlock-workflow': window.dispatchEvent(new CustomEvent('unlock-workflow')) - break + return default: logger.warn('Unknown action type', { notificationId, actionType: action.type }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 1ebde480ef5..a2019e537eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -349,7 +349,7 @@ const WorkflowContent = React.memo( const workflowReadOnly = workflowLocked && !sandbox const canvasOpacityClass = isCanvasReady ? workflowReadOnly - ? 'opacity-60' + ? 'opacity-75' : 'opacity-100' : 'opacity-0' @@ -1251,13 +1251,16 @@ const WorkflowContent = React.memo( return findLockedAncestorFolder(workflowMetadata?.folderId, folders)?.name ?? null }, [workflowFolderLocked, workflowMetadata?.folderId, folders]) - const prevCanAdminRef = useRef(effectivePermissions.canAdmin) + const prevIsAdminRef = useRef( + workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin + ) const prevLockSignatureRef = useRef(null) useEffect(() => { if (!isWorkflowReady) return - const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin - prevCanAdminRef.current = effectivePermissions.canAdmin + const isAdmin = workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin + const canAdminChanged = prevIsAdminRef.current !== isAdmin + prevIsAdminRef.current = isAdmin const lockSignature = workflowReadOnly ? workflowRowLocked @@ -1273,8 +1276,6 @@ const WorkflowContent = React.memo( if (workflowReadOnly) { if (lockNotificationIdRef.current) return - - const isAdmin = effectivePermissions.canAdmin const isFolderInherited = workflowFolderLocked && !workflowRowLocked const message = isFolderInherited ? inheritedLockFolderName @@ -1304,6 +1305,7 @@ const WorkflowContent = React.memo( inheritedLockFolderName, isWorkflowReady, effectivePermissions.canAdmin, + workspacePermissions, addNotification, activeWorkflowId, clearLockNotification, @@ -2182,6 +2184,18 @@ const WorkflowContent = React.memo( [screenToFlowPosition, handleToolbarDrop] ) + const onDropLocked = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + if (!event.dataTransfer?.types.includes('application/json')) return + const message = effectivePermissions.canAdmin + ? 'Unlock the workflow to add blocks.' + : 'This workflow is locked. Ask an admin to unlock it.' + addNotification({ level: 'info', message, workflowId: activeWorkflowId || undefined }) + }, + [effectivePermissions.canAdmin, addNotification, activeWorkflowId] + ) + const handleCanvasPointerMove = useCallback( (event: React.PointerEvent) => { const position = screenToFlowPosition({ @@ -4072,8 +4086,16 @@ const WorkflowContent = React.memo( nodeTypes={nodeTypes} edgeTypes={edgeTypes} onMouseDown={handleCanvasMouseDown} - onDrop={effectivePermissions.canEdit ? onDrop : undefined} - onDragOver={effectivePermissions.canEdit ? onDragOver : undefined} + onDrop={ + effectivePermissions.canEdit + ? onDrop + : workflowReadOnly + ? onDropLocked + : undefined + } + onDragOver={ + effectivePermissions.canEdit || workflowReadOnly ? onDragOver : undefined + } onInit={(instance) => { if (embedded) { return @@ -4176,7 +4198,7 @@ const WorkflowContent = React.memo( edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0 } onToggleLocked={handleContextToggleLocked} - canAdmin={effectivePermissions.canAdmin} + canAdmin={effectivePermissions.canAdmin && !workflowReadOnly} /> diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 8e70e8f26b1..0dd14fcc536 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -454,6 +454,10 @@ export const fileViewParamsSchema = z.object({ id: z.string().uuid('File ID must be a valid UUID'), }) +export const fileExportParamsSchema = z.object({ + id: z.string().uuid('File ID must be a valid UUID'), +}) + export const boxUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/box/upload', @@ -707,6 +711,13 @@ export const fileViewContract = defineRouteContract({ response: { mode: 'binary' }, }) +export const fileExportContract = defineRouteContract({ + method: 'GET', + path: '/api/files/export/[id]', + params: fileExportParamsSchema, + response: { mode: 'binary' }, +}) + export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput @@ -749,3 +760,4 @@ export type GetMultipartPartUrlsBody = z.output export type FileServeQuery = ContractQueryInput export type FileViewParams = ContractParamsInput +export type FileExportParams = ContractParamsInput diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 61cf5ed6219..8d6139d4c56 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getTableById, queryRows } from '@/lib/table/service' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, listWorkspaceFiles, @@ -44,7 +44,7 @@ async function resolveInputFiles( logger.warn('Total input size limit reached') break } - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) totalSize += buffer.length const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( record.type || '' diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index b6c2056fb33..d53f0c80158 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -8,7 +8,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { getServePathPrefix } from '@/lib/uploads' -import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -83,7 +83,7 @@ async function executeImport( } } - const buffer = await downloadWorkspaceFile(toFileRecord(row)) + const buffer = await fetchWorkspaceFileBuffer(toFileRecord(row)) const content = buffer.toString('utf-8') let parsed: unknown diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 43409613fb6..ecb9fdf08e2 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -148,7 +148,7 @@ export async function loadWorkspaceFileTextForPreview( try { const record = await getWorkspaceFile(workspaceId, fileId) if (!record) return undefined - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) return buffer.toString('utf-8') } catch (error) { logger.warn('Failed to load workspace file text for preview', { diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 23f7753cac5..5bbe8e3ddd8 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -9,7 +9,7 @@ import { import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { deleteWorkspaceFile, - downloadWorkspaceFile as downloadWsFile, + fetchWorkspaceFileBuffer as downloadWsFile, getWorkspaceFile, getWorkspaceFileByName, renameWorkspaceFile, diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index ec97898cc5c..e745da95678 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -10,7 +10,7 @@ import { import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { getServePathPrefix } from '@/lib/uploads' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, updateWorkspaceFileContent, uploadWorkspaceFile, @@ -94,7 +94,7 @@ export const generateImageServerTool: BaseServerTool ({ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, - downloadWorkspaceFile: mockDownloadWorkspaceFile, + fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, })) vi.mock('@/lib/table/service', () => ({ diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 64d3da5f762..22c807dfdba 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -58,7 +58,7 @@ import type { } from '@/lib/table/types' import { cancelWorkflowGroupRuns, triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, resolveWorkspaceFileReference, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { @@ -92,7 +92,7 @@ async function resolveWorkspaceFile( `File not found: "${fileReference}". Use glob("files/by-id/*/meta.json") to list canonical file IDs.` ) } - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) return { buffer, name: record.name, type: record.type } } diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts index b639f3f1fe4..bd979ee89b0 100644 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts @@ -10,7 +10,7 @@ import { CodeLanguage } from '@/lib/execution/languages' import { getTableById, queryRows } from '@/lib/table/service' import { getServePathPrefix } from '@/lib/uploads' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, getWorkspaceFile, @@ -100,7 +100,7 @@ async function collectSandboxFiles( logger.warn('Sandbox input total size limit reached, skipping remaining files') break } - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) totalSize += buffer.length const textContent = buffer.toString('utf-8') sandboxFiles.push({ diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index 115ad959496..f4326b32035 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -6,13 +6,13 @@ import { randomFillSync } from 'node:crypto' import { loggerMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -const { downloadWorkspaceFile } = vi.hoisted(() => ({ - downloadWorkspaceFile: vi.fn(), +const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({ + fetchWorkspaceFileBuffer: vi.fn(), })) vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, })) import { readFileRecord } from '@/lib/copilot/vfs/file-reader' @@ -37,7 +37,7 @@ describe('readFileRecord', () => { const largePng = await makeNoisePng(1800, 1800) expect(largePng.length).toBeGreaterThan(MAX_IMAGE_READ_BYTES) - downloadWorkspaceFile.mockResolvedValue(largePng) + fetchWorkspaceFileBuffer.mockResolvedValue(largePng) const result = await readFileRecord({ id: 'wf_large', diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 874d67ee7c2..ba464a4c945 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -11,7 +11,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { markSpanForError } from '@/lib/copilot/request/otel' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { isImageFileType } from '@/lib/uploads/utils/file-utils' // Lazy tracer (same pattern as lib/copilot/request/otel.ts). @@ -294,7 +294,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return { @@ -520,7 +520,7 @@ export class WorkspaceVFS { const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx') return null const ext: 'docx' | 'pptx' = rawExt - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null const json = JSON.stringify(summary, null, 2) diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts index c2fc4bd86e4..38ea0695863 100644 --- a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { SandboxBroker } from '@/lib/execution/sandbox/types' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -40,7 +40,7 @@ export const workspaceFileBroker: SandboxBroker { + const isMarkdown = + record.type === 'text/markdown' || + record.type === 'text/x-markdown' || + /\.(?:md|markdown)$/i.test(record.name) + + const url = isMarkdown + ? `/api/files/export/${encodeURIComponent(record.id)}` + : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`) + + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = objectUrl + a.download = + response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(objectUrl) +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 20780b71392..3fe38de8570 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -720,7 +720,7 @@ export async function getWorkspaceFile( /** * Download workspace file content */ -export async function downloadWorkspaceFile(fileRecord: WorkspaceFileRecord): Promise { +export async function fetchWorkspaceFileBuffer(fileRecord: WorkspaceFileRecord): Promise { logger.info(`Downloading workspace file: ${fileRecord.name}`) try { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index b625c22ea42..3dc0d75506b 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -845,24 +845,3 @@ export function getViewerUrl(fileKey: string, workspaceId?: string): string | nu return `/workspace/${resolvedWorkspaceId}/files/${fileKey}` } - -/** - * Downloads a workspace file to the user's device via the serve API. - * Fetches the file as a blob and triggers a browser download. - */ -export async function downloadWorkspaceFile(file: { key: string; name: string }): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}` - const response = await fetch(serveUrl, { cache: 'no-store' }) - if (!response.ok) { - throw new Error(`Failed to download file: ${response.statusText}`) - } - const blob = await response.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 07f0e43802b..34cbacb0f6e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 724, - zodRoutes: 724, + totalRoutes: 725, + zodRoutes: 725, nonZodRoutes: 0, } as const