From 38491bb1ff050b1d8161241c8d5e883d6b51a3f0 Mon Sep 17 00:00:00 2001 From: Matin Gathani Date: Thu, 2 Apr 2026 18:51:12 -0700 Subject: [PATCH] fix(blob): normalize contentDisposition in copy() when addRandomSuffix is enabled When copy() is called with addRandomSuffix: true, the Vercel Blob API returns a contentDisposition header containing the suffixed filename (e.g. 'attachment; filename="report-abc123.pdf"'). The SDK now normalizes this to always use the original toPathname filename by moving the shared normalizeContentDisposition helper to put-helpers.ts and applying it in copy.ts, matching the behaviour already enforced for put(). Adds two regression tests: one confirming normalization fires on suffix mismatch, one confirming no-op when pathname is unchanged. --- .../fix-blob-copy-content-disposition.md | 11 +++++ packages/blob/src/copy.ts | 7 ++- packages/blob/src/index.node.test.ts | 47 +++++++++++++++++++ packages/blob/src/put-helpers.ts | 27 +++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-blob-copy-content-disposition.md diff --git a/.changeset/fix-blob-copy-content-disposition.md b/.changeset/fix-blob-copy-content-disposition.md new file mode 100644 index 000000000..1de0f08e2 --- /dev/null +++ b/.changeset/fix-blob-copy-content-disposition.md @@ -0,0 +1,11 @@ +--- +'@vercel/blob': patch +--- + +fix(blob): normalize contentDisposition filename in copy() when addRandomSuffix is enabled + +When `copy()` is called with `addRandomSuffix: true`, the Vercel Blob API +returns a `contentDisposition` header containing the suffixed filename +(e.g. `attachment; filename="report-abc123.pdf"`). The SDK now normalizes +this to always use the original `toPathname` filename, matching the +behaviour already enforced for `put()`. diff --git a/packages/blob/src/copy.ts b/packages/blob/src/copy.ts index 5589f0be8..f69bf4c79 100644 --- a/packages/blob/src/copy.ts +++ b/packages/blob/src/copy.ts @@ -1,6 +1,7 @@ import { MAXIMUM_PATHNAME_LENGTH, requestApi } from './api'; import type { CommonCreateBlobOptions } from './helpers'; import { BlobError, disallowedPathnameCharacters } from './helpers'; +import { normalizeContentDisposition } from './put-helpers'; export type CopyCommandOptions = CommonCreateBlobOptions; @@ -98,7 +99,11 @@ export async function copy( downloadUrl: response.downloadUrl, pathname: response.pathname, contentType: response.contentType, - contentDisposition: response.contentDisposition, + contentDisposition: normalizeContentDisposition( + response.contentDisposition, + toPathname, + response.pathname, + ), etag: response.etag, }; } diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index 68285aaa7..2c3f79b75 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -1192,6 +1192,53 @@ describe('blob client', () => { }); expect(headers['x-vercel-blob-access']).toEqual('public'); }); + + it('normalizes contentDisposition to use original toPathname when API adds random suffix', async () => { + const mockedCopyResult = { + url: `${BLOB_STORE_BASE_URL}/destination-abc123.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/destination-abc123.txt?download=1`, + pathname: 'destination-abc123.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="destination-abc123.txt"', + etag: '"def456"', + }; + + mockClient + .intercept({ path: () => true, method: 'PUT' }) + .reply(200, mockedCopyResult); + + const result = await copy('source.txt', 'destination.txt', { + access: 'public', + addRandomSuffix: true, + }); + + expect(result.contentDisposition).toBe( + 'attachment; filename="destination.txt"', + ); + }); + + it('does not modify contentDisposition when copy pathname is unchanged', async () => { + const mockedCopyResult = { + url: `${BLOB_STORE_BASE_URL}/destination.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/destination.txt?download=1`, + pathname: 'destination.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="destination.txt"', + etag: '"def456"', + }; + + mockClient + .intercept({ path: () => true, method: 'PUT' }) + .reply(200, mockedCopyResult); + + const result = await copy('source.txt', 'destination.txt', { + access: 'public', + }); + + expect(result.contentDisposition).toBe( + 'attachment; filename="destination.txt"', + ); + }); }); describe('get', () => { diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index aa9f265ed..d862f5715 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -8,6 +8,33 @@ import type { ClientCommonCreateBlobOptions } from './client'; import type { CommonCreateBlobOptions } from './helpers'; import { BlobError, disallowedPathnameCharacters } from './helpers'; +/** + * Normalizes contentDisposition to use the original requested filename + * instead of the API-returned pathname (which may include a random suffix). + * This ensures `contentDisposition` always reflects the name the caller + * provided, regardless of `addRandomSuffix` being enabled. + */ +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 && + contentDisposition.includes(`"${responseFilename}"`) + ) { + return contentDisposition.replace( + `"${responseFilename}"`, + `"${originalFilename}"`, + ); + } + return contentDisposition; +} + export const putOptionHeaderMap = { cacheControlMaxAge: 'x-cache-control-max-age', addRandomSuffix: 'x-add-random-suffix',