Skip to content

Commit a27a62f

Browse files
committed
fix(chat): download generated files without stale storage URLs
1 parent ae4981e commit a27a62f

4 files changed

Lines changed: 239 additions & 26 deletions

File tree

apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4+
import { Blob as NodeBlob } from 'node:buffer'
45
import { act } from 'react'
56
import { createRoot } from 'react-dom/client'
6-
import { afterEach, describe, expect, it, vi } from 'vitest'
7-
import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import {
9+
ChatFileDownload,
10+
ChatFileDownloadAll,
11+
} from '@/app/(interfaces)/chat/components/message/components/file-download'
812
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
913

1014
const imageFile: ChatFile = {
@@ -17,20 +21,47 @@ const imageFile: ChatFile = {
1721
base64: 'YWJj',
1822
}
1923

24+
const fetchMock = vi.fn<typeof fetch>()
25+
const createObjectURL = vi.fn((_blob: Blob) => 'blob:download')
26+
const downloadedNames: string[] = []
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
downloadedNames.length = 0
31+
vi.stubGlobal('fetch', fetchMock)
32+
vi.stubGlobal('Blob', NodeBlob)
33+
vi.stubGlobal(
34+
'URL',
35+
class extends URL {
36+
static createObjectURL = createObjectURL
37+
static revokeObjectURL = vi.fn()
38+
}
39+
)
40+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () {
41+
downloadedNames.push(this.download)
42+
})
43+
vi.spyOn(window, 'open').mockImplementation(() => null)
44+
})
45+
2046
const mounts: Array<() => void> = []
2147

22-
function renderFile(file: ChatFile): HTMLDivElement {
48+
function renderFile(file: ChatFile | ChatFile[]): HTMLDivElement {
2349
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
2450
const container = document.createElement('div')
2551
const root = createRoot(container)
26-
act(() => root.render(<ChatFileDownload file={file} />))
52+
act(() =>
53+
root.render(
54+
Array.isArray(file) ? <ChatFileDownloadAll files={file} /> : <ChatFileDownload file={file} />
55+
)
56+
)
2757
mounts.push(() => act(() => root.unmount()))
2858
return container
2959
}
3060

3161
afterEach(() => {
3262
while (mounts.length) mounts.pop()?.()
3363
vi.restoreAllMocks()
64+
vi.unstubAllGlobals()
3465
})
3566

3667
describe('ChatFileDownload', () => {
@@ -67,3 +98,102 @@ describe('ChatFileDownload', () => {
6798
expect(container.querySelector('button')?.textContent).toContain('report.pdf')
6899
})
69100
})
101+
102+
async function clickDownload(container: HTMLDivElement): Promise<void> {
103+
await act(async () => container.querySelector('button')!.click())
104+
}
105+
106+
describe('chat file downloads', () => {
107+
it('downloads exact inline bytes without fetching a data URL or needing a session', async () => {
108+
fetchMock.mockRejectedValue(new TypeError('Blocked by connect-src'))
109+
const container = renderFile({ ...imageFile, base64: 'AP9/gAE=' })
110+
await clickDownload(container)
111+
expect(fetchMock).not.toHaveBeenCalled()
112+
const blob = createObjectURL.mock.calls[0]![0]
113+
expect([...new Uint8Array(await blob.arrayBuffer())]).toEqual([0, 255, 127, 128, 1])
114+
expect(blob.type).toBe('image/png')
115+
expect(downloadedNames).toEqual(['generated.png'])
116+
expect(window.open).not.toHaveBeenCalled()
117+
})
118+
119+
it.each(['s3', 'blob', 'gcs', 'local'])(
120+
'downloads stored %s files through the logs serve route instead of stale URLs',
121+
async (provider) => {
122+
fetchMock.mockResolvedValue(new Response('current stored bytes'))
123+
const file = {
124+
...imageFile,
125+
base64: undefined,
126+
key: `execution/workspace/workflow/run/${provider}.png`,
127+
url: 'https://files.example.com/expired?X-Amz-Expires=300',
128+
}
129+
await clickDownload(renderFile(file))
130+
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
131+
`/api/files/serve/${encodeURIComponent(file.key)}?context=execution`,
132+
{ cache: 'no-store' }
133+
)
134+
expect(await createObjectURL.mock.calls[0]![0].text()).toBe('current stored bytes')
135+
expect(downloadedNames).toEqual(['generated.png'])
136+
}
137+
)
138+
139+
it('keeps external URL files on their existing URL path', async () => {
140+
fetchMock.mockResolvedValue(new Response('external bytes'))
141+
await clickDownload(renderFile({ ...imageFile, base64: undefined, key: 'url/external' }))
142+
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(imageFile.url, { cache: 'no-store' })
143+
})
144+
145+
it('preserves delivered signed access for public visitors without a workspace session', async () => {
146+
fetchMock
147+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
148+
.mockResolvedValueOnce(new Response('publicly delivered bytes'))
149+
await clickDownload(renderFile({ ...imageFile, base64: undefined }))
150+
expect(fetchMock).toHaveBeenNthCalledWith(2, imageFile.url, { cache: 'no-store' })
151+
expect(downloadedNames).toEqual(['generated.png'])
152+
})
153+
154+
it('shows download errors without opening an expired storage error page', async () => {
155+
fetchMock
156+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
157+
.mockResolvedValueOnce(new Response('<Error>Request has expired</Error>', { status: 403 }))
158+
const container = renderFile({ ...imageFile, base64: undefined })
159+
await clickDownload(container)
160+
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Unable to download')
161+
expect(downloadedNames).toEqual([])
162+
expect(window.open).not.toHaveBeenCalled()
163+
expect(container.querySelector('button')?.disabled).toBe(false)
164+
})
165+
166+
it.each([403, 404])(
167+
'does not retry denied or deleted stored files through their old URLs (%s)',
168+
async (status) => {
169+
fetchMock.mockResolvedValue(new Response(null, { status }))
170+
await clickDownload(renderFile({ ...imageFile, base64: undefined }))
171+
expect(fetchMock).toHaveBeenCalledTimes(1)
172+
expect(downloadedNames).toEqual([])
173+
}
174+
)
175+
176+
it('uses the same inline and storage handling for download all', async () => {
177+
fetchMock.mockResolvedValue(new Response('stored bytes'))
178+
const stored = { ...imageFile, id: 'stored', name: 'stored.png', base64: undefined }
179+
const container = renderFile([imageFile, stored])
180+
await act(async () => {
181+
container.querySelector('button')!.click()
182+
await vi.waitFor(() => expect(downloadedNames).toEqual(['generated.png', 'stored.png']))
183+
})
184+
expect(fetchMock).toHaveBeenCalledTimes(1)
185+
})
186+
})
187+
188+
it('refuses unsafe external file URLs', async () => {
189+
const container = renderFile({
190+
...imageFile,
191+
base64: undefined,
192+
key: 'url/external',
193+
url: 'javascript:alert(1)',
194+
})
195+
await clickDownload(container)
196+
expect(fetchMock).not.toHaveBeenCalled()
197+
expect(window.open).not.toHaveBeenCalled()
198+
expect(downloadedNames).toEqual([])
199+
})

apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger'
77
import { sleep } from '@sim/utils/helpers'
88
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
99
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
10+
import { saveBlob } from '@/lib/uploads/client/download'
1011
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
1112

1213
const logger = createLogger('ChatFileDownload')
@@ -56,45 +57,58 @@ function getFileUrl(file: ChatFile): string {
5657
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
5758
}
5859

59-
async function triggerDownload(url: string, filename: string): Promise<void> {
60-
const response = await fetch(url)
61-
if (!response.ok) {
62-
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
60+
async function triggerDownload(file: ChatFile): Promise<void> {
61+
if (file.base64) {
62+
/** Decoding locally avoids a data-URL fetch, which connect-src does not allow. */
63+
const decoded = atob(file.base64)
64+
const bytes = new Uint8Array(decoded.length)
65+
for (let index = 0; index < decoded.length; index++) {
66+
bytes[index] = decoded.charCodeAt(index)
67+
}
68+
saveBlob(new Blob([bytes], { type: file.type }), file.name)
69+
return
6370
}
6471

65-
const blob = await response.blob()
66-
const blobUrl = URL.createObjectURL(blob)
67-
68-
const link = document.createElement('a')
69-
link.href = blobUrl
70-
link.download = filename
71-
document.body.appendChild(link)
72-
link.click()
73-
document.body.removeChild(link)
72+
const hasStorageKey = Boolean(file.key && !file.key.startsWith('url/'))
73+
const url = hasStorageKey
74+
? `/api/files/serve/${encodeURIComponent(file.key)}?context=${encodeURIComponent(file.context || 'execution')}`
75+
: isSafeHttpUrl(file.url)
76+
? file.url
77+
: null
78+
if (!url) throw new Error('File has no download URL')
79+
80+
/** The same serve route as execution logs resolves current storage access on each click. */
81+
// boundary-raw-fetch: binary file download, including externally hosted file URLs
82+
let response = await fetch(url, { cache: 'no-store' })
83+
if (hasStorageKey && response.status === 401 && isSafeHttpUrl(file.url)) {
84+
/** Public chat visitors may only have the file access already delivered in the response. */
85+
response = await fetch(file.url, { cache: 'no-store' })
86+
}
87+
if (!response.ok) {
88+
throw new Error('Unable to download this file. Please try again or request a new copy.')
89+
}
7490

75-
URL.revokeObjectURL(blobUrl)
76-
logger.info(`Downloaded: ${filename}`)
91+
saveBlob(await response.blob(), file.name)
7792
}
7893

7994
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
8095
const [isDownloading, setIsDownloading] = useState(false)
96+
const [downloadFailed, setDownloadFailed] = useState(false)
8197
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null)
8298
const fileUrl = getFileUrl(file)
8399

84100
const handleDownload = async () => {
85101
if (isDownloading) return
86102

87103
setIsDownloading(true)
104+
setDownloadFailed(false)
88105

89106
try {
90107
logger.info(`Initiating download for file: ${file.name}`)
91-
const url = getFileUrl(file)
92-
await triggerDownload(url, file.name)
108+
await triggerDownload(file)
93109
} catch (error) {
94110
logger.error(`Failed to download file ${file.name}:`, error)
95-
if (file.url && isSafeHttpUrl(file.url)) {
96-
window.open(file.url, '_blank', 'noopener,noreferrer')
97-
}
111+
setDownloadFailed(true)
98112
} finally {
99113
setIsDownloading(false)
100114
}
@@ -142,6 +156,11 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
142156
)}
143157
</div>
144158
</Button>
159+
{downloadFailed && (
160+
<p role='alert' className='text-[var(--text-error)] text-xs'>
161+
Unable to download this file. Please try again or request a new copy.
162+
</p>
163+
)}
145164
</div>
146165
)
147166
}
@@ -162,8 +181,7 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
162181
for (let i = 0; i < files.length; i++) {
163182
const file = files[i]
164183
try {
165-
const url = getFileUrl(file)
166-
await triggerDownload(url, file.name)
184+
await triggerDownload(file)
167185
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)
168186

169187
if (i < files.length - 1) {

apps/sim/app/api/files/authorization.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,48 @@ describe('KB file live source authorization', () => {
491491
expect(get).not.toHaveBeenCalled()
492492
})
493493
})
494+
495+
/** Execution downloads share the logs endpoint's current workspace permission check. */
496+
describe('execution file download authorization', () => {
497+
const executionKey = 'execution/owner-workspace/workflow/run/image.png'
498+
499+
beforeEach(() => {
500+
vi.clearAllMocks()
501+
})
502+
503+
it('allows a current reader of the workspace named by the storage key', async () => {
504+
mockGetUserEntityPermissions.mockResolvedValue('read')
505+
await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe(
506+
true
507+
)
508+
expect(mockGetUserEntityPermissions).toHaveBeenCalledExactlyOnceWith(
509+
USER_ID,
510+
'workspace',
511+
'owner-workspace'
512+
)
513+
})
514+
515+
it('denies a caller without access to the file workspace', async () => {
516+
mockGetUserEntityPermissions.mockResolvedValue(null)
517+
await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe(
518+
false
519+
)
520+
})
521+
522+
it('rechecks access after membership is revoked', async () => {
523+
mockGetUserEntityPermissions.mockResolvedValueOnce('read').mockResolvedValueOnce(null)
524+
await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe(
525+
true
526+
)
527+
await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe(
528+
false
529+
)
530+
})
531+
532+
it('denies a malformed execution key before looking up workspace access', async () => {
533+
await expect(
534+
verifyFileAccess('execution/image.png', USER_ID, undefined, 'execution')
535+
).resolves.toBe(false)
536+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
537+
})
538+
})

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,26 @@ describe('File Serve API Route', () => {
200200
})
201201
})
202202

203+
it('requires authentication for execution downloads before reading bytes', async () => {
204+
mockResolveStoredFileContext.mockResolvedValue('execution')
205+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
206+
success: false,
207+
error: 'Unauthorized',
208+
})
209+
const response = await GET(
210+
new NextRequest(
211+
'http://localhost/api/files/serve/execution%2Fworkspace%2Fworkflow%2Frun%2Fimage.png?context=execution'
212+
),
213+
{
214+
params: Promise.resolve({ path: ['execution/workspace/workflow/run/image.png'] }),
215+
}
216+
)
217+
expect(response.status).toBe(401)
218+
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
219+
expect(mockReadFile).not.toHaveBeenCalled()
220+
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
221+
})
222+
203223
it('bounds every buffered read at the shared transfer ceiling', async () => {
204224
mockIsUsingCloudStorage.mockReturnValue(true)
205225
mockResolveStoredFileContext.mockResolvedValue('copilot')

0 commit comments

Comments
 (0)