Skip to content

Commit a479e88

Browse files
authored
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
1 parent 7921449 commit a479e88

26 files changed

Lines changed: 268 additions & 79 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import path from 'node:path'
2+
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
4+
import JSZip from 'jszip'
5+
import type { NextRequest } from 'next/server'
6+
import { NextResponse } from 'next/server'
7+
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
8+
import { parseRequest } from '@/lib/api/server'
9+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import type { StorageContext } from '@/lib/uploads/config'
12+
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
13+
import { downloadFile } from '@/lib/uploads/core/storage-service'
14+
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
15+
import { verifyFileAccess } from '@/app/api/files/authorization'
16+
17+
const logger = createLogger('FilesExportAPI')
18+
19+
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
20+
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
21+
const VIEW_URL_RE =
22+
/\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
23+
const MAX_EMBEDDED_IMAGES = 50
24+
25+
function isMarkdown(originalName: string, contentType: string): boolean {
26+
if (MARKDOWN_MIME_TYPES.has(contentType)) return true
27+
const ext = originalName.split('.').pop()?.toLowerCase() ?? ''
28+
return MARKDOWN_EXTENSIONS.has(ext)
29+
}
30+
31+
function safeFilename(name: string): string {
32+
return path
33+
.basename(name)
34+
.replace(/["\\]/g, '_')
35+
.replace(/[\r\n\t]/g, '')
36+
}
37+
38+
function deduplicatedFilename(preferred: string, existing: Set<string>, imageId: string): string {
39+
if (!existing.has(preferred)) return preferred
40+
const ext = path.extname(preferred)
41+
const base = path.basename(preferred, ext)
42+
const short = `${base}_${imageId.slice(0, 8)}${ext}`
43+
if (!existing.has(short)) return short
44+
return `${base}_${imageId}${ext}`
45+
}
46+
47+
export const GET = withRouteHandler(
48+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
49+
const parsed = await parseRequest(fileExportContract, request, context)
50+
if (!parsed.success) return parsed.response
51+
52+
const { id } = parsed.data.params
53+
54+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
55+
if (!authResult.success || !authResult.userId) {
56+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
57+
}
58+
const userId = authResult.userId
59+
60+
const record = await getFileMetadataById(id)
61+
if (!record) {
62+
logger.warn('File not found by ID', { id })
63+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
64+
}
65+
66+
const hasAccess = await verifyFileAccess(record.key, userId)
67+
if (!hasAccess) {
68+
logger.warn('Unauthorized file export attempt', { id, userId })
69+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
70+
}
71+
72+
if (!isMarkdown(record.originalName, record.contentType)) {
73+
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
74+
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
75+
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
76+
}
77+
78+
const mdBuffer = await downloadFile({
79+
key: record.key,
80+
context: record.context as StorageContext,
81+
})
82+
let mdContent = mdBuffer.toString('utf-8')
83+
84+
const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice(
85+
0,
86+
MAX_EMBEDDED_IMAGES
87+
)
88+
89+
logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length })
90+
91+
const fetchResults = await Promise.allSettled(
92+
imageIds.map(async (imageId) => {
93+
const imgRecord = await getFileMetadataById(imageId)
94+
if (!imgRecord) return null
95+
const imgHasAccess = await verifyFileAccess(imgRecord.key, userId)
96+
if (!imgHasAccess) return null
97+
const imgBuffer = await downloadFile({
98+
key: imgRecord.key,
99+
context: imgRecord.context as StorageContext,
100+
})
101+
return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer }
102+
})
103+
)
104+
105+
const assetMap = new Map<string, { filename: string; buffer: Buffer }>()
106+
const usedFilenames = new Set<string>()
107+
108+
for (let i = 0; i < fetchResults.length; i++) {
109+
const result = fetchResults[i]
110+
if (result.status === 'rejected') {
111+
logger.warn('Failed to fetch asset for export', {
112+
imageId: imageIds[i],
113+
error: toError(result.reason).message,
114+
})
115+
continue
116+
}
117+
if (!result.value) continue
118+
const { imageId, originalName, buffer } = result.value
119+
const preferred = safeFilename(originalName)
120+
const filename = deduplicatedFilename(preferred, usedFilenames, imageId)
121+
usedFilenames.add(filename)
122+
assetMap.set(imageId, { filename, buffer })
123+
}
124+
125+
for (const [imageId, asset] of assetMap) {
126+
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
127+
const replacement = `./assets/${asset.filename}`
128+
mdContent = mdContent.replace(
129+
new RegExp(`/api/files/view/${escapedId}`, 'g'),
130+
() => replacement
131+
)
132+
}
133+
134+
const zip = new JSZip()
135+
zip.file(safeFilename(record.originalName), mdContent)
136+
const assetsFolder = zip.folder('assets')!
137+
for (const { filename, buffer } of assetMap.values()) {
138+
assetsFolder.file(filename, buffer)
139+
}
140+
141+
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
142+
const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`)
143+
144+
return new NextResponse(new Uint8Array(zipBuffer), {
145+
status: 200,
146+
headers: {
147+
'Content-Type': 'application/zip',
148+
'Content-Disposition': `attachment; filename="${zipName}"`,
149+
'Content-Length': String(zipBuffer.length),
150+
},
151+
})
152+
}
153+
)

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { acquireLock, releaseLock } from '@/lib/core/config/redis'
77
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import {
10-
downloadWorkspaceFile,
10+
fetchWorkspaceFileBuffer,
1111
getWorkspaceFileByName,
1212
updateWorkspaceFileContent,
1313
uploadWorkspaceFile,
@@ -91,7 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9191
}
9292

9393
try {
94-
const existingBuffer = await downloadWorkspaceFile(existing)
94+
const existingBuffer = await fetchWorkspaceFileBuffer(existing)
9595
const finalContent = existingBuffer.toString('utf-8') + content
9696
const fileBuffer = Buffer.from(finalContent, 'utf-8')
9797
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)

apps/sim/app/api/v1/files/[fileId]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import {
99
deleteWorkspaceFile,
10-
downloadWorkspaceFile,
10+
fetchWorkspaceFileBuffer,
1111
getWorkspaceFile,
1212
} from '@/lib/uploads/contexts/workspace'
1313
import {
@@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
5050
return NextResponse.json({ error: 'File not found' }, { status: 404 })
5151
}
5252

53-
const buffer = await downloadWorkspaceFile(fileRecord)
53+
const buffer = await fetchWorkspaceFileBuffer(fileRecord)
5454

5555
return new Response(new Uint8Array(buffer), {
5656
status: 200,

apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
99
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
1010
import { validateMermaidSource } from '@/lib/mermaid/validate'
11-
import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
11+
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
1212
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
1313

1414
export const dynamic = 'force-dynamic'
@@ -62,7 +62,7 @@ export const GET = withRouteHandler(
6262

6363
let buffer: Buffer
6464
try {
65-
buffer = await downloadWorkspaceFile(fileRecord)
65+
buffer = await fetchWorkspaceFileBuffer(fileRecord)
6666
} catch (err) {
6767
logger.error('Failed to download file for compiled check', {
6868
fileId,

apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
9+
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
1010
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
1111

1212
export const dynamic = 'force-dynamic'
@@ -52,7 +52,7 @@ export const GET = withRouteHandler(
5252

5353
let buffer: Buffer
5454
try {
55-
buffer = await downloadWorkspaceFile(fileRecord)
55+
buffer = await fetchWorkspaceFileBuffer(fileRecord)
5656
} catch (err) {
5757
logger.error('Failed to download file for style extraction', {
5858
fileId,

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ import {
2828
} from '@/components/emcn'
2929
import { File as FilesIcon } from '@/components/emcn/icons'
3030
import { getDocumentIcon } from '@/components/icons/document-icons'
31+
import { triggerFileDownload } from '@/lib/uploads/client/download'
3132
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
3233
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
3334
import {
34-
downloadWorkspaceFile,
3535
formatFileSize,
3636
getFileExtension,
3737
getMimeTypeFromExtension,
@@ -475,7 +475,7 @@ export function Files() {
475475

476476
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
477477
try {
478-
await downloadWorkspaceFile(file)
478+
await triggerFileDownload(file)
479479
} catch (err) {
480480
logger.error('Failed to download file:', err)
481481
}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,8 @@ import {
1919
markRunToolManuallyStopped,
2020
reportManualRunToolStop,
2121
} from '@/lib/copilot/tools/client/run-tool-execution'
22-
import {
23-
downloadWorkspaceFile,
24-
getFileExtension,
25-
getMimeTypeFromExtension,
26-
} from '@/lib/uploads/utils/file-utils'
22+
import { triggerFileDownload } from '@/lib/uploads/client/download'
23+
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
2724
import { workflowBorderColor } from '@/lib/workspaces/colors'
2825
import {
2926
FileViewer,
@@ -422,7 +419,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
422419
const handleDownload = async () => {
423420
if (!file) return
424421
try {
425-
await downloadWorkspaceFile(file)
422+
await triggerFileDownload(file)
426423
} catch (err) {
427424
fileLogger.error('Failed to download file:', err)
428425
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
2626
} as const
2727

2828
function isAutoDismissable(n: Notification): boolean {
29-
return !!n.workflowId
29+
return !!n.workflowId && n.action?.type !== 'unlock-workflow'
3030
}
3131

3232
function NotificationCountdownRing({ onPause }: { onPause: () => void }) {
@@ -99,7 +99,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat
9999
break
100100
case 'unlock-workflow':
101101
window.dispatchEvent(new CustomEvent('unlock-workflow'))
102-
break
102+
return
103103
default:
104104
logger.warn('Unknown action type', { notificationId, actionType: action.type })
105105
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ const WorkflowContent = React.memo(
349349
const workflowReadOnly = workflowLocked && !sandbox
350350
const canvasOpacityClass = isCanvasReady
351351
? workflowReadOnly
352-
? 'opacity-60'
352+
? 'opacity-75'
353353
: 'opacity-100'
354354
: 'opacity-0'
355355

@@ -1251,13 +1251,16 @@ const WorkflowContent = React.memo(
12511251
return findLockedAncestorFolder(workflowMetadata?.folderId, folders)?.name ?? null
12521252
}, [workflowFolderLocked, workflowMetadata?.folderId, folders])
12531253

1254-
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
1254+
const prevIsAdminRef = useRef(
1255+
workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin
1256+
)
12551257
const prevLockSignatureRef = useRef<string | null>(null)
12561258
useEffect(() => {
12571259
if (!isWorkflowReady) return
12581260

1259-
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
1260-
prevCanAdminRef.current = effectivePermissions.canAdmin
1261+
const isAdmin = workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin
1262+
const canAdminChanged = prevIsAdminRef.current !== isAdmin
1263+
prevIsAdminRef.current = isAdmin
12611264

12621265
const lockSignature = workflowReadOnly
12631266
? workflowRowLocked
@@ -1273,8 +1276,6 @@ const WorkflowContent = React.memo(
12731276

12741277
if (workflowReadOnly) {
12751278
if (lockNotificationIdRef.current) return
1276-
1277-
const isAdmin = effectivePermissions.canAdmin
12781279
const isFolderInherited = workflowFolderLocked && !workflowRowLocked
12791280
const message = isFolderInherited
12801281
? inheritedLockFolderName
@@ -1304,6 +1305,7 @@ const WorkflowContent = React.memo(
13041305
inheritedLockFolderName,
13051306
isWorkflowReady,
13061307
effectivePermissions.canAdmin,
1308+
workspacePermissions,
13071309
addNotification,
13081310
activeWorkflowId,
13091311
clearLockNotification,
@@ -2182,6 +2184,18 @@ const WorkflowContent = React.memo(
21822184
[screenToFlowPosition, handleToolbarDrop]
21832185
)
21842186

2187+
const onDropLocked = useCallback(
2188+
(event: React.DragEvent) => {
2189+
event.preventDefault()
2190+
if (!event.dataTransfer?.types.includes('application/json')) return
2191+
const message = effectivePermissions.canAdmin
2192+
? 'Unlock the workflow to add blocks.'
2193+
: 'This workflow is locked. Ask an admin to unlock it.'
2194+
addNotification({ level: 'info', message, workflowId: activeWorkflowId || undefined })
2195+
},
2196+
[effectivePermissions.canAdmin, addNotification, activeWorkflowId]
2197+
)
2198+
21852199
const handleCanvasPointerMove = useCallback(
21862200
(event: React.PointerEvent<Element>) => {
21872201
const position = screenToFlowPosition({
@@ -4072,8 +4086,16 @@ const WorkflowContent = React.memo(
40724086
nodeTypes={nodeTypes}
40734087
edgeTypes={edgeTypes}
40744088
onMouseDown={handleCanvasMouseDown}
4075-
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
4076-
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
4089+
onDrop={
4090+
effectivePermissions.canEdit
4091+
? onDrop
4092+
: workflowReadOnly
4093+
? onDropLocked
4094+
: undefined
4095+
}
4096+
onDragOver={
4097+
effectivePermissions.canEdit || workflowReadOnly ? onDragOver : undefined
4098+
}
40774099
onInit={(instance) => {
40784100
if (embedded) {
40794101
return
@@ -4176,7 +4198,7 @@ const WorkflowContent = React.memo(
41764198
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
41774199
}
41784200
onToggleLocked={handleContextToggleLocked}
4179-
canAdmin={effectivePermissions.canAdmin}
4201+
canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}
41804202
/>
41814203

41824204
<CanvasMenu
@@ -4202,7 +4224,7 @@ const WorkflowContent = React.memo(
42024224
hasLockedBlocks={hasLockedBlocks}
42034225
onToggleWorkflowLock={handleToggleWorkflowLock}
42044226
allBlocksLocked={allBlocksLocked}
4205-
canAdmin={effectivePermissions.canAdmin}
4227+
canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}
42064228
hasBlocks={hasBlocks}
42074229
/>
42084230
</>

0 commit comments

Comments
 (0)