diff --git a/.changeset/fix-blob-client-content-disposition.md b/.changeset/fix-blob-client-content-disposition.md new file mode 100644 index 000000000..bc9afbcda --- /dev/null +++ b/.changeset/fix-blob-client-content-disposition.md @@ -0,0 +1,13 @@ +--- +"@vercel/blob": patch +--- + +fix(blob): normalize contentDisposition to use original filename when addRandomSuffix is enabled + +When `addRandomSuffix: true` is set for client uploads, the Vercel Blob API +returns a `contentDisposition` header that includes the random suffix in the +filename (e.g. `attachment; filename="img-abc123.jpg"`). This fix ensures the +SDK always returns the original filename in `contentDisposition` (e.g. +`attachment; filename="img.jpg"`), consistent with server-side `put()` behavior. + +Fixes #903 diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index c51ab9312..67c7a7349 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -458,6 +458,13 @@ describe('blob client', () => { contentDisposition: mockedFileMeta.contentDisposition, etag: mockedFileMeta.etag, }; + const mockedProgressFileMetaPut = { + ...mockedFileMetaPut, + url: `${BLOB_STORE_BASE_URL}/progress-id.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/progress-id.txt?download=1`, + pathname: 'progress.txt', + contentDisposition: 'attachment; filename="progress.txt"', + }; it('has an onUploadProgress option', async () => { mockClient @@ -466,7 +473,7 @@ describe('blob client', () => { method: 'PUT', }) .reply(200, () => { - return mockedFileMetaPut; + return mockedProgressFileMetaPut; }); const onUploadProgress = jest.fn(); @@ -478,12 +485,12 @@ describe('blob client', () => { }), ).resolves.toMatchInlineSnapshot(` { - "contentDisposition": "attachment; filename="foo.txt"", + "contentDisposition": "attachment; filename="progress.txt"", "contentType": "text/plain", - "downloadUrl": "https://storeId.public.blob.vercel-storage.com/foo-id.txt?download=1", + "downloadUrl": "https://storeId.public.blob.vercel-storage.com/progress-id.txt?download=1", "etag": ""abc123"", - "pathname": "foo.txt", - "url": "https://storeId.public.blob.vercel-storage.com/foo-id.txt", + "pathname": "progress.txt", + "url": "https://storeId.public.blob.vercel-storage.com/progress-id.txt", } `); expect(onUploadProgress).toHaveBeenCalledTimes(1); @@ -711,6 +718,64 @@ describe('blob client', () => { expect(headers['x-add-random-suffix']).toEqual('0'); }); + it('normalizes contentDisposition to use original filename when API adds random suffix', async () => { + mockClient.intercept({ path: () => true, method: 'PUT' }).reply(200, { + url: `${BLOB_STORE_BASE_URL}/foo-abc123.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/foo-abc123.txt?download=1`, + pathname: 'foo-abc123.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo-abc123.txt"', + etag: '"abc123"', + }); + + const result = await put('foo.txt', 'Test Body', { + access: 'public', + addRandomSuffix: true, + }); + + expect(result.contentDisposition).toBe('attachment; filename="foo.txt"'); + expect(result.pathname).toBe('foo-abc123.txt'); + }); + + it('does not modify contentDisposition when pathname is unchanged', async () => { + mockClient + .intercept({ path: () => true, method: 'PUT' }) + .reply(200, mockedFileMetaPut); + + const result = await put('foo.txt', 'Test Body', { + access: 'public', + addRandomSuffix: false, + }); + + expect(result.contentDisposition).toBe('attachment; filename="foo.txt"'); + }); + + it('normalizes contentDisposition for multipart put when completion returns a suffixed pathname', async () => { + mockClient.intercept({ path: () => true, method: 'POST' }).reply(200, { + uploadId: 'upload-123', + key: 'foo.txt', + }); + mockClient.intercept({ path: () => true, method: 'POST' }).reply(200, { + etag: 'etag-123', + }); + mockClient.intercept({ path: () => true, method: 'POST' }).reply(200, { + url: `${BLOB_STORE_BASE_URL}/foo-abc123.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/foo-abc123.txt?download=1`, + pathname: 'foo-abc123.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo-abc123.txt"', + }); + + const result = await put('foo.txt', 'Test Body', { + access: 'public', + multipart: true, + addRandomSuffix: true, + }); + + expect(result.contentDisposition).toBe('attachment; filename="foo.txt"'); + expect(result.pathname).toBe('foo-abc123.txt'); + }); + it('sets the correct header when using the cacheControlMaxAge option', async () => { let headers: Record = {}; @@ -1150,6 +1215,29 @@ describe('blob client', () => { expect(headers['x-vercel-blob-access']).toEqual('public'); }); + it('normalizes contentDisposition when multipart completion returns a suffixed pathname', async () => { + mockClient.intercept({ path: () => true, method: 'POST' }).reply(200, { + url: `${BLOB_STORE_BASE_URL}/foo-abc123.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/foo-abc123.txt?download=1`, + pathname: 'foo-abc123.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo-abc123.txt"', + }); + + const result = await completeMultipartUpload( + 'foo.txt', + [{ partNumber: 1, etag: 'etag-123' }], + { + access: 'public', + key: 'foo.txt', + uploadId: 'upload-123', + }, + ); + + expect(result.contentDisposition).toBe('attachment; filename="foo.txt"'); + expect(result.pathname).toBe('foo-abc123.txt'); + }); + it('should throw when using an invalid access value', async () => { await expect( completeMultipartUpload( diff --git a/packages/blob/src/multipart/complete.ts b/packages/blob/src/multipart/complete.ts index 1a5cf7f2d..2387dee60 100644 --- a/packages/blob/src/multipart/complete.ts +++ b/packages/blob/src/multipart/complete.ts @@ -6,7 +6,11 @@ import type { PutBlobApiResponse, PutBlobResult, } from '../put-helpers'; -import { createPutHeaders, createPutOptions } from '../put-helpers'; +import { + createPutHeaders, + createPutOptions, + normalizeContentDisposition, +} from '../put-helpers'; import type { Part } from './helpers'; /** @@ -43,7 +47,7 @@ export function createCompleteMultipartUploadMethod< const headers = createPutHeaders(allowedOptions, options); - return completeMultipartUpload({ + const response = await completeMultipartUpload({ uploadId: options.uploadId, key: options.key, pathname, @@ -51,6 +55,15 @@ export function createCompleteMultipartUploadMethod< options, parts, }); + + return { + ...response, + contentDisposition: normalizeContentDisposition( + response.contentDisposition, + pathname, + response.pathname, + ), + }; }; } diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index aa9f265ed..50f945b99 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -65,6 +65,30 @@ export type PutBody = export type CommonPutCommandOptions = CommonCreateBlobOptions & ClientCommonCreateBlobOptions; +export function normalizeContentDisposition( + contentDisposition: string, + originalPathname: string, + responsePathname: string, +): string { + const originalFilename = + originalPathname.split('/').pop() ?? originalPathname; + const responseFilename = + responsePathname.split('/').pop() ?? responsePathname; + + if (originalFilename === responseFilename) { + return contentDisposition; + } + + return contentDisposition.replace( + new RegExp(`filename="${escapeRegExp(responseFilename)}"`), + `filename="${originalFilename}"`, + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export interface CreatePutMethodOptions { allowedOptions: (keyof typeof putOptionHeaderMap)[]; getToken?: (pathname: string, options: TOptions) => Promise; diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index fba85317b..afc8f3c48 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -9,7 +9,11 @@ import type { PutBlobResult, PutBody, } from './put-helpers'; -import { createPutHeaders, createPutOptions } from './put-helpers'; +import { + createPutHeaders, + createPutOptions, + normalizeContentDisposition, +} from './put-helpers'; export interface PutCommandOptions extends CommonCreateBlobOptions, @@ -51,7 +55,20 @@ export function createPutMethod({ const headers = createPutHeaders(allowedOptions, options); if (options.multipart === true) { - return uncontrolledMultipartUpload(pathname, body, headers, options); + const result = await uncontrolledMultipartUpload( + pathname, + body, + headers, + options, + ); + return { + ...result, + contentDisposition: normalizeContentDisposition( + result.contentDisposition, + pathname, + result.pathname, + ), + }; } const onUploadProgress = options.onUploadProgress @@ -79,7 +96,11 @@ export function createPutMethod({ downloadUrl: response.downloadUrl, pathname: response.pathname, contentType: response.contentType, - contentDisposition: response.contentDisposition, + contentDisposition: normalizeContentDisposition( + response.contentDisposition, + pathname, + response.pathname, + ), etag: response.etag, }; };