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

Add `ttlDays` option to `put`, `copy`, `rename`, `createMultipartUpload`, client tokens and presigned URLs. Blobs uploaded with a TTL are deleted automatically after that many days; `head()` returns the scheduled `expiresAt`.
6 changes: 6 additions & 0 deletions packages/blob/src/client-token-constraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface BlobClientTokenConstraintOptions {
*/
ifMatch?: string;

/**
* Number of days (1-30) after which the blob is automatically deleted. Expiration is
* evaluated daily at UTC midnight, so the blob lives at least this many days.
*/
ttlDays?: number;

/**
* Configuration for upload completion callback.
*/
Expand Down
13 changes: 13 additions & 0 deletions packages/blob/src/client.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ describe('client uploads', () => {
});
});

it('embeds ttlDays in the client token payload', async () => {
const uploadToken = await generateClientTokenFromReadWriteToken({
pathname: 'foo.txt',
ttlDays: 7,
token: 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678',
});

expect(getPayloadFromClientToken(uploadToken)).toMatchObject({
pathname: 'foo.txt',
ttlDays: 7,
});
});

it('throws when ifMatch is used with allowOverwrite: false', async () => {
await expect(
generateClientTokenFromReadWriteToken({
Expand Down
19 changes: 13 additions & 6 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,12 @@ 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.ttlDays !== 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\` or \`ttlDays\`. Configure these options at the server side when generating client tokens.`,
);
}
};
Expand Down Expand Up @@ -301,10 +303,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.ttlDays !== 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 `ttlDays`. Configure these options at the server side when generating client tokens.",
);
}
},
Expand Down Expand Up @@ -355,10 +359,12 @@ export const uploadPresigned = 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.ttlDays !== undefined
) {
throw new BlobError(
"client/`uploadPresigned` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating presigned URLs.",
"client/`uploadPresigned` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite`, `ifMatch` or `ttlDays`. Configure these options at the server side when generating presigned URLs.",
);
}
},
Expand Down Expand Up @@ -717,6 +723,7 @@ export interface HandleUploadOptions {
| 'allowOverwrite'
| 'cacheControlMaxAge'
| 'ifMatch'
| 'ttlDays'
> & { tokenPayload?: string | null; callbackUrl?: string }
>;

Expand Down
10 changes: 9 additions & 1 deletion packages/blob/src/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,17 @@ export interface HeadBlobResult {
* The ETag of the blob. Can be used with `ifMatch` for conditional writes.
*/
etag: string;

/**
* When the blob will be deleted. Only set for blobs uploaded with `ttlDays`.
*/
expiresAt?: Date;
}

interface HeadBlobApiResponse extends Omit<HeadBlobResult, 'uploadedAt'> {
interface HeadBlobApiResponse
extends Omit<HeadBlobResult, 'uploadedAt' | 'expiresAt'> {
uploadedAt: string; // when receiving data from our API, uploadedAt is a string
expiresAt?: string;
}

/**
Expand Down Expand Up @@ -88,5 +95,6 @@ export async function head(
cacheControl: response.cacheControl,
uploadedAt: new Date(response.uploadedAt),
etag: response.etag,
...(response.expiresAt && { expiresAt: new Date(response.expiresAt) }),
};
}
5 changes: 5 additions & 0 deletions packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export interface CommonCreateBlobOptions extends BlobCommandOptions {
* If the ETag doesn't match, a `BlobPreconditionFailedError` will be thrown.
*/
ifMatch?: string;
/**
* Number of days (1-30) after which the blob is automatically deleted. Expiration is
* evaluated daily at UTC midnight, so the blob lives at least this many days.
*/
ttlDays?: number;
/**
* Maximum size in bytes allowed for this upload. Currently only enforced
* client-side for multipart uploads (`put(..., { multipart: true })`).
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 @@ -50,6 +50,21 @@ describe('blob client', () => {
});

describe('head', () => {
it('returns expiresAt as a Date when the blob has a TTL', async () => {
mockClient
.intercept({
path: () => true,
method: 'GET',
})
.reply(200, () => ({
...mockedFileMeta,
expiresAt: '2030-01-02T00:00:00.000Z',
}));

const result = await head(`${BLOB_STORE_BASE_URL}/foo-id.txt`);
expect(result.expiresAt).toEqual(new Date('2030-01-02T00:00:00.000Z'));
});

it('should return Blob metadata when calling `head()`', async () => {
let path: string | null = null;
let headers: Record<string, string> = {};
Expand Down Expand Up @@ -787,6 +802,26 @@ describe('blob client', () => {
expect(headers['x-cache-control-max-age']).toEqual('60');
});

it('sets the correct header when using the ttlDays option', 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',
ttlDays: 7,
});
expect(headers['x-ttl-days']).toEqual('7');
});

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

it('sets x-ttl-days when renaming with ttlDays', async () => {
let headers: Record<string, string> = {};

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

await rename('source.txt', 'destination.txt', {
access: 'public',
ttlDays: 7,
});
expect(headers['x-ttl-days']).toEqual('7');
});

it('should rename a file with a POST to /rename', async () => {
let path: string | null = null;
let headers: Record<string, string> = {};
Expand Down
3 changes: 3 additions & 0 deletions packages/blob/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const put = createPutMethod<PutCommandOptions>({
'allowOverwrite',
'contentType',
'ifMatch',
'ttlDays',
],
});

Expand Down Expand Up @@ -157,6 +158,7 @@ export const createMultipartUpload =
'allowOverwrite',
'contentType',
'ifMatch',
'ttlDays',
],
});

Expand Down Expand Up @@ -189,6 +191,7 @@ export const createMultipartUploader =
'allowOverwrite',
'contentType',
'ifMatch',
'ttlDays',
],
});

Expand Down
30 changes: 30 additions & 0 deletions packages/blob/src/presign-query-params.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
BLOB_PRESIGN_QUERY_TTL_DAYS,
buildPresignCanonicalQueryEntries,
} from './presign-query-params';

const nowMs = 1_700_000_000_000;
const delegation = { validUntil: nowMs + 3_600_000 };

describe('buildPresignCanonicalQueryEntries ttlDays', () => {
it('signs ttlDays into the presigned put URL', () => {
const entries = buildPresignCanonicalQueryEntries({
operation: 'put',
delegation,
urlOptions: { ttlDays: 7 },
nowMs,
});
expect(entries).toContainEqual([BLOB_PRESIGN_QUERY_TTL_DAYS, '7']);
});

it.each([0, 31, 1.5])('rejects ttlDays=%p', (ttlDays) => {
expect(() =>
buildPresignCanonicalQueryEntries({
operation: 'put',
delegation,
urlOptions: { ttlDays },
nowMs,
}),
).toThrow('ttlDays must be an integer between 1 and 30');
});
});
17 changes: 17 additions & 0 deletions packages/blob/src/presign-query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE =
export const BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE =
'vercel-blob-cache-control-max-age' as const;
export const BLOB_PRESIGN_QUERY_IF_MATCH = 'vercel-blob-if-match' as const;
export const BLOB_PRESIGN_QUERY_TTL_DAYS = 'vercel-blob-ttl-days' as const;
export const BLOB_PRESIGN_QUERY_CALLBACK_URL =
'vercel-blob-callback-url' as const;
export const BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD =
Expand All @@ -38,6 +39,7 @@ export const PRESIGN_QUERY = {
allowOverwrite: BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE,
cacheControlMaxAge: BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE,
ifMatch: BLOB_PRESIGN_QUERY_IF_MATCH,
ttlDays: BLOB_PRESIGN_QUERY_TTL_DAYS,
callbackUrl: BLOB_PRESIGN_QUERY_CALLBACK_URL,
callbackTokenPayload: BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD,
} as const;
Expand All @@ -52,6 +54,7 @@ export const PRESIGN_CANONICAL_QUERY_KEYS = [
BLOB_PRESIGN_QUERY_CALLBACK_URL,
BLOB_PRESIGN_QUERY_IF_MATCH,
BLOB_PRESIGN_QUERY_MAXIMUM_SIZE,
BLOB_PRESIGN_QUERY_TTL_DAYS,
BLOB_PRESIGN_QUERY_VALID_UNTIL,
] as const;

Expand Down Expand Up @@ -170,6 +173,7 @@ function validatePresignUrlOnUploadCompletedWire(
}

export const MAX_PRESIGN_CACHE_CONTROL_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
export const MAX_PRESIGN_TTL_DAYS = 30;
const MAX_PRESIGN_IF_MATCH_LENGTH = 256;

// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally blocking them
Expand All @@ -183,6 +187,7 @@ type PresignUrlConstraintOptions = {
allowOverwrite?: boolean;
cacheControlMaxAge?: number;
ifMatch?: string;
ttlDays?: number;
onUploadCompleted?: PresignOptionsOnUploadCompletedWire;
};

Expand Down Expand Up @@ -217,6 +222,14 @@ function validateUrlOnlyPresignUploadOptions(
);
}
}
if (urlOptions.ttlDays !== undefined) {
const n = urlOptions.ttlDays;
if (!Number.isInteger(n) || n < 1 || n > MAX_PRESIGN_TTL_DAYS) {
throw new Error(
`${label}: ttlDays must be an integer between 1 and ${MAX_PRESIGN_TTL_DAYS}.`,
);
}
}
}

function sortedContentTypesCsv(types: readonly string[]): string {
Expand Down Expand Up @@ -281,6 +294,7 @@ export function buildPresignCanonicalQueryEntries(args: {
allowOverwrite?: boolean;
cacheControlMaxAge?: number;
ifMatch?: string;
ttlDays?: number;
onUploadCompleted?: PresignOptionsOnUploadCompletedWire;
};
nowMs: number;
Expand Down Expand Up @@ -359,6 +373,9 @@ export function buildPresignCanonicalQueryEntries(args: {
if (urlOptions.ifMatch !== undefined) {
entries.push([BLOB_PRESIGN_QUERY_IF_MATCH, urlOptions.ifMatch]);
}
if (urlOptions.ttlDays !== undefined) {
entries.push([BLOB_PRESIGN_QUERY_TTL_DAYS, String(urlOptions.ttlDays)]);
}
if (urlOptions.onUploadCompleted !== undefined) {
const { callbackUrl, tokenPayload } = urlOptions.onUploadCompleted;
if (callbackUrl.length > MAX_PRESIGN_CALLBACK_URL_CHARS) {
Expand Down
5 changes: 5 additions & 0 deletions packages/blob/src/put-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const putOptionHeaderMap = {
contentType: 'x-content-type',
access: 'x-vercel-blob-access',
ifMatch: 'x-if-match',
ttlDays: 'x-ttl-days',
};

/**
Expand Down Expand Up @@ -229,6 +230,10 @@ export function createPutHeaders<TOptions extends CommonPutCommandOptions>(
options.cacheControlMaxAge.toString();
}

if (allowedOptions.includes('ttlDays') && options.ttlDays !== undefined) {
headers[putOptionHeaderMap.ttlDays] = options.ttlDays.toString();
}

return headers;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/blob/src/rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export async function rename(
headers['x-if-match'] = options.ifMatch;
}

if (options.ttlDays !== undefined) {
headers['x-ttl-days'] = options.ttlDays.toString();
}

const params = new URLSearchParams({
pathname: toPathname,
fromUrl: fromUrlOrPathname,
Expand Down
2 changes: 2 additions & 0 deletions packages/blob/src/signed-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export type PresignPutUrlOptions = {
cacheControlMaxAge?: number;

ifMatch?: string;

ttlDays?: number;
};

/**
Expand Down
Loading