mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-6
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')}` }
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user