diff --git a/.gitignore b/.gitignore index c4e5c6d72dd..77ef9ccae80 100644 --- a/.gitignore +++ b/.gitignore @@ -317,3 +317,4 @@ test/databaseAdapter.js /filename-compound-index /media-with-relation-preview /media-without-relation-preview +/media-without-cache-tags diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index cd1e3cc519a..9097ef917df 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -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). | diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 2b414aa5ecf..b00b11ff5d0 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -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 = diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 03643d8b267..42a863dd97d 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -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 diff --git a/packages/ui/src/elements/Thumbnail/index.tsx b/packages/ui/src/elements/Thumbnail/index.tsx index d133359dd9c..eec3c509b9d 100644 --- a/packages/ui/src/elements/Thumbnail/index.tsx +++ b/packages/ui/src/elements/Thumbnail/index.tsx @@ -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 = (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 (
{fileExists === undefined && } - {fileExists && ( - {filename - )} + {fileExists && {filename} {fileExists === false && }
) @@ -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 (
{fileExists === undefined && } - {fileExists && ( - {alt - )} + {fileExists && {alt} {fileExists === false && }
) diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 6f99c7d18c1..d225bab5e0d 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -237,7 +237,7 @@ export const Upload: React.FC = (props) => { enableAdjustments={showCrop || showFocalPoint} handleRemove={canRemoveUpload ? handleFileRemoval : undefined} hasImageSizes={hasImageSizes} - imageCacheTag={savedDocumentData.updatedAt} + imageCacheTag={uploadConfig?.cacheTags && savedDocumentData.updatedAt} uploadConfig={uploadConfig} /> )} diff --git a/test/uploads/collections/AdminThumbnailWithSearchQueries/index.ts b/test/uploads/collections/AdminThumbnailWithSearchQueries/index.ts new file mode 100644 index 00000000000..0bb4a468a6e --- /dev/null +++ b/test/uploads/collections/AdminThumbnailWithSearchQueries/index.ts @@ -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: [], +} diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 426818f70b9..d2d845ae6e2 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -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: {}, diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index a2f4d8339fb..ca544334659 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -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) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 594059fd2c1..bfffbb00e16 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -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 | Uploads1Select; 'uploads-2': Uploads2Select | Uploads2Select; 'admin-thumbnail-function': AdminThumbnailFunctionSelect | AdminThumbnailFunctionSelect; + 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect | AdminThumbnailWithSearchQueriesSelect; 'admin-thumbnail-size': AdminThumbnailSizeSelect | AdminThumbnailSizeSelect; 'optional-file': OptionalFileSelect | OptionalFileSelect; 'required-file': RequiredFileSelect | RequiredFileSelect; versions: VersionsSelect | VersionsSelect; 'custom-upload-field': CustomUploadFieldSelect | CustomUploadFieldSelect; 'media-with-relation-preview': MediaWithRelationPreviewSelect | MediaWithRelationPreviewSelect; + 'media-without-cache-tags': MediaWithoutCacheTagsSelect | MediaWithoutCacheTagsSelect; 'media-without-relation-preview': MediaWithoutRelationPreviewSelect | MediaWithoutRelationPreviewSelect; 'relation-preview': RelationPreviewSelect | RelationPreviewSelect; users: UsersSelect | UsersSelect; @@ -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 { 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 { + 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 { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-without-cache-tags_select". + */ +export interface MediaWithoutCacheTagsSelect { + 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". diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index e05ff1cfe8c..ee007dffc76 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -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'