diff --git a/src/base/common/downloadUtil.test.ts b/src/base/common/downloadUtil.test.ts index 8684c8e1c..7ab107f39 100644 --- a/src/base/common/downloadUtil.test.ts +++ b/src/base/common/downloadUtil.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { downloadFile } from '@/base/common/downloadUtil' +import { + downloadFile, + extractFilenameFromContentDisposition +} from '@/base/common/downloadUtil' let mockIsCloud = false @@ -155,10 +158,14 @@ describe('downloadUtil', () => { const testUrl = 'https://storage.googleapis.com/bucket/file.bin' const blob = new Blob(['test']) const blobFn = vi.fn().mockResolvedValue(blob) + const headersMock = { + get: vi.fn().mockReturnValue(null) + } fetchMock.mockResolvedValue({ ok: true, status: 200, - blob: blobFn + blob: blobFn, + headers: headersMock } as unknown as Response) downloadFile(testUrl) @@ -195,5 +202,147 @@ describe('downloadUtil', () => { expect(createObjectURLSpy).not.toHaveBeenCalled() consoleSpy.mockRestore() }) + + it('uses filename from Content-Disposition header in cloud mode', async () => { + mockIsCloud = true + const testUrl = 'https://storage.googleapis.com/bucket/abc123.png' + const blob = new Blob(['test']) + const blobFn = vi.fn().mockResolvedValue(blob) + const headersMock = { + get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"') + } + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + } as unknown as Response) + + downloadFile(testUrl) + + expect(fetchMock).toHaveBeenCalledWith(testUrl) + const fetchPromise = fetchMock.mock.results[0].value as Promise + await fetchPromise + const blobPromise = blobFn.mock.results[0].value as Promise + await blobPromise + await Promise.resolve() + expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition') + expect(mockLink.download).toBe('user-friendly.png') + }) + + it('uses RFC 5987 filename from Content-Disposition header', async () => { + mockIsCloud = true + const testUrl = 'https://storage.googleapis.com/bucket/abc123.png' + const blob = new Blob(['test']) + const blobFn = vi.fn().mockResolvedValue(blob) + const headersMock = { + get: vi + .fn() + .mockReturnValue( + 'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png' + ) + } + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + } as unknown as Response) + + downloadFile(testUrl) + + const fetchPromise = fetchMock.mock.results[0].value as Promise + await fetchPromise + const blobPromise = blobFn.mock.results[0].value as Promise + await blobPromise + await Promise.resolve() + expect(mockLink.download).toBe('中文.png') + }) + + it('falls back to provided filename when Content-Disposition is missing', async () => { + mockIsCloud = true + const testUrl = 'https://storage.googleapis.com/bucket/abc123.png' + const blob = new Blob(['test']) + const blobFn = vi.fn().mockResolvedValue(blob) + const headersMock = { + get: vi.fn().mockReturnValue(null) + } + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + } as unknown as Response) + + downloadFile(testUrl, 'my-fallback.png') + + const fetchPromise = fetchMock.mock.results[0].value as Promise + await fetchPromise + const blobPromise = blobFn.mock.results[0].value as Promise + await blobPromise + await Promise.resolve() + expect(mockLink.download).toBe('my-fallback.png') + }) + }) + + describe('extractFilenameFromContentDisposition', () => { + it('returns null for null header', () => { + expect(extractFilenameFromContentDisposition(null)).toBeNull() + }) + + it('returns null for empty header', () => { + expect(extractFilenameFromContentDisposition('')).toBeNull() + }) + + it('extracts filename from simple quoted format', () => { + const header = 'attachment; filename="test-file.png"' + expect(extractFilenameFromContentDisposition(header)).toBe( + 'test-file.png' + ) + }) + + it('extracts filename from unquoted format', () => { + const header = 'attachment; filename=test-file.png' + expect(extractFilenameFromContentDisposition(header)).toBe( + 'test-file.png' + ) + }) + + it('extracts filename from RFC 5987 format', () => { + const header = "attachment; filename*=UTF-8''test%20file.png" + expect(extractFilenameFromContentDisposition(header)).toBe( + 'test file.png' + ) + }) + + it('prefers RFC 5987 format over simple format', () => { + const header = + 'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png' + expect(extractFilenameFromContentDisposition(header)).toBe( + 'preferred.png' + ) + }) + + it('handles unicode characters in RFC 5987 format', () => { + const header = + "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png" + expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png') + }) + + it('falls back to simple format when RFC 5987 decoding fails', () => { + const header = + 'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid' + expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png') + }) + + it('handles header with only attachment disposition', () => { + const header = 'attachment' + expect(extractFilenameFromContentDisposition(header)).toBeNull() + }) + + it('handles case-insensitive filename parameter', () => { + const header = 'attachment; FILENAME="test.png"' + expect(extractFilenameFromContentDisposition(header)).toBe('test.png') + }) }) }) diff --git a/src/base/common/downloadUtil.ts b/src/base/common/downloadUtil.ts index 4462dd1a7..78bb1ca56 100644 --- a/src/base/common/downloadUtil.ts +++ b/src/base/common/downloadUtil.ts @@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => { } } +/** + * Extract filename from Content-Disposition header + * Handles both simple format: attachment; filename="name.png" + * And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png + * @param header - The Content-Disposition header value + * @returns The extracted filename or null if not found + */ +export function extractFilenameFromContentDisposition( + header: string | null +): string | null { + if (!header) return null + + // Try RFC 5987 extended format first (filename*=UTF-8''...) + const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i) + if (extendedMatch?.[1]) { + try { + return decodeURIComponent(extendedMatch[1]) + } catch { + // Fall through to simple format + } + } + + // Try simple quoted format: filename="..." + const quotedMatch = header.match(/filename="([^"]+)"/i) + if (quotedMatch?.[1]) { + return quotedMatch[1] + } + + // Try unquoted format: filename=... + const unquotedMatch = header.match(/filename=([^;\s]+)/i) + if (unquotedMatch?.[1]) { + return unquotedMatch[1] + } + + return null +} + const downloadViaBlobFetch = async ( href: string, - filename: string + fallbackFilename: string ): Promise => { const response = await fetch(href) if (!response.ok) { throw new Error(`Failed to fetch ${href}: ${response.status}`) } + + // Try to get filename from Content-Disposition header (set by backend) + const contentDisposition = response.headers.get('Content-Disposition') + const headerFilename = + extractFilenameFromContentDisposition(contentDisposition) + const blob = await response.blob() - downloadBlob(filename, blob) + downloadBlob(headerFilename ?? fallbackFilename, blob) }