Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions apps/sim/app/api/files/export/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Comment thread
waleedlatif1 marked this conversation as resolved.

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 })
Comment thread
waleedlatif1 marked this conversation as resolved.
}

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
)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

const zip = new JSZip()
zip.file(safeFilename(record.originalName), mdContent)
const assetsFolder = zip.folder('assets')!
for (const { filename, buffer } of assetMap.values()) {
Comment thread
waleedlatif1 marked this conversation as resolved.
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),
},
})
}
)
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/v1/files/[fileId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 })
}
Expand Down
42 changes: 32 additions & 10 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ const WorkflowContent = React.memo(
const workflowReadOnly = workflowLocked && !sandbox
const canvasOpacityClass = isCanvasReady
? workflowReadOnly
? 'opacity-60'
? 'opacity-75'
: 'opacity-100'
: 'opacity-0'

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1304,6 +1305,7 @@ const WorkflowContent = React.memo(
inheritedLockFolderName,
isWorkflowReady,
effectivePermissions.canAdmin,
workspacePermissions,
addNotification,
activeWorkflowId,
clearLockNotification,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -4202,7 +4224,7 @@ const WorkflowContent = React.memo(
hasLockedBlocks={hasLockedBlocks}
onToggleWorkflowLock={handleToggleWorkflowLock}
allBlocksLocked={allBlocksLocked}
canAdmin={effectivePermissions.canAdmin}
canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}
hasBlocks={hasBlocks}
/>
</>
Expand Down
Loading
Loading