Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-blob-copy-content-disposition.md
Original file line number Diff line number Diff line change
@@ -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()`.
Comment on lines +7 to +11
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset says the copy() normalization "matches the behaviour already enforced for put()", but put() currently returns response.contentDisposition unchanged (see packages/blob/src/put.ts). Either update the changeset wording to avoid claiming parity with put(), or update put() to use the same normalization helper so the statement is accurate.

Copilot uses AI. Check for mistakes.
7 changes: 6 additions & 1 deletion packages/blob/src/copy.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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,
};
}
47 changes: 47 additions & 0 deletions packages/blob/src/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
27 changes: 27 additions & 0 deletions packages/blob/src/put-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +13 to +15
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring claims this "ensures contentDisposition always reflects the name the caller provided", but the implementation only rewrites when the header contains an exact quoted match of the response filename (and otherwise returns the original string). Consider tightening the wording to reflect the actual conditional behavior, or expanding the normalization to cover all expected Content-Disposition formats if the stronger guarantee is required.

Suggested change
* 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.
* instead of the API-returned pathname (which may include a random suffix),
* when the header contains an exact quoted match for the response filename.
* If no such quoted filename is present, the original contentDisposition
* value is returned unchanged.

Copilot uses AI. Check for mistakes.
*/
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',
Expand Down
Loading