Skip to content

Commit 54ce5f8

Browse files
authored
fix(@vercel/blob): Allow any character to be used in filenames. (#830)
* fix(@vercel/blob): Allow any character to be used in filenames. Before this commit, we would fail and even refuse to create files that contained special characters like `#` `!` `%` `'` `"`. We've since prepared an API update of the Vercel Blob API to allow any character to be used and properly hanlded. Fixes #828 * add changeset
1 parent 31532bf commit 54ce5f8

File tree

13 files changed

+78
-62
lines changed

13 files changed

+78
-62
lines changed

.changeset/sour-turkeys-beg.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@vercel/blob': patch
3+
---
4+
5+
Allow all special characters to be used as pathname.
6+
You can now use all the characters you want in pathname even the ones that have
7+
special meaning in urls like `%!'()@{}[]#` and it will work as expected.

packages/blob/src/api.node.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('api', () => {
6666
authorization: 'Bearer 123',
6767
'x-api-blob-request-attempt': '0',
6868
'x-api-blob-request-id': expect.any(String) as string,
69-
'x-api-version': '8',
69+
'x-api-version': '9',
7070
},
7171
method: 'POST',
7272
},

packages/blob/src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export interface BlobApiError {
126126
// This version is used to ensure that the client and server are compatible
127127
// The server (Vercel Blob API) uses this information to change its behavior like the
128128
// response format
129-
const BLOB_API_VERSION = 8;
129+
const BLOB_API_VERSION = 9;
130130

131131
function getApiVersion(): string {
132132
let versionOverride = null;

packages/blob/src/client.browser.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ describe('client', () => {
8585
);
8686
expect(fetchMock).toHaveBeenNthCalledWith(
8787
2,
88-
'https://blob.vercel-storage.com/foo.txt',
88+
'https://blob.vercel-storage.com/?pathname=foo.txt',
8989
{
9090
body: 'Test file data',
9191
headers: {
9292
authorization: 'Bearer vercel_blob_client_fake_123',
9393
'x-api-blob-request-attempt': '0',
9494
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
95-
'x-api-version': '8',
95+
'x-api-version': '9',
9696
},
9797
method: 'PUT',
9898
},
@@ -201,13 +201,13 @@ describe('client', () => {
201201

202202
expect(fetchMock).toHaveBeenNthCalledWith(
203203
1,
204-
'https://blob.vercel-storage.com/mpu/foo.txt',
204+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
205205
{
206206
headers: {
207207
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
208208
'x-api-blob-request-attempt': '0',
209209
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
210-
'x-api-version': '8',
210+
'x-api-version': '9',
211211
'x-mpu-action': 'create',
212212
},
213213
method: 'POST',
@@ -217,14 +217,14 @@ describe('client', () => {
217217
const internalAbortSignal = new AbortController().signal;
218218
expect(fetchMock).toHaveBeenNthCalledWith(
219219
2,
220-
'https://blob.vercel-storage.com/mpu/foo.txt',
220+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
221221
{
222222
body: 'data1',
223223
headers: {
224224
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
225225
'x-api-blob-request-attempt': '0',
226226
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
227-
'x-api-version': '8',
227+
'x-api-version': '9',
228228
'x-mpu-action': 'upload',
229229
'x-mpu-key': 'key',
230230
'x-mpu-upload-id': 'uploadId',
@@ -236,14 +236,14 @@ describe('client', () => {
236236
);
237237
expect(fetchMock).toHaveBeenNthCalledWith(
238238
3,
239-
'https://blob.vercel-storage.com/mpu/foo.txt',
239+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
240240
{
241241
body: 'data2',
242242
headers: {
243243
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
244244
'x-api-blob-request-attempt': '0',
245245
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
246-
'x-api-version': '8',
246+
'x-api-version': '9',
247247
'x-mpu-action': 'upload',
248248
'x-mpu-key': 'key',
249249
'x-mpu-upload-id': 'uploadId',
@@ -255,7 +255,7 @@ describe('client', () => {
255255
);
256256
expect(fetchMock).toHaveBeenNthCalledWith(
257257
4,
258-
'https://blob.vercel-storage.com/mpu/foo.txt',
258+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
259259
{
260260
body: JSON.stringify([
261261
{ etag: 'etag1', partNumber: 1 },
@@ -266,7 +266,7 @@ describe('client', () => {
266266
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
267267
'x-api-blob-request-attempt': '0',
268268
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
269-
'x-api-version': '8',
269+
'x-api-version': '9',
270270
'x-mpu-action': 'complete',
271271
'x-mpu-key': 'key',
272272
'x-mpu-upload-id': 'uploadId',
@@ -343,13 +343,13 @@ describe('client', () => {
343343

344344
expect(fetchMock).toHaveBeenNthCalledWith(
345345
1,
346-
'https://blob.vercel-storage.com/mpu/foo.txt',
346+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
347347
{
348348
headers: {
349349
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
350350
'x-api-blob-request-attempt': '0',
351351
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
352-
'x-api-version': '8',
352+
'x-api-version': '9',
353353
'x-mpu-action': 'create',
354354
},
355355
method: 'POST',
@@ -359,14 +359,14 @@ describe('client', () => {
359359
const internalAbortSignal = new AbortController().signal;
360360
expect(fetchMock).toHaveBeenNthCalledWith(
361361
2,
362-
'https://blob.vercel-storage.com/mpu/foo.txt',
362+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
363363
{
364364
body: 'data1',
365365
headers: {
366366
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
367367
'x-api-blob-request-attempt': '0',
368368
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
369-
'x-api-version': '8',
369+
'x-api-version': '9',
370370
'x-mpu-action': 'upload',
371371
'x-mpu-key': 'key',
372372
'x-mpu-upload-id': 'uploadId',
@@ -378,14 +378,14 @@ describe('client', () => {
378378
);
379379
expect(fetchMock).toHaveBeenNthCalledWith(
380380
3,
381-
'https://blob.vercel-storage.com/mpu/foo.txt',
381+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
382382
{
383383
body: 'data2',
384384
headers: {
385385
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
386386
'x-api-blob-request-attempt': '0',
387387
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
388-
'x-api-version': '8',
388+
'x-api-version': '9',
389389
'x-mpu-action': 'upload',
390390
'x-mpu-key': 'key',
391391
'x-mpu-upload-id': 'uploadId',
@@ -397,7 +397,7 @@ describe('client', () => {
397397
);
398398
expect(fetchMock).toHaveBeenNthCalledWith(
399399
4,
400-
'https://blob.vercel-storage.com/mpu/foo.txt',
400+
'https://blob.vercel-storage.com/mpu?pathname=foo.txt',
401401
{
402402
body: JSON.stringify([
403403
{ etag: 'etag1', partNumber: 1 },
@@ -408,7 +408,7 @@ describe('client', () => {
408408
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
409409
'x-api-blob-request-attempt': '0',
410410
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
411-
'x-api-version': '8',
411+
'x-api-version': '9',
412412
'x-mpu-action': 'complete',
413413
'x-mpu-key': 'key',
414414
'x-mpu-upload-id': 'uploadId',

packages/blob/src/copy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ export async function copy(
6363
headers['x-cache-control-max-age'] = options.cacheControlMaxAge.toString();
6464
}
6565

66+
const params = new URLSearchParams({ pathname: toPathname, fromUrl });
67+
6668
const response = await requestApi<CopyBlobResult>(
67-
`/${toPathname}?fromUrl=${fromUrl}`,
69+
`?${params.toString()}`,
6870
{
6971
method: 'PUT',
7072
headers,

packages/blob/src/create-folder.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ export async function createFolder(
1818
pathname: string,
1919
options: BlobCommandOptions = {},
2020
): Promise<CreateFolderResult> {
21-
const path = pathname.endsWith('/') ? pathname : `${pathname}/`;
21+
const folderPathname = pathname.endsWith('/') ? pathname : `${pathname}/`;
2222

2323
const headers: Record<string, string> = {};
2424

2525
headers[putOptionHeaderMap.addRandomSuffix] = '0';
2626

27+
const params = new URLSearchParams({ pathname: folderPathname });
2728
const response = await requestApi<PutBlobApiResponse>(
28-
`/${path}`,
29+
`/?${params.toString()}`,
2930
{
3031
method: 'PUT',
3132
headers,

packages/blob/src/helpers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function isPlainObject(value: unknown): boolean {
124124
);
125125
}
126126

127-
export const disallowedPathnameCharacters = ['#', '?', '//'];
127+
export const disallowedPathnameCharacters = ['//'];
128128

129129
// Chrome: implemented https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
130130
// Microsoft Edge: implemented (Chromium)
@@ -137,6 +137,13 @@ export const supportsRequestStreams = (() => {
137137
return true;
138138
}
139139

140+
const apiUrl = getApiUrl();
141+
142+
// Localhost generally doesn't work with HTTP 2 so we can stop here
143+
if (apiUrl.startsWith('http://localhost')) {
144+
return false;
145+
}
146+
140147
let duplexAccessed = false;
141148

142149
const hasContentType = new Request(getApiUrl(), {
@@ -164,6 +171,7 @@ export function getApiUrl(pathname = ''): string {
164171
} catch {
165172
// noop
166173
}
174+
167175
return `${baseUrl || 'https://blob.vercel-storage.com'}${pathname}`;
168176
}
169177

packages/blob/src/index.node.test.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ describe('blob client', () => {
455455
"url": "https://storeId.public.blob.vercel-storage.com/foo-id.txt",
456456
}
457457
`);
458-
expect(path).toBe('/foo.txt');
458+
expect(path).toBe('/?pathname=foo.txt');
459459
expect(headers.authorization).toEqual('Bearer NEW_TOKEN');
460460
expect(body).toMatchInlineSnapshot(`"Test Body"`);
461461
});
@@ -615,30 +615,6 @@ describe('blob client', () => {
615615
);
616616
});
617617

618-
it('throws when pathname contains #', async () => {
619-
await expect(
620-
put('foo#bar.txt', 'Test Body', {
621-
access: 'public',
622-
}),
623-
).rejects.toThrow(
624-
new Error(
625-
'Vercel Blob: pathname cannot contain "#", please encode it if needed',
626-
),
627-
);
628-
});
629-
630-
it('throws when pathname contains ?', async () => {
631-
await expect(
632-
put('foo?bar.txt', 'Test Body', {
633-
access: 'public',
634-
}),
635-
).rejects.toThrow(
636-
new Error(
637-
'Vercel Blob: pathname cannot contain "?", please encode it if needed',
638-
),
639-
);
640-
});
641-
642618
it('throws when pathname contains //', async () => {
643619
await expect(
644620
put('foo//bar.txt', 'Test Body', {

packages/blob/src/multipart/complete.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ export async function completeMultipartUpload({
5757
headers: Record<string, string>;
5858
options: BlobCommandOptions;
5959
}): Promise<PutBlobResult> {
60+
const params = new URLSearchParams({ pathname });
61+
6062
try {
6163
const response = await requestApi<PutBlobApiResponse>(
62-
`/mpu/${pathname}`,
64+
`/mpu?${params.toString()}`,
6365
{
6466
method: 'POST',
6567
headers: {
@@ -69,7 +71,7 @@ export async function completeMultipartUpload({
6971
'x-mpu-upload-id': uploadId,
7072
// key can be any utf8 character so we need to encode it as HTTP headers can only be us-ascii
7173
// https://www.rfc-editor.org/rfc/rfc7230#swection-3.2.4
72-
'x-mpu-key': encodeURI(key),
74+
'x-mpu-key': encodeURIComponent(key),
7375
},
7476
body: JSON.stringify(parts),
7577
signal: options.abortSignal,

packages/blob/src/multipart/create.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ export async function createMultipartUpload(
4242
): Promise<CreateMultipartUploadApiResponse> {
4343
debug('mpu: create', 'pathname:', pathname);
4444

45+
const params = new URLSearchParams({ pathname });
46+
4547
try {
4648
const response = await requestApi<CreateMultipartUploadApiResponse>(
47-
`/mpu/${pathname}`,
49+
`/mpu?${params.toString()}`,
4850
{
4951
method: 'POST',
5052
headers: {

0 commit comments

Comments
 (0)