Skip to content

Commit 800c424

Browse files
authored
feat(storage-s3): presigned URLs for file downloads (#12307)
Adds pre-signed URLs support file downloads with the S3 adapter. Can be enabled per-collection: ```ts s3Storage({ collections: { media: { signedDownloads: true }, // or { signedDownloads: { expiresIn: 3600 }} for custom expiresIn (default 7200) }, bucket: process.env.S3_BUCKET, config: { credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, }, endpoint: process.env.S3_ENDPOINT, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', region: process.env.S3_REGION, }, }), ``` The main use case is when you care about the Payload access control (so you don't want to use `disablePayloadAccessControl: true` but you don't want your files to be served through Payload (which can affect performance with large videos for example). This feature instead generates a signed URL (after verifying the access control) and redirects you directly to the S3 provider. This is an addition to #11382 which added pre-signed URLs for file uploads.
1 parent 9a6bb44 commit 800c424

File tree

8 files changed

+162
-13
lines changed

8 files changed

+162
-13
lines changed

docs/upload/storage-adapters.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pnpm add @payloadcms/storage-s3
8484
- 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.
8585
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
8686
- 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.
87+
- 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.
8788

8889
```ts
8990
import { s3Storage } from '@payloadcms/storage-s3'

packages/storage-s3/src/index.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import * as AWS from '@aws-sdk/client-s3'
1212
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
1313
import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
1414

15+
import type { SignedDownloadsConfig } from './staticHandler.js'
16+
1517
import { getGenerateSignedURLHandler } from './generateSignedURL.js'
1618
import { getGenerateURL } from './generateURL.js'
1719
import { getHandleDelete } from './handleDelete.js'
@@ -24,6 +26,7 @@ export type S3StorageOptions = {
2426
*/
2527

2628
acl?: 'private' | 'public-read'
29+
2730
/**
2831
* Bucket name to upload files to.
2932
*
@@ -39,8 +42,15 @@ export type S3StorageOptions = {
3942
/**
4043
* Collection options to apply the S3 adapter to.
4144
*/
42-
collections: Partial<Record<UploadCollectionSlug, Omit<CollectionOptions, 'adapter'> | true>>
43-
45+
collections: Partial<
46+
Record<
47+
UploadCollectionSlug,
48+
| ({
49+
signedDownloads?: SignedDownloadsConfig
50+
} & Omit<CollectionOptions, 'adapter'>)
51+
| true
52+
>
53+
>
4454
/**
4555
* AWS S3 client configuration. Highly dependent on your AWS setup.
4656
*
@@ -61,6 +71,10 @@ export type S3StorageOptions = {
6171
* Default: true
6272
*/
6373
enabled?: boolean
74+
/**
75+
* Use pre-signed URLs for files downloading. Can be overriden per-collection.
76+
*/
77+
signedDownloads?: SignedDownloadsConfig
6478
}
6579

6680
type S3StoragePlugin = (storageS3Args: S3StorageOptions) => Plugin
@@ -158,9 +172,27 @@ export const s3Storage: S3StoragePlugin =
158172

159173
function s3StorageInternal(
160174
getStorageClient: () => AWS.S3,
161-
{ acl, bucket, clientUploads, config = {} }: S3StorageOptions,
175+
{
176+
acl,
177+
bucket,
178+
clientUploads,
179+
collections,
180+
config = {},
181+
signedDownloads: topLevelSignedDownloads,
182+
}: S3StorageOptions,
162183
): Adapter {
163184
return ({ collection, prefix }): GeneratedAdapter => {
185+
const collectionStorageConfig = collections[collection.slug]
186+
187+
let signedDownloads: null | SignedDownloadsConfig =
188+
typeof collectionStorageConfig === 'object'
189+
? (collectionStorageConfig.signedDownloads ?? false)
190+
: null
191+
192+
if (signedDownloads === null) {
193+
signedDownloads = topLevelSignedDownloads ?? null
194+
}
195+
164196
return {
165197
name: 's3',
166198
clientUploads,
@@ -173,7 +205,12 @@ function s3StorageInternal(
173205
getStorageClient,
174206
prefix,
175207
}),
176-
staticHandler: getHandler({ bucket, collection, getStorageClient }),
208+
staticHandler: getHandler({
209+
bucket,
210+
collection,
211+
getStorageClient,
212+
signedDownloads: signedDownloads ?? false,
213+
}),
177214
}
178215
}
179216
}

packages/storage-s3/src/staticHandler.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
33
import type { CollectionConfig } from 'payload'
44
import type { Readable } from 'stream'
55

6+
import { GetObjectCommand } from '@aws-sdk/client-s3'
7+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
68
import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities'
79
import path from 'path'
810

11+
export type SignedDownloadsConfig =
12+
| {
13+
/** @default 7200 */
14+
expiresIn?: number
15+
}
16+
| boolean
17+
918
interface Args {
1019
bucket: string
1120
collection: CollectionConfig
1221
getStorageClient: () => AWS.S3
22+
signedDownloads?: SignedDownloadsConfig
1323
}
1424

1525
// Type guard for NodeJS.Readable streams
@@ -40,14 +50,30 @@ const streamToBuffer = async (readableStream: any) => {
4050
return Buffer.concat(chunks)
4151
}
4252

43-
export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => {
53+
export const getHandler = ({
54+
bucket,
55+
collection,
56+
getStorageClient,
57+
signedDownloads,
58+
}: Args): StaticHandler => {
4459
return async (req, { params: { clientUploadContext, filename } }) => {
4560
let object: AWS.GetObjectOutput | undefined = undefined
4661
try {
4762
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
4863

4964
const key = path.posix.join(prefix, filename)
5065

66+
if (signedDownloads && !clientUploadContext) {
67+
const command = new GetObjectCommand({ Bucket: bucket, Key: key })
68+
const signedUrl = await getSignedUrl(
69+
// @ts-expect-error mismatch versions
70+
getStorageClient(),
71+
command,
72+
typeof signedDownloads === 'object' ? signedDownloads : { expiresIn: 7200 },
73+
)
74+
return Response.redirect(signedUrl)
75+
}
76+
5177
object = await getStorageClient().getObject({
5278
Bucket: bucket,
5379
Key: key,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { mediaWithSignedDownloadsSlug } from '../shared.js'
4+
5+
export const MediaWithSignedDownloads: CollectionConfig = {
6+
slug: mediaWithSignedDownloadsSlug,
7+
upload: true,
8+
fields: [],
9+
}

test/storage-s3/config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
77
import { devUser } from '../credentials.js'
88
import { Media } from './collections/Media.js'
99
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
10+
import { MediaWithSignedDownloads } from './collections/MediaWithSignedDownloads.js'
1011
import { Users } from './collections/Users.js'
11-
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
12+
import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js'
1213
const filename = fileURLToPath(import.meta.url)
1314
const dirname = path.dirname(filename)
1415

@@ -25,7 +26,7 @@ export default buildConfigWithDefaults({
2526
baseDir: path.resolve(dirname),
2627
},
2728
},
28-
collections: [Media, MediaWithPrefix, Users],
29+
collections: [Media, MediaWithPrefix, MediaWithSignedDownloads, Users],
2930
onInit: async (payload) => {
3031
await payload.create({
3132
collection: 'users',
@@ -42,6 +43,9 @@ export default buildConfigWithDefaults({
4243
[mediaWithPrefixSlug]: {
4344
prefix,
4445
},
46+
[mediaWithSignedDownloadsSlug]: {
47+
signedDownloads: true,
48+
},
4549
},
4650
bucket: process.env.S3_BUCKET,
4751
config: {

test/storage-s3/int.spec.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@ import * as AWS from '@aws-sdk/client-s3'
44
import path from 'path'
55
import { fileURLToPath } from 'url'
66

7+
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
8+
79
import { initPayloadInt } from '../helpers/initPayloadInt.js'
8-
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
10+
import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js'
911

1012
const filename = fileURLToPath(import.meta.url)
1113
const dirname = path.dirname(filename)
1214

15+
let restClient: NextRESTClient
16+
1317
let payload: Payload
1418

1519
describe('@payloadcms/storage-s3', () => {
1620
let TEST_BUCKET: string
1721
let client: AWS.S3Client
1822

1923
beforeAll(async () => {
20-
;({ payload } = await initPayloadInt(dirname))
24+
;({ payload, restClient } = await initPayloadInt(dirname))
2125
TEST_BUCKET = process.env.S3_BUCKET
2226

2327
client = new AWS.S3({
@@ -77,15 +81,38 @@ describe('@payloadcms/storage-s3', () => {
7781
expect(upload.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${String(upload.filename)}`)
7882
})
7983

84+
it('can download with signed downloads', async () => {
85+
await payload.create({
86+
collection: mediaWithSignedDownloadsSlug,
87+
data: {},
88+
filePath: path.resolve(dirname, '../uploads/image.png'),
89+
})
90+
91+
const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/image.png`)
92+
expect(response.status).toBe(302)
93+
const url = response.headers.get('Location')
94+
expect(url).toBeDefined()
95+
expect(url!).toContain(`/${TEST_BUCKET}/image.png`)
96+
expect(new URLSearchParams(url!).get('x-id')).toBe('GetObject')
97+
const file = await fetch(url!)
98+
expect(file.headers.get('Content-Type')).toBe('image/png')
99+
})
100+
80101
describe('R2', () => {
81102
it.todo('can upload')
82103
})
83104

84105
async function createTestBucket() {
85-
const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET }))
106+
try {
107+
const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET }))
86108

87-
if (makeBucketRes.$metadata.httpStatusCode !== 200) {
88-
throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`)
109+
if (makeBucketRes.$metadata.httpStatusCode !== 200) {
110+
throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`)
111+
}
112+
} catch (e) {
113+
if (e instanceof AWS.BucketAlreadyOwnedByYou) {
114+
console.log('Bucket already exists')
115+
}
89116
}
90117
}
91118

@@ -96,7 +123,9 @@ describe('@payloadcms/storage-s3', () => {
96123
}),
97124
)
98125

99-
if (!listedObjects?.Contents?.length) return
126+
if (!listedObjects?.Contents?.length) {
127+
return
128+
}
100129

101130
const deleteParams = {
102131
Bucket: TEST_BUCKET,

test/storage-s3/payload-types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface Config {
6969
collections: {
7070
media: Media;
7171
'media-with-prefix': MediaWithPrefix;
72+
'media-with-signed-downloads': MediaWithSignedDownload;
7273
users: User;
7374
'payload-locked-documents': PayloadLockedDocument;
7475
'payload-preferences': PayloadPreference;
@@ -78,6 +79,7 @@ export interface Config {
7879
collectionsSelect: {
7980
media: MediaSelect<false> | MediaSelect<true>;
8081
'media-with-prefix': MediaWithPrefixSelect<false> | MediaWithPrefixSelect<true>;
82+
'media-with-signed-downloads': MediaWithSignedDownloadsSelect<false> | MediaWithSignedDownloadsSelect<true>;
8183
users: UsersSelect<false> | UsersSelect<true>;
8284
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
8385
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -171,6 +173,24 @@ export interface MediaWithPrefix {
171173
focalX?: number | null;
172174
focalY?: number | null;
173175
}
176+
/**
177+
* This interface was referenced by `Config`'s JSON-Schema
178+
* via the `definition` "media-with-signed-downloads".
179+
*/
180+
export interface MediaWithSignedDownload {
181+
id: string;
182+
updatedAt: string;
183+
createdAt: string;
184+
url?: string | null;
185+
thumbnailURL?: string | null;
186+
filename?: string | null;
187+
mimeType?: string | null;
188+
filesize?: number | null;
189+
width?: number | null;
190+
height?: number | null;
191+
focalX?: number | null;
192+
focalY?: number | null;
193+
}
174194
/**
175195
* This interface was referenced by `Config`'s JSON-Schema
176196
* via the `definition` "users".
@@ -203,6 +223,10 @@ export interface PayloadLockedDocument {
203223
relationTo: 'media-with-prefix';
204224
value: string | MediaWithPrefix;
205225
} | null)
226+
| ({
227+
relationTo: 'media-with-signed-downloads';
228+
value: string | MediaWithSignedDownload;
229+
} | null)
206230
| ({
207231
relationTo: 'users';
208232
value: string | User;
@@ -309,6 +333,23 @@ export interface MediaWithPrefixSelect<T extends boolean = true> {
309333
focalX?: T;
310334
focalY?: T;
311335
}
336+
/**
337+
* This interface was referenced by `Config`'s JSON-Schema
338+
* via the `definition` "media-with-signed-downloads_select".
339+
*/
340+
export interface MediaWithSignedDownloadsSelect<T extends boolean = true> {
341+
updatedAt?: T;
342+
createdAt?: T;
343+
url?: T;
344+
thumbnailURL?: T;
345+
filename?: T;
346+
mimeType?: T;
347+
filesize?: T;
348+
width?: T;
349+
height?: T;
350+
focalX?: T;
351+
focalY?: T;
352+
}
312353
/**
313354
* This interface was referenced by `Config`'s JSON-Schema
314355
* via the `definition` "users_select".

test/storage-s3/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const mediaSlug = 'media'
22
export const mediaWithPrefixSlug = 'media-with-prefix'
33
export const prefix = 'test-prefix'
4+
5+
export const mediaWithSignedDownloadsSlug = 'media-with-signed-downloads'

0 commit comments

Comments
 (0)