diff --git a/.changeset/blob-delete-after.md b/.changeset/blob-delete-after.md new file mode 100644 index 000000000..f85028f25 --- /dev/null +++ b/.changeset/blob-delete-after.md @@ -0,0 +1,5 @@ +--- +'@vercel/blob': minor +--- + +Add a `deleteAfter` upload option for automatic Blob deletion after 1, 7, or 30 days. diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index d86f29c9d..7c71d4028 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -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.", ), ); }); diff --git a/packages/blob/src/client.node.test.ts b/packages/blob/src/client.node.test.ts index a1d8c9e95..2b7768943 100644 --- a/packages/blob/src/client.node.test.ts +++ b/packages/blob/src/client.node.test.ts @@ -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', () => { @@ -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'; diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 8c7eb12f1..024bc4301 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -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'; @@ -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. @@ -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.`, ); } }; @@ -289,10 +300,12 @@ export const upload = createPutMethod({ // @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.", ); } }, @@ -534,6 +547,7 @@ export interface HandleUploadOptions { | 'allowOverwrite' | 'cacheControlMaxAge' | 'ifMatch' + | 'deleteAfter' > & { tokenPayload?: string | null; callbackUrl?: string } >; @@ -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({ @@ -785,6 +800,8 @@ export async function generateClientTokenFromReadWriteToken({ ); } + serializeBlobDeleteAfter(argsWithoutToken.deleteAfter); + const payload = Buffer.from( JSON.stringify({ ...argsWithoutToken, @@ -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; } /** diff --git a/packages/blob/src/copy.ts b/packages/blob/src/copy.ts index 5589f0be8..d447ade16 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 { createPutHeaders } from './put-helpers'; export type CopyCommandOptions = CommonCreateBlobOptions; @@ -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, @@ -53,30 +54,17 @@ export async function copy( } } - const headers: Record = {}; - - // 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, diff --git a/packages/blob/src/create-folder.ts b/packages/blob/src/create-folder.ts index 7c4187542..93500b77e 100644 --- a/packages/blob/src/create-folder.ts +++ b/packages/blob/src/create-folder.ts @@ -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< diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index c399a084f..2b5ab6a2c 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -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 { /** @@ -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; } /** @@ -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 diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index c51ab9312..c7cb9af0b 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -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 = {}; + + mockClient + .intercept({ + path: () => true, + method: 'PUT', + }) + .reply(200, (req) => { + headers = req.headers as Record; + 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', { @@ -1171,6 +1205,27 @@ describe('blob client', () => { }); describe('copy', () => { + it('sets the correct header when using deleteAfter', async () => { + let headers: Record = {}; + + mockClient + .intercept({ + path: () => true, + method: 'PUT', + }) + .reply(200, (req) => { + headers = req.headers as Record; + 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), { diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index cc72f8a7e..9feaae124 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -27,6 +27,7 @@ export { // expose generic BlobError and download url util export { type BlobAccessType, + type BlobDeleteAfter, BlobError, getDownloadUrl, type OnUploadProgressCallback, @@ -52,6 +53,7 @@ export type { PutCommandOptions }; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) A string indicating the media type. By default, it's extracted from the pathname's extension. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. + * - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days". * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. * - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts. * - abortSignal - (Optional) AbortSignal to cancel the operation. @@ -65,6 +67,7 @@ export const put = createPutMethod({ 'allowOverwrite', 'contentType', 'ifMatch', + 'deleteAfter', ], }); @@ -113,6 +116,7 @@ export { copy } from './copy'; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. + * - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days". * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an object containing: @@ -127,6 +131,7 @@ export const createMultipartUpload = 'allowOverwrite', 'contentType', 'ifMatch', + 'deleteAfter', ], }); @@ -141,6 +146,7 @@ export const createMultipartUpload = * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. + * - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days". * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an uploader object with the following properties and methods: @@ -157,6 +163,7 @@ export const createMultipartUploader = 'allowOverwrite', 'contentType', 'ifMatch', + 'deleteAfter', ], }); @@ -178,6 +185,7 @@ export type { UploadPartCommandOptions }; * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. + * - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days". * - abortSignal - (Optional) AbortSignal to cancel the running request. * - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\}) * @returns A promise that resolves to the uploaded part information containing etag and partNumber, which will be needed for the completeMultipartUpload call. @@ -188,6 +196,7 @@ export const uploadPart = createUploadPartMethod({ 'addRandomSuffix', 'allowOverwrite', 'contentType', + 'deleteAfter', ], }); @@ -208,6 +217,7 @@ export type { CompleteMultipartUploadCommandOptions }; * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to true. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. + * - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days". * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to the finalized blob information, including pathname, contentType, contentDisposition, url, and downloadUrl. */ @@ -218,6 +228,7 @@ export const completeMultipartUpload = 'addRandomSuffix', 'allowOverwrite', 'contentType', + 'deleteAfter', ], }); diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index aa9f265ed..da700623b 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -6,7 +6,11 @@ import type { File } from 'undici'; import { MAXIMUM_PATHNAME_LENGTH } from './api'; import type { ClientCommonCreateBlobOptions } from './client'; import type { CommonCreateBlobOptions } from './helpers'; -import { BlobError, disallowedPathnameCharacters } from './helpers'; +import { + BlobError, + disallowedPathnameCharacters, + serializeBlobDeleteAfter, +} from './helpers'; export const putOptionHeaderMap = { cacheControlMaxAge: 'x-cache-control-max-age', @@ -15,6 +19,7 @@ export const putOptionHeaderMap = { contentType: 'x-content-type', access: 'x-vercel-blob-access', ifMatch: 'x-if-match', + deleteAfter: 'x-vercel-blob-deletion-lifecycle', }; /** @@ -131,6 +136,13 @@ export function createPutHeaders( options.cacheControlMaxAge.toString(); } + if (allowedOptions.includes('deleteAfter')) { + const deleteAfter = serializeBlobDeleteAfter(options.deleteAfter); + if (deleteAfter) { + headers[putOptionHeaderMap.deleteAfter] = deleteAfter; + } + } + return headers; }