diff --git a/.changeset/presigned-get-use-cache.md b/.changeset/presigned-get-use-cache.md new file mode 100644 index 000000000..b56e715c5 --- /dev/null +++ b/.changeset/presigned-get-use-cache.md @@ -0,0 +1,5 @@ +--- +'@vercel/blob': patch +--- + +Add a `useCache` option to `presignUrl()` for `get` operations. When `useCache: false`, the presigned URL includes a `cache=0` query param so fetches bypass the CDN cache and read the latest content directly from origin storage. Like `get()`, the bypass only applies to private blobs. The param is not part of the signed payload, so holders of a presigned URL can also add or remove it manually. diff --git a/packages/blob/src/get.node.test.ts b/packages/blob/src/get.node.test.ts index c9659999e..2002774ea 100644 --- a/packages/blob/src/get.node.test.ts +++ b/packages/blob/src/get.node.test.ts @@ -108,6 +108,57 @@ describe('presignUrl (get)', () => { ); }); + it('appends cache=0 when useCache is false on a private blob', async () => { + const pathname = 'media/photo.png'; + const token = makeSignedToken(pathname); + const { presignedUrl: url } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'private', + useCache: false, + }); + + const parsed = new URL(url); + expect(parsed.searchParams.get('cache')).toBe('0'); + + // cache is not part of the signed payload: the signature is identical to + // a URL presigned without useCache. + const { presignedUrl: withoutBypass } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'private', + }); + const withoutBypassParsed = new URL(withoutBypass); + expect(withoutBypassParsed.searchParams.get('cache')).toBeNull(); + expect(parsed.searchParams.get('vercel-blob-signature')).toBe( + withoutBypassParsed.searchParams.get('vercel-blob-signature'), + ); + }); + + it('does not append cache=0 when useCache is true or omitted', async () => { + const pathname = 'media/photo.png'; + const token = makeSignedToken(pathname); + const { presignedUrl: url } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'private', + useCache: true, + }); + expect(new URL(url).searchParams.get('cache')).toBeNull(); + }); + + it('ignores useCache: false for public blobs (CDN only supports the bypass for private)', async () => { + const pathname = 'a.png'; + const token = makeSignedToken(pathname); + const { presignedUrl: url } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'public', + useCache: false, + }); + expect(new URL(url).searchParams.get('cache')).toBeNull(); + }); + it('rejects an invalid delegation token', async () => { const token = { delegationToken: 'not-a-jwt', diff --git a/packages/blob/src/signed-token.ts b/packages/blob/src/signed-token.ts index 9d5bfd6ec..9ce6c4b99 100644 --- a/packages/blob/src/signed-token.ts +++ b/packages/blob/src/signed-token.ts @@ -265,6 +265,15 @@ export type PresignGetUrlOptions = { * Omitted on the wire when equal to the delegation ceiling (server defaults to delegation). */ validUntil?: number; + /** + * Whether the presigned URL may be served from CDN cache. When false, a + * `cache=0` query param is appended so fetches read the latest content + * directly from origin storage. The CDN only supports the bypass for + * private blobs, so it's ignored for public ones. The param is not part + * of the signed payload: whoever holds the URL can add or remove it. + * @defaultValue true + */ + useCache?: boolean; }; /** @@ -430,14 +439,25 @@ function buildPresignedGetUrl( presignedUrlPayload: PresignedUrlPayload, options: { access: 'public' | 'private'; + useCache?: boolean; }, ): string { const storeId = parseStoreIdFromDelegationToken( presignedUrlPayload.delegationToken, ); - const blobUrl = isUrl(pathnameOrUrl) + let blobUrl = isUrl(pathnameOrUrl) ? pathnameOrUrl : constructBlobUrl(storeId, pathnameOrUrl, options.access); + + // Same gating as get(): the CDN only supports the cache bypass for private + // blobs. The param is deliberately outside the signed payload, so adding it + // here doesn't affect signature verification. + if (options.useCache === false && options.access === 'private') { + const url = new URL(blobUrl); + url.searchParams.set('cache', '0'); + blobUrl = url.toString(); + } + return addPresignedParams(blobUrl, presignedUrlPayload); }