From fa3fa2a277f8c49dbe8331593fc4a8e6ed2a8f55 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 11 Apr 2025 09:15:43 -0400 Subject: [PATCH 1/5] feat(storage*): include modified upload headers --- .../payload/src/uploads/endpoints/getFile.ts | 5 +++- packages/payload/src/uploads/types.ts | 3 +- packages/plugin-cloud-storage/src/types.ts | 1 + packages/storage-gcs/src/staticHandler.ts | 28 +++++++++++-------- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/payload/src/uploads/endpoints/getFile.ts b/packages/payload/src/uploads/endpoints/getFile.ts index 876956e3f19..efe3bde8789 100644 --- a/packages/payload/src/uploads/endpoints/getFile.ts +++ b/packages/payload/src/uploads/endpoints/getFile.ts @@ -39,9 +39,12 @@ export const getFileHandler: PayloadHandler = async (req) => { if (collection.config.upload.handlers?.length) { let customResponse = null + const headers = new Headers() + for (const handler of collection.config.upload.handlers) { customResponse = await handler(req, { doc: accessResult, + headers, params: { collection: collection.config.slug, filename, @@ -96,7 +99,7 @@ export const getFileHandler: PayloadHandler = async (req) => { headers.set('Content-Type', fileTypeResult.mime) headers.set('Content-Length', stats.size + '') headers = collection.config.upload?.modifyResponseHeaders - ? collection.config.upload.modifyResponseHeaders({ headers }) + ? collection.config.upload.modifyResponseHeaders({ headers }) || headers : headers return new Response(data, { diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index da29c9dd08b..e279779a8a7 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -178,6 +178,7 @@ export type UploadConfig = { req: PayloadRequest, args: { doc: TypeWithID + headers: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Promise | Response | void)[] @@ -200,7 +201,7 @@ export type UploadConfig = { * Ability to modify the response headers fetching a file. * @default undefined */ - modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers + modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers | void /** * Controls the behavior of pasting/uploading files from URLs. * If set to `false`, fetching from remote URLs is disabled. diff --git a/packages/plugin-cloud-storage/src/types.ts b/packages/plugin-cloud-storage/src/types.ts index a1e1da9aee1..b83cb3aef8f 100644 --- a/packages/plugin-cloud-storage/src/types.ts +++ b/packages/plugin-cloud-storage/src/types.ts @@ -58,6 +58,7 @@ export type StaticHandler = ( req: PayloadRequest, args: { doc?: TypeWithID + headers: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Response diff --git a/packages/storage-gcs/src/staticHandler.ts b/packages/storage-gcs/src/staticHandler.ts index f6828fe8345..e81344d9790 100644 --- a/packages/storage-gcs/src/staticHandler.ts +++ b/packages/storage-gcs/src/staticHandler.ts @@ -12,7 +12,7 @@ interface Args { } export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) const file = getStorageClient().bucket(bucket).file(path.posix.join(prefix, filename)) @@ -22,13 +22,23 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = metadata.etag + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(metadata.size)) + headers.append('Content-Type', String(metadata.contentType)) + headers.append('ETag', String(metadata.etag)) + + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Content-Length': String(metadata.size), - 'Content-Type': String(metadata.contentType), - ETag: String(metadata.etag), - }), + headers, status: 304, }) } @@ -50,11 +60,7 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat }) return new Response(readableStream, { - headers: new Headers({ - 'Content-Length': String(metadata.size), - 'Content-Type': String(metadata.contentType), - ETag: String(metadata.etag), - }), + headers, status: 200, }) } catch (err: unknown) { From d76eeb2a8f20d3020098d83aade5449495dbf1b4 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sat, 12 Apr 2025 20:10:21 +0100 Subject: [PATCH 2/5] add support for modifying headers in the rest of the adapters --- packages/storage-azure/src/staticHandler.ts | 18 ++++++--- packages/storage-s3/src/staticHandler.ts | 31 ++++++++------ .../storage-uploadthing/src/staticHandler.ts | 33 +++++++++------ .../storage-vercel-blob/src/staticHandler.ts | 40 ++++++++++--------- test/storage-gcs/collections/Media.ts | 3 ++ 5 files changed, 77 insertions(+), 48 deletions(-) diff --git a/packages/storage-azure/src/staticHandler.ts b/packages/storage-azure/src/staticHandler.ts index 58ea8211208..6945d4d6ced 100644 --- a/packages/storage-azure/src/staticHandler.ts +++ b/packages/storage-azure/src/staticHandler.ts @@ -13,7 +13,7 @@ interface Args { } export const getHandler = ({ collection, getStorageClient }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) const blockBlobClient = getStorageClient().getBlockBlobClient( @@ -29,14 +29,22 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle const response = blob._response + let headers = new Headers({ ...incomingHeaders, ...response.headers.rawHeaders() }) + const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = response.headers.get('etag') + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - ...response.headers.rawHeaders(), - }), + headers, status: 304, }) } @@ -62,7 +70,7 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle }) return new Response(readableStream, { - headers: response.headers.rawHeaders(), + headers, status: response.status, }) } catch (err: unknown) { diff --git a/packages/storage-s3/src/staticHandler.ts b/packages/storage-s3/src/staticHandler.ts index 0c01319c143..c70e5dc7235 100644 --- a/packages/storage-s3/src/staticHandler.ts +++ b/packages/storage-s3/src/staticHandler.ts @@ -41,7 +41,7 @@ const streamToBuffer = async (readableStream: any) => { } export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => { - return async (req, { params: { clientUploadContext, filename } }) => { + return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => { let object: AWS.GetObjectOutput | undefined = undefined try { const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req }) @@ -57,17 +57,27 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat return new Response(null, { status: 404, statusText: 'Not Found' }) } + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(object.ContentLength)) + headers.append('Content-Type', String(object.ContentType)) + headers.append('Accept-Ranges', String(object.AcceptRanges)) + headers.append('ETag', String(object.ETag)) + const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = object.ETag + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Accept-Ranges': String(object.AcceptRanges), - 'Content-Length': String(object.ContentLength), - 'Content-Type': String(object.ContentType), - ETag: String(object.ETag), - }), + headers, status: 304, }) } @@ -88,12 +98,7 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat const bodyBuffer = await streamToBuffer(object.Body) return new Response(bodyBuffer, { - headers: new Headers({ - 'Accept-Ranges': String(object.AcceptRanges), - 'Content-Length': String(object.ContentLength), - 'Content-Type': String(object.ContentType), - ETag: String(object.ETag), - }), + headers, status: 200, }) } catch (err) { diff --git a/packages/storage-uploadthing/src/staticHandler.ts b/packages/storage-uploadthing/src/staticHandler.ts index 39aaff93320..ed2d9518682 100644 --- a/packages/storage-uploadthing/src/staticHandler.ts +++ b/packages/storage-uploadthing/src/staticHandler.ts @@ -9,9 +9,13 @@ type Args = { } export const getHandler = ({ utApi }: Args): StaticHandler => { - return async (req, { doc, params: { clientUploadContext, collection, filename } }) => { + return async ( + req, + { doc, headers: incomingHeaders, params: { clientUploadContext, collection, filename } }, + ) => { try { let key: string + const collectionConfig = req.payload.collections[collection]?.config if ( clientUploadContext && @@ -21,7 +25,6 @@ export const getHandler = ({ utApi }: Args): StaticHandler => { ) { key = clientUploadContext.key } else { - const collectionConfig = req.payload.collections[collection]?.config let retrievedDoc = doc if (!retrievedDoc) { @@ -82,23 +85,29 @@ export const getHandler = ({ utApi }: Args): StaticHandler => { const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = response.headers.get('etag') + let headers = new Headers(incomingHeaders) + + headers.append('Content-Length', String(blob.size)) + headers.append('Content-Type', blob.type) + headers.append('ETag', objectEtag) + + if ( + collectionConfig.upload && + typeof collectionConfig.upload === 'object' && + typeof collectionConfig.upload.modifyResponseHeaders === 'function' + ) { + headers = collectionConfig.upload.modifyResponseHeaders({ headers }) || headers + } + if (etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { - headers: new Headers({ - 'Content-Length': String(blob.size), - 'Content-Type': blob.type, - ETag: objectEtag, - }), + headers, status: 304, }) } return new Response(blob, { - headers: new Headers({ - 'Content-Length': String(blob.size), - 'Content-Type': blob.type, - ETag: objectEtag, - }), + headers, status: 200, }) } catch (err) { diff --git a/packages/storage-vercel-blob/src/staticHandler.ts b/packages/storage-vercel-blob/src/staticHandler.ts index 008576f3360..1e20b846990 100644 --- a/packages/storage-vercel-blob/src/staticHandler.ts +++ b/packages/storage-vercel-blob/src/staticHandler.ts @@ -15,7 +15,7 @@ export const getStaticHandler = ( { baseUrl, cacheControlMaxAge = 0, token }: StaticHandlerArgs, collection: CollectionConfig, ): StaticHandler => { - return async (req, { params: { filename } }) => { + return async (req, { headers: incomingHeaders, params: { filename } }) => { try { const prefix = await getFilePrefix({ collection, filename, req }) const fileKey = path.posix.join(prefix, encodeURIComponent(filename)) @@ -23,20 +23,29 @@ export const getStaticHandler = ( const fileUrl = `${baseUrl}/${fileKey}` const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const blobMetadata = await head(fileUrl, { token }) - const uploadedAtString = blobMetadata.uploadedAt.toISOString() + const { contentDisposition, contentType, size, uploadedAt } = blobMetadata + const uploadedAtString = uploadedAt.toISOString() const ETag = `"${fileKey}-${uploadedAtString}"` - const { contentDisposition, contentType, size } = blobMetadata + let headers = new Headers(incomingHeaders) + + headers.append('Cache-Control', `public, max-age=${cacheControlMaxAge}`) + headers.append('Content-Disposition', contentDisposition) + headers.append('Content-Length', String(size)) + headers.append('Content-Type', contentType) + headers.append('ETag', ETag) + + if ( + collection.upload && + typeof collection.upload === 'object' && + typeof collection.upload.modifyResponseHeaders === 'function' + ) { + headers = collection.upload.modifyResponseHeaders({ headers }) || headers + } if (etagFromHeaders && etagFromHeaders === ETag) { return new Response(null, { - headers: new Headers({ - 'Cache-Control': `public, max-age=${cacheControlMaxAge}`, - 'Content-Disposition': contentDisposition, - 'Content-Length': String(size), - 'Content-Type': contentType, - ETag, - }), + headers, status: 304, }) } @@ -53,15 +62,10 @@ export const getStaticHandler = ( const bodyBuffer = await blob.arrayBuffer() + headers.append('Last-Modified', uploadedAtString) + return new Response(bodyBuffer, { - headers: new Headers({ - 'Cache-Control': `public, max-age=${cacheControlMaxAge}`, - 'Content-Disposition': contentDisposition, - 'Content-Length': String(size), - 'Content-Type': contentType, - ETag, - 'Last-Modified': blobMetadata.uploadedAt.toUTCString(), - }), + headers, status: 200, }) } catch (err: unknown) { diff --git a/test/storage-gcs/collections/Media.ts b/test/storage-gcs/collections/Media.ts index c5997222ca7..fa2bcf69a26 100644 --- a/test/storage-gcs/collections/Media.ts +++ b/test/storage-gcs/collections/Media.ts @@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { slug: 'media', upload: { + modifyResponseHeaders({ headers }) { + headers.set('X-Universal-Truth', 'Set') + }, disableLocalStorage: true, resizeOptions: { position: 'center', From 75dd918201c60ae26635672c67ff5149d5064aac Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sat, 12 Apr 2025 20:39:19 +0100 Subject: [PATCH 3/5] add docs --- docs/upload/overview.mdx | 46 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index fe3e3de7082..e7ded81c963 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -113,6 +113,7 @@ _An asterisk denotes that an option is required._ | **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. | | **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. | | **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. | +| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) | ### Payload-wide Upload Options @@ -409,7 +410,7 @@ To fetch files from **restricted URLs** that would otherwise be blocked by CORS, Here’s how to configure the pasteURL option to control remote URL fetching: -``` +```ts import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { @@ -422,7 +423,7 @@ export const Media: CollectionConfig = { pathname: '', port: '', protocol: 'https', - search: '' + search: '', }, { hostname: 'example.com', @@ -457,3 +458,44 @@ _An asterisk denotes that an option is required._ ## Access Control All files that are uploaded to each Collection automatically support the `read` [Access Control](/docs/access-control/overview) function from the Collection itself. You can use this to control who should be allowed to see your uploads, and who should not. + +## Modifying response headers + +You can modify the response headers for files by specifying the `modifyResponseHeaders` option in your upload config. This option accepts an object with existing headers and allows you to manipulate the response headers for media files. + +### Modifying existing headers + +With this method you can directly interface with the `Headers` object and modify the existing headers to append or remove headers. + +```ts +import type { CollectionConfig } from 'payload' + +export const Media: CollectionConfig = { + slug: 'media', + upload: { + modifyResponseHeaders: ({ headers }) => { + headers.set('X-Frame-Options', 'DENY') // You can directly set headers without returning + }, + }, +} +``` + +### Return new headers + +You can also return a new `Headers` object with the modified headers. This is useful if you want to set new headers or remove existing ones. + +```ts +import type { CollectionConfig } from 'payload' + +export const Media: CollectionConfig = { + slug: 'media', + upload: { + modifyResponseHeaders: ({ headers }) => { + const newHeaders = new Headers(headers) // Copy existing headers + newHeaders.set('X-Frame-Options', 'DENY') // Set new header + + return newHeaders + }, + }, +} +``` From 4bbffaf02d86aff7163d09af806ebb95eb5bd346 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sat, 12 Apr 2025 21:28:04 +0100 Subject: [PATCH 4/5] fix build --- packages/payload/src/uploads/types.ts | 2 +- packages/plugin-cloud-storage/src/types.ts | 2 +- packages/storage-azure/src/staticHandler.ts | 16 +++++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index e279779a8a7..f5f1e2d3776 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -178,7 +178,7 @@ export type UploadConfig = { req: PayloadRequest, args: { doc: TypeWithID - headers: Headers + headers?: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Promise | Response | void)[] diff --git a/packages/plugin-cloud-storage/src/types.ts b/packages/plugin-cloud-storage/src/types.ts index b83cb3aef8f..4b33931924f 100644 --- a/packages/plugin-cloud-storage/src/types.ts +++ b/packages/plugin-cloud-storage/src/types.ts @@ -58,7 +58,7 @@ export type StaticHandler = ( req: PayloadRequest, args: { doc?: TypeWithID - headers: Headers + headers?: Headers params: { clientUploadContext?: unknown; collection: string; filename: string } }, ) => Promise | Response diff --git a/packages/storage-azure/src/staticHandler.ts b/packages/storage-azure/src/staticHandler.ts index 6945d4d6ced..e5ae79b1464 100644 --- a/packages/storage-azure/src/staticHandler.ts +++ b/packages/storage-azure/src/staticHandler.ts @@ -29,7 +29,21 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle const response = blob._response - let headers = new Headers({ ...incomingHeaders, ...response.headers.rawHeaders() }) + const rawHeaders = { ...response.headers.rawHeaders() } + + let initHeaders: HeadersInit = { + ...rawHeaders, + } + + // Typescript is difficult here with merging these types from Azure + if (incomingHeaders) { + initHeaders = { + ...initHeaders, + ...incomingHeaders, + } + } + + let headers = new Headers(initHeaders) const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') const objectEtag = response.headers.get('etag') From 14afc297a6e62ba75eb4417510c210cf8fd76d0e Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sat, 12 Apr 2025 21:31:29 +0100 Subject: [PATCH 5/5] update s3 storage test to use modified response headers --- test/storage-s3/collections/Media.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/storage-s3/collections/Media.ts b/test/storage-s3/collections/Media.ts index c5997222ca7..fa2bcf69a26 100644 --- a/test/storage-s3/collections/Media.ts +++ b/test/storage-s3/collections/Media.ts @@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { slug: 'media', upload: { + modifyResponseHeaders({ headers }) { + headers.set('X-Universal-Truth', 'Set') + }, disableLocalStorage: true, resizeOptions: { position: 'center',