From 3c9f53d00b38edbb3721a2c432aafdbbed19e333 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Thu, 2 Apr 2026 13:11:28 -0700 Subject: [PATCH 1/4] fix(blob): normalize contentDisposition to use original filename when addRandomSuffix is enabled When addRandomSuffix is configured for client uploads, the Blob API returns contentDisposition with the suffixed filename. This fix ensures the SDK always returns the original filename in contentDisposition, consistent with server put(). Fixes #903 --- .../fix-blob-client-content-disposition.md | 13 ++++++ packages/blob/src/index.node.test.ts | 34 ++++++++++++++- packages/blob/src/put.ts | 42 ++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-blob-client-content-disposition.md 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 68285aaa7..e35ce3e87 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -478,7 +478,7 @@ 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", "etag": ""abc123"", @@ -711,6 +711,38 @@ 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('sets the correct header when using the cacheControlMaxAge option', async () => { let headers: Record = {}; diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index fba85317b..e1a5018de 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -21,6 +21,27 @@ export interface PutCommandOptions multipart?: boolean; } +function normalizeContentDisposition( + contentDisposition: string, + originalPathname: string, + responsePathname: string, +): string { + const originalFilename = + originalPathname.split('/').pop() ?? originalPathname; + const responseFilename = + responsePathname.split('/').pop() ?? responsePathname; + if ( + originalFilename !== responseFilename && + contentDisposition.includes(`"${responseFilename}"`) + ) { + return contentDisposition.replace( + `"${responseFilename}"`, + `"${originalFilename}"`, + ); + } + return contentDisposition; +} + export function createPutMethod({ allowedOptions, getToken, @@ -51,7 +72,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 +113,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, }; }; From 37df19e9cbcd807882b645dc0e1b167731f01cd0 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Thu, 2 Apr 2026 13:15:48 -0700 Subject: [PATCH 2/4] fix(blob): include HTTP status in error when client token retrieval fails Replaces the vague 'Failed to retrieve the client token' message (also fixing the double-space typo) with one that includes the HTTP status code and status text, making auth errors (401, 403) easier to diagnose. Closes #488 --- .../fix-blob-client-token-error-message.md | 11 +++++++++++ packages/blob/src/client.browser.test.ts | 19 +++++++++++++++++++ packages/blob/src/client.ts | 4 +++- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-blob-client-token-error-message.md diff --git a/.changeset/fix-blob-client-token-error-message.md b/.changeset/fix-blob-client-token-error-message.md new file mode 100644 index 000000000..e28d28a4f --- /dev/null +++ b/.changeset/fix-blob-client-token-error-message.md @@ -0,0 +1,11 @@ +--- +"@vercel/blob": patch +--- + +fix(blob): include HTTP status in error when client token retrieval fails + +When `upload()` fails to retrieve a client token from `handleUploadUrl` +(e.g. the route returns 401, 403, or 500), the error message now includes +the HTTP status code and status text to help with debugging. + +Closes #488 diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index d86f29c9d..348a2a414 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -163,6 +163,25 @@ describe('client', () => { }, ); }); + + it('throws with HTTP status when handleUploadUrl returns an error', async () => { + jest.spyOn(undici, 'fetch').mockImplementation( + jest.fn().mockResolvedValueOnce({ + status: 403, + statusText: 'Forbidden', + ok: false, + }), + ); + + await expect( + upload('foo.txt', 'Test file data', { + access: 'public', + handleUploadUrl: '/api/upload', + }), + ).rejects.toThrow( + 'Vercel Blob: Failed to retrieve the client token: 403 Forbidden', + ); + }); }); describe('multipart upload', () => { diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 8c7eb12f1..d3eb5e658 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -698,7 +698,9 @@ async function retrieveClientToken(options: { }); if (!res.ok) { - throw new BlobError('Failed to retrieve the client token'); + throw new BlobError( + `Failed to retrieve the client token: ${res.status} ${res.statusText}`, + ); } try { From 3435dcf75416bb3f26e5a7d012ed4614ff798d97 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Thu, 2 Apr 2026 13:16:20 -0700 Subject: [PATCH 3/4] Revert "fix(blob): include HTTP status in error when client token retrieval fails" This reverts commit 37df19e9cbcd807882b645dc0e1b167731f01cd0. --- .../fix-blob-client-token-error-message.md | 11 ----------- packages/blob/src/client.browser.test.ts | 19 ------------------- packages/blob/src/client.ts | 4 +--- 3 files changed, 1 insertion(+), 33 deletions(-) delete mode 100644 .changeset/fix-blob-client-token-error-message.md diff --git a/.changeset/fix-blob-client-token-error-message.md b/.changeset/fix-blob-client-token-error-message.md deleted file mode 100644 index e28d28a4f..000000000 --- a/.changeset/fix-blob-client-token-error-message.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@vercel/blob": patch ---- - -fix(blob): include HTTP status in error when client token retrieval fails - -When `upload()` fails to retrieve a client token from `handleUploadUrl` -(e.g. the route returns 401, 403, or 500), the error message now includes -the HTTP status code and status text to help with debugging. - -Closes #488 diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index 348a2a414..d86f29c9d 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -163,25 +163,6 @@ describe('client', () => { }, ); }); - - it('throws with HTTP status when handleUploadUrl returns an error', async () => { - jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValueOnce({ - status: 403, - statusText: 'Forbidden', - ok: false, - }), - ); - - await expect( - upload('foo.txt', 'Test file data', { - access: 'public', - handleUploadUrl: '/api/upload', - }), - ).rejects.toThrow( - 'Vercel Blob: Failed to retrieve the client token: 403 Forbidden', - ); - }); }); describe('multipart upload', () => { diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index d3eb5e658..8c7eb12f1 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -698,9 +698,7 @@ async function retrieveClientToken(options: { }); if (!res.ok) { - throw new BlobError( - `Failed to retrieve the client token: ${res.status} ${res.statusText}`, - ); + throw new BlobError('Failed to retrieve the client token'); } try { From bc768a4917898da2054a666b9a68eff6838a4ff1 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Thu, 2 Apr 2026 15:02:29 -0700 Subject: [PATCH 4/4] test(blob): cover multipart contentDisposition normalization --- packages/blob/src/index.node.test.ts | 64 +++++++++++++++++++++++-- packages/blob/src/multipart/complete.ts | 17 ++++++- packages/blob/src/put-helpers.ts | 24 ++++++++++ packages/blob/src/put.ts | 27 ++--------- 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index dfa22e0a2..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(); @@ -480,10 +487,10 @@ describe('blob client', () => { { "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); @@ -743,6 +750,32 @@ describe('blob client', () => { 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 = {}; @@ -1182,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 e1a5018de..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, @@ -21,27 +25,6 @@ export interface PutCommandOptions multipart?: boolean; } -function normalizeContentDisposition( - contentDisposition: string, - originalPathname: string, - responsePathname: string, -): string { - const originalFilename = - originalPathname.split('/').pop() ?? originalPathname; - const responseFilename = - responsePathname.split('/').pop() ?? responsePathname; - if ( - originalFilename !== responseFilename && - contentDisposition.includes(`"${responseFilename}"`) - ) { - return contentDisposition.replace( - `"${responseFilename}"`, - `"${originalFilename}"`, - ); - } - return contentDisposition; -} - export function createPutMethod({ allowedOptions, getToken,