Skip to content

Commit a49b4f9

Browse files
committed
fix(chat): preserve direct downloads for CORS-restricted files
1 parent 5a09f8d commit a49b4f9

2 files changed

Lines changed: 81 additions & 9 deletions

File tree

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,42 @@ describe('chat file downloads', () => {
154154
expect(downloadedNames).toEqual(['generated.png'])
155155
})
156156

157+
it.each([false, true])(
158+
'offers a safe browser download when an external host blocks CORS (stored=%s)',
159+
async (stored) => {
160+
if (stored) fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))
161+
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
162+
const container = renderFile({
163+
...imageFile,
164+
base64: undefined,
165+
key: stored ? imageFile.key : 'result-123',
166+
})
167+
await clickDownload(container)
168+
const link = container.querySelector('a')!
169+
expect(link.href).toBe(imageFile.url)
170+
expect(link.download).toBe(imageFile.name)
171+
expect(link.rel).toBe('noopener noreferrer')
172+
expect(link.target).toBe('_blank')
173+
expect(window.open).not.toHaveBeenCalled()
174+
}
175+
)
176+
177+
it('cancels both discarded authentication and failed download response bodies', async () => {
178+
const cancelAuthentication = vi.fn()
179+
const cancelDownload = vi.fn()
180+
fetchMock.mockResolvedValueOnce(
181+
new Response(new ReadableStream({ cancel: cancelAuthentication }), { status: 401 })
182+
)
183+
fetchMock.mockResolvedValueOnce(
184+
new Response(new ReadableStream({ cancel: cancelDownload }), { status: 403 })
185+
)
186+
const container = renderFile({ ...imageFile, base64: undefined })
187+
await clickDownload(container)
188+
expect(cancelAuthentication).toHaveBeenCalledTimes(1)
189+
expect(cancelDownload).toHaveBeenCalledTimes(1)
190+
expect(container.querySelector('a')).toBeNull()
191+
})
192+
157193
it('shows download errors without opening an expired storage error page', async () => {
158194
fetchMock
159195
.mockResolvedValueOnce(new Response(null, { status: 401 }))
@@ -170,9 +206,11 @@ describe('chat file downloads', () => {
170206
'does not retry denied or deleted stored files through their old URLs (%s)',
171207
async (status) => {
172208
fetchMock.mockResolvedValue(new Response(null, { status }))
173-
await clickDownload(renderFile({ ...imageFile, base64: undefined }))
209+
const container = renderFile({ ...imageFile, base64: undefined })
210+
await clickDownload(container)
174211
expect(fetchMock).toHaveBeenCalledTimes(1)
175212
expect(downloadedNames).toEqual([])
213+
expect(container.querySelector('a')).toBeNull()
176214
}
177215
)
178216

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ interface ChatFileDownloadAllProps {
2121
files: ChatFile[]
2222
}
2323

24+
class DirectDownloadRequiredError extends Error {
25+
constructor(readonly url: string) {
26+
super('This file must be downloaded directly in the browser.')
27+
}
28+
}
29+
30+
async function fetchExternalFile(url: string): Promise<Response> {
31+
try {
32+
return await fetch(url, { cache: 'no-store' })
33+
} catch {
34+
/** A navigation can download external files whose hosts do not allow CORS reads. */
35+
throw new DirectDownloadRequiredError(url)
36+
}
37+
}
38+
2439
function formatFileSize(bytes: number): string {
2540
if (bytes === 0) return '0 B'
2641
const k = 1024
@@ -80,13 +95,17 @@ async function triggerDownload(file: ChatFile): Promise<void> {
8095
if (!url) throw new Error('File has no download URL')
8196

8297
/** The same serve route as execution logs resolves current storage access on each click. */
83-
// boundary-raw-fetch: binary file download, including externally hosted file URLs
84-
let response = await fetch(url, { cache: 'no-store' })
98+
let response = hasStorageKey
99+
? // boundary-raw-fetch: binary file download through the authorized serve route
100+
await fetch(url, { cache: 'no-store' })
101+
: await fetchExternalFile(url)
85102
if (hasStorageKey && response.status === 401 && isSafeHttpUrl(file.url)) {
103+
await response.body?.cancel()
86104
/** Public chat visitors may only have the file access already delivered in the response. */
87-
response = await fetch(file.url, { cache: 'no-store' })
105+
response = await fetchExternalFile(file.url)
88106
}
89107
if (!response.ok) {
108+
await response.body?.cancel()
90109
throw new Error('Unable to download this file. Please try again or request a new copy.')
91110
}
92111

@@ -95,22 +114,22 @@ async function triggerDownload(file: ChatFile): Promise<void> {
95114

96115
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
97116
const [isDownloading, setIsDownloading] = useState(false)
98-
const [downloadFailed, setDownloadFailed] = useState(false)
117+
const [downloadError, setDownloadError] = useState<{ directUrl?: string } | null>(null)
99118
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null)
100119
const fileUrl = getFileUrl(file)
101120

102121
const handleDownload = async () => {
103122
if (isDownloading) return
104123

105124
setIsDownloading(true)
106-
setDownloadFailed(false)
125+
setDownloadError(null)
107126

108127
try {
109128
logger.info(`Initiating download for file: ${file.name}`)
110129
await triggerDownload(file)
111130
} catch (error) {
112131
logger.error(`Failed to download file ${file.name}:`, error)
113-
setDownloadFailed(true)
132+
setDownloadError(error instanceof DirectDownloadRequiredError ? { directUrl: error.url } : {})
114133
} finally {
115134
setIsDownloading(false)
116135
}
@@ -158,9 +177,24 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
158177
)}
159178
</div>
160179
</Button>
161-
{downloadFailed && (
180+
{downloadError && (
162181
<p role='alert' className='text-[var(--text-error)] text-xs'>
163-
Unable to download this file. Please try again or request a new copy.
182+
{downloadError.directUrl ? (
183+
<>
184+
Unable to download automatically.{' '}
185+
<a
186+
href={downloadError.directUrl}
187+
download={file.name}
188+
target='_blank'
189+
rel='noopener noreferrer'
190+
className='underline'
191+
>
192+
Download directly
193+
</a>
194+
</>
195+
) : (
196+
'Unable to download this file. Please try again or request a new copy.'
197+
)}
164198
</p>
165199
)}
166200
</div>

0 commit comments

Comments
 (0)