From 865a9cd9d1eaf0c6d38b9da26b3ae81134280894 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 9 Jun 2025 22:03:06 +0300 Subject: [PATCH] feat(storage-s3): dynamic presigned URL downloads (#12706) Previously, if you enabled presigned URL downloads for a collection, all the files would use them. However, it might be possible that you want to use presigned URLs only for specific files (like videos), this PR allows you to pass `shouldUseSignedURL` to control that behavior dynamically. ```ts s3Storage({ collections: { media: { signedDownloads: { shouldUseSignedURL: ({ collection, filename, req }) => { return req.headers.get('X-Disable-Signed-URL') !== 'false' }, }, }, }, }) ``` --- docs/upload/storage-adapters.mdx | 10 ++++++- packages/storage-s3/README.md | 9 +++++++ packages/storage-s3/src/staticHandler.ts | 33 +++++++++++++++++------- test/storage-s3/config.ts | 6 ++++- test/storage-s3/int.spec.ts | 14 ++++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/docs/upload/storage-adapters.mdx b/docs/upload/storage-adapters.mdx index 3d4f3639a..fa2557193 100644 --- a/docs/upload/storage-adapters.mdx +++ b/docs/upload/storage-adapters.mdx @@ -84,7 +84,7 @@ pnpm add @payloadcms/storage-s3 - The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information. - When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection. - When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website. -- Configure `signedDownloads` (either globally of per-collection in `collections`) to use [presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) for files downloading. This can improve performance for large files (like videos) while still respecting your access control. +- Configure `signedDownloads` (either globally of per-collection in `collections`) to use [presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) for files downloading. This can improve performance for large files (like videos) while still respecting your access control. Additionally, with `signedDownloads.shouldUseSignedURL` you can specify a condition whether Payload should use a presigned URL, if you want to use this feature only for specific files. ```ts import { s3Storage } from '@payloadcms/storage-s3' @@ -100,6 +100,14 @@ export default buildConfig({ 'media-with-prefix': { prefix, }, + 'media-with-presigned-downloads': { + // Filter only mp4 files + signedDownloads: { + shouldUseSignedURL: ({ collection, filename, req }) => { + return filename.endsWith('.mp4') + }, + }, + }, }, bucket: process.env.S3_BUCKET, config: { diff --git a/packages/storage-s3/README.md b/packages/storage-s3/README.md index ada7b756a..73b8ae91b 100644 --- a/packages/storage-s3/README.md +++ b/packages/storage-s3/README.md @@ -16,6 +16,7 @@ pnpm add @payloadcms/storage-s3 - The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information. - When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection. - When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website. +- Configure `signedDownloads` (either globally of per-collection in `collections`) to use [presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) for files downloading. This can improve performance for large files (like videos) while still respecting your access control. Additionally, with `signedDownloads.shouldUseSignedURL` you can specify a condition whether Payload should use a presigned URL, if you want to use this feature only for specific files. ```ts import { s3Storage } from '@payloadcms/storage-s3' @@ -31,6 +32,14 @@ export default buildConfig({ 'media-with-prefix': { prefix, }, + 'media-with-presigned-downloads': { + // Filter only mp4 files + signedDownloads: { + shouldUseSignedURL: ({ collection, filename, req }) => { + return filename.endsWith('.mp4') + }, + }, + }, }, bucket: process.env.S3_BUCKET, config: { diff --git a/packages/storage-s3/src/staticHandler.ts b/packages/storage-s3/src/staticHandler.ts index 7f328c107..4c4fd47d6 100644 --- a/packages/storage-s3/src/staticHandler.ts +++ b/packages/storage-s3/src/staticHandler.ts @@ -1,6 +1,6 @@ import type * as AWS from '@aws-sdk/client-s3' import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types' -import type { CollectionConfig } from 'payload' +import type { CollectionConfig, PayloadRequest } from 'payload' import type { Readable } from 'stream' import { GetObjectCommand } from '@aws-sdk/client-s3' @@ -12,6 +12,11 @@ export type SignedDownloadsConfig = | { /** @default 7200 */ expiresIn?: number + shouldUseSignedURL?(args: { + collection: CollectionConfig + filename: string + req: PayloadRequest + }): boolean | Promise } | boolean @@ -64,14 +69,24 @@ export const getHandler = ({ const key = path.posix.join(prefix, filename) if (signedDownloads && !clientUploadContext) { - const command = new GetObjectCommand({ Bucket: bucket, Key: key }) - const signedUrl = await getSignedUrl( - // @ts-expect-error mismatch versions - getStorageClient(), - command, - typeof signedDownloads === 'object' ? signedDownloads : { expiresIn: 7200 }, - ) - return Response.redirect(signedUrl) + let useSignedURL = true + if ( + typeof signedDownloads === 'object' && + typeof signedDownloads.shouldUseSignedURL === 'function' + ) { + useSignedURL = await signedDownloads.shouldUseSignedURL({ collection, filename, req }) + } + + if (useSignedURL) { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }) + const signedUrl = await getSignedUrl( + // @ts-expect-error mismatch versions + getStorageClient(), + command, + typeof signedDownloads === 'object' ? signedDownloads : { expiresIn: 7200 }, + ) + return Response.redirect(signedUrl) + } } object = await getStorageClient().getObject({ diff --git a/test/storage-s3/config.ts b/test/storage-s3/config.ts index 59327fda9..28fe14aa2 100644 --- a/test/storage-s3/config.ts +++ b/test/storage-s3/config.ts @@ -44,7 +44,11 @@ export default buildConfigWithDefaults({ prefix, }, [mediaWithSignedDownloadsSlug]: { - signedDownloads: true, + signedDownloads: { + shouldUseSignedURL: (args) => { + return args.req.headers.get('X-Disable-Signed-URL') !== 'true' + }, + }, }, }, bucket: process.env.S3_BUCKET, diff --git a/test/storage-s3/int.spec.ts b/test/storage-s3/int.spec.ts index 6fa8fdf86..60c4a9431 100644 --- a/test/storage-s3/int.spec.ts +++ b/test/storage-s3/int.spec.ts @@ -96,6 +96,20 @@ describe('@payloadcms/storage-s3', () => { expect(file.headers.get('Content-Type')).toBe('image/png') }) + it('should skip signed download', async () => { + await payload.create({ + collection: mediaWithSignedDownloadsSlug, + data: {}, + filePath: path.resolve(dirname, '../uploads/small.png'), + }) + + const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/small.png`, { + headers: { 'X-Disable-Signed-URL': 'true' }, + }) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('image/png') + }) + describe('R2', () => { it.todo('can upload') })