feat(files): export markdown as zip with embedded images (#4413)

* feat(files): export markdown as zip with embedded images in assets/ folder

* fix(files): sanitize zip filenames, fix storage context cast, cap embedded image count

* fix(files): fix race condition in asset filename deduplication

* chore(files): remove extraneous comments from export route

* fix(files): sanitize markdown zip entry name, full uuid fallback for filename dedup

* fix(files): extract userId const to satisfy TypeScript narrowing in async callback

* fix(files): use replacer function to prevent $ special-char corruption in markdown URL rewrite

* fix(files): wrap zip buffer in Uint8Array for NextResponse BodyInit compatibility

* lock behavior

* fix(workflow): track resolved isAdmin in prevIsAdminRef to prevent stale lock notification

When workspacePermissions loads asynchronously, prevCanAdminRef (which only
tracked effectivePermissions.canAdmin) would not detect the change, causing
the early-return guard to skip rebuilding the notification with the correct
unlock-button visibility. Track the same resolved value (workspacePermissions
?.viewer?.isAdmin ?? effectivePermissions.canAdmin) that is actually used to
build the notification.

* refactor(uploads): rename server fn to fetchWorkspaceFileBuffer, move client download to uploads/client/download.ts as triggerFileDownload

* more lock updates

* fix(tests): update mocks for fetchWorkspaceFileBuffer rename
This commit is contained in:
Waleed
2026-05-02 20:07:48 -07:00
committed by GitHub
parent 79214497c5
commit a479e88718
26 changed files with 268 additions and 79 deletions
+153
View File
@@ -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<string>, 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<string, { filename: string; buffer: Buffer }>()
const usedFilenames = new Set<string>()
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),
},
})
}
)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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)
}
@@ -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)
}
@@ -26,7 +26,7 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
} 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 })
}
@@ -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<string | null>(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<Element>) => {
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}
/>
<CanvasMenu
@@ -4202,7 +4224,7 @@ const WorkflowContent = React.memo(
hasLockedBlocks={hasLockedBlocks}
onToggleWorkflowLock={handleToggleWorkflowLock}
allBlocksLocked={allBlocksLocked}
canAdmin={effectivePermissions.canAdmin}
canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}
hasBlocks={hasBlocks}
/>
</>
@@ -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<typeof boxUploadContract>
export type BoxUploadResponse = ContractJsonResponse<typeof boxUploadContract>
export type DropboxUploadBody = ContractBodyInput<typeof dropboxUploadContract>
@@ -749,3 +760,4 @@ export type GetMultipartPartUrlsBody = z.output<typeof getMultipartPartUrlsBodyS
export type FileServeParams = ContractParamsInput<typeof fileServeContract>
export type FileServeQuery = ContractQueryInput<typeof fileServeContract>
export type FileViewParams = ContractParamsInput<typeof fileViewContract>
export type FileExportParams = ContractParamsInput<typeof fileExportContract>
@@ -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 || ''
@@ -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
@@ -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', {
@@ -9,7 +9,7 @@ import {
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
import {
deleteWorkspaceFile,
downloadWorkspaceFile as downloadWsFile,
fetchWorkspaceFileBuffer as downloadWsFile,
getWorkspaceFile,
getWorkspaceFileByName,
renameWorkspaceFile,
@@ -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<GenerateImageArgs, Generate
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (fileRecord) {
referenceRecords.push({ id: fileRecord.id, name: fileRecord.name })
const buffer = await downloadWorkspaceFile(fileRecord)
const buffer = await fetchWorkspaceFileBuffer(fileRecord)
const base64 = buffer.toString('base64')
const mime = fileRecord.type || 'image/png'
parts.push({
@@ -26,7 +26,7 @@ vi.mock('@sim/utils/id', () => ({
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
resolveWorkspaceFileReference: mockResolveWorkspaceFileReference,
downloadWorkspaceFile: mockDownloadWorkspaceFile,
fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile,
}))
vi.mock('@/lib/table/service', () => ({
@@ -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 }
}
@@ -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({
+4 -4
View File
@@ -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',
+4 -4
View File
@@ -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<FileR
try {
if (isImageFileType(record.type)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
const originalBuffer = await downloadWorkspaceFile(record)
const originalBuffer = await fetchWorkspaceFileBuffer(record)
const prepared = await prepareImageForVision(originalBuffer, record.type)
if (!prepared) {
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
@@ -335,7 +335,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
}
}
const buffer = await downloadWorkspaceFile(record)
const buffer = await fetchWorkspaceFileBuffer(record)
const content = buffer.toString('utf-8')
const lines = content.split('\n').length
span.setAttributes({
@@ -359,7 +359,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
totalLines: 1,
}
}
const buffer = await downloadWorkspaceFile(record)
const buffer = await fetchWorkspaceFileBuffer(record)
try {
const { parseBuffer } = await import('@/lib/file-parsers')
const result = await parseBuffer(buffer, ext)
+3 -3
View File
@@ -64,7 +64,7 @@ import { getKnowledgeBases } from '@/lib/knowledge/service'
import { validateMermaidSource } from '@/lib/mermaid/validate'
import { listTables } from '@/lib/table/service'
import {
downloadWorkspaceFile,
fetchWorkspaceFileBuffer,
findWorkspaceFileRecord,
getWorkspaceFile,
listWorkspaceFiles,
@@ -473,7 +473,7 @@ export class WorkspaceVFS {
const taskId = BINARY_DOC_TASKS[ext]
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!taskId && !isMermaidFile) return null
const buffer = await downloadWorkspaceFile(record)
const buffer = await fetchWorkspaceFileBuffer(record)
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > 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)
@@ -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<WorkspaceFileArgs, WorkspaceFile
throw new Error(`File not found: ${args.fileId}`)
}
const buffer = await downloadWorkspaceFile(record)
const buffer = await fetchWorkspaceFileBuffer(record)
const mime = record.type || 'image/png'
return { dataUri: `data:${mime};base64,${buffer.toString('base64')}` }
},
+26
View File
@@ -0,0 +1,26 @@
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
export async function triggerFileDownload(record: WorkspaceFileRecord): Promise<void> {
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)
}
@@ -720,7 +720,7 @@ export async function getWorkspaceFile(
/**
* Download workspace file content
*/
export async function downloadWorkspaceFile(fileRecord: WorkspaceFileRecord): Promise<Buffer> {
export async function fetchWorkspaceFileBuffer(fileRecord: WorkspaceFileRecord): Promise<Buffer> {
logger.info(`Downloading workspace file: ${fileRecord.name}`)
try {
-21
View File
@@ -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<void> {
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)
}
+2 -2
View File
@@ -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