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'
        },
      },
    },
  },
})
```
This commit is contained in:
Sasha
2025-06-09 22:03:06 +03:00
committed by GitHub
parent 4ac1894cbe
commit 865a9cd9d1
5 changed files with 61 additions and 11 deletions

View File

@@ -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. - 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 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. - 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 ```ts
import { s3Storage } from '@payloadcms/storage-s3' import { s3Storage } from '@payloadcms/storage-s3'
@@ -100,6 +100,14 @@ export default buildConfig({
'media-with-prefix': { 'media-with-prefix': {
prefix, prefix,
}, },
'media-with-presigned-downloads': {
// Filter only mp4 files
signedDownloads: {
shouldUseSignedURL: ({ collection, filename, req }) => {
return filename.endsWith('.mp4')
},
},
},
}, },
bucket: process.env.S3_BUCKET, bucket: process.env.S3_BUCKET,
config: { config: {

View File

@@ -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. - 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 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. - 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 ```ts
import { s3Storage } from '@payloadcms/storage-s3' import { s3Storage } from '@payloadcms/storage-s3'
@@ -31,6 +32,14 @@ export default buildConfig({
'media-with-prefix': { 'media-with-prefix': {
prefix, prefix,
}, },
'media-with-presigned-downloads': {
// Filter only mp4 files
signedDownloads: {
shouldUseSignedURL: ({ collection, filename, req }) => {
return filename.endsWith('.mp4')
},
},
},
}, },
bucket: process.env.S3_BUCKET, bucket: process.env.S3_BUCKET,
config: { config: {

View File

@@ -1,6 +1,6 @@
import type * as AWS from '@aws-sdk/client-s3' import type * as AWS from '@aws-sdk/client-s3'
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types' 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 type { Readable } from 'stream'
import { GetObjectCommand } from '@aws-sdk/client-s3' import { GetObjectCommand } from '@aws-sdk/client-s3'
@@ -12,6 +12,11 @@ export type SignedDownloadsConfig =
| { | {
/** @default 7200 */ /** @default 7200 */
expiresIn?: number expiresIn?: number
shouldUseSignedURL?(args: {
collection: CollectionConfig
filename: string
req: PayloadRequest
}): boolean | Promise<boolean>
} }
| boolean | boolean
@@ -64,14 +69,24 @@ export const getHandler = ({
const key = path.posix.join(prefix, filename) const key = path.posix.join(prefix, filename)
if (signedDownloads && !clientUploadContext) { if (signedDownloads && !clientUploadContext) {
const command = new GetObjectCommand({ Bucket: bucket, Key: key }) let useSignedURL = true
const signedUrl = await getSignedUrl( if (
// @ts-expect-error mismatch versions typeof signedDownloads === 'object' &&
getStorageClient(), typeof signedDownloads.shouldUseSignedURL === 'function'
command, ) {
typeof signedDownloads === 'object' ? signedDownloads : { expiresIn: 7200 }, useSignedURL = await signedDownloads.shouldUseSignedURL({ collection, filename, req })
) }
return Response.redirect(signedUrl)
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({ object = await getStorageClient().getObject({

View File

@@ -44,7 +44,11 @@ export default buildConfigWithDefaults({
prefix, prefix,
}, },
[mediaWithSignedDownloadsSlug]: { [mediaWithSignedDownloadsSlug]: {
signedDownloads: true, signedDownloads: {
shouldUseSignedURL: (args) => {
return args.req.headers.get('X-Disable-Signed-URL') !== 'true'
},
},
}, },
}, },
bucket: process.env.S3_BUCKET, bucket: process.env.S3_BUCKET,

View File

@@ -96,6 +96,20 @@ describe('@payloadcms/storage-s3', () => {
expect(file.headers.get('Content-Type')).toBe('image/png') 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', () => { describe('R2', () => {
it.todo('can upload') it.todo('can upload')
}) })