Skip to content

Commit 6b051bd

Browse files
authored
feat: add ability to disable cache tags for admin thumbnails (#10319)
This PR adds `cacheTags: boolean` (default `true`) to allow users to disable the appended document updatedAt value in the case of hosting with third party CDNs which may not allow additional search params and throw an error. It also fixes how we append this value to consider the case where the URL already contains parameters and appends it with `&` instead. In the future `cacheTags` can be made an object to allow granularity for disabling `eTag` headers used for caching as well. The cache tag control should help with these two issues: - Fixes #9880 - Fixes #9993 The appending of the value correctly addresses this: - Fixes #10139
1 parent 082c4f0 commit 6b051bd

File tree

11 files changed

+256
-11
lines changed

11 files changed

+256
-11
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,4 @@ test/databaseAdapter.js
317317
/filename-compound-index
318318
/media-with-relation-preview
319319
/media-without-relation-preview
320+
/media-without-cache-tags

docs/upload/overview.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ _An asterisk denotes that an option is required._
9292
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9393
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
9494
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
95+
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
9596
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
9697
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
9798
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |

packages/payload/src/collections/config/sanitize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export const sanitizeCollection = async (
152152
// sanitize fields for reserved names
153153
sanitizeUploadFields(sanitized.fields, sanitized)
154154

155+
sanitized.upload.cacheTags = sanitized.upload?.cacheTags ?? true
155156
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
156157
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
157158
sanitized.admin.useAsTitle =

packages/payload/src/uploads/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export type UploadConfig = {
100100
* @default true
101101
*/
102102
bulkUpload?: boolean
103+
/**
104+
* Appends a cache tag to the image URL when fetching the thumbnail in the admin panel. It may be desirable to disable this when hosting via CDNs with strict parameters.
105+
*
106+
* @default true
107+
*/
108+
cacheTags?: boolean
103109
/**
104110
* Enables cropping of images.
105111
* @default true

packages/ui/src/elements/Thumbnail/index.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const baseClass = 'thumbnail'
88
import type { SanitizedCollectionConfig } from 'payload'
99

1010
import { File } from '../../graphics/File/index.js'
11-
import { useIntersect } from '../../hooks/useIntersect.js'
1211
import { ShimmerEffect } from '../ShimmerEffect/index.js'
1312

1413
export type ThumbnailProps = {
@@ -43,15 +42,21 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
4342
}
4443
}, [fileSrc])
4544

45+
let src: string = ''
46+
47+
/**
48+
* If an imageCacheTag is provided, append it to the fileSrc
49+
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
50+
*/
51+
if (fileSrc) {
52+
const queryChar = fileSrc?.includes('?') ? '&' : '?'
53+
src = imageCacheTag ? `${fileSrc}${queryChar}${imageCacheTag}` : fileSrc
54+
}
55+
4656
return (
4757
<div className={classNames}>
4858
{fileExists === undefined && <ShimmerEffect height="100%" />}
49-
{fileExists && (
50-
<img
51-
alt={filename as string}
52-
src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`}
53-
/>
54-
)}
59+
{fileExists && <img alt={filename as string} src={src} />}
5560
{fileExists === false && <File />}
5661
</div>
5762
)
@@ -87,12 +92,17 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
8792
}
8893
}, [fileSrc])
8994

95+
/**
96+
* If an imageCacheTag is provided, append it to the fileSrc
97+
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
98+
*/
99+
const queryChar = fileSrc.includes('?') ? '&' : '?'
100+
const src = imageCacheTag ? `${fileSrc}${queryChar}${imageCacheTag}` : fileSrc
101+
90102
return (
91103
<div className={classNames}>
92104
{fileExists === undefined && <ShimmerEffect height="100%" />}
93-
{fileExists && (
94-
<img alt={alt || filename} src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`} />
95-
)}
105+
{fileExists && <img alt={alt || filename} src={src} />}
96106
{fileExists === false && <File />}
97107
</div>
98108
)

packages/ui/src/elements/Upload/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
237237
enableAdjustments={showCrop || showFocalPoint}
238238
handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
239239
hasImageSizes={hasImageSizes}
240-
imageCacheTag={savedDocumentData.updatedAt}
240+
imageCacheTag={uploadConfig?.cacheTags && savedDocumentData.updatedAt}
241241
uploadConfig={uploadConfig}
242242
/>
243243
)}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import path from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
import { adminThumbnailWithSearchQueries } from '../../shared.js'
7+
const filename = fileURLToPath(import.meta.url)
8+
const dirname = path.dirname(filename)
9+
10+
export const AdminThumbnailWithSearchQueries: CollectionConfig = {
11+
slug: adminThumbnailWithSearchQueries,
12+
hooks: {
13+
afterRead: [
14+
({ doc }) => {
15+
return {
16+
...doc,
17+
// Test that URLs with additional queries are handled correctly
18+
thumbnailURL: `/_next/image?url=${doc.url}&w=384&q=5`,
19+
}
20+
},
21+
],
22+
},
23+
upload: {
24+
staticDir: path.resolve(dirname, 'test/uploads/media'),
25+
},
26+
fields: [],
27+
}

test/uploads/config.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { devUser } from '../credentials.js'
77
import removeFiles from '../helpers/removeFiles.js'
88
import { AdminThumbnailFunction } from './collections/AdminThumbnailFunction/index.js'
99
import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js'
10+
import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWithSearchQueries/index.js'
1011
import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js'
1112
import { Uploads1 } from './collections/Upload1/index.js'
1213
import { Uploads2 } from './collections/Upload2/index.js'
@@ -17,6 +18,7 @@ import {
1718
enlargeSlug,
1819
focalNoSizesSlug,
1920
mediaSlug,
21+
mediaWithoutCacheTagsSlug,
2022
mediaWithoutRelationPreviewSlug,
2123
mediaWithRelationPreviewSlug,
2224
reduceSlug,
@@ -582,6 +584,7 @@ export default buildConfigWithDefaults({
582584
Uploads1,
583585
Uploads2,
584586
AdminThumbnailFunction,
587+
AdminThumbnailWithSearchQueries,
585588
AdminThumbnailSize,
586589
{
587590
slug: 'optional-file',
@@ -628,6 +631,18 @@ export default buildConfigWithDefaults({
628631
displayPreview: true,
629632
},
630633
},
634+
{
635+
slug: mediaWithoutCacheTagsSlug,
636+
fields: [
637+
{
638+
name: 'title',
639+
type: 'text',
640+
},
641+
],
642+
upload: {
643+
cacheTags: false,
644+
},
645+
},
631646
{
632647
slug: mediaWithoutRelationPreviewSlug,
633648
fields: [
@@ -799,13 +814,31 @@ export default buildConfigWithDefaults({
799814
},
800815
})
801816

817+
await payload.create({
818+
collection: AdminThumbnailWithSearchQueries.slug,
819+
data: {},
820+
file: {
821+
...imageFile,
822+
name: `searchQueries-image-${imageFile.name}`,
823+
},
824+
})
825+
802826
// Create media with and without relation preview
803827
const { id: uploadedImageWithPreview } = await payload.create({
804828
collection: mediaWithRelationPreviewSlug,
805829
data: {},
806830
file: imageFile,
807831
})
808832

833+
await payload.create({
834+
collection: mediaWithoutCacheTagsSlug,
835+
data: {},
836+
file: {
837+
...imageFile,
838+
name: `withoutCacheTags-image-${imageFile.name}`,
839+
},
840+
})
841+
809842
const { id: uploadedImageWithoutPreview } = await payload.create({
810843
collection: mediaWithoutRelationPreviewSlug,
811844
data: {},

test/uploads/e2e.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { RESTClient } from '../helpers/rest.js'
2222
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
2323
import {
2424
adminThumbnailFunctionSlug,
25+
adminThumbnailWithSearchQueries,
26+
mediaWithoutCacheTagsSlug,
2527
adminThumbnailSizeSlug,
2628
animatedTypeMedia,
2729
audioSlug,
@@ -48,6 +50,8 @@ let audioURL: AdminUrlUtil
4850
let relationURL: AdminUrlUtil
4951
let adminThumbnailSizeURL: AdminUrlUtil
5052
let adminThumbnailFunctionURL: AdminUrlUtil
53+
let adminThumbnailWithSearchQueriesURL: AdminUrlUtil
54+
let mediaWithoutCacheTagsSlugURL: AdminUrlUtil
5155
let focalOnlyURL: AdminUrlUtil
5256
let withMetadataURL: AdminUrlUtil
5357
let withoutMetadataURL: AdminUrlUtil
@@ -68,6 +72,11 @@ describe('Uploads', () => {
6872
relationURL = new AdminUrlUtil(serverURL, relationSlug)
6973
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
7074
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
75+
adminThumbnailWithSearchQueriesURL = new AdminUrlUtil(
76+
serverURL,
77+
adminThumbnailWithSearchQueries,
78+
)
79+
mediaWithoutCacheTagsSlugURL = new AdminUrlUtil(serverURL, mediaWithoutCacheTagsSlug)
7180
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug)
7281
withMetadataURL = new AdminUrlUtil(serverURL, withMetadataSlug)
7382
withoutMetadataURL = new AdminUrlUtil(serverURL, withoutMetadataSlug)
@@ -430,6 +439,77 @@ describe('Uploads', () => {
430439
)
431440
})
432441

442+
test('should render adminThumbnail when using a custom thumbnail URL with additional queries', async () => {
443+
await page.goto(adminThumbnailWithSearchQueriesURL.list)
444+
await page.waitForURL(adminThumbnailWithSearchQueriesURL.list)
445+
446+
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
447+
// Match the URL with the regex pattern
448+
const regexPattern = /\/_next\/image\?url=.*?&w=384&q=5/
449+
450+
await expect(genericUploadImage).toHaveAttribute('src', regexPattern)
451+
})
452+
453+
test('should render adminThumbnail without the additional cache tag', async () => {
454+
const imageDoc = (
455+
await payload.find({
456+
collection: mediaWithoutCacheTagsSlug,
457+
depth: 0,
458+
pagination: false,
459+
where: {
460+
mimeType: {
461+
equals: 'image/png',
462+
},
463+
},
464+
})
465+
).docs[0]
466+
467+
await page.goto(mediaWithoutCacheTagsSlugURL.edit(imageDoc.id))
468+
469+
const genericUploadImage = page.locator('.file-details .thumbnail img')
470+
471+
const src = await genericUploadImage.getAttribute('src')
472+
473+
/**
474+
* Regex matcher for date cache tags.
475+
*
476+
* @example it will match `?2022-01-01T00:00:00.000Z`
477+
*/
478+
const cacheTagPattern = /\?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
479+
480+
expect(src).not.toMatch(cacheTagPattern)
481+
})
482+
483+
test('should render adminThumbnail with the cache tag by default', async () => {
484+
const imageDoc = (
485+
await payload.find({
486+
collection: adminThumbnailFunctionSlug,
487+
depth: 0,
488+
pagination: false,
489+
where: {
490+
mimeType: {
491+
equals: 'image/png',
492+
},
493+
},
494+
})
495+
).docs[0]
496+
497+
await page.goto(adminThumbnailFunctionURL.edit(imageDoc.id))
498+
499+
const genericUploadImage = page.locator('.file-details .thumbnail img')
500+
501+
const src = await genericUploadImage.getAttribute('src')
502+
503+
/**
504+
* Regex matcher for date cache tags.
505+
*
506+
* @example it will match `?2022-01-01T00:00:00.000Z`
507+
*/
508+
const cacheTagPattern = /\?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
509+
510+
expect(src).toMatch(cacheTagPattern)
511+
})
512+
433513
test('should render adminThumbnail when using a specific size', async () => {
434514
await page.goto(adminThumbnailSizeURL.list)
435515
await page.waitForURL(adminThumbnailSizeURL.list)

0 commit comments

Comments
 (0)