Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/blob-delete-after.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/blob': minor
---

Add a `deleteAfter` upload option for automatic Blob deletion after 1, 7, or 30 days.
2 changes: 1 addition & 1 deletion packages/blob/src/client.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ describe('client', () => {
}),
).rejects.toThrow(
new Error(
"Vercel Blob: client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.",
"Vercel Blob: client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite`, `ifMatch` or `deleteAfter`. Configure these options at the server side when generating client tokens.",
),
);
});
Expand Down
48 changes: 48 additions & 0 deletions packages/blob/src/client.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ describe('client uploads', () => {
validUntil: 1672531230000,
});
});

it('includes deleteAfter in the client token payload', async () => {
const uploadToken = await generateClientTokenFromReadWriteToken({
pathname: 'foo.txt',
deleteAfter: '1 day',
token: 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678',
});

expect(getPayloadFromClientToken(uploadToken)).toEqual({
pathname: 'foo.txt',
deleteAfter: '1 day',
validUntil: 1672531230000,
});
});
});

describe('handleUpload', () => {
Expand Down Expand Up @@ -276,6 +290,40 @@ describe('client uploads', () => {
});
});

it('allows deleteAfter from onBeforeGenerateToken', async () => {
const token =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';
const jsonResponse = await handleUpload({
token,
request: {
headers: { 'x-vercel-signature': '123' },
} as unknown as IncomingMessage,
body: {
type: 'blob.generate-client-token',
payload: {
pathname: 'newfile.txt',
clientPayload: null,
multipart: false,
},
},
onBeforeGenerateToken: async () => {
return {
deleteAfter: '7 days',
};
},
});

expect(jsonResponse.type).toEqual('blob.generate-client-token');

const decodedPayload = getPayloadFromClientToken(
(jsonResponse.type === 'blob.generate-client-token' &&
jsonResponse.clientToken) ||
'',
);

expect(decodedPayload.deleteAfter).toEqual('7 days');
});

it('ignores client callbackUrl when server provides one', async () => {
const token =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';
Expand Down
32 changes: 27 additions & 5 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import { fetch } from 'undici';
import type {
BlobAccessType,
BlobCommandOptions,
BlobDeleteAfter,
WithUploadProgress,
} from './helpers';
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
import {
BlobError,
getTokenFromOptionsOrEnv,
serializeBlobDeleteAfter,
} from './helpers';
import type { CommonCompleteMultipartUploadOptions } from './multipart/complete';
import { createCompleteMultipartUploadMethod } from './multipart/complete';
import { createCreateMultipartUploadMethod } from './multipart/create';
Expand All @@ -19,6 +24,8 @@ import { createUploadPartMethod } from './multipart/upload';
import { createPutMethod } from './put';
import type { PutBlobResult } from './put-helpers';

export type { BlobDeleteAfter } from './helpers';

/**
* Interface for put, upload and multipart upload operations.
* This type omits all options that are encoded in the client token.
Expand Down Expand Up @@ -82,10 +89,14 @@ function createPutExtraChecks<
// @ts-expect-error -- Runtime check for DX.
options.allowOverwrite !== undefined ||
// @ts-expect-error -- Runtime check for DX.
options.cacheControlMaxAge !== undefined
options.cacheControlMaxAge !== undefined ||
// @ts-expect-error -- Runtime check for DX.
options.ifMatch !== undefined ||
// @ts-expect-error -- Runtime check for DX.
options.deleteAfter !== undefined
) {
throw new BlobError(
`${methodName} doesn't allow \`addRandomSuffix\`, \`cacheControlMaxAge\` or \`allowOverwrite\`. Configure these options at the server side when generating client tokens.`,
`${methodName} doesn't allow \`addRandomSuffix\`, \`cacheControlMaxAge\`, \`allowOverwrite\`, \`ifMatch\` or \`deleteAfter\`. Configure these options at the server side when generating client tokens.`,
);
}
};
Expand Down Expand Up @@ -289,10 +300,12 @@ export const upload = createPutMethod<UploadOptions>({
// @ts-expect-error -- Runtime check for DX.
options.cacheControlMaxAge !== undefined ||
// @ts-expect-error -- Runtime check for DX.
options.ifMatch !== undefined
options.ifMatch !== undefined ||
// @ts-expect-error -- Runtime check for DX.
options.deleteAfter !== undefined
) {
throw new BlobError(
"client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.",
"client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite`, `ifMatch` or `deleteAfter`. Configure these options at the server side when generating client tokens.",
);
}
},
Expand Down Expand Up @@ -534,6 +547,7 @@ export interface HandleUploadOptions {
| 'allowOverwrite'
| 'cacheControlMaxAge'
| 'ifMatch'
| 'deleteAfter'
> & { tokenPayload?: string | null; callbackUrl?: string }
>;

Expand Down Expand Up @@ -743,6 +757,7 @@ function isAbsoluteUrl(url: string): boolean {
* - allowOverwrite - (Optional) Whether to allow overwriting existing blobs. Defaults to false.
* - cacheControlMaxAge - (Optional) Number of seconds to configure cache duration. Defaults to one month.
* - ifMatch - (Optional) Only write if the ETag matches (optimistic concurrency control).
* - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days".
* @returns A promise that resolves to the generated client token string which can be used in client-side upload operations.
*/
export async function generateClientTokenFromReadWriteToken({
Expand Down Expand Up @@ -785,6 +800,8 @@ export async function generateClientTokenFromReadWriteToken({
);
}

serializeBlobDeleteAfter(argsWithoutToken.deleteAfter);

const payload = Buffer.from(
JSON.stringify({
...argsWithoutToken,
Expand Down Expand Up @@ -860,6 +877,11 @@ export interface GenerateClientTokenOptions extends BlobCommandOptions {
* If the ETag doesn't match, a `BlobPreconditionFailedError` will be thrown.
*/
ifMatch?: string;

/**
* Automatically delete the blob after the configured duration.
*/
deleteAfter?: BlobDeleteAfter;
}

/**
Expand Down
38 changes: 13 additions & 25 deletions 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 { createPutHeaders } from './put-helpers';

export type CopyCommandOptions = CommonCreateBlobOptions;

Expand All @@ -22,7 +23,7 @@ export interface CopyBlobResult {
*
* @param fromUrlOrPathname - The blob URL (or pathname) to copy. You can only copy blobs that are in the store, that your 'BLOB_READ_WRITE_TOKEN' has access to.
* @param toPathname - The pathname to copy the blob to. This includes the filename.
* @param options - Additional options. The copy method will not preserve any metadata configuration (e.g.: 'cacheControlMaxAge') of the source blob. If you want to copy the metadata, you need to define it here again.
* @param options - Additional options. The copy method will not preserve any metadata configuration (e.g.: 'cacheControlMaxAge' or 'deleteAfter') of the source blob. If you want to copy the metadata, you need to define it here again.
*/
export async function copy(
fromUrlOrPathname: string,
Expand Down Expand Up @@ -53,30 +54,17 @@ export async function copy(
}
}

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

// access is always required, so always add it to headers
headers['x-vercel-blob-access'] = options.access;

if (options.addRandomSuffix !== undefined) {
headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0';
}

if (options.allowOverwrite !== undefined) {
headers['x-allow-overwrite'] = options.allowOverwrite ? '1' : '0';
}

if (options.contentType) {
headers['x-content-type'] = options.contentType;
}

if (options.cacheControlMaxAge !== undefined) {
headers['x-cache-control-max-age'] = options.cacheControlMaxAge.toString();
}

if (options.ifMatch) {
headers['x-if-match'] = options.ifMatch;
}
const headers = createPutHeaders(
[
'cacheControlMaxAge',
'addRandomSuffix',
'allowOverwrite',
'contentType',
'ifMatch',
'deleteAfter',
],
options,
);

const params = new URLSearchParams({
pathname: toPathname,
Expand Down
1 change: 0 additions & 1 deletion packages/blob/src/create-folder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { requestApi } from './api';
import type { BlobAccessType, CommonCreateBlobOptions } from './helpers';
import { BlobError } from './helpers';
import { type PutBlobApiResponse, putOptionHeaderMap } from './put-helpers';

export type CreateFolderCommandOptions = Pick<
Expand Down
24 changes: 24 additions & 0 deletions packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface BlobCommandOptions {
*/
export type BlobAccessType = 'public' | 'private';

export type BlobDeleteAfter = '1 day' | '7 days' | '30 days';

// shared interface for put, copy and multipart upload
export interface CommonCreateBlobOptions extends BlobCommandOptions {
/**
Expand Down Expand Up @@ -72,6 +74,10 @@ export interface CommonCreateBlobOptions extends BlobCommandOptions {
* The maximum allowed value is 5TB.
*/
maximumSizeInBytes?: number;
/**
* Automatically delete the blob after the configured duration.
*/
deleteAfter?: BlobDeleteAfter;
}

/**
Expand Down Expand Up @@ -148,6 +154,24 @@ export class BlobError extends Error {
}
}

const supportedDeleteAfterValues = ['1 day', '7 days', '30 days'];

export function serializeBlobDeleteAfter(
deleteAfter: BlobDeleteAfter | undefined,
): string | undefined {
if (!deleteAfter) {
return undefined;
}

if (!supportedDeleteAfterValues.includes(deleteAfter)) {
throw new BlobError(
'`deleteAfter` must be one of "1 day", "7 days", or "30 days".',
);
}

return deleteAfter;
}

/**
* Generates a download URL for a blob.
* The download URL includes a ?download=1 parameter which causes browsers to download
Expand Down
55 changes: 55 additions & 0 deletions packages/blob/src/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,40 @@ describe('blob client', () => {
expect(headers['x-cache-control-max-age']).toEqual('60');
});

it('sets the correct header when using deleteAfter', async () => {
let headers: Record<string, string> = {};

mockClient
.intercept({
path: () => true,
method: 'PUT',
})
.reply(200, (req) => {
headers = req.headers as Record<string, string>;
return mockedFileMetaPut;
});

await put('foo.txt', 'Test Body', {
access: 'public',
deleteAfter: '7 days',
});
expect(headers['x-vercel-blob-deletion-lifecycle']).toEqual('7 days');
});

it('throws when using an unsupported deleteAfter value', async () => {
await expect(
put('foo.txt', 'Test Body', {
access: 'public',
// @ts-expect-error: Runtime check for DX
deleteAfter: '2 days',
}),
).rejects.toThrow(
new Error(
'Vercel Blob: `deleteAfter` must be one of "1 day", "7 days", or "30 days".',
),
);
});

it('throws when filepath is too long', async () => {
await expect(
put('a'.repeat(951), 'Test Body', {
Expand Down Expand Up @@ -1171,6 +1205,27 @@ describe('blob client', () => {
});

describe('copy', () => {
it('sets the correct header when using deleteAfter', async () => {
let headers: Record<string, string> = {};

mockClient
.intercept({
path: () => true,
method: 'PUT',
})
.reply(200, (req) => {
headers = req.headers as Record<string, string>;
return mockedFileMeta;
});

await copy('source.txt', 'destination.txt', {
access: 'public',
deleteAfter: '30 days',
});

expect(headers['x-vercel-blob-deletion-lifecycle']).toEqual('30 days');
});

it('throws when filepath is too long', async () => {
await expect(
copy('source', 'a'.repeat(951), {
Expand Down
Loading
Loading