From 6eb3f0427d51bee58cc423b745100a64b274ff1c Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 17:26:38 -0700 Subject: [PATCH 01/13] feat(files): export markdown as zip with embedded images in assets/ folder --- apps/sim/app/api/files/export/[id]/route.ts | 111 ++++++++++++++++++ .../sim/lib/api/contracts/storage-transfer.ts | 12 ++ apps/sim/lib/uploads/utils/file-utils.ts | 29 ++++- scripts/check-api-validation-contracts.ts | 4 +- 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/api/files/export/[id]/route.ts diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts new file mode 100644 index 00000000000..ccddf03ec4c --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -0,0 +1,111 @@ +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 { 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 + +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) +} + +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 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, authResult.userId) + if (!hasAccess) { + logger.warn('Unauthorized file export attempt', { id, userId: authResult.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 'workspace' }) + let mdContent = mdBuffer.toString('utf-8') + + const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))] + logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length }) + + const assetMap = new Map() + + await Promise.allSettled( + imageIds.map(async (imageId) => { + try { + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return + const imgHasAccess = await verifyFileAccess(imgRecord.key, authResult.userId) + if (!imgHasAccess) return + const imgBuffer = await downloadFile({ + key: imgRecord.key, + context: imgRecord.context as 'workspace', + }) + assetMap.set(imageId, { filename: imgRecord.originalName, buffer: imgBuffer }) + } catch (err) { + logger.warn('Failed to fetch asset for export', { imageId, error: toError(err).message }) + } + }) + ) + + for (const [imageId, asset] of assetMap) { + const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + mdContent = mdContent.replace( + new RegExp(`/api/files/view/${escapedId}`, 'g'), + `./assets/${asset.filename}` + ) + } + + const zip = new JSZip() + zip.file(record.originalName, mdContent) + const assets = zip.folder('assets')! + for (const { filename, buffer } of assetMap.values()) { + assets.file(filename, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + const zipName = `${record.originalName.replace(/\.[^.]+$/, '')}.zip` + + return new NextResponse(zipBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${zipName}"`, + 'Content-Length': String(zipBuffer.length), + }, + }) + } +) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 8e70e8f26b1..0dd14fcc536 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -454,6 +454,10 @@ export const fileViewParamsSchema = z.object({ id: z.string().uuid('File ID must be a valid UUID'), }) +export const fileExportParamsSchema = z.object({ + id: z.string().uuid('File ID must be a valid UUID'), +}) + export const boxUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/box/upload', @@ -707,6 +711,13 @@ export const fileViewContract = defineRouteContract({ response: { mode: 'binary' }, }) +export const fileExportContract = defineRouteContract({ + method: 'GET', + path: '/api/files/export/[id]', + params: fileExportParamsSchema, + response: { mode: 'binary' }, +}) + export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput @@ -749,3 +760,4 @@ export type GetMultipartPartUrlsBody = z.output export type FileServeQuery = ContractQueryInput export type FileViewParams = ContractParamsInput +export type FileExportParams = ContractParamsInput diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index b625c22ea42..feb24c5fc08 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -847,12 +847,27 @@ export function getViewerUrl(fileKey: string, workspaceId?: string): string | nu } /** - * Downloads a workspace file to the user's device via the serve API. - * Fetches the file as a blob and triggers a browser download. + * Downloads a workspace file to the user's device. + * Markdown files with embedded images are exported as a zip with an assets/ folder. + * All other files are fetched directly from the serve API. */ -export async function downloadWorkspaceFile(file: { key: string; name: string }): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}` - const response = await fetch(serveUrl, { cache: 'no-store' }) +export async function downloadWorkspaceFile(file: { + id?: string + key: string + name: string + type?: string +}): Promise { + const isMarkdown = + file.type === 'text/markdown' || + file.type === 'text/x-markdown' || + /\.(?:md|markdown)$/i.test(file.name) + + const fetchUrl = + isMarkdown && file.id + ? `/api/files/export/${encodeURIComponent(file.id)}` + : `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}` + + const response = await fetch(fetchUrl, { cache: 'no-store' }) if (!response.ok) { throw new Error(`Failed to download file: ${response.statusText}`) } @@ -860,7 +875,9 @@ export async function downloadWorkspaceFile(file: { key: string; name: string }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = file.name + const contentDisposition = response.headers.get('Content-Disposition') + const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/) + a.download = filenameMatch?.[1] ?? file.name document.body.appendChild(a) a.click() document.body.removeChild(a) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 07f0e43802b..34cbacb0f6e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 724, - zodRoutes: 724, + totalRoutes: 725, + zodRoutes: 725, nonZodRoutes: 0, } as const From 879a869dc546bdf4dfac6ec69ea4bf3aaddd1dc5 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 17:28:56 -0700 Subject: [PATCH 02/13] fix(files): sanitize zip filenames, fix storage context cast, cap embedded image count --- apps/sim/app/api/files/export/[id]/route.ts | 44 +++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index ccddf03ec4c..b0b08b0223d 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import JSZip from 'jszip' @@ -7,6 +8,7 @@ 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' @@ -18,6 +20,7 @@ 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 @@ -25,6 +28,22 @@ function isMarkdown(originalName: string, contentType: string): boolean { return MARKDOWN_EXTENSIONS.has(ext) } +/** Strip characters that would break Content-Disposition header or zip entry paths. */ +function safeFilename(name: string): string { + return path + .basename(name) + .replace(/["\\]/g, '_') + .replace(/[\r\n\t]/g, '') +} + +/** Deduplicate asset filename by appending the first 8 chars of its UUID when a collision exists. */ +function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { + if (!existing.has(preferred)) return preferred + const ext = path.extname(preferred) + const base = path.basename(preferred, ext) + return `${base}_${imageId.slice(0, 8)}${ext}` +} + export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const parsed = await parseRequest(fileExportContract, request, context) @@ -55,13 +74,21 @@ export const GET = withRouteHandler( return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) } - const mdBuffer = await downloadFile({ key: record.key, context: record.context as 'workspace' }) + 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]))] + 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 assetMap = new Map() + const usedFilenames = new Set() await Promise.allSettled( imageIds.map(async (imageId) => { @@ -72,9 +99,12 @@ export const GET = withRouteHandler( if (!imgHasAccess) return const imgBuffer = await downloadFile({ key: imgRecord.key, - context: imgRecord.context as 'workspace', + context: imgRecord.context as StorageContext, }) - assetMap.set(imageId, { filename: imgRecord.originalName, buffer: imgBuffer }) + const preferred = safeFilename(imgRecord.originalName) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + usedFilenames.add(filename) + assetMap.set(imageId, { filename, buffer: imgBuffer }) } catch (err) { logger.warn('Failed to fetch asset for export', { imageId, error: toError(err).message }) } @@ -91,13 +121,13 @@ export const GET = withRouteHandler( const zip = new JSZip() zip.file(record.originalName, mdContent) - const assets = zip.folder('assets')! + const assetsFolder = zip.folder('assets')! for (const { filename, buffer } of assetMap.values()) { - assets.file(filename, buffer) + assetsFolder.file(filename, buffer) } const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) - const zipName = `${record.originalName.replace(/\.[^.]+$/, '')}.zip` + const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) return new NextResponse(zipBuffer, { status: 200, From 5ff59a079c58ec4c34a3662a5035c3b573ddafe4 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 17:31:04 -0700 Subject: [PATCH 03/13] fix(files): fix race condition in asset filename deduplication --- apps/sim/app/api/files/export/[id]/route.ts | 52 +++++++++++++-------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index b0b08b0223d..d7f6cad201b 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -87,30 +87,42 @@ export const GET = withRouteHandler( logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length }) - const assetMap = new Map() - const usedFilenames = new Set() - - await Promise.allSettled( + // Fetch all images in parallel, then deduplicate filenames serially to avoid + // a race where two concurrent callbacks both pass the "not yet seen" check. + const fetchResults = await Promise.allSettled( imageIds.map(async (imageId) => { - try { - const imgRecord = await getFileMetadataById(imageId) - if (!imgRecord) return - const imgHasAccess = await verifyFileAccess(imgRecord.key, authResult.userId) - if (!imgHasAccess) return - const imgBuffer = await downloadFile({ - key: imgRecord.key, - context: imgRecord.context as StorageContext, - }) - const preferred = safeFilename(imgRecord.originalName) - const filename = deduplicatedFilename(preferred, usedFilenames, imageId) - usedFilenames.add(filename) - assetMap.set(imageId, { filename, buffer: imgBuffer }) - } catch (err) { - logger.warn('Failed to fetch asset for export', { imageId, error: toError(err).message }) - } + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return null + const imgHasAccess = await verifyFileAccess(imgRecord.key, authResult.userId) + if (!imgHasAccess) return null + const imgBuffer = await downloadFile({ + key: imgRecord.key, + context: imgRecord.context as StorageContext, + }) + return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer } }) ) + const assetMap = new Map() + const usedFilenames = new Set() + + for (let i = 0; i < fetchResults.length; i++) { + const result = fetchResults[i] + if (result.status === 'rejected') { + logger.warn('Failed to fetch asset for export', { + imageId: imageIds[i], + error: toError(result.reason).message, + }) + continue + } + if (!result.value) continue + const { imageId, originalName, buffer } = result.value + const preferred = safeFilename(originalName) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + usedFilenames.add(filename) + assetMap.set(imageId, { filename, buffer }) + } + for (const [imageId, asset] of assetMap) { const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') mdContent = mdContent.replace( From 39b5082cc1e7d250966ae3d032412633ae48a1ac Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 17:56:21 -0700 Subject: [PATCH 04/13] chore(files): remove extraneous comments from export route --- apps/sim/app/api/files/export/[id]/route.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index d7f6cad201b..3e939fb93c5 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -28,7 +28,6 @@ function isMarkdown(originalName: string, contentType: string): boolean { return MARKDOWN_EXTENSIONS.has(ext) } -/** Strip characters that would break Content-Disposition header or zip entry paths. */ function safeFilename(name: string): string { return path .basename(name) @@ -36,7 +35,6 @@ function safeFilename(name: string): string { .replace(/[\r\n\t]/g, '') } -/** Deduplicate asset filename by appending the first 8 chars of its UUID when a collision exists. */ function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) @@ -87,8 +85,6 @@ export const GET = withRouteHandler( logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length }) - // Fetch all images in parallel, then deduplicate filenames serially to avoid - // a race where two concurrent callbacks both pass the "not yet seen" check. const fetchResults = await Promise.allSettled( imageIds.map(async (imageId) => { const imgRecord = await getFileMetadataById(imageId) From 79990848c32626601429185fae8ca819ec0bfe9b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 18:14:55 -0700 Subject: [PATCH 05/13] fix(files): sanitize markdown zip entry name, full uuid fallback for filename dedup --- apps/sim/app/api/files/export/[id]/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 3e939fb93c5..300cd2e541e 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -39,7 +39,9 @@ function deduplicatedFilename(preferred: string, existing: Set, imageId: if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) - return `${base}_${imageId.slice(0, 8)}${ext}` + const short = `${base}_${imageId.slice(0, 8)}${ext}` + if (!existing.has(short)) return short + return `${base}_${imageId}${ext}` } export const GET = withRouteHandler( @@ -128,7 +130,7 @@ export const GET = withRouteHandler( } const zip = new JSZip() - zip.file(record.originalName, mdContent) + zip.file(safeFilename(record.originalName), mdContent) const assetsFolder = zip.folder('assets')! for (const { filename, buffer } of assetMap.values()) { assetsFolder.file(filename, buffer) From e2384665607bbd7fd19551b9fbd95235ec29d476 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 18:38:18 -0700 Subject: [PATCH 06/13] fix(files): extract userId const to satisfy TypeScript narrowing in async callback --- apps/sim/app/api/files/export/[id]/route.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 300cd2e541e..183dee77953 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -55,6 +55,7 @@ export const GET = withRouteHandler( if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const userId = authResult.userId const record = await getFileMetadataById(id) if (!record) { @@ -62,9 +63,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const hasAccess = await verifyFileAccess(record.key, authResult.userId) + const hasAccess = await verifyFileAccess(record.key, userId) if (!hasAccess) { - logger.warn('Unauthorized file export attempt', { id, userId: authResult.userId }) + logger.warn('Unauthorized file export attempt', { id, userId }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } @@ -91,7 +92,7 @@ export const GET = withRouteHandler( imageIds.map(async (imageId) => { const imgRecord = await getFileMetadataById(imageId) if (!imgRecord) return null - const imgHasAccess = await verifyFileAccess(imgRecord.key, authResult.userId) + const imgHasAccess = await verifyFileAccess(imgRecord.key, userId) if (!imgHasAccess) return null const imgBuffer = await downloadFile({ key: imgRecord.key, From e1978315a697b78db792c5cdcca4c339111569ea Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 18:42:32 -0700 Subject: [PATCH 07/13] fix(files): use replacer function to prevent $ special-char corruption in markdown URL rewrite --- apps/sim/app/api/files/export/[id]/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 183dee77953..05ec2e41340 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -124,9 +124,10 @@ export const GET = withRouteHandler( 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'), - `./assets/${asset.filename}` + () => replacement ) } From 577a9893535b6c2cecf0437cc02d014dbe3551f7 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 18:49:02 -0700 Subject: [PATCH 08/13] fix(files): wrap zip buffer in Uint8Array for NextResponse BodyInit compatibility --- apps/sim/app/api/files/export/[id]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 05ec2e41340..ba5b4366803 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -141,7 +141,7 @@ export const GET = withRouteHandler( const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) - return new NextResponse(zipBuffer, { + return new NextResponse(new Uint8Array(zipBuffer), { status: 200, headers: { 'Content-Type': 'application/zip', From 8d16465d6e543d7f05642f9ac3815b5c04ad0466 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 19:34:31 -0700 Subject: [PATCH 09/13] lock behavior --- .../components/notifications/notifications.tsx | 4 ++-- .../workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index a6d56d389c6..e6c74e8bf0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -26,7 +26,7 @@ const ACTION_LABELS: Record = { } as const function isAutoDismissable(n: Notification): boolean { - return !!n.workflowId + return !!n.workflowId && n.action?.type !== 'unlock-workflow' } function NotificationCountdownRing({ onPause }: { onPause: () => void }) { @@ -99,7 +99,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat break case 'unlock-workflow': window.dispatchEvent(new CustomEvent('unlock-workflow')) - break + return default: logger.warn('Unknown action type', { notificationId, actionType: action.type }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 1ebde480ef5..c6f307c20ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1274,7 +1274,7 @@ const WorkflowContent = React.memo( if (workflowReadOnly) { if (lockNotificationIdRef.current) return - const isAdmin = effectivePermissions.canAdmin + const isAdmin = workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin const isFolderInherited = workflowFolderLocked && !workflowRowLocked const message = isFolderInherited ? inheritedLockFolderName @@ -1304,6 +1304,7 @@ const WorkflowContent = React.memo( inheritedLockFolderName, isWorkflowReady, effectivePermissions.canAdmin, + workspacePermissions, addNotification, activeWorkflowId, clearLockNotification, @@ -4176,7 +4177,7 @@ const WorkflowContent = React.memo( edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0 } onToggleLocked={handleContextToggleLocked} - canAdmin={effectivePermissions.canAdmin} + canAdmin={effectivePermissions.canAdmin && !workflowReadOnly} /> From 0d5ac8e5cc15a49aa18f6ab6a2ece20d7fe3ac7b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 19:48:16 -0700 Subject: [PATCH 10/13] 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. --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index c6f307c20ed..dabe15bc1fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1251,13 +1251,16 @@ const WorkflowContent = React.memo( return findLockedAncestorFolder(workflowMetadata?.folderId, folders)?.name ?? null }, [workflowFolderLocked, workflowMetadata?.folderId, folders]) - const prevCanAdminRef = useRef(effectivePermissions.canAdmin) + const prevIsAdminRef = useRef( + workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin + ) const prevLockSignatureRef = useRef(null) useEffect(() => { if (!isWorkflowReady) return - const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin - prevCanAdminRef.current = effectivePermissions.canAdmin + const isAdmin = workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin + const canAdminChanged = prevIsAdminRef.current !== isAdmin + prevIsAdminRef.current = isAdmin const lockSignature = workflowReadOnly ? workflowRowLocked @@ -1273,8 +1276,6 @@ const WorkflowContent = React.memo( if (workflowReadOnly) { if (lockNotificationIdRef.current) return - - const isAdmin = workspacePermissions?.viewer?.isAdmin ?? effectivePermissions.canAdmin const isFolderInherited = workflowFolderLocked && !workflowRowLocked const message = isFolderInherited ? inheritedLockFolderName From 0610a598ad9852bd2b7973e9d9a4f5cc4290cd31 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 19:49:28 -0700 Subject: [PATCH 11/13] refactor(uploads): rename server fn to fetchWorkspaceFileBuffer, move client download to uploads/client/download.ts as triggerFileDownload --- apps/sim/app/api/tools/file/manage/route.ts | 4 +- apps/sim/app/api/v1/files/[fileId]/route.ts | 4 +- .../files/[fileId]/compiled-check/route.ts | 4 +- .../[id]/files/[fileId]/style/route.ts | 4 +- .../workspace/[workspaceId]/files/files.tsx | 4 +- .../resource-content/resource-content.tsx | 9 ++--- .../tools/handlers/function-execute.ts | 4 +- .../tools/handlers/materialize-file.ts | 4 +- .../tools/server/files/file-preview.ts | 4 +- .../tools/server/files/workspace-file.ts | 2 +- .../tools/server/image/generate-image.ts | 4 +- .../copilot/tools/server/table/user-table.ts | 4 +- .../visualization/generate-visualization.ts | 4 +- apps/sim/lib/copilot/vfs/file-reader.ts | 8 ++-- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 6 +-- .../sandbox/brokers/workspace-file.ts | 4 +- apps/sim/lib/uploads/client/download.ts | 26 +++++++++++++ .../workspace/workspace-file-manager.ts | 2 +- apps/sim/lib/uploads/utils/file-utils.ts | 38 ------------------- 19 files changed, 62 insertions(+), 77 deletions(-) create mode 100644 apps/sim/lib/uploads/client/download.ts diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 01048b92950..3880e587bcc 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -7,7 +7,7 @@ import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFileByName, updateWorkspaceFileContent, uploadWorkspaceFile, @@ -91,7 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const existingBuffer = await downloadWorkspaceFile(existing) + const existingBuffer = await fetchWorkspaceFileBuffer(existing) const finalContent = existingBuffer.toString('utf-8') + content const fileBuffer = Buffer.from(finalContent, 'utf-8') await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 0a7e4b6020c..a2e4c029d1d 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -7,7 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceFile, - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { @@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - const buffer = await downloadWorkspaceFile(fileRecord) + const buffer = await fetchWorkspaceFileBuffer(fileRecord) return new Response(new Uint8Array(buffer), { status: 200, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index a6d54e8983f..7324c915c20 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -8,7 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { validateMermaidSource } from '@/lib/mermaid/validate' -import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -62,7 +62,7 @@ export const GET = withRouteHandler( let buffer: Buffer try { - buffer = await downloadWorkspaceFile(fileRecord) + buffer = await fetchWorkspaceFileBuffer(fileRecord) } catch (err) { logger.error('Failed to download file for compiled check', { fileId, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index 815d8eb4f6f..c30d0e9723f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -52,7 +52,7 @@ export const GET = withRouteHandler( let buffer: Buffer try { - buffer = await downloadWorkspaceFile(fileRecord) + buffer = await fetchWorkspaceFileBuffer(fileRecord) } catch (err) { logger.error('Failed to download file for style extraction', { fileId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index c599a313c2a..b172d8bc5d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -28,10 +28,10 @@ import { } from '@/components/emcn' import { File as FilesIcon } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' +import { triggerFileDownload } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { - downloadWorkspaceFile, formatFileSize, getFileExtension, getMimeTypeFromExtension, @@ -475,7 +475,7 @@ export function Files() { const handleDownload = useCallback(async (file: WorkspaceFileRecord) => { try { - await downloadWorkspaceFile(file) + await triggerFileDownload(file) } catch (err) { logger.error('Failed to download file:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index e60f2bebd21..46f78e1f89e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -19,11 +19,8 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' -import { - downloadWorkspaceFile, - getFileExtension, - getMimeTypeFromExtension, -} from '@/lib/uploads/utils/file-utils' +import { triggerFileDownload } from '@/lib/uploads/client/download' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { workflowBorderColor } from '@/lib/workspaces/colors' import { FileViewer, @@ -422,7 +419,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps) const handleDownload = async () => { if (!file) return try { - await downloadWorkspaceFile(file) + await triggerFileDownload(file) } catch (err) { fileLogger.error('Failed to download file:', err) } diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 61cf5ed6219..8d6139d4c56 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getTableById, queryRows } from '@/lib/table/service' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, listWorkspaceFiles, @@ -44,7 +44,7 @@ async function resolveInputFiles( logger.warn('Total input size limit reached') break } - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) totalSize += buffer.length const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( record.type || '' diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index b6c2056fb33..d53f0c80158 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -8,7 +8,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { getServePathPrefix } from '@/lib/uploads' -import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -83,7 +83,7 @@ async function executeImport( } } - const buffer = await downloadWorkspaceFile(toFileRecord(row)) + const buffer = await fetchWorkspaceFileBuffer(toFileRecord(row)) const content = buffer.toString('utf-8') let parsed: unknown diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 43409613fb6..ecb9fdf08e2 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -148,7 +148,7 @@ export async function loadWorkspaceFileTextForPreview( try { const record = await getWorkspaceFile(workspaceId, fileId) if (!record) return undefined - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) return buffer.toString('utf-8') } catch (error) { logger.warn('Failed to load workspace file text for preview', { diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 23f7753cac5..5bbe8e3ddd8 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -9,7 +9,7 @@ import { import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { deleteWorkspaceFile, - downloadWorkspaceFile as downloadWsFile, + fetchWorkspaceFileBuffer as downloadWsFile, getWorkspaceFile, getWorkspaceFileByName, renameWorkspaceFile, diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index ec97898cc5c..e745da95678 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -10,7 +10,7 @@ import { import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { getServePathPrefix } from '@/lib/uploads' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, updateWorkspaceFileContent, uploadWorkspaceFile, @@ -94,7 +94,7 @@ export const generateImageServerTool: BaseServerTool MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return { @@ -520,7 +520,7 @@ export class WorkspaceVFS { const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx') return null const ext: 'docx' | 'pptx' = rawExt - const buffer = await downloadWorkspaceFile(record) + const buffer = await fetchWorkspaceFileBuffer(record) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null const json = JSON.stringify(summary, null, 2) diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts index c2fc4bd86e4..38ea0695863 100644 --- a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { SandboxBroker } from '@/lib/execution/sandbox/types' import { - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -40,7 +40,7 @@ export const workspaceFileBroker: SandboxBroker { + const isMarkdown = + record.type === 'text/markdown' || + record.type === 'text/x-markdown' || + /\.(?:md|markdown)$/i.test(record.name) + + const url = isMarkdown + ? `/api/files/export/${encodeURIComponent(record.id)}` + : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`) + + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = objectUrl + a.download = + response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(objectUrl) +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 20780b71392..3fe38de8570 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -720,7 +720,7 @@ export async function getWorkspaceFile( /** * Download workspace file content */ -export async function downloadWorkspaceFile(fileRecord: WorkspaceFileRecord): Promise { +export async function fetchWorkspaceFileBuffer(fileRecord: WorkspaceFileRecord): Promise { logger.info(`Downloading workspace file: ${fileRecord.name}`) try { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index feb24c5fc08..3dc0d75506b 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -845,41 +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. - * Markdown files with embedded images are exported as a zip with an assets/ folder. - * All other files are fetched directly from the serve API. - */ -export async function downloadWorkspaceFile(file: { - id?: string - key: string - name: string - type?: string -}): Promise { - const isMarkdown = - file.type === 'text/markdown' || - file.type === 'text/x-markdown' || - /\.(?:md|markdown)$/i.test(file.name) - - const fetchUrl = - isMarkdown && file.id - ? `/api/files/export/${encodeURIComponent(file.id)}` - : `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}` - - const response = await fetch(fetchUrl, { 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 - const contentDisposition = response.headers.get('Content-Disposition') - const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/) - a.download = filenameMatch?.[1] ?? file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} From 9b1dbd66cfdb0edac6e0dcbe6c5c4e525b586f22 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 19:54:38 -0700 Subject: [PATCH 12/13] more lock updates --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index dabe15bc1fd..a2019e537eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -349,7 +349,7 @@ const WorkflowContent = React.memo( const workflowReadOnly = workflowLocked && !sandbox const canvasOpacityClass = isCanvasReady ? workflowReadOnly - ? 'opacity-60' + ? 'opacity-75' : 'opacity-100' : 'opacity-0' @@ -2184,6 +2184,18 @@ const WorkflowContent = React.memo( [screenToFlowPosition, handleToolbarDrop] ) + const onDropLocked = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + if (!event.dataTransfer?.types.includes('application/json')) return + const message = effectivePermissions.canAdmin + ? 'Unlock the workflow to add blocks.' + : 'This workflow is locked. Ask an admin to unlock it.' + addNotification({ level: 'info', message, workflowId: activeWorkflowId || undefined }) + }, + [effectivePermissions.canAdmin, addNotification, activeWorkflowId] + ) + const handleCanvasPointerMove = useCallback( (event: React.PointerEvent) => { const position = screenToFlowPosition({ @@ -4074,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 From 76a07aa18b0504ac72e67f0274644b7adf886f5b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 2 May 2026 19:56:10 -0700 Subject: [PATCH 13/13] fix(tests): update mocks for fetchWorkspaceFileBuffer rename --- .../sim/lib/copilot/tools/server/table/user-table.test.ts | 2 +- apps/sim/lib/copilot/vfs/file-reader.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index ecb8337380a..17adbcc4449 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -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', () => ({ diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index 115ad959496..f4326b32035 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -6,13 +6,13 @@ import { randomFillSync } from 'node:crypto' import { loggerMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -const { downloadWorkspaceFile } = vi.hoisted(() => ({ - downloadWorkspaceFile: vi.fn(), +const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({ + fetchWorkspaceFileBuffer: vi.fn(), })) vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - downloadWorkspaceFile, + fetchWorkspaceFileBuffer, })) import { readFileRecord } from '@/lib/copilot/vfs/file-reader' @@ -37,7 +37,7 @@ describe('readFileRecord', () => { const largePng = await makeNoisePng(1800, 1800) expect(largePng.length).toBeGreaterThan(MAX_IMAGE_READ_BYTES) - downloadWorkspaceFile.mockResolvedValue(largePng) + fetchWorkspaceFileBuffer.mockResolvedValue(largePng) const result = await readFileRecord({ id: 'wf_large',