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 https://github.com/payloadcms/payload/issues/9880
- Fixes https://github.com/payloadcms/payload/issues/9993

The appending of the value correctly addresses this:
- Fixes https://github.com/payloadcms/payload/issues/10139
This commit is contained in:
Paul
2025-01-13 09:26:47 -06:00
committed by GitHub
parent 082c4f0d71
commit 6b051bd59e
11 changed files with 256 additions and 11 deletions

1
.gitignore vendored
View File

@@ -317,3 +317,4 @@ test/databaseAdapter.js
/filename-compound-index
/media-with-relation-preview
/media-without-relation-preview
/media-without-cache-tags

View File

@@ -92,6 +92,7 @@ _An asterisk denotes that an option is required._
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`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. |
| **`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) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`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). |

View File

@@ -152,6 +152,7 @@ export const sanitizeCollection = async (
// sanitize fields for reserved names
sanitizeUploadFields(sanitized.fields, sanitized)
sanitized.upload.cacheTags = sanitized.upload?.cacheTags ?? true
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
sanitized.admin.useAsTitle =

View File

@@ -100,6 +100,12 @@ export type UploadConfig = {
* @default true
*/
bulkUpload?: boolean
/**
* 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.
*
* @default true
*/
cacheTags?: boolean
/**
* Enables cropping of images.
* @default true

View File

@@ -8,7 +8,6 @@ const baseClass = 'thumbnail'
import type { SanitizedCollectionConfig } from 'payload'
import { File } from '../../graphics/File/index.js'
import { useIntersect } from '../../hooks/useIntersect.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'
export type ThumbnailProps = {
@@ -43,15 +42,21 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
}
}, [fileSrc])
let src: string = ''
/**
* If an imageCacheTag is provided, append it to the fileSrc
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
*/
if (fileSrc) {
const queryChar = fileSrc?.includes('?') ? '&' : '?'
src = imageCacheTag ? `${fileSrc}${queryChar}${imageCacheTag}` : fileSrc
}
return (
<div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && (
<img
alt={filename as string}
src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`}
/>
)}
{fileExists && <img alt={filename as string} src={src} />}
{fileExists === false && <File />}
</div>
)
@@ -87,12 +92,17 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
}
}, [fileSrc])
/**
* If an imageCacheTag is provided, append it to the fileSrc
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
*/
const queryChar = fileSrc.includes('?') ? '&' : '?'
const src = imageCacheTag ? `${fileSrc}${queryChar}${imageCacheTag}` : fileSrc
return (
<div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && (
<img alt={alt || filename} src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`} />
)}
{fileExists && <img alt={alt || filename} src={src} />}
{fileExists === false && <File />}
</div>
)

View File

@@ -237,7 +237,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
enableAdjustments={showCrop || showFocalPoint}
handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
hasImageSizes={hasImageSizes}
imageCacheTag={savedDocumentData.updatedAt}
imageCacheTag={uploadConfig?.cacheTags && savedDocumentData.updatedAt}
uploadConfig={uploadConfig}
/>
)}

View File

@@ -0,0 +1,27 @@
import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { adminThumbnailWithSearchQueries } from '../../shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const AdminThumbnailWithSearchQueries: CollectionConfig = {
slug: adminThumbnailWithSearchQueries,
hooks: {
afterRead: [
({ doc }) => {
return {
...doc,
// Test that URLs with additional queries are handled correctly
thumbnailURL: `/_next/image?url=${doc.url}&w=384&q=5`,
}
},
],
},
upload: {
staticDir: path.resolve(dirname, 'test/uploads/media'),
},
fields: [],
}

View File

@@ -7,6 +7,7 @@ import { devUser } from '../credentials.js'
import removeFiles from '../helpers/removeFiles.js'
import { AdminThumbnailFunction } from './collections/AdminThumbnailFunction/index.js'
import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js'
import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWithSearchQueries/index.js'
import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js'
import { Uploads1 } from './collections/Upload1/index.js'
import { Uploads2 } from './collections/Upload2/index.js'
@@ -17,6 +18,7 @@ import {
enlargeSlug,
focalNoSizesSlug,
mediaSlug,
mediaWithoutCacheTagsSlug,
mediaWithoutRelationPreviewSlug,
mediaWithRelationPreviewSlug,
reduceSlug,
@@ -582,6 +584,7 @@ export default buildConfigWithDefaults({
Uploads1,
Uploads2,
AdminThumbnailFunction,
AdminThumbnailWithSearchQueries,
AdminThumbnailSize,
{
slug: 'optional-file',
@@ -628,6 +631,18 @@ export default buildConfigWithDefaults({
displayPreview: true,
},
},
{
slug: mediaWithoutCacheTagsSlug,
fields: [
{
name: 'title',
type: 'text',
},
],
upload: {
cacheTags: false,
},
},
{
slug: mediaWithoutRelationPreviewSlug,
fields: [
@@ -799,6 +814,15 @@ export default buildConfigWithDefaults({
},
})
await payload.create({
collection: AdminThumbnailWithSearchQueries.slug,
data: {},
file: {
...imageFile,
name: `searchQueries-image-${imageFile.name}`,
},
})
// Create media with and without relation preview
const { id: uploadedImageWithPreview } = await payload.create({
collection: mediaWithRelationPreviewSlug,
@@ -806,6 +830,15 @@ export default buildConfigWithDefaults({
file: imageFile,
})
await payload.create({
collection: mediaWithoutCacheTagsSlug,
data: {},
file: {
...imageFile,
name: `withoutCacheTags-image-${imageFile.name}`,
},
})
const { id: uploadedImageWithoutPreview } = await payload.create({
collection: mediaWithoutRelationPreviewSlug,
data: {},

View File

@@ -22,6 +22,8 @@ import { RESTClient } from '../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
adminThumbnailFunctionSlug,
adminThumbnailWithSearchQueries,
mediaWithoutCacheTagsSlug,
adminThumbnailSizeSlug,
animatedTypeMedia,
audioSlug,
@@ -48,6 +50,8 @@ let audioURL: AdminUrlUtil
let relationURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil
let adminThumbnailFunctionURL: AdminUrlUtil
let adminThumbnailWithSearchQueriesURL: AdminUrlUtil
let mediaWithoutCacheTagsSlugURL: AdminUrlUtil
let focalOnlyURL: AdminUrlUtil
let withMetadataURL: AdminUrlUtil
let withoutMetadataURL: AdminUrlUtil
@@ -68,6 +72,11 @@ describe('Uploads', () => {
relationURL = new AdminUrlUtil(serverURL, relationSlug)
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
adminThumbnailWithSearchQueriesURL = new AdminUrlUtil(
serverURL,
adminThumbnailWithSearchQueries,
)
mediaWithoutCacheTagsSlugURL = new AdminUrlUtil(serverURL, mediaWithoutCacheTagsSlug)
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug)
withMetadataURL = new AdminUrlUtil(serverURL, withMetadataSlug)
withoutMetadataURL = new AdminUrlUtil(serverURL, withoutMetadataSlug)
@@ -430,6 +439,77 @@ describe('Uploads', () => {
)
})
test('should render adminThumbnail when using a custom thumbnail URL with additional queries', async () => {
await page.goto(adminThumbnailWithSearchQueriesURL.list)
await page.waitForURL(adminThumbnailWithSearchQueriesURL.list)
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
// Match the URL with the regex pattern
const regexPattern = /\/_next\/image\?url=.*?&w=384&q=5/
await expect(genericUploadImage).toHaveAttribute('src', regexPattern)
})
test('should render adminThumbnail without the additional cache tag', async () => {
const imageDoc = (
await payload.find({
collection: mediaWithoutCacheTagsSlug,
depth: 0,
pagination: false,
where: {
mimeType: {
equals: 'image/png',
},
},
})
).docs[0]
await page.goto(mediaWithoutCacheTagsSlugURL.edit(imageDoc.id))
const genericUploadImage = page.locator('.file-details .thumbnail img')
const src = await genericUploadImage.getAttribute('src')
/**
* Regex matcher for date cache tags.
*
* @example it will match `?2022-01-01T00:00:00.000Z`
*/
const cacheTagPattern = /\?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
expect(src).not.toMatch(cacheTagPattern)
})
test('should render adminThumbnail with the cache tag by default', async () => {
const imageDoc = (
await payload.find({
collection: adminThumbnailFunctionSlug,
depth: 0,
pagination: false,
where: {
mimeType: {
equals: 'image/png',
},
},
})
).docs[0]
await page.goto(adminThumbnailFunctionURL.edit(imageDoc.id))
const genericUploadImage = page.locator('.file-details .thumbnail img')
const src = await genericUploadImage.getAttribute('src')
/**
* Regex matcher for date cache tags.
*
* @example it will match `?2022-01-01T00:00:00.000Z`
*/
const cacheTagPattern = /\?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
expect(src).toMatch(cacheTagPattern)
})
test('should render adminThumbnail when using a specific size', async () => {
await page.goto(adminThumbnailSizeURL.list)
await page.waitForURL(adminThumbnailSizeURL.list)

View File

@@ -34,12 +34,14 @@ export interface Config {
'uploads-1': Uploads1;
'uploads-2': Uploads2;
'admin-thumbnail-function': AdminThumbnailFunction;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery;
'admin-thumbnail-size': AdminThumbnailSize;
'optional-file': OptionalFile;
'required-file': RequiredFile;
versions: Version;
'custom-upload-field': CustomUploadField;
'media-with-relation-preview': MediaWithRelationPreview;
'media-without-cache-tags': MediaWithoutCacheTag;
'media-without-relation-preview': MediaWithoutRelationPreview;
'relation-preview': RelationPreview;
users: User;
@@ -72,12 +74,14 @@ export interface Config {
'uploads-1': Uploads1Select<false> | Uploads1Select<true>;
'uploads-2': Uploads2Select<false> | Uploads2Select<true>;
'admin-thumbnail-function': AdminThumbnailFunctionSelect<false> | AdminThumbnailFunctionSelect<true>;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect<false> | AdminThumbnailWithSearchQueriesSelect<true>;
'admin-thumbnail-size': AdminThumbnailSizeSelect<false> | AdminThumbnailSizeSelect<true>;
'optional-file': OptionalFileSelect<false> | OptionalFileSelect<true>;
'required-file': RequiredFileSelect<false> | RequiredFileSelect<true>;
versions: VersionsSelect<false> | VersionsSelect<true>;
'custom-upload-field': CustomUploadFieldSelect<false> | CustomUploadFieldSelect<true>;
'media-with-relation-preview': MediaWithRelationPreviewSelect<false> | MediaWithRelationPreviewSelect<true>;
'media-without-cache-tags': MediaWithoutCacheTagsSelect<false> | MediaWithoutCacheTagsSelect<true>;
'media-without-relation-preview': MediaWithoutRelationPreviewSelect<false> | MediaWithoutRelationPreviewSelect<true>;
'relation-preview': RelationPreviewSelect<false> | RelationPreviewSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
@@ -986,6 +990,24 @@ export interface AdminThumbnailFunction {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-with-search-queries".
*/
export interface AdminThumbnailWithSearchQuery {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-size".
@@ -1096,6 +1118,25 @@ export interface MediaWithRelationPreview {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-without-cache-tags".
*/
export interface MediaWithoutCacheTag {
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-without-relation-preview".
@@ -1246,6 +1287,10 @@ export interface PayloadLockedDocument {
relationTo: 'admin-thumbnail-function';
value: string | AdminThumbnailFunction;
} | null)
| ({
relationTo: 'admin-thumbnail-with-search-queries';
value: string | AdminThumbnailWithSearchQuery;
} | null)
| ({
relationTo: 'admin-thumbnail-size';
value: string | AdminThumbnailSize;
@@ -1270,6 +1315,10 @@ export interface PayloadLockedDocument {
relationTo: 'media-with-relation-preview';
value: string | MediaWithRelationPreview;
} | null)
| ({
relationTo: 'media-without-cache-tags';
value: string | MediaWithoutCacheTag;
} | null)
| ({
relationTo: 'media-without-relation-preview';
value: string | MediaWithoutRelationPreview;
@@ -2261,6 +2310,23 @@ export interface AdminThumbnailFunctionSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-with-search-queries_select".
*/
export interface AdminThumbnailWithSearchQueriesSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-size_select".
@@ -2391,6 +2457,24 @@ export interface MediaWithRelationPreviewSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-without-cache-tags_select".
*/
export interface MediaWithoutCacheTagsSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-without-relation-preview_select".

View File

@@ -9,7 +9,9 @@ export const reduceSlug = 'reduce'
export const relationPreviewSlug = 'relation-preview'
export const mediaWithRelationPreviewSlug = 'media-with-relation-preview'
export const mediaWithoutRelationPreviewSlug = 'media-without-relation-preview'
export const mediaWithoutCacheTagsSlug = 'media-without-cache-tags'
export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
export const adminThumbnailWithSearchQueries = 'admin-thumbnail-with-search-queries'
export const adminThumbnailSizeSlug = 'admin-thumbnail-size'
export const unstoredMediaSlug = 'unstored-media'
export const versionSlug = 'versions'