From 3239ab3d945f8f6ad11a8f067e0240a7ace4b8e8 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 30 Sep 2026 17:23:27 +0100 Subject: [PATCH 01/13] feat: add file transformers --- docs/fields/default-fields.mdx | 10 +- docs/migration-guide/v4.mdx | 70 +++ docs/upload/overview.mdx | 237 +++++---- docs/upload/storage-adapters.mdx | 2 + docs/upload/transformers.mdx | 402 +++++++++++++++ packages/codemod/README.md | 2 + packages/codemod/src/registry.ts | 2 + .../basic.input.ts | 21 + .../basic.output.ts | 19 + .../existing-transformers.input.ts | 18 + .../existing-transformers.output.ts | 17 + .../index.spec.ts | 242 +++++++++ .../migrate-sharp-to-transformer/index.ts | 473 ++++++++++++++++++ .../no-match.input.ts | 10 + .../no-match.output.ts | 10 + .../src/lib/init-tanstack.spec.ts | 4 - .../src/lib/init-tanstack.ts | 1 - .../payload/src/collections/config/client.ts | 2 - .../config/assertNoLegacySharpConfig.spec.ts | 88 ++++ .../src/config/assertNoLegacySharpConfig.ts | 55 ++ packages/payload/src/config/build.spec.ts | 80 +++ packages/payload/src/config/build.ts | 18 + packages/payload/src/config/client.ts | 4 +- packages/payload/src/config/defaults.ts | 2 +- packages/payload/src/config/sanitize.ts | 3 +- packages/payload/src/config/types.ts | 42 +- .../src/errors/TransformerContractError.ts | 15 + packages/payload/src/errors/index.ts | 1 + packages/payload/src/exports/internal.ts | 10 + packages/payload/src/exports/shared.ts | 1 + packages/payload/src/index.ts | 13 +- packages/payload/src/types/index.ts | 6 + .../src/uploads/checkFileAccess.spec.ts | 14 + .../payload/src/uploads/checkFileAccess.ts | 13 +- .../payload/src/uploads/cropImage.spec.ts | 62 --- packages/payload/src/uploads/cropImage.ts | 96 ---- .../payload/src/uploads/endpoints/getFile.ts | 58 ++- .../src/uploads/generateFileData.spec.ts | 136 ++++- .../payload/src/uploads/generateFileData.ts | 418 ++++++++-------- .../generateImageSizeFilename.ts | 0 .../uploads/generatePayloadFileURL.spec.ts | 150 ++++++ .../src/uploads/generatePayloadFileURL.ts | 96 ++++ packages/payload/src/uploads/getBaseFields.ts | 8 +- .../uploads/getFileContentRequirement.spec.ts | 4 +- .../src/uploads/getFileContentRequirement.ts | 19 +- .../getFileFromUploadInstructions.spec.ts | 5 - .../payload/src/uploads/getImageSize.spec.ts | 33 +- packages/payload/src/uploads/getImageSize.ts | 32 +- .../uploads/image-resizing/createImageSize.ts | 40 -- .../image-resizing/createImageSizes.spec.ts | 72 --- .../image-resizing/createImageSizes.ts | 300 ----------- .../image-resizing/extractHeightFromImage.ts | 14 - .../image-resizing/getImageResizeAction.ts | 76 --- .../image-resizing/sanitizeResizeConfig.ts | 21 - .../src/uploads/optionallyAppendMetadata.ts | 31 -- .../{image-resizing => }/parseFilename.ts | 0 .../payload/src/uploads/probeImageSize.ts | 4 +- .../createLazySourceGetter.spec.ts | 62 +++ .../transformers/createLazySourceGetter.ts | 36 ++ .../transformers/finalizeFileResponse.spec.ts | 137 +++++ .../transformers/finalizeFileResponse.ts | 64 +++ .../transformers/getSourceFileResponse.ts | 33 ++ .../handleDynamicFileRequest.spec.ts | 236 +++++++++ .../transformers/handleDynamicFileRequest.ts | 236 +++++++++ .../transformers/matchesMimeType.spec.ts | 61 +++ .../uploads/transformers/matchesMimeType.ts | 56 +++ .../planTransformerPipeline.spec.ts | 84 ++++ .../transformers/planTransformerPipeline.ts | 63 +++ .../resolveUploadDocument.spec.ts | 52 ++ .../transformers/resolveUploadDocument.ts | 98 ++++ .../transformers/transformUploadFile.spec.ts | 81 +++ .../transformers/transformUploadFile.ts | 49 ++ .../payload/src/uploads/transformers/types.ts | 80 +++ .../transformers/uploadTransformerBridge.ts | 47 ++ .../transformers/validateTransformers.spec.ts | 62 +++ .../transformers/validateTransformers.ts | 88 ++++ .../withFileTransformAccessContext.ts | 29 ++ packages/payload/src/uploads/types.ts | 100 ++-- .../src/fields/getFields.ts | 13 +- packages/plugin-cloud-storage/src/types.ts | 11 +- .../src/utilities/getFilePrefix.ts | 4 +- packages/storage-azure/src/adapter.ts | 3 +- packages/storage-azure/src/getFile.ts | 15 +- packages/storage-gcs/src/adapter.ts | 3 +- packages/storage-gcs/src/getFile.ts | 14 +- packages/storage-r2/src/adapter.ts | 3 +- packages/storage-r2/src/getFile.spec.ts | 79 +++ packages/storage-r2/src/getFile.ts | 24 +- packages/storage-s3/src/adapter.ts | 6 +- packages/storage-s3/src/getFile.ts | 15 +- packages/storage-vercel-blob/src/adapter.ts | 3 +- .../storage-vercel-blob/src/getFile.spec.ts | 81 +++ packages/storage-vercel-blob/src/getFile.ts | 18 +- .../ui/src/elements/FileManager/index.tsx | 4 +- packages/ui/src/elements/Upload/index.tsx | 6 +- templates/_template/package.json | 4 +- templates/_template/src/payload.config.ts | 6 +- templates/blank-tanstack/package.json | 2 - .../blank-tanstack/src/payload.config.ts | 2 - templates/blank/package.json | 4 +- templates/blank/src/payload.config.ts | 6 +- templates/plugin/dev/payload.config.ts | 6 +- templates/plugin/package.json | 2 +- templates/website/package.json | 2 +- templates/website/src/collections/Media.ts | 71 +-- templates/website/src/payload.config.ts | 8 +- templates/with-postgres/package.json | 4 +- templates/with-postgres/src/payload.config.ts | 6 +- templates/with-vercel-website/package.json | 2 +- .../src/collections/Media.ts | 71 +-- .../with-vercel-website/src/payload.config.ts | 8 +- 111 files changed, 4466 insertions(+), 1376 deletions(-) create mode 100644 docs/upload/transformers.mdx create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.input.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.output.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.input.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.output.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.input.ts create mode 100644 packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.output.ts create mode 100644 packages/payload/src/config/assertNoLegacySharpConfig.spec.ts create mode 100644 packages/payload/src/config/assertNoLegacySharpConfig.ts create mode 100644 packages/payload/src/config/build.spec.ts create mode 100644 packages/payload/src/errors/TransformerContractError.ts delete mode 100644 packages/payload/src/uploads/cropImage.spec.ts delete mode 100644 packages/payload/src/uploads/cropImage.ts rename packages/payload/src/uploads/{image-resizing => }/generateImageSizeFilename.ts (100%) create mode 100644 packages/payload/src/uploads/generatePayloadFileURL.spec.ts create mode 100644 packages/payload/src/uploads/generatePayloadFileURL.ts delete mode 100644 packages/payload/src/uploads/image-resizing/createImageSize.ts delete mode 100644 packages/payload/src/uploads/image-resizing/createImageSizes.spec.ts delete mode 100644 packages/payload/src/uploads/image-resizing/createImageSizes.ts delete mode 100644 packages/payload/src/uploads/image-resizing/extractHeightFromImage.ts delete mode 100644 packages/payload/src/uploads/image-resizing/getImageResizeAction.ts delete mode 100644 packages/payload/src/uploads/image-resizing/sanitizeResizeConfig.ts delete mode 100644 packages/payload/src/uploads/optionallyAppendMetadata.ts rename packages/payload/src/uploads/{image-resizing => }/parseFilename.ts (100%) create mode 100644 packages/payload/src/uploads/transformers/createLazySourceGetter.spec.ts create mode 100644 packages/payload/src/uploads/transformers/createLazySourceGetter.ts create mode 100644 packages/payload/src/uploads/transformers/finalizeFileResponse.spec.ts create mode 100644 packages/payload/src/uploads/transformers/finalizeFileResponse.ts create mode 100644 packages/payload/src/uploads/transformers/getSourceFileResponse.ts create mode 100644 packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts create mode 100644 packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts create mode 100644 packages/payload/src/uploads/transformers/matchesMimeType.spec.ts create mode 100644 packages/payload/src/uploads/transformers/matchesMimeType.ts create mode 100644 packages/payload/src/uploads/transformers/planTransformerPipeline.spec.ts create mode 100644 packages/payload/src/uploads/transformers/planTransformerPipeline.ts create mode 100644 packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts create mode 100644 packages/payload/src/uploads/transformers/resolveUploadDocument.ts create mode 100644 packages/payload/src/uploads/transformers/transformUploadFile.spec.ts create mode 100644 packages/payload/src/uploads/transformers/transformUploadFile.ts create mode 100644 packages/payload/src/uploads/transformers/types.ts create mode 100644 packages/payload/src/uploads/transformers/uploadTransformerBridge.ts create mode 100644 packages/payload/src/uploads/transformers/validateTransformers.spec.ts create mode 100644 packages/payload/src/uploads/transformers/validateTransformers.ts create mode 100644 packages/payload/src/uploads/transformers/withFileTransformAccessContext.ts create mode 100644 packages/storage-r2/src/getFile.spec.ts create mode 100644 packages/storage-vercel-blob/src/getFile.spec.ts diff --git a/docs/fields/default-fields.mdx b/docs/fields/default-fields.mdx index 4b9c1d2ea6c..804927adeaf 100644 --- a/docs/fields/default-fields.mdx +++ b/docs/fields/default-fields.mdx @@ -116,11 +116,11 @@ The `salt` and `hash` fields are reserved and cannot be overridden. The following fields are added to [upload-enabled collections](../upload/overview). Payload deep-merges matching fields from your collection's `fields` array into these defaults. -| Fields | Added when | -| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `filename`, `mimeType`, `filesize`, `width`, `height`, `url`, `thumbnailURL` | Uploads are enabled. | -| `focalX`, `focalY` | Focal points are enabled, or `imageSizes` or `resizeOptions` is configured. | -| `sizes` | `imageSizes` is configured. Matching fields nested under `sizes` are also merged recursively. | +| Fields | Added when | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `filename`, `mimeType`, `filesize`, `width`, `height`, `url`, `thumbnailURL` | Uploads are enabled. | +| `focalX`, `focalY` | Uploads are enabled, unless `focalPoint` is `false` and no Sharp `variants` or image adjustment options (`resizeOptions`, `formatOptions`, and so on) are configured. | +| `sizes` | Sharp `variants` are configured. Matching fields nested under `sizes` are also merged recursively. | The `file` field is reserved and cannot be overridden. diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index d140203df65..7a6b6a6344d 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1805,6 +1805,76 @@ azureStorage({ Run `npx @payloadcms/codemod --transform migrate-azure-chunk-large-files` to remove the flag automatically (it collapses `clientUploads: { chunkLargeFiles: true }` to `clientUploads: true` and keeps any other `clientUploads` options). +### Sharp moved out of core into `@payloadcms/transformer-sharp` + +Image resizing is no longer built into `payload` itself. It now ships as an official [file transformer](../upload/transformers), `@payloadcms/transformer-sharp`, that you register under `upload.transformers`. + +This affects four things: + +1. The top-level `sharp` config property has been removed. Passing a Sharp instance now happens through `sharpTransformer({ sharp })` instead. +2. Per-collection Sharp options — `constructorOptions`, `formatOptions`, `resizeOptions`, `trimOptions`, and `withMetadata` — have been removed from `CollectionConfig['upload']`. They're now authored through `sharpTransformer({ collections })`. + - `imageSizes` has also been removed from `CollectionConfig['upload']`. Image sizes are now declared as `variants` on `sharpTransformer({ collections: { : { variants } } })`. The entries themselves (`name`, `width`, `height`, `generateImageName`, and so on) are unchanged. A collection that still sets `upload.imageSizes` fails the build. +3. `sharp` is no longer a dependency of `payload` at all (previously a `devDependency`, not a runtime `dependency`). Install `@payloadcms/transformer-sharp` explicitly. +4. The Sharp-specific types `SharpDependency`, `ImageUploadFormatOptions`, `ImageUploadTrimOptions`, and `SharpImageSizeOptions` are no longer exported from `payload`. Import `SharpDependency` from `@payloadcms/transformer-sharp`. For the option types, use `SharpCollectionConfig['formatOptions']`, `SharpCollectionConfig['trimOptions']`, and `NonNullable[number]` from the same package. + +```sh +pnpm add @payloadcms/transformer-sharp +``` + +```diff ++ import { sharpTransformer } from '@payloadcms/transformer-sharp' + import { buildConfig } from 'payload' + + export default buildConfig({ + collections: [ + { + slug: 'media', + upload: { +- resizeOptions: { width: 2048, height: 2048 }, +- imageSizes: [{ name: 'thumbnail', width: 400, height: 300 }], + }, + }, + ], +- sharp, + upload: { ++ transformers: [ ++ sharpTransformer({ ++ sharp, ++ collections: { ++ media: { ++ resizeOptions: { width: 2048, height: 2048 }, ++ variants: [{ name: 'thumbnail', width: 400, height: 300 }], ++ }, ++ }, ++ }), ++ ], + }, + }) +``` + +Run `npx @payloadcms/codemod --transform migrate-sharp-to-transformer` to migrate automatically. The codemod moves a top-level `sharp` dependency and inline collection Sharp options — including `crop` and `focalPoint` — into `sharpTransformer({ collections })` (writing `imageSizes` as `variants`), appends to an existing `upload.transformers` array if you already have one, and leaves anything it can't safely rewrite in place with a note — for example, a `collections` array built by a function call, or a collection defined outside the `buildConfig` call. Review those manually. For a config that already registers `sharpTransformer`, it only renames `imageSizes` to `variants` inside the existing `sharpTransformer` call and flags a leftover top-level `sharp` property; it doesn't move any remaining collection options. + +Your `variants`, `crop`, and `focalPoint` are still readable at `collection.upload.imageSizes`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.imageSizes`). `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel, generated types, and the stored `sizes` shape on your documents are unchanged. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. `imageSizes` can't be set on the collection at all. + +If you inject a custom Sharp build or version, pass it the same way: + +```diff +- buildConfig({ sharp: myCustomSharp }) ++ sharpTransformer({ sharp: myCustomSharp }) +``` + +`sharpTransformer` can also resize images at request time (`/api/media/file/photo.png?width=400`). This is new in Payload 4 and **disabled by default**, so a migrated config keeps serving only the original file and your pre-generated `variants`. The codemod doesn't enable it. To opt in, pass `dynamic: true` or `dynamic: { collections: ['media'] }` — see [Dynamic (request-time) resizing](../upload/transformers#dynamic-request-time-resizing), and [Securing dynamic resizing](../upload/transformers#securing-dynamic-resizing) before enabling it on a publicly readable collection. + +#### The file endpoint changes once any transformer is registered + +Registering any transformer under `upload.transformers` — including `sharpTransformer()` with dynamic resizing off — routes every file request through the transformer-aware handler: + +- A file must belong to an upload document to be served. A file that exists in storage without a matching document now returns `404`. +- For files whose MIME type a transformer declares (every image, with `sharpTransformer()`), `access.read` can run twice per request: once with `req.fileTransform` set to `true` to decide a possible transform, and once without it for an ordinary read. Keep `access.read` cheap, and use `req.fileTransform` to restrict transformed reads — see [Access control](../upload/transformers#access-control). +- Responses produced by a transformer always carry Payload's CORS headers, which `modifyResponseHeaders` can't override. + +To link to a file from your own code, including with transformer query parameters, use the new `generatePayloadFileURL` helper (exported from `payload` and `payload/shared`). It always builds the access-controlled Payload file endpoint, so links never bypass `access.read` by pointing straight at storage. + ### Storage adapters always insert the `prefix` field now — `alwaysInsertFields` removed The `alwaysInsertFields` option (on `cloudStoragePlugin` and every `storage-*` adapter: `-s3`, `-azure`, `-gcs`, `-r2`, `-vercel-blob`) has been removed. The behavior it used to opt into — always inserting the hidden `prefix` field into the collection schema — is now unconditional, so the schema stays consistent whether or not a collection configures a `prefix` and whether or not the plugin is `enabled`. diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 8ddfaffc9b7..0a71b4fca5b 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -27,7 +27,7 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy **By simply enabling Upload functionality on a Collection, Payload will automatically transform your Collection into a robust file management / storage solution. The following modifications will be made:** -1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you pass `imageSizes` to your Collection's Upload config, a [`sizes`](#image-sizes) array will also be added containing auto-resized image sizes and filenames. +1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you declare image sizes as `variants` on [`sharpTransformer`](#image-sizes), a [`sizes`](#image-sizes) group will also be added containing auto-resized image sizes and filenames. 1. The Admin Panel will modify its built-in `List` component to show a thumbnail for each upload within the List View 1. The Admin Panel will modify its `Edit` view(s) to add a new set of corresponding Upload UI which will allow for file upload 1. The `create`, `update`, and `delete` Collection operations will be modified to support file upload, re-upload, and deletion @@ -50,30 +50,6 @@ export const Media: CollectionConfig = { slug: 'media', upload: { staticDir: 'media', - imageSizes: [ - { - name: 'thumbnail', - width: 400, - height: 300, - position: 'centre', - }, - { - name: 'card', - width: 768, - height: 1024, - position: 'centre', - }, - { - name: 'tablet', - width: 1024, - // By specifying `undefined` or leaving a height undefined, - // the image will be sized to a certain width, - // but it will retain its original aspect ratio - // and calculate a height automatically. - height: undefined, - position: 'centre', - }, - ], adminThumbnail: 'thumbnail', mimeTypes: ['image/*'], }, @@ -86,57 +62,67 @@ export const Media: CollectionConfig = { } ``` + + **Note:** Image resizing (image sizes, `resizeOptions`, and related Sharp + options) is configured through + [`@payloadcms/transformer-sharp`](./transformers#payloadcmstransformer-sharp), + not on the collection's own `upload` config. See [Image Sizes](#image-sizes) + below. + + ### Collection Upload Options _An asterisk denotes that an option is required._ -| Option | Description | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`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. | -| **`constructorOptions`** | An object passed to the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) | -| **`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). | -| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. | -| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. | -| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. | -| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) | -| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) | -| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. | -| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) | -| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | -| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) | -| **`resizeOptions`** | An object passed to the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) | -| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. | -| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) | -| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug | -| **`trimOptions`** | An object passed to the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) | -| **`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) | +| Option | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`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. For a collection configured on `sharpTransformer`, a `crop` set there replaces this value. [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). | +| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. | +| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. | +| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. | +| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The selector is shown when a registered transformer configures image sizes or image adjustments (`resizeOptions`, `formatOptions`, and so on) for this collection, or when this is set to `true`. For a collection configured on `sharpTransformer`, a `focalPoint` set there replaces this value. [More](#crop-and-focal-point-selector) | +| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. | +| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | +| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) | +| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. | +| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) | +| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug | +| **`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) | + + + **Note:** Sharp-specific options — `constructorOptions`, `formatOptions`, + `resizeOptions`, `trimOptions`, and `withMetadata` — are no longer set here. + They're configured through `sharpTransformer({collections})`. See [File + Transformers](./transformers#payloadcmstransformer-sharp). + ### Payload-wide Upload Options Upload options are specifiable on a Collection by Collection basis, you can also control app wide options by passing your base Payload Config an `upload` property containing an object supportive of all `Busboy` configuration options. -| Option | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | -| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | -| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | -| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | -| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | -| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | -| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | -| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | -| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | -| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | -| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | -| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | -| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | +| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | +| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | +| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | +| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | +| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | +| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | +| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | +| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | +| **`transformers`** | An ordered array of [file transformers](./transformers), such as `sharpTransformer()`, that process uploads and serve dynamically-transformed files. Registering any transformer changes the file endpoint — see [File Transformers](./transformers#registering-a-transformer-changes-the-file-endpoint). | +| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | +| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | +| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | +| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | [Click here](https://github.com/mscdex/busboy#api) for more documentation about what you can control with `Busboy`. @@ -190,13 +176,58 @@ The filename from here will also be threaded to image sizes if they're enabled. ## Image Sizes -If you specify an array of `imageSizes` to your `upload` config, Payload will automatically crop and resize your uploads to fit each of the sizes specified by your config. +If you declare image sizes, Payload will automatically crop and resize your uploads to fit each of the sizes specified by your config. The [Admin Panel](../admin/overview) will also automatically display all available files, including width, height, and file size, for each of your uploaded files. -Behind the scenes, Payload relies on [`sharp`](https://sharp.pixelplumbing.com/api-resize#resize) to perform its image resizing. You can specify additional options for `sharp` to use while resizing your images. +Image sizes — declared as `variants`, along with the rest of Sharp's image-resizing options — are configured through [`@payloadcms/transformer-sharp`](./transformers#payloadcmstransformer-sharp), Payload's official image-resizing transformer: -Note that for image resizing to work, `sharp` must be specified in your [Payload Config](../configuration/overview). This is configured by default if you created your Payload project with `create-payload-app`. See `sharp` in [Config Options](../configuration/overview#config-options). +```ts +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + collections: [ + /* your Media collection, with `upload: true` */ + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + media: { + variants: [ + { + name: 'thumbnail', + width: 400, + height: 300, + position: 'centre', + }, + { + name: 'card', + width: 768, + height: 1024, + position: 'centre', + }, + { + name: 'tablet', + width: 1024, + // By specifying `undefined` or leaving a height undefined, + // the image will be sized to a certain width, + // but it will retain its original aspect ratio + // and calculate a height automatically. + height: undefined, + position: 'centre', + }, + ], + }, + }, + }), + ], + }, +}) +``` + +`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.imageSizes`, so the rest of this section — and the generated `sizes` shape on your documents — is unaffected by where the configuration is authored. `imageSizes` can't be set on the collection itself. #### Admin List View Options @@ -269,11 +300,11 @@ This function receives the original file name, the resize name, the extension, h This feature is only available for image file types. -Setting `crop: false` and `focalPoint: false` in your Upload config will be disable the respective selector in the [Admin Panel](../admin/overview). +Setting `crop: false` and `focalPoint: false` on `sharpTransformer({ collections })` for this collection will disable the respective selector in the [Admin Panel](../admin/overview). Image cropping occurs before any resizing, the resized images will therefore be generated from the cropped image (**not** the original image). -If no resizing options are specified (`imageSizes` or `resizeOptions`), the focal point selector will not be displayed. +The focal point selector is only displayed when the collection has image sizes (`variants`) or image adjustments (`resizeOptions`, `formatOptions`, `trimOptions`, `constructorOptions`, or `withMetadata`), or when `focalPoint` is explicitly `true`. ## Disabling Local Upload Storage @@ -293,33 +324,49 @@ provide your own admin thumbnail using **upload.adminThumbnail**. You can specify how Payload retrieves admin thumbnails for your upload-enabled Collections with one of the following: -1. `adminThumbnail` as a **string**, equal to one of your provided image size names. +1. `adminThumbnail` as a **string**, equal to one of your image size names (declared as `variants` on `sharpTransformer`). ```ts -import type { CollectionConfig } from 'payload' +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' -export const Media: CollectionConfig = { - slug: 'media', - upload: { - // highlight-start - adminThumbnail: 'small', - // highlight-end - imageSizes: [ - { - name: 'small', - fit: 'cover', - height: 300, - width: 900, - }, - { - name: 'large', - fit: 'cover', - height: 600, - width: 1800, +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + // highlight-start + adminThumbnail: 'small', + // highlight-end }, + }, + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + media: { + variants: [ + { + name: 'small', + fit: 'cover', + height: 300, + width: 900, + }, + { + name: 'large', + fit: 'cover', + height: 600, + width: 1800, + }, + ], + }, + }, + }), ], }, -} +}) ``` 2. `adminThumbnail` as a **function** that takes the document's data and sends back a full URL to load the thumbnail. @@ -794,7 +841,7 @@ The `data` property should still include all the required fields of your `media` **Important:** Remember that all custom hooks attached to the `media` collection will still trigger. -Ensure that files match the specified mimeTypes or sizes defined in the collection's `formatOptions` or custom `hooks`. +Ensure that files match the specified mimeTypes or sizes defined for the collection, or custom `hooks`. @@ -877,6 +924,8 @@ _An asterisk denotes that an option is required._ 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. +If any [file transformer](./transformers) is configured, requests for a dynamically-transformed variant of a file (for example, a resized image) run this same `read` access control, with `req.fileTransform` set to `true` for the duration of the transform-aware call. `access.read` can run a second time, without the flag, to decide an ordinary read — see [Access control](./transformers#access-control) for details. + ## 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. diff --git a/docs/upload/storage-adapters.mdx b/docs/upload/storage-adapters.mdx index 32a4d4f0acc..419fb8c9b2a 100644 --- a/docs/upload/storage-adapters.mdx +++ b/docs/upload/storage-adapters.mdx @@ -467,6 +467,8 @@ export interface GeneratedAdapter { When providing upload instructions, set `uploadInstructions.useInAdmin` to `true` when the Admin panel should use them instead of attaching the whole file to the document request. This is useful for direct-to-storage uploads and for flows such as chunked uploads that still pass through Payload. +`staticHandler` receives `params.operation`: `'read'` for an ordinary file request (treat a missing value as `'read'`, for backward compatibility), or `'transform'` when Payload is reading the file's bytes on behalf of a [file transformer](./transformers), which only happens when at least one transformer is configured. For `'transform'`, return the full object body rather than a redirect to a signed/public URL, and skip `Range` handling, conditional/ETag short-circuiting, and `modifyResponseHeaders` — Payload applies headers once, on the final response, after every transformer has run. The five first-party adapters already implement this; a custom adapter only needs to branch on `operation` if it currently redirects to an external URL for `'read'`. + ```ts import { buildConfig } from 'payload' import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage' diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx new file mode 100644 index 00000000000..8adf3f3ea34 --- /dev/null +++ b/docs/upload/transformers.mdx @@ -0,0 +1,402 @@ +--- +title: File Transformers +label: File Transformers +order: 30 +desc: Payload's file-transformer boundary lets you process uploaded files and serve dynamically-transformed variants of them, without changing where or how files are stored. +keywords: uploads, images, media, transformers, sharp, resize, documentation, Content Management System, cms, headless, javascript, node, react, nextjs +--- + +A file transformer is a small, ordered stage in Payload's upload pipeline. Transformers can: + +- Process a file's bytes at upload time (resizing an image, converting a format, and so on) +- Serve a dynamically-transformed variant of an already-stored file at request time (for example, resizing an image on the fly based on query parameters) + +Payload core owns transformer configuration, ordering, access control, and storage retrieval. Transformer packages own the actual byte manipulation. `@payloadcms/transformer-sharp` is Payload's official transformer, and covers the image-resizing behavior that shipped as part of core in Payload 3.x. + + + **Note:** If you only need Sharp-based image resizing at upload time — the + same behavior Payload has always provided — see [Image + Sizes](./overview#image-sizes) and [`@payloadcms/transformer-sharp` + configuration](#payloadcmstransformer-sharp) below. You do not need to write a + custom transformer for that. + + +## Configuring transformers + +Transformers are configured once, globally, under `upload.transformers` in your Payload Config: + +```ts +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + collections: [ + /* ... */ + ], + upload: { + transformers: [sharpTransformer()], + }, +}) +``` + +`upload.transformers` is the only transformer configuration surface — there is no per-collection or per-field transformer list. Each transformer decides, per request or per upload, which collections and MIME types it applies to. + +Every transformer must declare a unique, non-empty `slug`. Payload throws a startup error if two transformers share a slug, if a transformer's `mimeTypes` array is empty or contains a malformed pattern, or if a declared capability (`init`, `canTransform`, `transformFile`, `handleRequest`) isn't a function. + +### Registering a transformer changes the file endpoint + +As soon as `upload.transformers` contains any transformer, every request to a collection's file endpoint (`/api//file/`) goes through the transformer-aware handler, even when no transformer ends up applying: + +- The requested file must belong to an upload document. A file that exists on disk but has no matching document returns `404`, where Payload 3.x would have served it. +- For a file whose MIME type any transformer declares (every image, with `sharpTransformer()` registered), `access.read` is called up to twice per request — see [Access control](#access-control). +- A response produced by a transformer always carries Payload's CORS headers, which `modifyResponseHeaders` can't override. When no transformer applies, the original file is served with the usual headers. + +### MIME patterns + +A transformer's `mimeTypes` array accepts: + +- An exact value, e.g. `image/png` +- A category wildcard, e.g. `image/*` +- The universal wildcard, `*/*` + +## How the pipeline runs + +For both upload-time and request-time processing, Payload builds a fixed, ordered list of eligible transformers before running any of them: + +1. Filter to transformers that declare the capability being used (`transformFile` for uploads, `handleRequest` for dynamic requests). +2. Filter to transformers whose `mimeTypes` match the file's MIME type. +3. Call each remaining transformer's `canTransform` (if defined) to decide whether it's eligible for this specific file/request. A transformer with no `canTransform` is always eligible once it passes the MIME filter. + +The result is a plan — a fixed list — computed before any transformer actually runs. Every eligible transformer then runs once, in the order it appears in `upload.transformers`, passing its result to the next: + +- **`continue`** — hand the (possibly-replaced) file/response to the next transformer in the pipeline. Returning `continue` without a replacement preserves the same object reference; nothing is copied. +- **`complete`** — stop the pipeline here and use this result. No later transformer runs. +- **Throwing** — aborts the pipeline immediately. For dynamic requests, the error is logged and rethrown: an `APIError` keeps its own status, anything else becomes a `500`. For uploads, the error fails the upload with a `FileUploadError`, no document is committed, and no partial file is written. A `canTransform` that throws aborts planning the same way, before any stage runs. + +Payload only ever keeps the current accumulator, never a history of every stage's output. + +## The `canTransform` capability + +```ts +canTransform: (args: { + collectionSlug: string + documentID?: number | string + mimeType: string + operation: 'request' | 'upload' + req: PayloadRequest +}) => boolean | Promise +``` + +`canTransform` must be inexpensive and side-effect-free — it must not fetch the file's source, call an external service, or perform the transformation itself. It only decides eligibility. If you omit it, the transformer is eligible for every request/upload that already passed the MIME filter. + +## The `transformFile` capability (uploads) + +```ts +transformFile: (args: { + collectionSlug: string + file: File + options: unknown + req: PayloadRequest +}) => + Promise< + { file?: File; status: 'continue' } | { file: File; status: 'complete' } + > +``` + +`transformFile` is a one-file-in, one-file-out primitive — it never writes to storage itself. Payload's upload pipeline calls every eligible transformer's `transformFile` in declaration order, threading the result of one into the next, then persists whatever the pipeline produces. `options` is `undefined` for an ordinary upload. + + + **Note:** When `sharpTransformer` is registered, it drives every image upload, + whether or not the collection is listed in its `collections`: every other + eligible transformer's `transformFile` runs inside Sharp's processing, once + for the main file and once for each of the collection's `variants`, and + receives Sharp's own task object as `options`. A transformer that should only + see the main file must not be combined with Sharp for image uploads. + + +Transformers only run on uploads whose full bytes are available. When a client uploads directly to cloud storage (`clientUploads`) on a collection with `disableLocalStorage`, Payload downloads the whole file only when `upload.mimeTypes` restricts the allowed types, the request carries a crop or resize edit, the file is an animated image, or the collection has Sharp `variants` or image adjustments (`resizeOptions`, `formatOptions`, and so on). Otherwise the upload is saved without running any transformer. + +The stored `filename` is based on the returned `File`'s `name`. Its `mimeType` and extension come from the output bytes when their format can be detected, such as an image converted to WebP. Otherwise, as for text-like output like CSV or JSON, they come from the returned `File`'s `type` and `name`. Set both on the `File` you return when your transformer changes the format. + +## The `handleRequest` capability (dynamic requests) + +```ts +handleRequest: (args: { + collectionSlug: string + documentID: number | string + filename: string + getSourceFile: () => Promise + mimeType: string + req: PayloadRequest +}) => + Promise< + | { response?: Response; status: 'continue' } + | { response: Response; status: 'complete' } + > +``` + +`getSourceFile` is a **lazy, single-use** getter, given to each stage separately. It performs no work until called, and calling it a second time in the same stage — including while the first call is still pending, or after it has rejected — throws. It returns the previous stage's response when there is one, and the stored file's bytes otherwise. This means: + +- A transformer that only redirects, or that answers from a source it already has, never has to pay the cost of reading the stored file. +- The stored file is read at most once per request, and each stage reads the output of the one before it. +- A stage that calls `getSourceFile` must return a `response` — returning `continue` without one throws a `TransformerContractError` (a `500`), because the source it consumed would otherwise be lost. + +Dynamic requests never write anything back to storage, and never trigger a document's own upload/delete hooks. A dynamic-transform request is a read-only view over an already-uploaded file. + +### Example: a multi-stage local transformer + +```ts +import type { UploadTransformer } from 'payload' + +const uppercaseTextTransformer: UploadTransformer = { + slug: 'uppercase-text', + mimeTypes: ['text/plain'], + canTransform: ({ req }) => req.searchParams.has('uppercase'), + handleRequest: async ({ getSourceFile }) => { + const source = await getSourceFile() + const text = await source.text() + + return { + response: new Response(text.toUpperCase(), { headers: source.headers }), + status: 'complete', + } + }, +} +``` + +### Example: redirecting without fetching the source + +A transformer that hands off to an external image-processing service never needs to read the stored file itself — it can `complete` with a redirect and skip `getSourceFile` entirely: + +```ts +import type { UploadTransformer } from 'payload' + +const externalResizeTransformer: UploadTransformer = { + slug: 'external-resize', + mimeTypes: ['image/*'], + canTransform: ({ req }) => req.searchParams.has('width'), + handleRequest: async ({ collectionSlug, filename, req }) => { + const width = req.searchParams.get('width') + + return { + response: Response.redirect( + `https://images.example.com/${collectionSlug}/${filename}?w=${width}`, + 302, + ), + status: 'complete', + } + }, +} +``` + +## Access control + +Ordinary file reads are unaffected by transformers: they use the same collection `read` access control that governs every other file request. + +A **dynamic-transform request** — one for a file whose MIME type at least one configured `handleRequest` transformer declares — additionally sets `req.fileTransform: true` for the duration of the `access.read` call that decides whether the request may proceed. This is decided from `mimeTypes` alone, before any `canTransform` runs, so with `sharpTransformer()` registered every image request counts, even when dynamic resizing is off. This lets a collection distinguish "someone is asking for a transformed variant of this file" from an ordinary read: + +```ts +import type { CollectionConfig } from 'payload' + +export const Media: CollectionConfig = { + slug: 'media', + access: { + // Ordinary reads stay public; transformed variants require a signed-in user. + read: ({ req }) => (req.fileTransform ? Boolean(req.user) : true), + }, + upload: true, +} +``` + +`req.fileTransform` is `true` only during that access-control call. It is not set during `canTransform`, `transformFile`, or `handleRequest`. No transformer code runs until an access check has passed: if every check is denied, `canTransform`, `getSourceFile`, every transformer's `handleRequest`, and any external service a transformer would have called are all skipped entirely. + +`access.read` may run a second time with `req.fileTransform` unset: when the transform-aware call is denied, or when no transformer ends up applying to the request. If the transform-aware call is denied but the ordinary read is allowed, `canTransform` then runs to decide the request: it is served as the original file when no transformer applies, and denied with `403` when one does. Whichever way the request is finally served — transformed or original — the matching access mode must allow it. Keep `access.read` cheap, since it can run twice per file request. + +This access check runs before any transformer executes, and before Payload resolves which storage adapter or external provider would serve the source bytes. A denied request never reaches storage. + + + **Note:** This access-control lifecycle only applies to requests that go + through Payload's file endpoint. A direct storage/CDN URL (for example, an S3 + bucket URL or a CDN-fronted URL returned by a storage adapter's + `generateFileURL`) bypasses Payload entirely, including this access check. If + you need every access to a file to be gated by Payload's access control, do + not expose direct storage/CDN URLs for that collection. + + +## `@payloadcms/transformer-sharp` + +`@payloadcms/transformer-sharp` is Payload's official Sharp-based transformer. Install it and register it once: + +```sh +pnpm add @payloadcms/transformer-sharp +``` + +```ts +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + upload: { + transformers: [sharpTransformer()], + }, +}) +``` + +### Upload-time image processing + +Per-collection Sharp settings — `variants` (image sizes), `resizeOptions`, `formatOptions`, `trimOptions`, `constructorOptions`, `withMetadata`, `crop`, and `focalPoint` — are authored through `sharpTransformer({ collections })`, not on the collection's own `upload` config: + +```ts +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + collections: [ + { + slug: 'media', + upload: true, + fields: [], + }, + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + media: { + variants: [ + { name: 'thumbnail', width: 400, height: 300 }, + { name: 'card', width: 768, height: 1024 }, + ], + resizeOptions: { + width: 2048, + height: 2048, + withoutEnlargement: true, + }, + }, + }, + }), + ], + }, +}) +``` + +At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.imageSizes`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. The `sizes` shape on your documents is unchanged. `imageSizes` exists only on the sanitized config: setting it on the collection itself fails the build. + +See [Image Sizes](./overview#image-sizes) for what each Sharp-specific image size option does, and [Crop and Focal Point Selector](./overview#crop-and-focal-point-selector) for the Admin Panel behavior these settings control. + +### Dynamic (request-time) resizing + +`sharpTransformer` can resize a stored image on the fly when it's requested with recognized query parameters, without storing the resized result. This is **disabled by default**: until you enable it, resize parameters are ignored and the original file is served. (Registering the transformer still changes the file endpoint in the ways described in [Registering a transformer changes the file endpoint](#registering-a-transformer-changes-the-file-endpoint).) + +Enable it with the `dynamic` option, either for every upload collection or only for the collections that need it: + +```ts +// Every upload collection, with the default limits +sharpTransformer({ dynamic: true }) + +// Only the `media` collection +sharpTransformer({ dynamic: { collections: ['media'] } }) +``` + +A slug in `dynamic.collections` that isn't an upload-enabled collection throws at startup. For collections that aren't listed, resize parameters are ignored and the original file is served. + +Once enabled, request a file with any of these parameters: + +``` +GET /api/media/file/photo.png?width=400 +GET /api/media/file/photo.png?height=300 +GET /api/media/file/photo.png?width=400&height=300 +GET /api/media/file/photo.png?width=400&withoutEnlargement=true +``` + +| Parameter | Description | +| -------------------- | ---------------------------------------------------------------------------------------- | +| `width` | Target width, in pixels. A positive integer. | +| `height` | Target height, in pixels. A positive integer. | +| `withoutEnlargement` | `true` or `false`. Overrides the transformer's configured default for this request only. | + +Recognized behavior: + +- Presence of any recognized parameter routes the request through this transformer. Malformed values (a non-integer `width`, for example) return `400`, not a fallback to the original file. +- Repeated occurrences of the same parameter are invalid and return `400`. +- `withoutEnlargement` on its own, without `width` or `height`, returns `400`. +- A request over `maxWidth`, `maxHeight`, or `maxPixels` (below) returns `400` rather than being clamped. With `fit: 'outside'`, or a single dimension, the limits are checked against the actual output size once the source is read. +- Unrelated query keys (`draft`, `depth`, `where`, `prefix`, and so on) are ignored — the request falls through as an ordinary read. +- Specifying both `width` and `height` crops to that exact box, centered (`fit: 'cover'`), by default. +- Specifying only one of `width`/`height` preserves the original aspect ratio. +- Images smaller than the requested size are upscaled by default. Pass `withoutEnlargement=true` to return the original size instead, or configure a different default (below). +- The output keeps the source image's own format — there is no `format`/quality query parameter in this version. +- The stored file's own MIME type governs whether it's eligible. `sharpTransformer` handles JPEG, PNG, GIF, WebP, TIFF, and AVIF; this list isn't configurable. Other types (for example, JPEG XL) are served unchanged, without error. +- `GET` and `HEAD` are both supported. A `Range` header on a recognized dynamic-resize request returns `416` — partial dynamic resizes are not supported. +- `prefix` is core-owned routing context, used to locate the stored document. It is never a resize parameter. + +Passing an object enables dynamic resizing and configures the defaults for every dynamic-resize request on this transformer instance: + +```ts +sharpTransformer({ + dynamic: { + collections: ['media'], // omit to allow every upload collection + fit: 'cover', // default + position: 'center', // default + maxWidth: 4096, // default + maxHeight: 4096, // default + maxPixels: 16_777_216, // default + withoutEnlargement: false, // default + }, +}) +``` + +As with every transformer, dynamic-resize output is never written back to storage — it is generated for that one response and discarded. + +#### Securing dynamic resizing + +Every dynamic-resize request decodes the full source image and encodes a new one, and the output is not cached. Anyone who can read a file can ask for it at any size within your limits, so on a publicly readable collection an unbounded stream of distinct sizes costs you CPU and memory on every request. Before enabling it, especially on public collections: + +1. **Enable it only where it's needed.** Prefer `dynamic: { collections: [...] }` over `dynamic: true`. + +2. **Restrict transformed reads in access control.** `req.fileTransform` is `true` while `access.read` decides whether a dynamic-transform request may proceed (see [Access control](#access-control)), and the request's query is available on `req.searchParams`. Use it to require a signed-in user, or to allow only the sizes your frontend actually uses: + + ```ts + const allowedWidths = new Set(['320', '640', '1280']) + + export const Media: CollectionConfig = { + slug: 'media', + access: { + read: ({ req }) => { + // Ordinary reads are unaffected. + if (!req.fileTransform) { + return true + } + + // Only a known set of widths, no arbitrary height or enlargement override. + const width = req.searchParams.get('width') + + return ( + width !== null && + allowedWidths.has(width) && + !req.searchParams.has('height') && + !req.searchParams.has('withoutEnlargement') + ) + }, + }, + upload: true, + } + ``` + + A denied transform request returns `403`, and the source file is never fetched. + +3. **Lower the limits to what you serve.** Set `maxWidth`, `maxHeight`, and `maxPixels` to your largest real rendition, and set `withoutEnlargement: true` so requests can't upscale small images. `maxWidth` and `maxHeight` apply per frame, while `maxPixels` counts every frame: animated GIF, WebP, and AVIF images are resized frame by frame, so a 44-frame image at 1000x1000 counts as 44,000,000 pixels. + +4. **Cache the output in front of Payload.** Dynamic-resize responses don't set `Cache-Control`. Add one with the collection's [`modifyResponseHeaders`](./overview#modifying-response-headers), which also applies to dynamic-resize responses, and serve the endpoint through a CDN or reverse proxy that caches by full URL. A fixed set of allowed sizes (step 2) keeps the cache effective. Only mark responses `public` for collections whose files are publicly readable, or a shared cache will serve access-controlled files to anyone. + +5. **Rate-limit at the edge.** Payload doesn't rate-limit file requests. If the endpoint is public, apply rate limits in your CDN, load balancer, or reverse proxy. + +## Non-goals + +The transformer boundary intentionally does not include: + +- **Caching.** Every dynamic-transform request re-runs the pipeline. A future version may introduce a cache-key insertion point ahead of `handleRequest` (derived from the collection, filename, and the recognized transform parameters), but no caching exists today. +- **CDN integration.** Transformers don't know about, and don't configure, any CDN in front of Payload. Serving transformed output through a CDN is entirely up to your own infrastructure. +- **A background jobs queue.** All transformation happens synchronously, in the request/upload path. A future version may accept a job-shaped input for expensive, queueable transforms, but no such queue exists today. + +None of the above is implemented as a stub or a partial feature — they are simply not built yet. diff --git a/packages/codemod/README.md b/packages/codemod/README.md index cdd1bc6ad5a..126c4497f5a 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -91,6 +91,7 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t - `migrate-aliased-exports`: rewrites imports of types and utilities that used to be re-exported from `@payloadcms/ui` and `@payloadcms/next/utilities` to their canonical sources in `payload` / `payload/shared`. - `migrate-payload-request-creation`: renames named request-helper and admin-context imports from `payload` and `payload/internal`. Calls migrate only with a plain identifier payload and either `{}` or an initialized local `const` options identifier whose type exposes all seven old option properties (or is `any`). The generated argument captures payload first and forwards only `context`, `depth`, `fallbackLocale`, `locale`, `req`, `urlSuffix`, and `user` through getters, preserving inherited getters, read order, and async rejection behavior. Existing `CreateLocalReqOptions` named imports from `payload` become options-only usages of `Omit`; relative/private imports of that internal type require manual migration. Unsupported shapes, type usages, and collisions leave the affected binding unchanged with notes. - `migrate-document-title-context`: migrates `title` and `setDocumentTitle` destructured from `useDocumentInfo()` to `useDocumentTitle()`. They were removed from `DocumentInfoContext` in v4 and now live on `DocumentTitleContext`. +- `migrate-sharp-to-transformer`: moves a top-level `sharp` dependency and per-collection Sharp-specific `upload` options (`resizeOptions`, `imageSizes`, `formatOptions`, `trimOptions`, `constructorOptions`, `withMetadata`, `crop`, `focalPoint`) into `sharpTransformer({ collections })`, registered under `upload.transformers`. Appends to an existing `transformers` array instead of overwriting it, and no-ops if a `sharpTransformer` call is already present. **Limitations:** only inline `buildConfig({...})` object literals and inline collection/`upload` object literals are migrated; a dynamic `collections` array, an externally-defined collection, or a non-inline `upload` object are left untouched with a note for manual review. A collection's `upload` object built from a spread (e.g. `upload: { ...sharedUploadConfig }`) is noted for manual review, since Sharp-specific fields hidden inside the spread aren't visible to the transform and are left un-migrated. Surfaces a note reminding you to install `@payloadcms/transformer-sharp` (`pnpm add @payloadcms/transformer-sharp`). The transform preserves AST structure but does not re-format output. Run `prettier --write` (or your project's formatter) after applying. - `migrate-storage-adapters-to-config`: moves storage adapter factory calls (`s3Storage`, `gcsStorage`, `azureStorage`, `r2Storage`, `vercelBlobStorage`) from `plugins` to the new top-level `storage` array. Removes `plugins` if it becomes empty after the move. **Limitations:** aliased imports (e.g. `import { s3Storage as myS3 }`) are not detected; rename any aliases to the canonical factory name before running, or migrate those calls manually. The transform preserves AST structure but does not re-format output. Run `prettier --write` (or your project's formatter) after applying. - `rename-storage-adapters-to-storage`: renames the top-level `storageAdapters` config property to `storage`. Skips any object that already has a `storage` property. Run this if you previously ran `migrate-storage-adapters-to-config` and need to update the property name. - `migrate-azure-chunk-large-files`: removes the `chunkLargeFiles` option from `azureStorage` `clientUploads` config. Chunked client uploads are the default in v4, so the flag no longer exists; `clientUploads: { chunkLargeFiles: true }` collapses to `clientUploads: true`. Surfaces a note when `chunkLargeFiles: false` was removed, since v4 can no longer disable chunking (and its broader CORS requirements apply). @@ -99,6 +100,7 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t - `migrate-db-types-subpath`: rewrites imports from the removed `/types` subpath exports of `@payloadcms/drizzle`, `@payloadcms/db-postgres`, `@payloadcms/db-sqlite`, `@payloadcms/db-vercel-postgres`, and `@payloadcms/db-d1-sqlite` to their main entry points. Also handles re-export declarations and `declare module` augmentations. - `migrate-next-subpath-exports`: rewrites imports, re-exports, and string-literal component paths from the removed `@payloadcms/next/client`, `@payloadcms/next/rsc`, and `@payloadcms/next/templates` subpaths to their canonical `@payloadcms/ui` or `@payloadcms/ui/rsc` sources. After running, regenerate the import map with `payload generate:importmap`. - `migrate-next-generate-viewport-export`: adds a `generateViewport` export to app router layout files that already use Payload's shared Next.js layout, preserving Next.js viewport behavior without touching custom viewport implementations. +- `migrate-lexical-is-html-element`: rewrites imports of the removed `isHTMLElement` utility from `@payloadcms/richtext-lexical` (and `/client`) to its canonical source, `lexical`, splitting it out of mixed imports and merging into an existing `lexical` import when present. Surfaces a note reminding you that `lexical` is now a required dependency (`pnpm add lexical`). - `migrate-after-operation-read`: rewrites `operation === 'read'` checks inside collection `afterOperation` hooks to handle the `find` and `findByID` operations (the deprecated `'read'` value was removed). Handles `===`/`!==`/`==`/`!=` against a destructured (or aliased) or property-accessed `operation` argument. Leaves `beforeOperation` (which still uses `'read'`) untouched, and surfaces notes for non-inline hooks and `switch` statements that need manual review. - `migrate-versions-default`: adds `versions: false` to every `CollectionConfig` or `GlobalConfig` object that does not already have a `versions` property. Preserves the previous opt-in behaviour now that `versions` defaults to `true` for both collections and globals. Detects the three common annotation forms: `: CollectionConfig`, `satisfies GlobalConfig`, and `as CollectionConfig`. - `migrate-authorship-default`: adds `authorship: false` to every `CollectionConfig` or `GlobalConfig` object that does not already have an `authorship` property. Preserves the previous behaviour now that authorship tracking (`createdBy` / `updatedBy`) is enabled by default for both collections and globals. Detects the three common annotation forms: `: CollectionConfig`, `satisfies GlobalConfig`, and `as CollectionConfig`. diff --git a/packages/codemod/src/registry.ts b/packages/codemod/src/registry.ts index eec98ffe72b..be1da26ebf3 100644 --- a/packages/codemod/src/registry.ts +++ b/packages/codemod/src/registry.ts @@ -20,6 +20,7 @@ import { migrateListViewSelectAPI } from './transforms/migrate-list-view-select- import { migrateNextGenerateViewportExport } from './transforms/migrate-next-generate-viewport-export/index.js' import { migrateNextSubpathExports } from './transforms/migrate-next-subpath-exports/index.js' import { migratePayloadRequestCreation } from './transforms/migrate-payload-request-creation/index.js' +import { migrateSharpToTransformer } from './transforms/migrate-sharp-to-transformer/index.js' import { migrateSlugField } from './transforms/migrate-slug-field/index.js' import { migrateStorageAdaptersToConfig } from './transforms/migrate-storage-adapters-to-config/index.js' import { migrateVersionsDefault } from './transforms/migrate-versions-default/index.js' @@ -48,6 +49,7 @@ export const transforms: Transform[] = [ migrateBlockReferencesToBlocks, migrateBuildScript, migrateDocumentTitleContext, + migrateSharpToTransformer, migrateStorageAdaptersToConfig, migrateAzureChunkLargeFiles, renameStorageAdaptersToStorage, diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.input.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.input.ts new file mode 100644 index 00000000000..e0147d47389 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.input.ts @@ -0,0 +1,21 @@ +import sharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + crop: false, + imageSizes: [{ name: 'square', height: 400, width: 400 }], + resizeOptions: { + width: 200, + height: 200, + }, + staticDir: 'media', + }, + }, + ], + sharp, +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.output.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.output.ts new file mode 100644 index 00000000000..45e78744073 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/basic.output.ts @@ -0,0 +1,19 @@ +import sharp from 'sharp' +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + staticDir: 'media', + }, + }, + ], + upload: { transformers: [sharpTransformer({ sharp, collections: { media: { resizeOptions: { + width: 200, + height: 200, + }, variants: [{ name: 'square', height: 400, width: 400 }], crop: false } } })] }, +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.input.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.input.ts new file mode 100644 index 00000000000..eedc30fe72b --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.input.ts @@ -0,0 +1,18 @@ +import { customTransformer } from './customTransformer.js' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + imageSizes: [{ name: 'square', height: 400, width: 400 }], + }, + }, + ], + upload: { + abortOnLimit: true, + transformers: [customTransformer()], + }, +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.output.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.output.ts new file mode 100644 index 00000000000..6059e82b595 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/existing-transformers.output.ts @@ -0,0 +1,17 @@ +import { customTransformer } from './customTransformer.js' +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: {}, + }, + ], + upload: { + abortOnLimit: true, + transformers: [customTransformer(), sharpTransformer({ collections: { media: { variants: [{ name: 'square', height: 400, width: 400 }] } } })], + }, +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts new file mode 100644 index 00000000000..3a45dbd189e --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts @@ -0,0 +1,242 @@ +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Project } from 'ts-morph' +import { describe, expect, it } from 'vitest' + +import { runTransform } from '../../utils/test-helpers.js' +import { migrateSharpToTransformer } from './index.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixture = (name: string) => readFile(join(here, name), 'utf8') + +const runTransformWithNotes = async ({ input }: { input: string }) => { + const project = new Project({ useInMemoryFileSystem: true }) + const file = project.createSourceFile('/payload.config.ts', input) + const result = await migrateSharpToTransformer.apply({ packageJsons: [], project }) + + return { notes: result.notes ?? [], source: file.getFullText() } +} + +describe('migrate-sharp-to-transformer', () => { + it('moves a top-level sharp dependency and per-collection Sharp options into sharpTransformer', async () => { + const input = await fixture('basic.input.ts') + const output = await fixture('basic.output.ts') + + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toBe(output) + }) + + it('is idempotent', async () => { + const output = await fixture('basic.output.ts') + + const result = await runTransform({ source: output, transform: migrateSharpToTransformer }) + + expect(result).toBe(output) + }) + + it('no-ops on unrelated code', async () => { + const input = await fixture('no-match.input.ts') + const output = await fixture('no-match.output.ts') + + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toBe(output) + }) + + it('appends sharpTransformer to an existing transformers array without disturbing other entries', async () => { + const input = await fixture('existing-transformers.input.ts') + const output = await fixture('existing-transformers.output.ts') + + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toBe(output) + }) + + it.each([ + ['a call expression', 'transformers: makeTransformers(),'], + ['an identifier', 'transformers: sharedTransformers,'], + ['a shorthand property', 'transformers,'], + ])( + 'does not add a duplicate transformers property when the existing one is %s', + async (_, transformersPropertyText) => { + const input = `import sharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + sharp, + upload: { + ${transformersPropertyText} + }, +}) +` + const { notes, source } = await runTransformWithNotes({ input }) + + expect(source.match(/transformers\b/g)).toHaveLength(1) + expect(source).toContain(transformersPropertyText) + expect(notes).toContainEqual( + expect.stringContaining( + "`upload.transformers` isn't an inline array — add `sharpTransformer({ sharp })`", + ), + ) + }, + ) + + it('does not add a transformers property after a spread that may already set it', async () => { + const input = `import sharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + sharp, + upload: { + ...sharedUpload, + }, +}) +` + const { notes, source } = await runTransformWithNotes({ input }) + + expect(source).not.toContain('transformers') + expect(notes).toContainEqual( + expect.stringContaining( + '`upload` contains a spread that may already set `transformers` — add `sharpTransformer({ sharp })`', + ), + ) + }) + + it('renames imageSizes to variants in an already-migrated sharpTransformer config', async () => { + const input = `import { sharpTransformer } from '@payloadcms/transformer-sharp' +import { buildConfig } from 'payload' + +const imageSizes = [{ name: 'card', width: 600 }] + +export default buildConfig({ + collections: [], + upload: { + transformers: [ + sharpTransformer({ + collections: { + media: { crop: false, imageSizes: [{ name: 'thumbnail', width: 400 }] }, + posters: { imageSizes }, + }, + }), + ], + }, +}) +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toContain( + "media: { crop: false, variants: [{ name: 'thumbnail', width: 400 }] }", + ) + expect(result).toContain('posters: { variants: imageSizes }') + expect(await runTransform({ source: result, transform: migrateSharpToTransformer })).toBe( + result, + ) + }) + + it('renames imageSizes to variants in a sharpTransformer call outside buildConfig', async () => { + const input = `import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export const transformers = [ + sharpTransformer({ collections: { media: { imageSizes: [{ name: 'thumbnail' }] } } }), +] +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toContain("media: { variants: [{ name: 'thumbnail' }] }") + }) + + it('moves shorthand Sharp options into sharpTransformer', async () => { + const input = `import { buildConfig } from 'payload' + +const imageSizes = [{ name: 'thumbnail', width: 400 }] +const focalPoint = true + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + focalPoint, + imageSizes, + staticDir: 'media', + }, + }, + ], +}) +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + const [beforeTransformerCall, afterTransformerCall] = result.split('sharpTransformer(') + + expect(beforeTransformerCall).toContain("upload: {\n staticDir: 'media',\n },") + expect(afterTransformerCall).toContain('media: { variants: imageSizes, focalPoint }') + }) + + it('reports a Sharp option it cannot move instead of leaving it behind silently', async () => { + const input = `import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + get imageSizes() { + return [] + }, + }, + }, + ], +}) +` + const { notes } = await runTransformWithNotes({ input }) + + expect(notes).toContainEqual( + expect.stringContaining("collection 'media''s `upload.imageSizes` isn't a plain property"), + ) + }) + + it('preserves an injected (non-default) sharp dependency', async () => { + const input = `import myCustomSharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + sharp: myCustomSharp, +}) +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toContain('sharpTransformer({ sharp: myCustomSharp })') + expect(result).not.toContain('sharp: myCustomSharp,\n})') + }) + + it('moves crop and focalPoint into sharpTransformer instead of leaving them on the collection upload', async () => { + const input = `import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + crop: false, + focalPoint: true, + staticDir: 'media', + }, + }, + ], +}) +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + const [beforeTransformerCall, afterTransformerCall] = result.split('sharpTransformer(') + + expect(beforeTransformerCall).not.toMatch(/crop|focalPoint/) + expect(afterTransformerCall).toContain('crop: false') + expect(afterTransformerCall).toContain('focalPoint: true') + }) +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts new file mode 100644 index 00000000000..13052202256 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts @@ -0,0 +1,473 @@ +import type { + ArrayLiteralExpression, + ObjectLiteralExpression, + PropertyAssignment, + ShorthandPropertyAssignment, + SourceFile, +} from 'ts-morph' + +import { Node, SyntaxKind } from 'ts-morph' + +import type { Transform } from '../../types.js' + +const TRANSFORMER_MODULE = '@payloadcms/transformer-sharp' +const TRANSFORMER_NAME = 'sharpTransformer' +const VARIANTS_KEY = 'variants' + +const IDENTIFIER_PATTERN = /^[A-Z_$][\w$]*$/i +const RESERVED_WORDS = new Set([ + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'enum', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'implements', + 'import', + 'in', + 'instanceof', + 'interface', + 'let', + 'new', + 'null', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'static', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', +]) + +/** Whether `value` can be used as a plain (unquoted, non-bracketed) object literal key. */ +function isSafePlainObjectKey(value: string): boolean { + return IDENTIFIER_PATTERN.test(value) && !RESERVED_WORDS.has(value) +} + +/** Fields moved from a collection's `upload` object into `sharpTransformer({ collections })`. */ +const MOVED_UPLOAD_FIELDS = [ + 'constructorOptions', + 'formatOptions', + 'resizeOptions', + 'trimOptions', + 'withMetadata', + 'imageSizes', + 'crop', + 'focalPoint', +] + +const findBuildConfigLocalNames = (file: SourceFile): Set => { + const localNames = new Set() + + for (const importDecl of file.getImportDeclarations()) { + if (importDecl.getModuleSpecifierValue() !== 'payload') { + continue + } + for (const spec of importDecl.getNamedImports()) { + if (spec.getName() === 'buildConfig') { + localNames.add(spec.getAliasNode()?.getText() ?? spec.getName()) + } + } + } + + return localNames +} + +function ensureSharpTransformerImport(sourceFile: SourceFile): void { + const existing = sourceFile + .getImportDeclarations() + .find((decl) => decl.getModuleSpecifierValue() === TRANSFORMER_MODULE) + + if (!existing) { + const otherImports = sourceFile.getImportDeclarations() + const fileOmitsSemicolonsOnImports = + otherImports.length > 0 && otherImports.every((decl) => !decl.getText().endsWith(';')) + + const insertedImport = sourceFile.addImportDeclaration({ + moduleSpecifier: TRANSFORMER_MODULE, + namedImports: [TRANSFORMER_NAME], + }) + + // ts-morph always appends a semicolon; strip it when the file's other imports don't use them. + if (fileOmitsSemicolonsOnImports) { + const insertedImportText = insertedImport.getText() + if (insertedImportText.endsWith(';')) { + insertedImport.replaceWithText(insertedImportText.slice(0, -1)) + } + } + + return + } + + const alreadyImported = existing + .getNamedImports() + .some((named) => named.getName() === TRANSFORMER_NAME) + + if (!alreadyImported) { + existing.addNamedImport(TRANSFORMER_NAME) + } +} + +/** + * Extracts Sharp-owned fields off one collection's `upload` object literal into + * a `: { ... }` entry text for the `sharpTransformer({ collections })` map. + * Returns `undefined` when the collection has nothing to migrate. + */ +function extractCollectionSharpEntry({ + collectionObj, + filePath, + notes, +}: { + collectionObj: ObjectLiteralExpression + filePath: string + notes: string[] +}): string | undefined { + const slugProp = collectionObj.getProperty('slug') + const slugAssignment = slugProp?.asKind(SyntaxKind.PropertyAssignment) + const slugInitializer = slugAssignment?.getInitializer() + + if (!slugInitializer) { + return undefined + } + + const uploadProp = collectionObj.getProperty('upload') + if (!uploadProp || !Node.isPropertyAssignment(uploadProp)) { + return undefined + } + + const uploadObj = uploadProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression) + if (!uploadObj) { + if (uploadProp.getInitializer()?.getKind() !== SyntaxKind.TrueKeyword) { + notes.push( + `${filePath}: collection ${slugInitializer.getText()}'s \`upload\` option is not an inline object — check it for Sharp-specific fields (resizeOptions/imageSizes/formatOptions/trimOptions/constructorOptions/withMetadata/crop/focalPoint) manually.`, + ) + } + return undefined + } + + const uploadHasSpread = uploadObj.getProperties().some((prop) => Node.isSpreadAssignment(prop)) + if (uploadHasSpread) { + notes.push( + `${filePath}: collection ${slugInitializer.getText()}'s \`upload\` object contains a spread — Sharp-specific fields (resizeOptions/imageSizes/formatOptions/trimOptions/constructorOptions/withMetadata/crop/focalPoint) hidden inside it need manual review.`, + ) + } + + const movedTexts: string[] = [] + for (const name of MOVED_UPLOAD_FIELDS) { + const prop = uploadObj.getProperty(name) + if (!prop) { + continue + } + + // A shorthand `imageSizes` reads the same binding from `buildConfig`'s argument, + // where the `sharpTransformer` call is inserted, so it moves verbatim. + if (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) { + movedTexts.push(name === 'imageSizes' ? printAsVariants(prop) : prop.print()) + prop.remove() + continue + } + + notes.push( + `${filePath}: collection ${slugInitializer.getText()}'s \`upload.${name}\` isn't a plain property — move it into \`sharpTransformer({ collections })\` manually.`, + ) + } + + if (movedTexts.length === 0) { + return undefined + } + + // Avoid leaving `upload: {\n}` spread across two lines once every property moves out. + if (uploadObj.getProperties().length === 0) { + uploadObj.replaceWithText('{}') + } + + const slugLiteralValue = Node.isStringLiteral(slugInitializer) + ? slugInitializer.getLiteralValue() + : undefined + const keyText = + slugLiteralValue !== undefined && isSafePlainObjectKey(slugLiteralValue) + ? slugLiteralValue + : `[${slugInitializer.getText()}]` + + return `${keyText}: { ${movedTexts.join(', ')} }` +} + +/** + * Builds every `: { ... }` entry for a `buildConfig({ collections })` array, + * skipping (and, for a present `sharp` signal, noting) entries that aren't inline + * object literals or whose `collections` array isn't statically analyzable. + */ +function extractSharpCollectionEntries({ + configObj, + filePath, + notes, +}: { + configObj: ObjectLiteralExpression + filePath: string + notes: string[] +}): string[] { + const collectionsProp = configObj.getProperty('collections') + if (!collectionsProp || !Node.isPropertyAssignment(collectionsProp)) { + return [] + } + + const arrayLiteral = collectionsProp.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) + if (!arrayLiteral) { + notes.push( + `${filePath}: \`collections\` isn't an inline array — check each collection for Sharp-specific \`upload\` fields (resizeOptions/imageSizes/formatOptions/trimOptions/constructorOptions/withMetadata/crop/focalPoint) and move them into \`sharpTransformer({ collections })\` manually.`, + ) + return [] + } + + const entries: string[] = [] + + for (const el of arrayLiteral.getElements()) { + if (!Node.isObjectLiteralExpression(el)) { + notes.push( + `${filePath}: a collection in \`collections\` is defined externally — check it for Sharp-specific \`upload\` fields and move them into \`sharpTransformer({ collections })\` manually.`, + ) + continue + } + + const entry = extractCollectionSharpEntry({ collectionObj: el, filePath, notes }) + if (entry) { + entries.push(entry) + } + } + + return entries +} + +/** `sharpTransformer` authors image sizes as `variants`; the collection's `imageSizes` is renamed on the way in. */ +function printAsVariants(prop: PropertyAssignment | ShorthandPropertyAssignment): string { + return Node.isShorthandPropertyAssignment(prop) + ? `${VARIANTS_KEY}: ${prop.getName()}` + : `${VARIANTS_KEY}: ${prop.getInitializerOrThrow().print()}` +} + +/** + * Renames `imageSizes` to `variants` inside every inline `sharpTransformer({ collections })` + * entry in the file — configs migrated before `variants` existed. Returns whether it changed + * anything. + */ +function renameImageSizesInSharpTransformerCalls({ + notes, + sourceFile, +}: { + notes: string[] + sourceFile: SourceFile +}): boolean { + let hasChanged = false + + const sharpTransformerCalls = sourceFile + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => { + const callee = call.getExpression() + return Node.isIdentifier(callee) && callee.getText() === TRANSFORMER_NAME + }) + + for (const call of sharpTransformerCalls) { + const collectionsObj = call + .getArguments()[0] + ?.asKind(SyntaxKind.ObjectLiteralExpression) + ?.getProperty('collections') + ?.asKind(SyntaxKind.PropertyAssignment) + ?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression) + + for (const entry of collectionsObj?.getProperties() ?? []) { + const entryObj = Node.isPropertyAssignment(entry) + ? entry.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression) + : undefined + + if (!entryObj) { + notes.push( + `${sourceFile.getFilePath()}: a \`${TRANSFORMER_NAME}({ collections })\` entry isn't an inline object — rename any \`imageSizes\` in it to \`${VARIANTS_KEY}\` manually.`, + ) + continue + } + + const imageSizesProp = entryObj.getProperty('imageSizes') + + if (imageSizesProp && Node.isPropertyAssignment(imageSizesProp)) { + imageSizesProp.getNameNode().replaceWithText(VARIANTS_KEY) + hasChanged = true + } else if (imageSizesProp && Node.isShorthandPropertyAssignment(imageSizesProp)) { + imageSizesProp.replaceWithText(printAsVariants(imageSizesProp)) + hasChanged = true + } + } + } + + return hasChanged +} + +function findSharpTransformerCall(transformersArray: ArrayLiteralExpression) { + return transformersArray.getElements().find((el) => { + if (!Node.isCallExpression(el)) { + return false + } + const callee = el.getExpression() + return Node.isIdentifier(callee) && callee.getText() === TRANSFORMER_NAME + }) +} + +export const migrateSharpToTransformer: Transform = { + name: 'migrate-sharp-to-transformer', + apply: ({ project }) => { + const filesChanged = new Set() + const notes: string[] = [] + + for (const sourceFile of project.getSourceFiles()) { + if (renameImageSizesInSharpTransformerCalls({ notes, sourceFile })) { + filesChanged.add(sourceFile.getFilePath()) + } + + const buildConfigLocalNames = findBuildConfigLocalNames(sourceFile) + if (buildConfigLocalNames.size === 0) { + continue + } + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => { + const expr = call.getExpression() + return Node.isIdentifier(expr) && buildConfigLocalNames.has(expr.getText()) + }) + + for (const call of calls) { + const [arg] = call.getArguments() + const configObj = arg?.asKind(SyntaxKind.ObjectLiteralExpression) + if (!configObj) { + notes.push( + `${sourceFile.getFilePath()}: \`buildConfig\` argument is not an inline object literal — check it manually for a top-level \`sharp\` dependency and per-collection Sharp-specific \`upload\` options.`, + ) + continue + } + + const uploadProp = configObj.getProperty('upload') + const uploadObj = uploadProp + ?.asKind(SyntaxKind.PropertyAssignment) + ?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression) + const existingTransformersProp = uploadObj?.getProperty('transformers') + const existingTransformersArray = existingTransformersProp + ?.asKind(SyntaxKind.PropertyAssignment) + ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) + + if (existingTransformersArray && findSharpTransformerCall(existingTransformersArray)) { + // Already migrated, but a leftover `sharp` property would fail a later + // type-check with no signal from this codemod — flag it here. + if (configObj.getProperty('sharp')) { + notes.push( + `${sourceFile.getFilePath()}: a top-level \`sharp\` property remains even though \`sharpTransformer\` is already registered — move any value you still need into the existing \`sharpTransformer\` call, then remove \`sharp\` manually.`, + ) + } + continue + } + + const sharpProp = configObj.getProperty('sharp') + let sharpExpressionText: string | undefined + if (sharpProp && Node.isPropertyAssignment(sharpProp)) { + sharpExpressionText = sharpProp.getInitializer()?.getText() + } else if (sharpProp && Node.isShorthandPropertyAssignment(sharpProp)) { + sharpExpressionText = sharpProp.getName() + } + + // Remove `sharp` before extracting collection entries — later removals + // shift node positions, and ts-morph node references taken before a + // sibling removal can go stale. + sharpProp?.remove() + + const collectionEntries = extractSharpCollectionEntries({ + configObj, + filePath: sourceFile.getFilePath(), + notes, + }) + + if (!sharpExpressionText && collectionEntries.length === 0) { + continue + } + + const transformerArgs: string[] = [] + if (sharpExpressionText) { + transformerArgs.push( + sharpExpressionText === 'sharp' ? 'sharp' : `sharp: ${sharpExpressionText}`, + ) + } + if (collectionEntries.length > 0) { + transformerArgs.push(`collections: { ${collectionEntries.join(', ')} }`) + } + + const transformerCallText = `${TRANSFORMER_NAME}({ ${transformerArgs.join(', ')} })` + + // Adding a `transformers` property next to a non-array one, or after a spread + // that may already set it, would create a duplicate key whose last value wins + // at runtime — silently dropping the existing transformers. + const uploadHasSpread = uploadObj + ?.getProperties() + .some((prop) => Node.isSpreadAssignment(prop)) + + if (existingTransformersArray) { + existingTransformersArray.addElement(transformerCallText) + } else if (existingTransformersProp) { + notes.push( + `${sourceFile.getFilePath()}: \`upload.transformers\` isn't an inline array — add \`${transformerCallText}\` to it manually.`, + ) + } else if (uploadHasSpread) { + notes.push( + `${sourceFile.getFilePath()}: \`upload\` contains a spread that may already set \`transformers\` — add \`${transformerCallText}\` to its transformers manually.`, + ) + } else if (uploadObj) { + uploadObj.addPropertyAssignment({ + name: 'transformers', + initializer: `[${transformerCallText}]`, + }) + } else if (uploadProp) { + notes.push( + `${sourceFile.getFilePath()}: \`upload\` isn't an inline object — add \`transformers: [${transformerCallText}]\` to it manually.`, + ) + } else { + configObj.addPropertyAssignment({ + name: 'upload', + initializer: `{ transformers: [${transformerCallText}] }`, + }) + } + + ensureSharpTransformerImport(sourceFile) + filesChanged.add(sourceFile.getFilePath()) + } + } + + if (filesChanged.size > 0) { + notes.push( + `Install the new dependency this migration relies on: pnpm add @payloadcms/transformer-sharp`, + ) + } + + return { filesChanged: Array.from(filesChanged), notes: notes.length ? notes : undefined } + }, + description: + 'Move a top-level `sharp` dependency and per-collection Sharp-specific `upload` options (resizeOptions, imageSizes, formatOptions, trimOptions, constructorOptions, withMetadata, crop, focalPoint) into `sharpTransformer({ collections })`, registered under `upload.transformers`, with `imageSizes` renamed to `variants`. Also renames `imageSizes` to `variants` in existing `sharpTransformer({ collections })` entries.', +} diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.input.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.input.ts new file mode 100644 index 00000000000..ce318b6f44d --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.input.ts @@ -0,0 +1,10 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'posts', + fields: [], + }, + ], +}) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.output.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.output.ts new file mode 100644 index 00000000000..ce318b6f44d --- /dev/null +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/no-match.output.ts @@ -0,0 +1,10 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'posts', + fields: [], + }, + ], +}) diff --git a/packages/create-payload-app/src/lib/init-tanstack.spec.ts b/packages/create-payload-app/src/lib/init-tanstack.spec.ts index b221e4208d5..8a509847d66 100644 --- a/packages/create-payload-app/src/lib/init-tanstack.spec.ts +++ b/packages/create-payload-app/src/lib/init-tanstack.spec.ts @@ -134,7 +134,6 @@ describe('initTanStack', () => { databasePackage, 'graphql@^16.8.1', 'payload@4.2.0', - 'sharp@0.34.2', ], projectDir, }) @@ -199,7 +198,6 @@ describe('initTanStack', () => { '@payloadcms/db-mongodb@4.2.0', 'graphql@^16.8.1', 'payload@4.2.0', - 'sharp@0.34.2', '@tanstack/react-start@^1.168.26', ], projectDir, @@ -250,7 +248,6 @@ describe('initTanStack', () => { graphql: '^16.8.1', payload: '4.2.0', react: '^19.0.0', - sharp: '0.34.2', }, expectedDevDependencies: { vite: '^7.0.0' }, kind: 'start' as const, @@ -276,7 +273,6 @@ describe('initTanStack', () => { graphql: '^16.8.1', payload: '4.2.0', react: '^19.0.0', - sharp: '0.34.2', }, expectedDevDependencies: { vite: '^7.0.0' }, kind: 'router-only' as const, diff --git a/packages/create-payload-app/src/lib/init-tanstack.ts b/packages/create-payload-app/src/lib/init-tanstack.ts index 3d0475f4be4..426d99ba820 100644 --- a/packages/create-payload-app/src/lib/init-tanstack.ts +++ b/packages/create-payload-app/src/lib/init-tanstack.ts @@ -157,7 +157,6 @@ function getRequiredDependencies({ [getDbPackageName(dbType)]: payloadVersion, graphql: '^16.8.1', payload: payloadVersion, - sharp: '0.34.2', } if (appDetails.kind === 'router-only') { diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index b5877996859..d79ff341d14 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -42,7 +42,6 @@ export type ServerOnlyUploadProperties = keyof Pick< | 'handlers' | 'modifyResponseHeaders' | 'uploadInstructions' - | 'withMetadata' > type ClientUploadConfig = { @@ -103,7 +102,6 @@ const serverOnlyUploadProperties: Partial[] = [ 'handlers', 'modifyResponseHeaders', 'uploadInstructions', - 'withMetadata', ] const serverOnlyCollectionAdminProperties: Partial[] = [ diff --git a/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts b/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts new file mode 100644 index 00000000000..44643ca8838 --- /dev/null +++ b/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import type { Config } from './types.js' + +import { assertNoLegacySharpConfig } from './assertNoLegacySharpConfig.js' + +describe('assertNoLegacySharpConfig', () => { + it('should throw when the top-level sharp option is present', () => { + const config = { collections: [], sharp: {} } as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).toThrow(/sharp/i) + }) + + it('should throw when a collection uses a removed Sharp-specific upload option', () => { + const config = { + collections: [ + { + slug: 'media', + upload: { resizeOptions: { width: 100 } }, + }, + ], + } as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).toThrow(/resizeOptions/) + }) + + it.each(['constructorOptions', 'formatOptions', 'trimOptions', 'withMetadata'])( + 'should throw when a collection uses the removed %s upload option', + (field) => { + const config = { + collections: [{ slug: 'media', upload: { [field]: {} } }], + } as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).toThrow(new RegExp(field)) + }, + ) + + it.each([ + { collections: [{ slug: 'media', upload: { imageSizes: [{ name: 'thumbnail' }] } }] }, + { + collections: [{ slug: 'media', upload: { imageSizes: [{ name: 'thumbnail' }] } }], + upload: { transformers: [{ mimeTypes: ['image/*'], slug: 'sharp' }] }, + }, + ])( + 'should throw when a collection declares imageSizes, even with a transformer (%#)', + (rawConfig) => { + const config = rawConfig as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).toThrow(/upload\.imageSizes/) + }, + ) + + it('should report every violation across multiple collections in a single error', () => { + const config = { + collections: [ + { slug: 'media-a', upload: { resizeOptions: {} } }, + { slug: 'media-b', upload: { formatOptions: {} } }, + ], + } as unknown as Config + + let thrownMessage = '' + + try { + assertNoLegacySharpConfig({ config }) + } catch (error) { + thrownMessage = (error as Error).message + } + + expect(thrownMessage).toContain('media-a') + expect(thrownMessage).toContain('resizeOptions') + expect(thrownMessage).toContain('media-b') + expect(thrownMessage).toContain('formatOptions') + }) + + it.each([ + { + collections: [{ slug: 'media', upload: { staticDir: 'media' } }], + upload: { transformers: [{ mimeTypes: ['image/*'], slug: 'sharp' }] }, + }, + { collections: [{ slug: 'pages' }] }, + { collections: [{ slug: 'media', upload: true }] }, + {}, + ])('should not throw for a config without legacy Sharp options (%#)', (rawConfig) => { + const config = rawConfig as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).not.toThrow() + }) +}) diff --git a/packages/payload/src/config/assertNoLegacySharpConfig.ts b/packages/payload/src/config/assertNoLegacySharpConfig.ts new file mode 100644 index 00000000000..24912c0f36b --- /dev/null +++ b/packages/payload/src/config/assertNoLegacySharpConfig.ts @@ -0,0 +1,55 @@ +import type { Config } from './types.js' + +import { InvalidConfiguration } from '../errors/index.js' + +const REMOVED_UPLOAD_FIELDS = [ + 'constructorOptions', + 'formatOptions', + 'imageSizes', + 'resizeOptions', + 'trimOptions', + 'withMetadata', +] + +const MIGRATION_HINT = + 'Run `npx @payloadcms/codemod --transform migrate-sharp-to-transformer` to migrate automatically, or see https://payloadcms.com/docs/upload/transformers.' + +/** + * Payload 4.0 removed the top-level `sharp` config option and the Sharp-specific + * per-collection `upload` options in favor of `@payloadcms/transformer-sharp` + * registered under `upload.transformers`. Neither removal is caught by the type + * system for JavaScript configs or configs built from untyped data, so without + * this check an app upgrading from 3.x would boot with no indication that image + * resizing had stopped. + * + * `upload.imageSizes` is included: image sizes are authored as + * `sharpTransformer({ collections: { : { variants } } })`, and a size declared on + * the collection itself would otherwise build without error while every upload skips it. + * + * Every violation is collected and reported together, since a config can have + * more than one and fixing them one at a time would take multiple build attempts. + */ +export function assertNoLegacySharpConfig({ config }: { config: Config }): void { + const errors: string[] = [] + + if ('sharp' in config) { + errors.push('The top-level `sharp` config option was removed in Payload 4.0.') + } + + for (const collection of config.collections ?? []) { + const upload = collection.upload + if (typeof upload !== 'object' || !upload) { + continue + } + + for (const field of REMOVED_UPLOAD_FIELDS) { + if (field in upload) { + errors.push(`Collection "${collection.slug}" uses the removed \`upload.${field}\` option.`) + } + } + } + + if (errors.length > 0) { + throw new InvalidConfiguration(`${errors.join(' ')} ${MIGRATION_HINT}`) + } +} diff --git a/packages/payload/src/config/build.spec.ts b/packages/payload/src/config/build.spec.ts new file mode 100644 index 00000000000..0657e496761 --- /dev/null +++ b/packages/payload/src/config/build.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./sanitize.js', () => ({ + sanitizeConfig: vi.fn((config: unknown) => config), +})) + +import type { UploadTransformer } from '../uploads/transformers/types.js' +import type { Config, StorageAdapter } from './types.js' + +import { buildConfig } from './build.js' + +const makeConfig = (overrides: Partial = {}): Config => + ({ + collections: [], + secret: 'test', + ...overrides, + }) as Config + +describe('buildConfig', () => { + it('should run transformer init between the plugins loop and the storage adapter init loop', async () => { + const callOrder: string[] = [] + + const plugin = vi.fn(async (config: Config) => { + callOrder.push('plugin') + return config + }) + + const transformer: UploadTransformer = { + init: vi.fn(async (config: Config) => { + callOrder.push('transformer-init') + return config + }), + mimeTypes: ['image/*'], + slug: 'test-transformer', + } + + const storageAdapter: StorageAdapter = { + init: vi.fn(async (config: Config) => { + callOrder.push('storage-init') + return config + }), + } as unknown as StorageAdapter + + await buildConfig( + makeConfig({ + plugins: [plugin], + storage: [storageAdapter], + upload: { transformers: [transformer] }, + }), + ) + + expect(callOrder).toEqual(['plugin', 'transformer-init', 'storage-init']) + }) + + it('should re-validate transformer slug uniqueness after init mutates the transformers list', async () => { + const transformerA: UploadTransformer = { mimeTypes: ['image/*'], slug: 'a' } + + const transformerB: UploadTransformer = { + init: vi.fn((config: Config) => ({ + ...config, + upload: { + ...config.upload, + transformers: [transformerA, { ...transformerA }], + }, + })), + mimeTypes: ['image/*'], + slug: 'b', + } + + await expect( + buildConfig(makeConfig({ upload: { transformers: [transformerA, transformerB] } })), + ).rejects.toThrow(/duplicate/i) + }) + + it('should reject a config still using the removed top-level sharp option', async () => { + await expect(buildConfig({ ...makeConfig(), sharp: {} } as unknown as Config)).rejects.toThrow( + /sharp/i, + ) + }) +}) diff --git a/packages/payload/src/config/build.ts b/packages/payload/src/config/build.ts index de230cd8b3a..b9625079e55 100644 --- a/packages/payload/src/config/build.ts +++ b/packages/payload/src/config/build.ts @@ -1,5 +1,7 @@ import type { Config, SanitizedConfig } from './types.js' +import { validateTransformers } from '../uploads/transformers/validateTransformers.js' +import { assertNoLegacySharpConfig } from './assertNoLegacySharpConfig.js' import { sanitizeConfig } from './sanitize.js' /** @@ -8,6 +10,8 @@ import { sanitizeConfig } from './sanitize.js' * @returns Built and sanitized Payload Config */ export async function buildConfig(config: Config): Promise { + assertNoLegacySharpConfig({ config }) + if (Array.isArray(config.plugins)) { const sorted = [...config.plugins].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) @@ -16,6 +20,20 @@ export async function buildConfig(config: Config): Promise { } } + if (Array.isArray(config.upload?.transformers) && config.upload.transformers.length > 0) { + validateTransformers({ transformers: config.upload.transformers }) + + for (const transformer of config.upload.transformers) { + if (typeof transformer.init === 'function') { + config = await transformer.init(config) + } + } + + if (Array.isArray(config.upload?.transformers) && config.upload.transformers.length > 0) { + validateTransformers({ transformers: config.upload.transformers }) + } + } + if (Array.isArray(config.storage)) { for (const adapter of config.storage) { if (typeof adapter?.init !== 'function') { diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 8174566d706..012d93f17bf 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -39,9 +39,9 @@ export type ServerOnlyRootProperties = keyof Pick< | 'plugins' | 'queryPresets' | 'secret' - | 'sharp' | 'storage' | 'typescript' + | 'upload' > export type ServerOnlyRootAdminProperties = keyof Pick @@ -86,7 +86,6 @@ export const serverOnlyConfigProperties: readonly Partial { typescript?.outputFile ?? `${typeof process?.cwd === 'function' ? process.cwd() : ''}/payload-types.ts`, } - config.upload = config.upload ?? {} + config.upload = config.upload ?? { transformers: [] } config.auth = { ...(config.auth || {}), diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index abd2116a6aa..a0ec874b34e 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -621,12 +621,13 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { } if (!config.upload) { - config.upload = { adapters: [] } + config.upload = { adapters: [], transformers: [] } } config.upload.adapters = Array.from( new Set(config.collections!.map((c) => c.upload?.adapter).filter(Boolean) as string[]), ) + config.upload.transformers = config.upload.transformers ?? [] // Pass through the email config as is so adapters don't break if (incomingConfig.email) { diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index f16a6d6a727..1a89152844b 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -14,7 +14,6 @@ import type { JSONSchema4 } from 'json-schema' import type { Metadata } from 'next' import type { DestinationStream, Level, LoggerOptions } from 'pino' import type React from 'react' -import type { default as sharp } from 'sharp' import type { ComponentRenderer } from '../admin/adapters/render.js' import type { ServerAdapter } from '../admin/adapters/server.js' @@ -73,6 +72,7 @@ import type { import type { QueryPreset, QueryPresetConstraints } from '../query-presets/types.js' import type { SanitizedJobsConfig } from '../queues/config/types/index.js' import type { MaybePromise, PayloadRequest, Where } from '../types/index.js' +import type { UploadTransformer } from '../uploads/transformers/types.js' import type { PayloadLogger } from '../utilities/logger.js' /** @@ -745,23 +745,6 @@ export type LabelFunction = (args: { export type StaticLabel = Record | string -export type SharpDependency = ( - input?: - | ArrayBuffer - | Buffer - | Float32Array - | Float64Array - | Int8Array - | Int16Array - | Int32Array - | string - | Uint8Array - | Uint8ClampedArray - | Uint16Array - | Uint32Array, - options?: sharp.SharpOptions, -) => sharp.Sharp - export type CORSConfig = { headers?: string[] origins: '*' | string[] @@ -888,6 +871,16 @@ export type FetchAPIFileUploadOptions = { useTempFiles?: boolean | undefined } & Partial +export type GlobalUploadConfig = { + /** + * Ordered list of file transformers. Every eligible transformer joins the pipeline + * for a given upload or dynamic request in declaration order. Slugs must be unique. + * + * @see https://payloadcms.com/docs/upload/transformers + */ + transformers?: UploadTransformer[] +} & FetchAPIFileUploadOptions + export type ErrorResult = { data?: any errors: { @@ -1744,11 +1737,6 @@ export type Config = { * @see https://payloadcms.com/docs/configuration/overview#options */ serverURL?: string - /** - * Pass in a local copy of Sharp if you'd like to use it. - * - */ - sharp?: SharpDependency /** * Storage adapters that handle where uploaded files are stored (S3, GCS, Azure, Vercel Blob, etc.). * @@ -1768,9 +1756,10 @@ export type Config = { /** Control how typescript interfaces are generated from your collections. */ typescript?: RootTypeScriptConfig /** - * Customize the handling of incoming file uploads for collections that have uploads enabled. + * Customize the handling of incoming file uploads for collections that have uploads enabled, + * including the `transformers` pipeline. */ - upload?: FetchAPIFileUploadOptions + upload?: GlobalUploadConfig } interface SanitizedAdminConfig @@ -1898,7 +1887,8 @@ export interface SanitizedConfig * Deduped list of adapters used in the project */ adapters: string[] - } & FetchAPIFileUploadOptions + } & GlobalUploadConfig & + Required> } export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot diff --git a/packages/payload/src/errors/TransformerContractError.ts b/packages/payload/src/errors/TransformerContractError.ts new file mode 100644 index 00000000000..b220dc520f6 --- /dev/null +++ b/packages/payload/src/errors/TransformerContractError.ts @@ -0,0 +1,15 @@ +import { status as httpStatus } from 'http-status' + +import { APIError } from './APIError.js' + +/** + * Thrown when a file transformer violates the `handleRequest`/`getSourceFile` + * contract — e.g. calling `getSourceFile` more than once, or consuming it and + * returning `continue` without a replacement response. Payload logs the cause + * and returns `500`, the same as any other uncaught transformer error. + */ +export class TransformerContractError extends APIError { + constructor(message: string) { + super(message, httpStatus.INTERNAL_SERVER_ERROR) + } +} diff --git a/packages/payload/src/errors/index.ts b/packages/payload/src/errors/index.ts index 72bec8dfa79..c4342b57e48 100644 --- a/packages/payload/src/errors/index.ts +++ b/packages/payload/src/errors/index.ts @@ -20,6 +20,7 @@ export { MissingFile } from './MissingFile.js' export { NotFound } from './NotFound.js' export { QueryError } from './QueryError.js' export { ReservedFieldName } from './ReservedFieldName.js' +export { TransformerContractError } from './TransformerContractError.js' export { UnauthorizedError } from './UnauthorizedError.js' export { UnverifiedEmail } from './UnverifiedEmail.js' export { ValidationError, ValidationErrorName } from './ValidationError.js' diff --git a/packages/payload/src/exports/internal.ts b/packages/payload/src/exports/internal.ts index 1b3db26748a..04b0f03024e 100644 --- a/packages/payload/src/exports/internal.ts +++ b/packages/payload/src/exports/internal.ts @@ -28,6 +28,16 @@ export { getSafeFileName } from '../uploads/getSafeFilename.js' export { parseRangeHeader } from '../uploads/parseRangeHeader.js' export { externalURLInputSchema, resolveURLUploadInput } from '../uploads/resolveURLUploadInput.js' export type { ExternalURLInput } from '../uploads/resolveURLUploadInput.js' +export { + getUploadTransformerInternal, + uploadTransformerInternal, +} from '../uploads/transformers/uploadTransformerBridge.js' +export type { + PreparedUploadTransformation, + TransformerWithInternalBridge, + UploadTransformerInternal, + UploadTransformTask, +} from '../uploads/transformers/uploadTransformerBridge.js' export { uploadContentSecurityPolicy } from '../uploads/uploadContentSecurityPolicy.js' export { getEntityPermissions } from '../utilities/getEntityPermissions/getEntityPermissions.js' export { isURLAllowed } from '../utilities/isURLAllowed.js' diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 4bb8e68a180..504b4e4f4c7 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -112,6 +112,7 @@ export { } from '../types/constants.js' export type { HasManyRelationshipOperator } from '../types/constants.js' export { formatFilesize } from '../uploads/formatFilesize.js' +export { generatePayloadFileURL } from '../uploads/generatePayloadFileURL.js' export { getSanitizedUploadFilename, uploadRequiresServerValidation, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 808e3050b90..354c63e6e0a 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1018,16 +1018,6 @@ export class BasePayload { this.email = consoleEmailAdapter({ payload: this }) } - // Warn if image resizing is enabled but sharp is not installed - if ( - !this.config.sharp && - this.config.collections.some((c) => c.upload.imageSizes || c.upload.formatOptions) - ) { - this.logger.warn( - `Image resizing is enabled for one or more collections, but sharp not installed. Please install 'sharp' and pass into the config.`, - ) - } - // Warn if user is deploying to Vercel, and any upload collection is missing a storage adapter if (process.env.VERCEL) { const uploadCollWithoutAdapter = this.config.collections.filter( @@ -1679,6 +1669,7 @@ export { MissingFile, NotFound, QueryError, + TransformerContractError, UnauthorizedError, UnverifiedEmail, ValidationError, @@ -2005,8 +1996,10 @@ export { } from './queues/utilities/getCurrentDate.js' export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' +export { generatePayloadFileURL } from './uploads/generatePayloadFileURL.js' export { getFileByPath } from './uploads/getFileByPath.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' +export type * from './uploads/transformers/types.js' export type * from './uploads/types.js' export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js' export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js' diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 946814d6d5d..98b0aee106b 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -38,6 +38,12 @@ export type CustomPayloadRequestProperties = { context: RequestContext /** The locale that should be used for a field when it is not translated to the requested locale */ fallbackLocale?: TypedFallbackLocale + /** + * Set by Payload only while running a collection's `read` access function for a + * dynamic file transformation request. Restored to its prior value immediately + * after that access check completes; never set for ordinary reads. + */ + fileTransform?: true i18n: I18n /** * The requested locale if specified diff --git a/packages/payload/src/uploads/checkFileAccess.spec.ts b/packages/payload/src/uploads/checkFileAccess.spec.ts index c7cad1fe00b..c1dacedebf7 100644 --- a/packages/payload/src/uploads/checkFileAccess.spec.ts +++ b/packages/payload/src/uploads/checkFileAccess.spec.ts @@ -96,4 +96,18 @@ describe('checkFileAccess', () => { ).rejects.toThrow() }) }) + + describe('fast path for fully public collections', () => { + it('should not query the database when access is unconditionally granted and no prefix is given', async () => { + vi.mocked(executeAccess).mockResolvedValueOnce(true) + + const findOne = makeFindOne() + const req = makeReq(findOne) + const collection = makeCollection() + + await checkFileAccess({ collection, filename: 'logo.png', req }) + + expect(findOne).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/payload/src/uploads/checkFileAccess.ts b/packages/payload/src/uploads/checkFileAccess.ts index 921559422d5..ab57b927b64 100644 --- a/packages/payload/src/uploads/checkFileAccess.ts +++ b/packages/payload/src/uploads/checkFileAccess.ts @@ -3,6 +3,7 @@ import type { PayloadRequest, Where } from '../types/index.js' import { executeAccess } from '../auth/executeAccess.js' import { Forbidden } from '../errors/Forbidden.js' +import { buildFilenameWhere } from './transformers/resolveUploadDocument.js' export const checkFileAccess = async ({ collection, @@ -36,17 +37,7 @@ export const checkFileAccess = async ({ } if (constraints.length > 0) { - const filenameCondition: Where = { - or: [{ filename: { equals: filename } }], - } - - if (config.upload.imageSizes) { - config.upload.imageSizes.forEach(({ name }) => { - filenameCondition.or!.push({ - [`sizes.${name}.filename`]: { equals: filename }, - }) - }) - } + const filenameCondition = buildFilenameWhere({ filename, imageSizes: config.upload.imageSizes }) const doc = await req.payload.db.findOne({ collection: config.slug, diff --git a/packages/payload/src/uploads/cropImage.spec.ts b/packages/payload/src/uploads/cropImage.spec.ts deleted file mode 100644 index f6209b26fc9..00000000000 --- a/packages/payload/src/uploads/cropImage.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { PayloadRequest } from '../types/index.js' - -import { describe, expect, it, vi } from 'vitest' - -import { cropImage } from './cropImage.js' - -const createSharpMock = () => { - const chain: any = { - extract: vi.fn(() => chain), - metadata: vi.fn().mockResolvedValue({ height: 100, pages: 1 }), - toBuffer: vi.fn().mockResolvedValue({ - data: Buffer.from('cropped'), - info: { height: 50, size: 7, width: 50 }, - }), - } - - const sharp = vi.fn(() => chain) - - return sharp -} - -const createFile = (mimetype: string): PayloadRequest['file'] => - ({ - data: Buffer.from('original'), - mimetype, - name: 'photo', - size: 8, - }) as PayloadRequest['file'] - -describe('cropImage', () => { - it('enables sharp animated reading for tiff, matching isAnimatedImage', async () => { - const sharp = createSharpMock() - const file = createFile('image/tiff') - - await cropImage({ - cropData: { x: 10, y: 10 }, - dimensions: { height: 100, width: 100 }, - file, - heightInPixels: 50, - sharp: sharp as any, - widthInPixels: 50, - }) - - expect(sharp).toHaveBeenCalledWith(file!.data, { animated: true }) - }) - - it('does not enable sharp animated reading for avif, matching isAnimatedImage', async () => { - const sharp = createSharpMock() - const file = createFile('image/avif') - - await cropImage({ - cropData: { x: 10, y: 10 }, - dimensions: { height: 100, width: 100 }, - file, - heightInPixels: 50, - sharp: sharp as any, - widthInPixels: 50, - }) - - expect(sharp).toHaveBeenCalledWith(file!.data, {}) - }) -}) diff --git a/packages/payload/src/uploads/cropImage.ts b/packages/payload/src/uploads/cropImage.ts deleted file mode 100644 index 03888944888..00000000000 --- a/packages/payload/src/uploads/cropImage.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { SharpOptions } from 'sharp' - -import type { SanitizedConfig } from '../config/types.js' -import type { PayloadRequest } from '../types/index.js' -import type { WithMetadata } from './optionallyAppendMetadata.js' -import type { UploadEdits } from './types.js' - -import { isAnimatedImage } from './isAnimatedImage.js' -import { optionallyAppendMetadata } from './optionallyAppendMetadata.js' - -const percentToPixel = (value: number, dimension: number) => { - return Math.floor((value / 100) * dimension) -} - -type CropImageArgs = { - cropData: UploadEdits['crop'] - dimensions: { height: number; width: number } - file: PayloadRequest['file'] - heightInPixels: number - req?: PayloadRequest - sharp: NonNullable - widthInPixels: number - withMetadata?: WithMetadata -} -export async function cropImage({ - cropData, - dimensions, - file: fileArg, - heightInPixels, - req, - sharp, - widthInPixels, - withMetadata, -}: CropImageArgs) { - try { - const { x, y } = cropData! - const file = fileArg! - - const fileIsAnimatedType = isAnimatedImage(file.mimetype) - - const sharpOptions: SharpOptions = {} - - if (fileIsAnimatedType) { - sharpOptions.animated = true - } - - const { height: originalHeight, width: originalWidth } = dimensions - const newWidth = Number(widthInPixels) - const newHeight = Number(heightInPixels) - - const dimensionsChanged = originalWidth !== newWidth || originalHeight !== newHeight - - if (!dimensionsChanged) { - let adjustedHeight = originalHeight - - if (fileIsAnimatedType) { - const animatedMetadata = await sharp( - file.tempFilePath || file.data, - sharpOptions, - ).metadata() - adjustedHeight = animatedMetadata.pages ? animatedMetadata.height! : originalHeight - } - - return { - data: file.data, - info: { - height: adjustedHeight, - size: file.size, - width: originalWidth, - }, - } - } - - const formattedCropData = { - height: Number(heightInPixels), - left: percentToPixel(x, dimensions.width), - top: percentToPixel(y, dimensions.height), - width: Number(widthInPixels), - } - - let cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData) - - cropped = await optionallyAppendMetadata({ - req: req!, - sharpFile: cropped, - withMetadata: withMetadata!, - }) - - return await cropped.toBuffer({ - resolveWithObject: true, - }) - } catch (error) { - console.error(`Error cropping image:`, error) - throw error - } -} diff --git a/packages/payload/src/uploads/endpoints/getFile.ts b/packages/payload/src/uploads/endpoints/getFile.ts index ff1ababc012..2bf9dabaf79 100644 --- a/packages/payload/src/uploads/endpoints/getFile.ts +++ b/packages/payload/src/uploads/endpoints/getFile.ts @@ -5,16 +5,20 @@ import fsPromises from 'fs/promises' import { status as httpStatus } from 'http-status' import path from 'path' +import type { Collection, TypeWithID } from '../../collections/config/types.js' import type { PayloadHandler } from '../../config/types.js' +import type { PayloadRequest } from '../../types/index.js' +import type { FileHandlerOperation } from '../types.js' import { APIError } from '../../errors/APIError.js' -import { checkFileAccess } from '../../uploads/checkFileAccess.js' -import { streamFile } from '../../uploads/fetchAPI-stream-file/index.js' -import { getFileTypeFallback } from '../../uploads/getFileTypeFallback.js' -import { getFileExtension, isXmlMimeType } from '../../uploads/getFileTypeIdentity.js' -import { parseRangeHeader } from '../../uploads/parseRangeHeader.js' import { getRequestCollection } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' +import { checkFileAccess } from '../checkFileAccess.js' +import { streamFile } from '../fetchAPI-stream-file/index.js' +import { getFileTypeFallback } from '../getFileTypeFallback.js' +import { getFileExtension, isXmlMimeType } from '../getFileTypeIdentity.js' +import { parseRangeHeader } from '../parseRangeHeader.js' +import { handleDynamicFileRequest } from '../transformers/handleDynamicFileRequest.js' import { uploadContentSecurityPolicy } from '../uploadContentSecurityPolicy.js' export const getFileHandler: PayloadHandler = async (req) => { @@ -30,6 +34,10 @@ export const getFileHandler: PayloadHandler = async (req) => { ) } + if (req.payload.config.upload.transformers.length > 0) { + return handleDynamicFileRequest({ collection, filename, prefix, req }) + } + const accessResult = (await checkFileAccess({ collection, filename, @@ -41,17 +49,42 @@ export const getFileHandler: PayloadHandler = async (req) => { return accessResult } - if (collection.config.upload.handlers?.length) { + return retrieveFileResponse({ collection, doc: accessResult, filename, prefix, req }) +} + +/** + * Shared by the ordinary `read` endpoint and, via `operation: 'transform'`, by + * `getSourceFileResponse` for internal source retrieval. `transform` ignores the + * `Range` header and skips `modifyResponseHeaders`/CORS wrapping, since those are + * applied once, later, to the transformer pipeline's final response. + */ +export async function retrieveFileResponse({ + collection, + doc, + filename, + operation = 'read', + prefix, + req, +}: { + collection: Collection + doc?: TypeWithID + filename: string + operation?: FileHandlerOperation + prefix?: string + req: PayloadRequest +}): Promise { + if (collection.config.upload && collection.config.upload.handlers?.length) { let customResponse: null | Response | void = null const headers = new Headers() for (const handler of collection.config.upload.handlers) { customResponse = await handler(req, { - doc: accessResult, + doc: doc!, headers, params: { collection: collection.config.slug, filename, + operation, prefix, }, }) @@ -118,8 +151,11 @@ export const getFileHandler: PayloadHandler = async (req) => { mimeType = 'image/svg+xml' } - // Parse Range header for byte range requests - const rangeHeader = req.headers.get('range') + const isTransformSource = operation === 'transform' + + // The client's Range header must never leak into an internal source retrieval — + // a transformer needs the complete original bytes to decode. + const rangeHeader = isTransformSource ? null : req.headers.get('range') const rangeResult = parseRangeHeader({ fileSize: stats.size, rangeHeader, @@ -166,6 +202,10 @@ export const getFileHandler: PayloadHandler = async (req) => { status = httpStatus.OK } + if (isTransformSource) { + return new Response(data, { headers, status }) + } + headers = collection.config.upload?.modifyResponseHeaders ? collection.config.upload.modifyResponseHeaders({ headers }) || headers : headers diff --git a/packages/payload/src/uploads/generateFileData.spec.ts b/packages/payload/src/uploads/generateFileData.spec.ts index 7aa673f06e8..97ff9f2b70a 100644 --- a/packages/payload/src/uploads/generateFileData.spec.ts +++ b/packages/payload/src/uploads/generateFileData.spec.ts @@ -95,6 +95,62 @@ describe('generateFileData', () => { expect(toBufferMock).not.toHaveBeenCalled() }) + describe('focal point without a transformer', () => { + const createPngReq = ({ query = {} }: { query?: Record } = {}) => + ({ + file: { + data: PNG_SIGNATURE, + mimetype: 'image/png', + name: 'photo.png', + size: PNG_SIGNATURE.length, + }, + payload: { + config: {}, + logger: { error: vi.fn() }, + }, + query, + }) as unknown as PayloadRequest + + it('should default the focal point to the center on create', async () => { + const result = await generateFileData({ + collection: createCollection({ focalPoint: true }), + config: {} as SanitizedConfig, + data: {}, + operation: 'create', + overwriteExistingFiles: true, + req: createPngReq(), + }) + + expect(result.data).toMatchObject({ focalX: 50, focalY: 50 }) + }) + + it('should save a focal point sent through the uploadEdits query param', async () => { + const result = await generateFileData({ + collection: createCollection({ focalPoint: true }), + config: {} as SanitizedConfig, + data: {}, + operation: 'create', + overwriteExistingFiles: true, + req: createPngReq({ query: { uploadEdits: { focalPoint: { x: 20.4, y: 80.6 } } } }), + }) + + expect(result.data).toMatchObject({ focalX: 20, focalY: 81 }) + }) + + it('should not save a focal point when focalPoint is disabled', async () => { + const result = await generateFileData({ + collection: createCollection({ focalPoint: false }), + config: {} as SanitizedConfig, + data: {}, + operation: 'create', + overwriteExistingFiles: true, + req: createPngReq(), + }) + + expect(result.data).not.toHaveProperty('focalX') + }) + }) + it('uses the inspected non-image type for image processing', async () => { const { sharp } = createSharpMock() const fileContent = Buffer.from( @@ -164,19 +220,89 @@ describe('generateFileData', () => { expect(result.data).toMatchObject({ height: 1, mimeType: 'image/svg+xml', width: 1 }) }) - it('still runs sharp processing when resize options are configured', async () => { - const { sharp, toBufferMock } = createSharpMock() + it('still runs the transformer pipeline when a transformer handles the file type', async () => { + const { sharp } = createSharpMock() + const transformFile = vi + .fn() + .mockResolvedValue({ file: new File([PNG_SIGNATURE], 'photo.png'), status: 'complete' }) + + const req = createReq(sharp) + req.payload.config.upload = { + transformers: [{ slug: 'test', mimeTypes: ['image/*'], transformFile }], + } as unknown as PayloadRequest['payload']['config']['upload'] await generateFileData({ - collection: createCollection({ resizeOptions: { width: 100 } }), + collection: createCollection(), config: {} as SanitizedConfig, data: {}, operation: 'create', overwriteExistingFiles: true, - req: createReq(sharp), + req, }) - expect(toBufferMock).toHaveBeenCalledTimes(1) + expect(transformFile).toHaveBeenCalledTimes(1) + }) + + describe('transformed file metadata', () => { + const runTransformerReturning = async (outputFile: File) => { + const req = createReq(undefined) + req.file = { + data: Buffer.from('{"a":1}'), + mimetype: 'application/json', + name: 'data.json', + size: 7, + } as PayloadRequest['file'] + req.payload.config.upload = { + transformers: [ + { + slug: 'test', + mimeTypes: ['application/json'], + transformFile: vi.fn().mockResolvedValue({ file: outputFile, status: 'complete' }), + }, + ], + } as unknown as PayloadRequest['payload']['config']['upload'] + + const result = await generateFileData({ + collection: createCollection(), + config: {} as SanitizedConfig, + data: {}, + operation: 'create', + overwriteExistingFiles: true, + req, + }) + + return result.data as { filename: string; filesize: number; mimeType: string } + } + + it('should use the returned file type and name when the output bytes have no detectable type', async () => { + const data = await runTransformerReturning( + new File(['a\n1\n'], 'data.csv', { type: 'text/csv' }), + ) + + expect(data).toMatchObject({ filename: 'data.csv', filesize: 4, mimeType: 'text/csv' }) + }) + + it('should use a renamed output file name', async () => { + const data = await runTransformerReturning( + new File(['a\n1\n'], 'report.csv', { type: 'text/csv' }), + ) + + expect(data.filename).toBe('report.csv') + }) + + it('should prefer the detected type over a stale type and extension on the returned file', async () => { + const data = await runTransformerReturning( + new File([PNG_SIGNATURE], 'data.json', { type: 'application/json' }), + ) + + expect(data).toMatchObject({ filename: 'data.png', mimeType: 'image/png' }) + }) + + it('should fall back to the upload type and name when the returned file declares neither', async () => { + const data = await runTransformerReturning(new File(['{"a":2}'], '')) + + expect(data).toMatchObject({ filename: 'data.json', mimeType: 'application/json' }) + }) }) it('does not overwrite req.file with a truncated header-only buffer', async () => { diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index fd999308c1e..246e173af41 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -1,5 +1,3 @@ -import type { OutputInfo, Sharp, SharpOptions } from 'sharp' - import { fileTypeFromBuffer } from 'file-type' import fs from 'fs/promises' @@ -7,25 +5,26 @@ import type { Collection } from '../collections/config/types.js' import type { SanitizedConfig } from '../config/types.js' import type { Document, PayloadRequest } from '../types/index.js' import type { ExternalUploadSource } from './sanitizeUploadData.js' -import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js' +import type { PreparedUploadTransformation } from './transformers/uploadTransformerBridge.js' +import type { FileData, FileSizes, FileToSave, UploadEdits } from './types.js' import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js' import { isNumber } from '../utilities/isNumber.js' import { canResizeImage } from './canResizeImage.js' import { checkFileRestrictions } from './checkFileRestrictions.js' -import { cropImage } from './cropImage.js' import { downloadFileToBuffer } from './downloadFileToBuffer.js' +import { generateImageSizeFilename } from './generateImageSizeFilename.js' import { getFileByPath } from './getFileByPath.js' import { getFileExtension, getSanitizedUploadFilename } from './getFileTypeIdentity.js' import { getImageSize } from './getImageSize.js' import { getSafeFileName } from './getSafeFilename.js' import { hasCropOrResizeEdit } from './hasCropOrResizeEdit.js' import { hasFullFileContents } from './hasFullFileContents.js' -import { createImageSizes } from './image-resizing/createImageSizes.js' -import { isAnimatedImage } from './isAnimatedImage.js' -import { isImage } from './isImage.js' import { isProcessableImage } from './isProcessableImage.js' -import { optionallyAppendMetadata } from './optionallyAppendMetadata.js' +import { parseFilename } from './parseFilename.js' +import { planTransformerPipeline } from './transformers/planTransformerPipeline.js' +import { transformUploadFile } from './transformers/transformUploadFile.js' +import { getUploadTransformerInternal } from './transformers/uploadTransformerBridge.js' type Args = { collection: Collection config: SanitizedConfig @@ -78,7 +77,7 @@ export type TempFileHandling = | { type: 'useBuffer' } /** - * Decides how to get a file's bytes onto disk when sharp did not need to process it (a processed + * Decides how to get a file's bytes onto disk when no transformer rewrote it (a transformed * file is already an in-memory buffer, so it always takes the `useBuffer` path). Copies straight * from the temp file when possible, rather than reading a potentially large temp file into memory * just to write it back out - see the `generateFileData` function doc for why. @@ -106,9 +105,9 @@ export const resolveTempFileHandling = ({ * * A large client upload may arrive as a temp file instead of an in-memory buffer (see * getFileFromUploadInstructions.ts), and `file.data` can hold only a partial probe rather than - * the full file. To avoid loading such files into memory unnecessarily, this skips reading a - * temp file entirely when local storage is disabled, and copies it straight to its destination - * when local storage is enabled. + * the full file. To avoid loading such files into memory unnecessarily, no transformer runs + * unless the full bytes are available, this skips reading a temp file entirely when local + * storage is disabled, and copies it straight to its destination when local storage is enabled. */ export const generateFileData = async ({ collection: { config: collectionConfig }, @@ -129,7 +128,7 @@ export const generateFileData = async ({ } } - const { serverURL, sharp } = req.payload.config + const { serverURL } = req.payload.config let file = isDuplicating ? undefined : req.file @@ -144,15 +143,9 @@ export const generateFileData = async ({ }) const { - constructorOptions, disableLocalStorage, focalPoint: focalPointEnabled = true, - formatOptions, - imageSizes, - resizeOptions, staticDir, - trimOptions, - withMetadata, } = collectionConfig.upload const staticPath = staticDir @@ -182,11 +175,13 @@ export const generateFileData = async ({ try { if (!externalUploadSource && !disableLocalStorage && isLocalFile) { + // File is stored locally const filePath = `${staticPath}/${filename}` const response = await getFileByPath(filePath) file = response overwriteExistingFiles = true } else if (filename && url) { + // File is remote file = await downloadFileToBuffer({ data: fileSourceData, req, @@ -235,96 +230,138 @@ export const generateFileData = async ({ let newData = incomingFileData as T const filesToSave: FileToSave[] = [] const fileData: Partial = {} - const fileIsAnimatedType = isAnimatedImage(file.mimetype) - const cropData = - typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined try { - const fileSupportsResize = canResizeImage(file.mimetype) - const fileHasCompleteContents = hasFullFileContents(file) - let fsSafeName: string - let sharpFile: Sharp | undefined - let dimensions: ProbedImageSize | undefined - let fileBuffer!: { data: Buffer; info: OutputInfo } - let ext - let mime: string - // Depends only on configured resize/format/trim options, not on whether the bytes are on - // disk or in memory. - const fileHasAdjustments = - fileSupportsResize && - Boolean(resizeOptions || formatOptions || trimOptions || constructorOptions) - - const sharpOptions: SharpOptions = { ...constructorOptions } - - if (fileIsAnimatedType) { - sharpOptions.animated = true - } + const pipeline = await planTransformerPipeline({ + args: { + collectionSlug: collectionConfig.slug, + mimeType: file.mimetype, + operation: 'upload', + req, + }, + capability: 'transformFile', + transformers: req.payload.config.upload?.transformers ?? [], + }) + + // A large client upload can arrive as a bounded probe alongside a temp file. Transformers + // need the whole file, so leave such an upload untouched rather than buffering it. + const canRunTransformers = pipeline.length > 0 && hasFullFileContents(file) + + const bridgeTransformer = canRunTransformers + ? pipeline.find((transformer) => + Boolean(getUploadTransformerInternal(transformer)?.prepareUpload), + ) + : undefined + + let originalWebFile: File | undefined + let mainWebFile: File | undefined + let hasDimensionsFromBridge = false + let sizeResults: PreparedUploadTransformation[] = [] + + if (canRunTransformers) { + originalWebFile = new File( + [file.tempFilePath ? await fs.readFile(file.tempFilePath) : file.data], + file.name, + { type: file.mimetype }, + ) + + if (bridgeTransformer) { + const bridge = getUploadTransformerInternal(bridgeTransformer)! + + const results = await bridge.prepareUpload!({ + collectionSlug: collectionConfig.slug, + file: originalWebFile, + req, + transform: (task) => + transformUploadFile({ + collectionSlug: collectionConfig.slug, + file: task.file ?? originalWebFile!, + options: task.options, + pipeline, + req, + }), + uploadEdits, + }) - if (sharp && fileHasCompleteContents && (fileIsAnimatedType || fileHasAdjustments)) { - // rotate() auto-rotates based on EXIF data - see #3081 - sharpFile = file.tempFilePath - ? sharp(file.tempFilePath, sharpOptions).rotate() - : sharp(file.data, sharpOptions).rotate() + const mainResult = results.find((result) => result.fieldPath === 'filename') - if (fileHasAdjustments) { - if (resizeOptions) { - sharpFile = sharpFile.resize(resizeOptions) - } - if (formatOptions) { - sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options) - } - if (trimOptions) { - sharpFile = sharpFile.trim(trimOptions) - } + mainWebFile = mainResult?.file ?? originalWebFile + fileData.width = mainResult?.width + fileData.height = mainResult?.height + hasDimensionsFromBridge = true + sizeResults = results.filter((result) => result.fieldPath !== 'filename') + } else { + mainWebFile = await transformUploadFile({ + collectionSlug: collectionConfig.slug, + file: originalWebFile, + options: undefined, + pipeline, + req, + }) } } - if (fileSupportsResize || isImage(file.mimetype)) { - dimensions = await getImageSize({ - file, - sharp: fileSupportsResize && fileHasCompleteContents ? sharp : undefined, - }) - fileData.width = dimensions.width - fileData.height = dimensions.height + // Saved for any resizable image, not just one a transformer processed, so it's kept with no + // transformer registered and on a header-only client upload. + if ( + focalPointEnabled && + uploadEdits?.focalPoint && + (hasDimensionsFromBridge || canResizeImage(file.mimetype)) + ) { + fileData.focalX = isNumber(uploadEdits.focalPoint.x) + ? Math.round(uploadEdits.focalPoint.x) + : 50 + fileData.focalY = isNumber(uploadEdits.focalPoint.y) + ? Math.round(uploadEdits.focalPoint.y) + : 50 } - if (sharpFile) { - const metadata = await sharpFile.metadata() - sharpFile = await optionallyAppendMetadata({ - req, - sharpFile, - withMetadata: withMetadata!, - }) - fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true }) - delete file.uploadReference - ;({ ext, mime } = (await fileTypeFromBuffer(fileBuffer.data))!) - fileData.width = fileBuffer.info.width - fileData.height = fileBuffer.info.height - fileData.filesize = fileBuffer.info.size - - // Animated GIFs/WebP report height summed across all frames - divide by page count. - if (metadata.pages) { - fileData.height = fileBuffer.info.height / metadata.pages - fileData.filesize = fileBuffer.data.length - } + const fileWasTransformed = Boolean(mainWebFile && mainWebFile !== originalWebFile) + const mainBuffer = fileWasTransformed + ? Buffer.from(await mainWebFile!.arrayBuffer()) + : undefined + + // A transformed file is named after what the transformer returned, not the upload. + const outputName = (fileWasTransformed && mainWebFile!.name) || file.name + + let mimeType: string + let ext: string | undefined + + if (mainBuffer) { + // Detected bytes win for binary formats (Sharp keeps the input's name and type on a + // converted output), but text-like output such as CSV has no signature to detect, so + // fall back to the type and extension the transformer declared on the returned File. + const typeResult = await fileTypeFromBuffer(mainBuffer) + ext = + typeResult?.ext ?? (getFileExtension(getSanitizedUploadFilename(outputName)) || undefined) + mimeType = typeResult?.mime ?? (mainWebFile!.type || file.mimetype) } else { - mime = file.mimetype - fileData.filesize = file.size - - if (file.name.includes('.')) { - ext = getFileExtension(getSanitizedUploadFilename(file.name)) - } else { - ext = '' - } + mimeType = file.mimetype + ext = getFileExtension(getSanitizedUploadFilename(file.name)) } // Adjust SVG mime type. fromBuffer modifies it. - if (mime === 'application/xml' && ext === 'svg') { - mime = 'image/svg+xml' + if (mimeType === 'application/xml' && ext === 'svg') { + mimeType = 'image/svg+xml' + } + fileData.mimeType = mimeType + fileData.filesize = mainBuffer ? mainBuffer.length : file.size + + // Only probe formats that could carry dimensions - probing reads the file, and a large + // non-image upload may only exist as a temp file we deliberately never buffer. + if (!hasDimensionsFromBridge && isProcessableImage(mimeType)) { + try { + const probed = await getImageSize({ + file: mainBuffer ? { ...file, data: mainBuffer, tempFilePath: undefined } : file, + }) + fileData.width = probed.width + fileData.height = probed.height + } catch { + // Not a recognized image format — leave width/height unset. + } } - fileData.mimeType = mime - fsSafeName = getSanitizedUploadFilename(file.name, ext) + let fsSafeName = getSanitizedUploadFilename(outputName, ext) if (!overwriteExistingFiles) { // Extract prefix if present (added by plugin-cloud-storage) @@ -340,85 +377,31 @@ export const generateFileData = async ({ fileData.filename = fsSafeName - let fileForResize = file + if (mainBuffer) { + // The stored bytes are no longer the ones the client uploaded, so the client's upload + // reference must not be reused for them. + delete file.uploadReference - if (cropData && fileSupportsResize && sharp && fileHasCompleteContents) { - const { data: croppedImage, info } = await cropImage({ - cropData, - dimensions: dimensions!, - file, - heightInPixels: uploadEdits.heightInPixels!, - req, - sharp, - widthInPixels: uploadEdits.widthInPixels!, - withMetadata, + filesToSave.push({ + buffer: mainBuffer, + path: `${staticPath}/${fsSafeName}`, }) - // Apply resize after cropping to ensure it conforms to resizeOptions - if (resizeOptions && !resizeOptions.withoutEnlargement) { - const resizedAfterCrop = await sharp(croppedImage) - .resize({ - fit: resizeOptions?.fit || 'cover', - height: resizeOptions?.height, - position: resizeOptions?.position || 'center', - width: resizeOptions?.width, - }) - .toBuffer({ resolveWithObject: true }) - - filesToSave.push({ - buffer: resizedAfterCrop.data, - path: `${staticPath}/${fsSafeName}`, - }) - - fileForResize = { - ...fileForResize, - data: resizedAfterCrop.data, - size: resizedAfterCrop.info.size, - } - - fileData.width = resizedAfterCrop.info.width - fileData.height = resizedAfterCrop.info.height - if (fileIsAnimatedType) { - const metadata = await sharpFile!.metadata() - fileData.height = metadata.pages - ? resizedAfterCrop.info.height / metadata.pages - : resizedAfterCrop.info.height - } - fileData.filesize = resizedAfterCrop.info.size + if (file.tempFilePath) { + await fs.writeFile(file.tempFilePath, mainBuffer) } else { - filesToSave.push({ - buffer: croppedImage, - path: `${staticPath}/${fsSafeName}`, - }) - - fileForResize = { + req.file = { ...file, - data: croppedImage, - size: info.size, - } - - fileData.width = info.width - fileData.height = info.height - if (fileIsAnimatedType) { - const metadata = await sharpFile!.metadata() - fileData.height = metadata.pages ? info.height / metadata.pages : info.height + data: mainBuffer, + size: mainBuffer.length, } - fileData.filesize = info.size - } - - delete file.uploadReference - delete fileForResize.uploadReference - if (file.tempFilePath) { - await fs.writeFile(file.tempFilePath, croppedImage) - } else { - req.file = fileForResize } } else { // file.data is empty when useTempFiles is on, so the real content lives at // file.tempFilePath instead (see the function doc for why we avoid buffering it). const tempFileHandling = resolveTempFileHandling({ disableLocalStorage: Boolean(disableLocalStorage), - hasProcessedBuffer: Boolean(fileBuffer?.data), + hasProcessedBuffer: false, tempFilePath: file.tempFilePath, }) @@ -428,19 +411,11 @@ export const generateFileData = async ({ sourcePath: tempFileHandling.sourcePath, }) } else if (tempFileHandling.type === 'useBuffer') { - let bufferToSave: Buffer - if (fileBuffer?.data) { - bufferToSave = fileBuffer.data - } else if (file.tempFilePath) { - bufferToSave = await fs.readFile(file.tempFilePath) - } else { - bufferToSave = file.data - } + const bufferToSave = file.tempFilePath ? await fs.readFile(file.tempFilePath) : file.data // A 'header'/'none' content requirement (see getFileContentRequirement.ts) means // file.data is only a partial probe, not the real content - never save it as-is. - const fileDataIsPartialView = - !fileBuffer?.data && !file.tempFilePath && bufferToSave.length !== file.size + const fileDataIsPartialView = !file.tempFilePath && bufferToSave.length !== file.size if (!fileDataIsPartialView) { filesToSave.push({ @@ -448,15 +423,15 @@ export const generateFileData = async ({ path: `${staticPath}/${fsSafeName}`, }) - if (fileBuffer?.data || bufferToSave.length > 0) { + if (bufferToSave.length > 0) { if (file.tempFilePath) { - await fs.writeFile(file.tempFilePath, fileBuffer?.data || bufferToSave) + await fs.writeFile(file.tempFilePath, bufferToSave) } else { // Keep req.file in sync, since downstream hooks/plugins may read it. req.file = { ...file, - data: fileBuffer?.data || bufferToSave, - size: fileBuffer?.info.size, + data: bufferToSave, + size: bufferToSave.length, } } } @@ -464,39 +439,70 @@ export const generateFileData = async ({ } } - if (fileSupportsResize && (Array.isArray(imageSizes) || focalPointEnabled !== false)) { + if (sizeResults.length > 0) { req.payloadUploadSizes = {} - const focalPoint = - focalPointEnabled && uploadEdits?.focalPoint - ? { - x: isNumber(uploadEdits.focalPoint.x) ? Math.round(uploadEdits.focalPoint.x) : 50, - y: isNumber(uploadEdits.focalPoint.y) ? Math.round(uploadEdits.focalPoint.y) : 50, - } - : undefined - - const { sizeData, sizesToSave } = await createImageSizes({ - config: collectionConfig, - dimensions: !cropData - ? dimensions! - : { - ...dimensions, - height: fileData.height!, - width: fileData.width!, - }, - file: fileForResize, - focalPoint, - mimeType: fileData.mimeType, - req, - savedFilename: fsSafeName || file.name, - sharp: fileHasCompleteContents ? sharp : undefined, - staticPath: staticPath!, - withMetadata, - }) + const sizes: FileSizes = {} + const { name: baseName, ext: baseExt } = parseFilename(fsSafeName) + + for (const result of sizeResults) { + const sizeName = result.fieldPath.slice('sizes.'.length) + + if (!result.file) { + sizes[sizeName] = { + filename: null, + filesize: null, + height: null, + mimeType: null, + url: null, + width: null, + } + continue + } + + const sizeBuffer = Buffer.from(await result.file.arrayBuffer()) + const sizeTypeResult = await fileTypeFromBuffer(sizeBuffer) + const sizeExt = sizeTypeResult?.ext || baseExt + const sizeMimeType = sizeTypeResult?.mime || result.mimeType || fileData.mimeType + + req.payloadUploadSizes[sizeName] = sizeBuffer + + const imageSizeConfig = collectionConfig.upload.imageSizes?.find( + (imageSize) => imageSize.name === sizeName, + ) + + const imageNameWithDimensions = imageSizeConfig?.generateImageName + ? imageSizeConfig.generateImageName({ + extension: sizeExt, + height: result.height!, + originalName: baseName, + sizeName, + width: result.width!, + }) + : generateImageSizeFilename({ + extension: sizeExt, + height: result.height!, + outputImageName: baseName, + width: result.width!, + }) + + const imagePath = `${staticPath}/${imageNameWithDimensions}` + + sizes[sizeName] = { + filename: imageNameWithDimensions, + filesize: sizeBuffer.length, + height: result.height!, + mimeType: sizeMimeType, + url: null, + width: result.width!, + } + + filesToSave.push({ + buffer: sizeBuffer, + path: imagePath, + }) + } - fileData.sizes = sizeData - fileData.focalX = focalPoint?.x - fileData.focalY = focalPoint?.y - filesToSave.push(...sizesToSave) + fileData.sizes = sizes } } catch (err) { req.payload.logger.error(err) diff --git a/packages/payload/src/uploads/image-resizing/generateImageSizeFilename.ts b/packages/payload/src/uploads/generateImageSizeFilename.ts similarity index 100% rename from packages/payload/src/uploads/image-resizing/generateImageSizeFilename.ts rename to packages/payload/src/uploads/generateImageSizeFilename.ts diff --git a/packages/payload/src/uploads/generatePayloadFileURL.spec.ts b/packages/payload/src/uploads/generatePayloadFileURL.spec.ts new file mode 100644 index 00000000000..b89ddaec6f9 --- /dev/null +++ b/packages/payload/src/uploads/generatePayloadFileURL.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' + +import type { Config } from '../config/types.js' + +import { generatePayloadFileURL } from './generatePayloadFileURL.js' + +const makeConfig = (overrides: Partial = {}): Config => + ({ + routes: { api: '/api' }, + serverURL: 'https://example.com', + ...overrides, + }) as Config + +describe('generatePayloadFileURL', () => { + it('should encode special characters in the filename', () => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'my file (1).png', + relative: true, + }) + + expect(url).toBe('/api/media/file/my%20file%20(1).png') + }) + + it.each([ + { + config: makeConfig({ routes: { api: '/custom-api' } }), + expected: '/custom-api/media/file/logo.png', + name: 'a relative URL using a custom API route', + relative: true, + }, + { + config: makeConfig(), + expected: 'https://example.com/api/media/file/logo.png', + name: 'an absolute URL from serverURL when relative is false', + relative: false, + }, + { + config: makeConfig(), + expected: '/api/media/file/logo.png', + name: 'a relative URL ignoring serverURL when relative is true', + relative: true, + }, + ])('should build $name', ({ config, expected, relative }) => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config, + filename: 'logo.png', + relative, + }) + + expect(url).toBe(expected) + }) + + it('should append prefix as a query parameter', () => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + prefix: 'tenants/acme', + relative: true, + }) + + expect(url).toBe('/api/media/file/logo.png?prefix=tenants%2Facme') + }) + + it('should serialize boolean and number query values', () => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { width: 500, withoutEnlargement: true }, + relative: true, + }) + + expect(url).toBe('/api/media/file/logo.png?width=500&withoutEnlargement=true') + }) + + it('should omit query keys whose value is undefined', () => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { height: undefined, width: 500 }, + relative: true, + }) + + expect(url).toBe('/api/media/file/logo.png?width=500') + }) + + it('should serialize an array query value as repeated keys, preserving order', () => { + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { tag: ['a', 'b', 'c'] }, + relative: true, + }) + + expect(url).toBe('/api/media/file/logo.png?tag=a&tag=b&tag=c') + }) + + it('should produce a deterministic key order regardless of input object key order', () => { + const first = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { height: 500, width: 400 }, + relative: true, + }) + const second = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { width: 400, height: 500 }, + relative: true, + }) + + expect(first).toBe(second) + expect(first).toBe('/api/media/file/logo.png?height=500&width=400') + }) + + it('should throw when query contains a `prefix` key', () => { + expect(() => + generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query: { prefix: 'tenants/acme' }, + relative: true, + }), + ).toThrow(/prefix/i) + }) + + it('should copy values out of an input URLSearchParams without mutating it', () => { + const query = new URLSearchParams({ width: '500' }) + + const url = generatePayloadFileURL({ + collectionSlug: 'media', + config: makeConfig(), + filename: 'logo.png', + query, + relative: true, + }) + + expect(url).toBe('/api/media/file/logo.png?width=500') + expect(Array.from(query.entries())).toEqual([['width', '500']]) + }) +}) diff --git a/packages/payload/src/uploads/generatePayloadFileURL.ts b/packages/payload/src/uploads/generatePayloadFileURL.ts new file mode 100644 index 00000000000..bd956c2d23c --- /dev/null +++ b/packages/payload/src/uploads/generatePayloadFileURL.ts @@ -0,0 +1,96 @@ +import type { Config } from '../config/types.js' + +import { generateFilePathOrURL } from './generateFilePathOrURL.js' + +type QueryValue = (boolean | number | string)[] | boolean | number | string | undefined + +function queryToEntries(query: Record | URLSearchParams): [string, string][] { + if (query instanceof URLSearchParams) { + return Array.from(query.entries()) + } + + const entries: [string, string][] = [] + + for (const [key, value] of Object.entries(query)) { + if (value === undefined) { + continue + } + + if (Array.isArray(value)) { + for (const item of value) { + if (item !== undefined) { + entries.push([key, String(item)]) + } + } + continue + } + + entries.push([key, String(value)]) + } + + return entries +} + +/** + * Builds an access-controlled Payload file URL (`/{collectionSlug}/file/{filename}`). + * Unlike the internal `generateFilePathOrURL`, this never inspects or returns a + * document's possibly external `url` — it always targets the Payload-controlled + * endpoint. `prefix` is core-owned routing context and must be passed as its own + * argument; passing it inside `query` is rejected. + */ +export function generatePayloadFileURL({ + collectionSlug, + config, + filename, + prefix, + query, + relative = false, +}: { + collectionSlug: string + config: Config + filename: string + /** Core-owned storage-key routing context. Not a transformer parameter. */ + prefix?: string + /** + * Transformer-owned query parameters. Accepts a plain record (array values are + * serialized as repeated keys, in order) or a `URLSearchParams`, which is read + * from without mutating the caller's instance. + */ + query?: Record | URLSearchParams + relative?: boolean +}): string { + const queryEntries = queryToEntries(query ?? {}) + + if (queryEntries.some(([key]) => key === 'prefix')) { + throw new Error( + 'generatePayloadFileURL: `prefix` is core-owned routing context. Pass it as the `prefix` argument, not inside `query`.', + ) + } + + const params = new URLSearchParams() + + if (prefix !== undefined) { + params.set('prefix', prefix) + } + + for (const [key, value] of [...queryEntries].sort(([a], [b]) => a.localeCompare(b))) { + params.append(key, value) + } + + const queryString = params.toString() + + const url = generateFilePathOrURL({ + collectionSlug, + config, + filename, + relative, + serverURL: config.serverURL, + urlOrPath: undefined, + }) + + if (!url) { + throw new Error('generatePayloadFileURL: `filename` must be a non-empty string.') + } + + return queryString ? `${url}?${queryString}` : url +} diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 623b64c8652..e19cdbe66c6 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -1,7 +1,7 @@ import type { CollectionConfig } from '../collections/config/types.js' import type { Config } from '../config/types.js' import type { Field } from '../fields/config/types.js' -import type { UploadConfig } from './types.js' +import type { SanitizedUploadConfig } from './types.js' import { generateFilePathOrURL } from './generateFilePathOrURL.js' import { mimeTypeValidator } from './mimeTypeValidator.js' @@ -25,7 +25,9 @@ type Options = { } export const getBaseUploadFields = ({ collection, config }: Options): Field[] => { - const uploadOptions: UploadConfig = typeof collection.upload === 'object' ? collection.upload : {} + // `imageSizes` only exists once a transformer (e.g. Sharp) has written it back during init. + const uploadOptions: Partial = + typeof collection.upload === 'object' ? collection.upload : {} const mimeType: Field = { name: 'mimeType', @@ -175,7 +177,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => if ( uploadOptions.focalPoint !== false || uploadOptions.imageSizes || - uploadOptions.resizeOptions + uploadOptions.hasImageAdjustments ) { uploadFields = uploadFields.concat( ['focalX', 'focalY'].map((name) => { diff --git a/packages/payload/src/uploads/getFileContentRequirement.spec.ts b/packages/payload/src/uploads/getFileContentRequirement.spec.ts index 0703ff5d171..71785a19179 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.spec.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.spec.ts @@ -62,11 +62,11 @@ describe('getFileContentRequirement', () => { ).toBe('header') }) - it('requires the full file for an image with resize options configured', () => { + it('requires the full file for an image a transformer will adjust', () => { expect( getFileContentRequirement({ mimeType: 'image/png', - uploadConfig: createUploadConfig({ resizeOptions: { width: 100 } }), + uploadConfig: createUploadConfig({ hasImageAdjustments: true }), }), ).toBe('full') }) diff --git a/packages/payload/src/uploads/getFileContentRequirement.ts b/packages/payload/src/uploads/getFileContentRequirement.ts index 2a8ed7604d4..2b473b072e2 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.ts @@ -18,11 +18,11 @@ export type FileContentRequirement = 'full' | 'header' | 'none' * so a client upload (e.g. Azure's chunkLargeFiles) only pays for what post-processing * actually reads instead of always being re-downloaded in full. * - * - `full`: local storage needs the real bytes, or the file will be resized/reformatted/ - * trimmed/animated, or additional image sizes will be generated from it, or the collection - * restricts mime types (which also runs SVG/PDF content-safety checks that must see the - * whole file), or the request itself carries a crop/resize edit (`uploadEdits`) that will - * run sharp against the fetched bytes. + * - `full`: local storage needs the real bytes, or a registered file transformer will adjust + * the image (`upload.hasImageAdjustments`), or the file is animated, or additional image + * sizes will be generated from it, or the collection restricts mime types (which also runs + * SVG/PDF content-safety checks that must see the whole file), or the request itself carries + * a crop/resize edit (`uploadEdits`) that will be applied to the fetched bytes. * - `header`: the file is an image and only its dimensions are needed. * - `none`: nothing downstream reads file content at all. */ @@ -52,11 +52,12 @@ export function getFileContentRequirement({ } const isResizableImage = canResizeImage(mimeType) + // `hasImageAdjustments` and `imageSizes` are the transformer-agnostic projection a file + // transformer writes back onto the sanitized upload config at startup (see + // `@payloadcms/transformer-sharp`'s `initSharpCollections`), so core can make this decision + // without knowing which transformer is registered or how it is configured. const hasConfiguredAdjustments = Boolean( - uploadConfig.resizeOptions || - uploadConfig.formatOptions || - uploadConfig.trimOptions || - uploadConfig.constructorOptions || + uploadConfig.hasImageAdjustments || (Array.isArray(uploadConfig.imageSizes) && uploadConfig.imageSizes.length > 0), ) diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts index d9c4e573801..161d6f89a9a 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts @@ -309,10 +309,6 @@ describe('getFileFromUploadInstructions', () => { }) const req = createReq([handler], {}) - const sharp = vi.fn(() => { - throw new Error('Unexpected image processing') - }) - req.payload.config.sharp = sharp const file = await getFileFromUploadInstructions({ collectionSlug: 'media', @@ -328,7 +324,6 @@ describe('getFileFromUploadInstructions', () => { expect(file.tempFilePath).toBeUndefined() expect(file.data.equals(MINIMAL_PNG)).toBe(true) expect(file.mimetype).toBe('image/png') - expect(sharp).not.toHaveBeenCalled() }) it('fetches the full file for an image with no configured adjustments when the request includes a crop edit', async () => { diff --git a/packages/payload/src/uploads/getImageSize.spec.ts b/packages/payload/src/uploads/getImageSize.spec.ts index 3dbb29c27eb..f7211d176d6 100644 --- a/packages/payload/src/uploads/getImageSize.spec.ts +++ b/packages/payload/src/uploads/getImageSize.spec.ts @@ -1,7 +1,6 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import os from 'os' import path from 'path' -import sharp from 'sharp' import { fileURLToPath } from 'url' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' @@ -32,7 +31,7 @@ const fixturesDir = path.resolve(dirname, '../../../../test/uploads') // Minimal JPEG XL bare-codestream encoder (bit-packed, little-endian, LSB // first). See probeImageSize.spec.ts for the full JXL parsing test suite; -// this only needs to produce a valid header for the sharp-fallback tests below +// this only needs to produce a valid header for the tests below const encodeJXLCodestream = (width: number, height: number): Buffer => { const bits: number[] = [] const pushBits = (value: number, length: number) => { @@ -84,20 +83,13 @@ describe('getImageSize', () => { { file: 'image.svg', height: 260, width: 260 }, ] - it.each(cases)('should read $file dimensions via sharp', async ({ file, height, width }) => { - expect(await getImageSize({ file: fileFor(file), sharp })).toEqual({ height, width }) + it.each(cases)('should read $file dimensions', async ({ file, height, width }) => { + expect(await getImageSize({ file: fileFor(file) })).toEqual({ height, width }) }) - it.each(cases)( - 'should read $file dimensions via the probe fallback', - async ({ file, height, width }) => { - expect(await getImageSize({ file: fileFor(file) })).toEqual({ height, width }) - }, - ) - - it('should fall back to the probe when sharp cannot decode a header-only image', async () => { + it('should measure a header-only truncated image', async () => { // A 1x1 PNG truncated after IHDR: the header is readable but there is no - // pixel data, so sharp throws while the probe still measures it. + // pixel data. const data = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489', 'hex', @@ -110,7 +102,6 @@ describe('getImageSize', () => { tempFilePath: '', } as PayloadRequest['file'] - expect(await getImageSize({ file, sharp })).toEqual({ height: 1, width: 1 }) expect(await getImageSize({ file })).toEqual({ height: 1, width: 1 }) }) @@ -122,21 +113,17 @@ describe('getImageSize', () => { tempFilePath, } as PayloadRequest['file'] - expect(await getImageSize({ file, sharp })).toEqual({ height: 100, width: 200 }) expect(await getImageSize({ file })).toEqual({ height: 100, width: 200 }) }) - describe('sharp cannot decode JPEG XL', () => { + describe('JPEG XL', () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), 'payload-get-image-size-jxl-')) afterAll(() => { rmSync(tempDir, { recursive: true, force: true }) }) - it('should fall back to the probe for an in-memory JPEG XL buffer', async () => { - // `isImage('image/jxl')` is true, so `generateFileData` always calls - // `getImageSize` with `sharp` for JXL uploads; sharp rejects the format - // (libvips has no JXL codec) and the probe must take over + it('should read an in-memory JPEG XL buffer', async () => { const data = encodeJXLCodestream(1920, 1080) const file = { data, @@ -146,10 +133,10 @@ describe('getImageSize', () => { tempFilePath: '', } as PayloadRequest['file'] - expect(await getImageSize({ file, sharp })).toEqual({ height: 1080, width: 1920 }) + expect(await getImageSize({ file })).toEqual({ height: 1080, width: 1920 }) }) - it('should fall back to the probe for a JPEG XL tempFilePath', async () => { + it('should read a JPEG XL tempFilePath', async () => { const tempFilePath = path.join(tempDir, 'test.jxl') writeFileSync(tempFilePath, encodeJXLCodestream(640, 480)) @@ -159,7 +146,7 @@ describe('getImageSize', () => { tempFilePath, } as PayloadRequest['file'] - expect(await getImageSize({ file, sharp })).toEqual({ height: 480, width: 640 }) + expect(await getImageSize({ file })).toEqual({ height: 480, width: 640 }) }) }) diff --git a/packages/payload/src/uploads/getImageSize.ts b/packages/payload/src/uploads/getImageSize.ts index c6be2cfb803..6aaef432fca 100644 --- a/packages/payload/src/uploads/getImageSize.ts +++ b/packages/payload/src/uploads/getImageSize.ts @@ -1,35 +1,21 @@ -import type { SharpDependency } from '../config/types.js' import type { PayloadRequest } from '../types/index.js' import type { ProbedImageSize } from './types.js' import { probeImageSize, probeImageSizeFromPath } from './probeImageSize.js' -type Args = { +/** + * Reads an uploaded file's dimensions via the dependency-free probe. Throws + * if the buffer isn't a recognized/parseable image format — callers that + * don't already know the file is an image should catch and ignore. + */ +export async function getImageSize({ + file, +}: { file: PayloadRequest['file'] - /** - * The configured `sharp` instance, when available. Preferred for reading - * dimensions because it covers every format sharp can process. Falls back to - * the dependency-free probe when sharp is not configured. - */ - sharp?: SharpDependency -} - -export async function getImageSize({ file, sharp }: Args): Promise { +}): Promise { // `tempFilePath` may be an empty string when the file is held in memory const tempFilePath = file?.tempFilePath || undefined - if (sharp) { - try { - const { height, width } = await sharp(tempFilePath ?? file!.data).metadata() - if (width && height) { - return { height, width } - } - } catch { - // sharp decodes the full image and rejects truncated/header-only files - // that the byte-level probe can still measure, so fall through to it - } - } - if (tempFilePath) { return probeImageSizeFromPath(tempFilePath) } diff --git a/packages/payload/src/uploads/image-resizing/createImageSize.ts b/packages/payload/src/uploads/image-resizing/createImageSize.ts deleted file mode 100644 index 8dc983e6007..00000000000 --- a/packages/payload/src/uploads/image-resizing/createImageSize.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Create the result object for the image resize operation based on the - * provided parameters. If the name is not provided, an empty result object - * is returned. - * - * @param filename - the filename of the image - * @param width - the width of the image - * @param height - the height of the image - * @param filesize - the filesize of the image - * @param mimeType - the mime type of the image - * @returns a FileSize result object - */ - -import type { FileSize } from '../types.js' - -type CreateResultArgs = { - filename?: FileSize['filename'] - filesize?: FileSize['filesize'] - height?: FileSize['height'] - mimeType?: FileSize['mimeType'] - url?: FileSize['url'] - width?: FileSize['width'] -} -export const createImageSize = ({ - filename = null, - filesize = null, - height = null, - mimeType = null, - url = null, - width = null, -}: CreateResultArgs): FileSize => { - return { - filename, - filesize, - height, - mimeType, - url, - width, - } -} diff --git a/packages/payload/src/uploads/image-resizing/createImageSizes.spec.ts b/packages/payload/src/uploads/image-resizing/createImageSizes.spec.ts deleted file mode 100644 index b58142dac1c..00000000000 --- a/packages/payload/src/uploads/image-resizing/createImageSizes.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { SanitizedCollectionConfig } from '../../collections/config/types.js' -import type { PayloadRequest } from '../../types/index.js' - -import { describe, expect, it, vi } from 'vitest' - -import { createImageSizes } from './createImageSizes.js' - -const createSharpMock = () => { - const chain: any = { - metadata: vi.fn().mockResolvedValue({ height: 10, orientation: 1, width: 10 }), - rotate: vi.fn(() => chain), - } - - const sharp = vi.fn(() => chain) - - return sharp -} - -const createFile = (mimetype: string): PayloadRequest['file'] => - ({ - data: Buffer.from('original'), - mimetype, - name: 'photo', - size: 8, - }) as PayloadRequest['file'] - -// Larger than the source image, with `withoutEnlargement` left undefined, so -// `getImageResizeAction` returns `'omit'` before any resize/extract sharp calls run - the initial -// `sharp(...)` call used to probe the source image's own metadata is all that's under test here. -const config = { - upload: { - imageSizes: [{ name: 'thumb', height: 100, width: 100 }], - }, -} as unknown as SanitizedCollectionConfig - -describe('createImageSizes', () => { - it('enables sharp animated reading for tiff, matching isAnimatedImage', async () => { - const sharp = createSharpMock() - const file = createFile('image/tiff') - - await createImageSizes({ - config, - dimensions: { height: 10, width: 10 }, - file, - mimeType: 'image/tiff', - req: { payloadUploadSizes: {} } as PayloadRequest, - savedFilename: 'photo.tiff', - sharp: sharp as any, - staticPath: '/tmp', - }) - - expect(sharp).toHaveBeenCalledWith(file!.data, { animated: true }) - }) - - it('does not enable sharp animated reading for avif, matching isAnimatedImage', async () => { - const sharp = createSharpMock() - const file = createFile('image/avif') - - await createImageSizes({ - config, - dimensions: { height: 10, width: 10 }, - file, - mimeType: 'image/avif', - req: { payloadUploadSizes: {} } as PayloadRequest, - savedFilename: 'photo.avif', - sharp: sharp as any, - staticPath: '/tmp', - }) - - expect(sharp).toHaveBeenCalledWith(file!.data, {}) - }) -}) diff --git a/packages/payload/src/uploads/image-resizing/createImageSizes.ts b/packages/payload/src/uploads/image-resizing/createImageSizes.ts deleted file mode 100644 index b1360b36c46..00000000000 --- a/packages/payload/src/uploads/image-resizing/createImageSizes.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { Sharp, SharpOptions } from 'sharp' - -import { fileTypeFromBuffer } from 'file-type' -import fs from 'fs/promises' - -import type { SanitizedCollectionConfig } from '../../collections/config/types.js' -import type { SharpDependency } from '../../config/types.js' -import type { PayloadRequest } from '../../types/index.js' -import type { WithMetadata } from '../optionallyAppendMetadata.js' -import type { FileSize, FileSizes, FileToSave, FocalPoint, ProbedImageSize } from '../types.js' - -import { fileExists } from '../fileExists.js' -import { isAnimatedImage } from '../isAnimatedImage.js' -import { optionallyAppendMetadata } from '../optionallyAppendMetadata.js' -import { createImageSize } from './createImageSize.js' -import { extractHeightFromImage } from './extractHeightFromImage.js' -import { generateImageSizeFilename } from './generateImageSizeFilename.js' -import { getImageResizeAction } from './getImageResizeAction.js' -import { parseFilename } from './parseFilename.js' -import { sanitizeResizeConfig } from './sanitizeResizeConfig.js' - -/** - * For the provided image sizes, handle the resizing and the transforms - * (format, trim, etc.) of each requested image size and return the result object. - * This only handles the image sizes. The transforms of the original image - * are handled in {@link ./generateFileData.ts}. - * - * The image will be resized according to the provided - * resize config. If no image sizes are requested, the resolved data will be empty. - * For every image that does not need to be resized, a result object with `null` - * parameters will be returned. - * - * @param resizeConfig - the resize config - * @returns the result of the resize operation(s) - */ - -type ResizeArgs = { - config: SanitizedCollectionConfig - dimensions: ProbedImageSize - file: PayloadRequest['file'] - focalPoint?: FocalPoint - mimeType: string - req: PayloadRequest - savedFilename: string - sharp?: SharpDependency - staticPath: string - withMetadata?: WithMetadata -} - -/** Result from resizing and transforming the requested image sizes */ -type ImageSizesResult = { - sizeData: FileSizes - sizesToSave: FileToSave[] -} - -export async function createImageSizes({ - config, - dimensions, - file, - focalPoint, - mimeType, - req, - savedFilename, - sharp, - staticPath, - withMetadata, -}: ResizeArgs): Promise { - const { imageSizes } = config.upload - - if (!imageSizes || !sharp) { - return { - sizeData: {}, - sizesToSave: [], - } - } - - // Determine if the file is animated - const fileIsAnimatedType = isAnimatedImage(file!.mimetype) - const sharpOptions: SharpOptions = {} - - if (fileIsAnimatedType) { - sharpOptions.animated = true - } - - const sharpBase: Sharp | undefined = sharp( - file!.tempFilePath || file!.data, - sharpOptions, - ).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 - const originalImageMeta = await sharpBase.metadata() - - let adjustedDimensions = { ...dimensions } - - // Images with an exif orientation of 5, 6, 7, or 8 are auto-rotated by sharp - // Need to adjust the dimensions to match the original image - if ([5, 6, 7, 8].includes(originalImageMeta.orientation!)) { - adjustedDimensions = { - ...dimensions, - height: dimensions.width, - width: dimensions.height, - } - } - - const resizeImageMeta = { - height: originalImageMeta.height, - width: originalImageMeta.width, - } - - const sizes = {} as Record - const imageSizeFiles: FileToSave[] = [] - - await Promise.all( - imageSizes.map(async (imageResizeConfig): Promise => { - imageResizeConfig = sanitizeResizeConfig(imageResizeConfig) - - const resizeAction = getImageResizeAction({ - dimensions, - hasFocalPoint: Boolean(focalPoint), - imageResizeConfig, - }) - - if (resizeAction === 'omit') { - sizes[imageResizeConfig.name] = createImageSize({}) - return - } - - const imageToResize = sharpBase.clone() - let resized = imageToResize - - if (resizeAction === 'resizeWithFocalPoint') { - let { height: resizeHeight, width: resizeWidth } = imageResizeConfig - - const originalAspectRatio = adjustedDimensions.width / adjustedDimensions.height - - // Calculate resizeWidth based on original aspect ratio if it's undefined - if (resizeHeight && !resizeWidth) { - resizeWidth = Math.round(resizeHeight * originalAspectRatio) - } - - // Calculate resizeHeight based on original aspect ratio if it's undefined - if (resizeWidth && !resizeHeight) { - resizeHeight = Math.round(resizeWidth / originalAspectRatio) - } - - if (!resizeHeight) { - resizeHeight = resizeImageMeta.height - } - if (!resizeWidth) { - resizeWidth = resizeImageMeta.width - } - - const resizeAspectRatio = resizeWidth! / resizeHeight! - const prioritizeHeight = resizeAspectRatio < originalAspectRatio - // Scales the image before extracting from it - resized = imageToResize.resize({ - fastShrinkOnLoad: false, - height: prioritizeHeight ? resizeHeight : undefined, - width: prioritizeHeight ? undefined : resizeWidth, - }) - - const metadataAppendedFile = await optionallyAppendMetadata({ - req, - sharpFile: resized, - withMetadata: withMetadata!, - }) - - // Must read from buffer, resized.metadata will return the original image metadata - const { info } = await metadataAppendedFile.toBuffer({ resolveWithObject: true }) - - resizeImageMeta.height = extractHeightFromImage({ - ...originalImageMeta, - height: info.height, - }) - resizeImageMeta.width = info.width - - const halfResizeX = resizeWidth! / 2 - const xFocalCenter = resizeImageMeta.width * (focalPoint!.x / 100) - const calculatedRightPixelBound = xFocalCenter + halfResizeX - let leftBound = xFocalCenter - halfResizeX - - // if the right bound is greater than the image width, adjust the left bound - // keeping focus on the right - if (calculatedRightPixelBound > resizeImageMeta.width) { - leftBound = resizeImageMeta.width - resizeWidth! - } - - // if the left bound is less than 0, adjust the left bound to 0 - // keeping the focus on the left - if (leftBound < 0) { - leftBound = 0 - } - - const halfResizeY = resizeHeight! / 2 - const yFocalCenter = resizeImageMeta.height * (focalPoint!.y / 100) - const calculatedBottomPixelBound = yFocalCenter + halfResizeY - let topBound = yFocalCenter - halfResizeY - - // if the bottom bound is greater than the image height, adjust the top bound - // keeping the image as far right as possible - if (calculatedBottomPixelBound > resizeImageMeta.height) { - topBound = resizeImageMeta.height - resizeHeight! - } - - // if the top bound is less than 0, adjust the top bound to 0 - // keeping the image focus near the top - if (topBound < 0) { - topBound = 0 - } - - resized = resized.extract({ - height: resizeHeight!, - left: Math.floor(leftBound), - top: Math.floor(topBound), - width: resizeWidth!, - }) - } else { - resized = imageToResize.resize(imageResizeConfig) - } - - if (imageResizeConfig.formatOptions) { - resized = resized.toFormat( - imageResizeConfig.formatOptions.format, - imageResizeConfig.formatOptions.options, - ) - } - - if (imageResizeConfig.trimOptions) { - resized = resized.trim(imageResizeConfig.trimOptions) - } - - const metadataAppendedFile = await optionallyAppendMetadata({ - req, - sharpFile: resized, - withMetadata: withMetadata!, - }) - - const { data: bufferData, info: bufferInfo } = await metadataAppendedFile.toBuffer({ - resolveWithObject: true, - }) - - const { name, ext } = parseFilename(savedFilename) - - if (req.payloadUploadSizes) { - req.payloadUploadSizes[imageResizeConfig.name] = bufferData - } - - const mimeInfo = await fileTypeFromBuffer(bufferData) - - const imageNameWithDimensions = imageResizeConfig.generateImageName - ? imageResizeConfig.generateImageName({ - extension: mimeInfo?.ext || ext, - height: extractHeightFromImage({ - ...originalImageMeta, - height: bufferInfo.height, - }), - originalName: name, - sizeName: imageResizeConfig.name, - width: bufferInfo.width, - }) - : generateImageSizeFilename({ - extension: mimeInfo?.ext || ext, - height: extractHeightFromImage({ - ...originalImageMeta, - height: bufferInfo.height, - }), - outputImageName: name, - width: bufferInfo.width, - }) - - const imagePath = `${staticPath}/${imageNameWithDimensions}` - - if (await fileExists(imagePath)) { - try { - await fs.unlink(imagePath) - } catch { - // Ignore unlink errors - } - } - - const { height, size, width } = bufferInfo - sizes[imageResizeConfig.name] = createImageSize({ - filename: imageNameWithDimensions, - filesize: size, - height: - fileIsAnimatedType && originalImageMeta.pages ? height / originalImageMeta.pages : height, - mimeType: mimeInfo?.mime || mimeType, - width, - }) - - imageSizeFiles.push({ - buffer: bufferData, - path: imagePath, - }) - }), - ) - - return { - sizeData: sizes, - sizesToSave: imageSizeFiles, - } -} diff --git a/packages/payload/src/uploads/image-resizing/extractHeightFromImage.ts b/packages/payload/src/uploads/image-resizing/extractHeightFromImage.ts deleted file mode 100644 index a8a23040f65..00000000000 --- a/packages/payload/src/uploads/image-resizing/extractHeightFromImage.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Metadata as SharpMetadata } from 'sharp' - -/** - * Used to extract height from images, animated or not. - * - * @param sharpMetadata - the sharp metadata - * @returns the height of the image - */ -export function extractHeightFromImage(sharpMetadata: SharpMetadata): number { - if (sharpMetadata?.pages) { - return sharpMetadata.height! / sharpMetadata.pages - } - return sharpMetadata.height! -} diff --git a/packages/payload/src/uploads/image-resizing/getImageResizeAction.ts b/packages/payload/src/uploads/image-resizing/getImageResizeAction.ts deleted file mode 100644 index b1046f1555b..00000000000 --- a/packages/payload/src/uploads/image-resizing/getImageResizeAction.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { ImageSize, ProbedImageSize } from '../types.js' - -import { isNumber } from '../../utilities/isNumber.js' - -/** - * Determine whether or not to resize the image. - * - resize using image config - * - resize using image config with focal adjustments - * - do not resize at all - * - * `imageResizeConfig.withoutEnlargement`: - * - undefined [default]: uploading images with smaller width AND height than the image size will return null - * - false: always enlarge images to the image size - * - true: if the image is smaller than the image size, return the original image - * - * `imageResizeConfig.withoutReduction`: - * - false [default]: always enlarge images to the image size - * - true: if the image is smaller than the image size, return the original image - * - * @return 'omit' | 'resize' | 'resizeWithFocalPoint' - */ -export const getImageResizeAction = ({ - dimensions: originalImage, - hasFocalPoint, - imageResizeConfig, -}: { - dimensions: ProbedImageSize - hasFocalPoint?: boolean - imageResizeConfig: ImageSize -}): 'omit' | 'resize' | 'resizeWithFocalPoint' => { - const { fit, withoutEnlargement, withoutReduction } = imageResizeConfig - const targetWidth = imageResizeConfig.width! - const targetHeight = imageResizeConfig.height! - - // prevent upscaling by default when x and y are both smaller than target image size - if (targetHeight && targetWidth) { - const originalImageIsSmallerXAndY = - originalImage.width < targetWidth && originalImage.height < targetHeight - if (withoutEnlargement === undefined && originalImageIsSmallerXAndY) { - return 'omit' // prevent image size from being enlarged - } - } - - if (withoutEnlargement === undefined && (!targetWidth || !targetHeight)) { - if ( - (targetWidth && originalImage.width < targetWidth) || - (targetHeight && originalImage.height < targetHeight) - ) { - return 'omit' - } - } - - const originalImageIsSmallerXOrY = - originalImage.width < targetWidth || originalImage.height < targetHeight - if (fit === 'contain' || fit === 'inside') { - return 'resize' - } - if (!isNumber(targetHeight) && !isNumber(targetWidth)) { - return 'resize' - } - - const targetAspectRatio = targetWidth / targetHeight - const originalAspectRatio = originalImage.width / originalImage.height - if (originalAspectRatio === targetAspectRatio) { - return 'resize' - } - - if (withoutEnlargement && originalImageIsSmallerXOrY) { - return 'resize' - } - if (withoutReduction && !originalImageIsSmallerXOrY) { - return 'resize' - } - - return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize' -} diff --git a/packages/payload/src/uploads/image-resizing/sanitizeResizeConfig.ts b/packages/payload/src/uploads/image-resizing/sanitizeResizeConfig.ts deleted file mode 100644 index 1569cca567e..00000000000 --- a/packages/payload/src/uploads/image-resizing/sanitizeResizeConfig.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ImageSize } from '../types.js' - -/** - * Sanitize the resize config. If the resize config has the `withoutReduction` - * property set to true, the `fit` and `position` properties will be set to `contain` - * and `top left` respectively. - * - * @param resizeConfig - the resize config - * @returns a sanitized resize config - */ -export const sanitizeResizeConfig = (resizeConfig: ImageSize): ImageSize => { - if (resizeConfig.withoutReduction) { - return { - ...resizeConfig, - // Why fit `contain` should also be set to https://github.com/lovell/sharp/issues/3595 - fit: resizeConfig?.fit || 'contain', - position: resizeConfig?.position || 'left top', - } - } - return resizeConfig -} diff --git a/packages/payload/src/uploads/optionallyAppendMetadata.ts b/packages/payload/src/uploads/optionallyAppendMetadata.ts deleted file mode 100644 index 5788cc4d8e0..00000000000 --- a/packages/payload/src/uploads/optionallyAppendMetadata.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Sharp, Metadata as SharpMetadata } from 'sharp' - -import type { PayloadRequest } from '../types/index.js' - -export type WithMetadata = - | ((options: { metadata: SharpMetadata; req: PayloadRequest }) => Promise) - | boolean - -export async function optionallyAppendMetadata({ - req, - sharpFile, - withMetadata, -}: { - req: PayloadRequest - sharpFile: Sharp - withMetadata: WithMetadata -}): Promise { - const metadata = await sharpFile.metadata() - - if (withMetadata === true) { - return sharpFile.withMetadata() - } else if (typeof withMetadata === 'function') { - const useMetadata = await withMetadata({ metadata, req }) - - if (useMetadata) { - return sharpFile.withMetadata() - } - } - - return sharpFile -} diff --git a/packages/payload/src/uploads/image-resizing/parseFilename.ts b/packages/payload/src/uploads/parseFilename.ts similarity index 100% rename from packages/payload/src/uploads/image-resizing/parseFilename.ts rename to packages/payload/src/uploads/parseFilename.ts diff --git a/packages/payload/src/uploads/probeImageSize.ts b/packages/payload/src/uploads/probeImageSize.ts index 4b0da44cfba..5e3a4a5fa10 100644 --- a/packages/payload/src/uploads/probeImageSize.ts +++ b/packages/payload/src/uploads/probeImageSize.ts @@ -5,8 +5,8 @@ import { imageDimensionsFromData, imageDimensionsFromStream } from 'image-dimens import type { ProbedImageSize } from './types.js' /** - * Image dimension probe used as a fallback when `sharp` is not configured. - * Reads only the header bytes needed to determine width and height. + * Dependency-free image dimension probe. Reads only the header bytes needed + * to determine width and height. * * PNG, JPEG, GIF, WebP, AVIF and HEIF/HEIC are delegated to `image-dimensions` * (actively maintained, zero dependencies). BMP, ICO/CUR, SVG, TIFF and JPEG diff --git a/packages/payload/src/uploads/transformers/createLazySourceGetter.spec.ts b/packages/payload/src/uploads/transformers/createLazySourceGetter.spec.ts new file mode 100644 index 00000000000..caca83359af --- /dev/null +++ b/packages/payload/src/uploads/transformers/createLazySourceGetter.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' + +import { TransformerContractError } from '../../errors/TransformerContractError.js' +import { createLazySourceGetter } from './createLazySourceGetter.js' + +describe('createLazySourceGetter', () => { + it('should flip wasCalled() to true as soon as get() begins, before it resolves', () => { + let resolveRetrieve: (response: Response) => void = () => {} + const retrieve = vi.fn( + () => + new Promise((resolve) => { + resolveRetrieve = resolve + }), + ) + const source = createLazySourceGetter({ retrieve }) + + expect(source.wasCalled()).toBe(false) + + const pending = source.get() + + expect(source.wasCalled()).toBe(true) + + resolveRetrieve(new Response('body')) + return pending + }) + + it('should return the retrieval response from the first get() call', async () => { + const response = new Response('body') + const retrieve = vi.fn().mockResolvedValue(response) + const source = createLazySourceGetter({ retrieve }) + + await expect(source.get()).resolves.toBe(response) + }) + + it('should reject a second call with TransformerContractError while the first is still pending', async () => { + let resolveRetrieve: (response: Response) => void = () => {} + const retrieve = vi.fn( + () => + new Promise((resolve) => { + resolveRetrieve = resolve + }), + ) + const source = createLazySourceGetter({ retrieve }) + + const first = source.get() + + await expect(source.get()).rejects.toThrow(TransformerContractError) + + resolveRetrieve(new Response('body')) + await first + }) + + it('should reject a second call with TransformerContractError after the first rejected, without retrying', async () => { + const retrieve = vi.fn().mockRejectedValue(new Error('retrieval failed')) + const source = createLazySourceGetter({ retrieve }) + + await expect(source.get()).rejects.toThrow('retrieval failed') + await expect(source.get()).rejects.toThrow(TransformerContractError) + + expect(retrieve).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/payload/src/uploads/transformers/createLazySourceGetter.ts b/packages/payload/src/uploads/transformers/createLazySourceGetter.ts new file mode 100644 index 00000000000..7851b9c74bd --- /dev/null +++ b/packages/payload/src/uploads/transformers/createLazySourceGetter.ts @@ -0,0 +1,36 @@ +import { TransformerContractError } from '../../errors/TransformerContractError.js' + +export type LazySourceGetter = { + get: () => Promise + wasCalled: () => boolean +} + +/** + * Wraps a source-retrieval function so it runs at most once: the first `get()` + * call performs the retrieval, and every later call rejects with a + * `TransformerContractError` — including while the first call is still pending, + * or after it has rejected. Deliberately doesn't memoize a reusable `Response`, + * since the underlying body is normally a single-use stream. + */ +export function createLazySourceGetter({ + retrieve, +}: { + retrieve: () => Promise +}): LazySourceGetter { + let wasCalled = false + + return { + get: async () => { + if (wasCalled) { + throw new TransformerContractError( + 'A transformer stage attempted to call getSourceFile more than once.', + ) + } + + wasCalled = true + + return retrieve() + }, + wasCalled: () => wasCalled, + } +} diff --git a/packages/payload/src/uploads/transformers/finalizeFileResponse.spec.ts b/packages/payload/src/uploads/transformers/finalizeFileResponse.spec.ts new file mode 100644 index 00000000000..b2dd9986a71 --- /dev/null +++ b/packages/payload/src/uploads/transformers/finalizeFileResponse.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' + +import { uploadContentSecurityPolicy } from '../uploadContentSecurityPolicy.js' +import { finalizeFileResponse } from './finalizeFileResponse.js' + +const makeCollection = (uploadOverrides: Record = {}): Collection => + ({ + config: { + slug: 'test-media', + upload: uploadOverrides, + }, + }) as unknown as Collection + +const makeReq = (overrides: Record = {}): PayloadRequest => + ({ + method: 'GET', + payload: { config: { cors: '*' } }, + ...overrides, + }) as unknown as PayloadRequest + +describe('finalizeFileResponse', () => { + it("should not let modifyResponseHeaders override the mandatory CORS 'Access-Control-Allow-Origin' header", async () => { + const modifyResponseHeaders = vi.fn(({ headers }: { headers: Headers }) => { + headers.set('Access-Control-Allow-Origin', 'https://attacker.example.com') + return headers + }) + const collection = makeCollection({ modifyResponseHeaders }) + const response = new Response('bytes', { headers: { 'Content-Type': 'image/png' } }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + + it('should not let modifyResponseHeaders remove the upload CSP header from an SVG response', async () => { + const modifyResponseHeaders = vi.fn(({ headers }: { headers: Headers }) => { + headers.delete('Content-Security-Policy') + return headers + }) + const collection = makeCollection({ modifyResponseHeaders }) + const response = new Response('', { + headers: { 'Content-Type': 'image/svg+xml' }, + }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBe(uploadContentSecurityPolicy) + }) + + it.each([ + 'image/svg+xml; charset=utf-8', + 'Image/SVG+XML', + 'application/xhtml+xml', + 'application/xml', + 'text/xml', + 'application/rss+xml', + ])('should apply the upload CSP header to a %s response', async (contentType) => { + const collection = makeCollection() + const response = new Response('', { headers: { 'Content-Type': contentType } }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBe(uploadContentSecurityPolicy) + }) + + it('should apply the upload CSP header when modifyResponseHeaders sets an XML content type', async () => { + const modifyResponseHeaders = vi.fn(({ headers }: { headers: Headers }) => { + headers.set('Content-Type', 'application/xhtml+xml') + return headers + }) + const collection = makeCollection({ modifyResponseHeaders }) + const response = new Response('bytes', { headers: { 'Content-Type': 'image/png' } }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBe(uploadContentSecurityPolicy) + }) + + it('should keep the upload CSP header when modifyResponseHeaders relabels an XML response', async () => { + const modifyResponseHeaders = vi.fn(({ headers }: { headers: Headers }) => { + headers.set('Content-Type', 'image/png') + return headers + }) + const collection = makeCollection({ modifyResponseHeaders }) + const response = new Response('', { + headers: { 'Content-Type': 'image/svg+xml' }, + }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBe(uploadContentSecurityPolicy) + }) + + it('should not weaken the upload CSP header on a passed-through SVG source response', async () => { + const collection = makeCollection() + const response = new Response('', { + headers: { + 'Content-Security-Policy': uploadContentSecurityPolicy, + 'Content-Type': 'image/svg+xml', + }, + }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBe(uploadContentSecurityPolicy) + }) + + it('should not add a CSP header to a non-XML response', async () => { + const collection = makeCollection() + const response = new Response('bytes', { headers: { 'Content-Type': 'image/png' } }) + + const result = await finalizeFileResponse({ collection, req: makeReq(), response }) + + expect(result.headers.get('Content-Security-Policy')).toBeNull() + }) + + it('should return no body for a HEAD request while preserving status and headers', async () => { + const collection = makeCollection() + const response = new Response('bytes', { + headers: { 'Content-Type': 'image/png' }, + status: 200, + }) + + const result = await finalizeFileResponse({ + collection, + req: makeReq({ method: 'HEAD' }), + response, + }) + + expect(await result.text()).toBe('') + expect(result.status).toBe(200) + expect(result.headers.get('Content-Type')).toBe('image/png') + }) +}) diff --git a/packages/payload/src/uploads/transformers/finalizeFileResponse.ts b/packages/payload/src/uploads/transformers/finalizeFileResponse.ts new file mode 100644 index 00000000000..ce1dfc5153e --- /dev/null +++ b/packages/payload/src/uploads/transformers/finalizeFileResponse.ts @@ -0,0 +1,64 @@ +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' + +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { isXmlMimeType } from '../getFileTypeIdentity.js' +import { uploadContentSecurityPolicy } from '../uploadContentSecurityPolicy.js' + +const MANDATORY_CORS_HEADER_NAMES = [ + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Origin', + 'Access-Control-Allow-Credentials', +] + +/** + * The single header pass for the dynamic-transform response path. Unlike the + * existing `serve` path — where a collection's `modifyResponseHeaders` can still + * win on any header it touches, including CORS — mandatory CORS and security + * headers are asserted last here, so they are never overridable. + */ +export function finalizeFileResponse({ + collection, + req, + response, +}: { + collection: Collection + req: PayloadRequest + response: Response +}): Response { + const headers = new Headers(response.headers) + const sourceContentType = headers.get('Content-Type') + + const modifyResponseHeaders = collection.config.upload + ? collection.config.upload.modifyResponseHeaders + : undefined + + const modifiedHeaders = + typeof modifyResponseHeaders === 'function' + ? modifyResponseHeaders({ headers }) || headers + : headers + + // Check the content type both before and after `modifyResponseHeaders`, so the hook + // can neither introduce an XML type nor relabel one to escape the policy. + const isXml = + isXmlMimeType(sourceContentType) || isXmlMimeType(modifiedHeaders.get('Content-Type')) + + if (isXml) { + modifiedHeaders.set('Content-Security-Policy', uploadContentSecurityPolicy) + } + + for (const corsHeaderName of MANDATORY_CORS_HEADER_NAMES) { + modifiedHeaders.delete(corsHeaderName) + } + + const finalHeaders = headersWithCors({ headers: modifiedHeaders, req }) + + const body = req.method === 'HEAD' ? null : response.body + + return new Response(body, { + headers: finalHeaders, + status: response.status, + statusText: response.statusText, + }) +} diff --git a/packages/payload/src/uploads/transformers/getSourceFileResponse.ts b/packages/payload/src/uploads/transformers/getSourceFileResponse.ts new file mode 100644 index 00000000000..08274a0a1d5 --- /dev/null +++ b/packages/payload/src/uploads/transformers/getSourceFileResponse.ts @@ -0,0 +1,33 @@ +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' +import type { ResolvedUploadDocument } from './resolveUploadDocument.js' + +import { retrieveFileResponse } from '../endpoints/getFile.js' + +/** + * Payload's internal source-retrieval used by a request transformer's lazy + * `getSourceFile` getter. A thin wrapper over the same handler-loop/local-fs + * logic the ordinary `read` endpoint uses, called with `operation: 'transform'`. + */ +export async function getSourceFileResponse({ + collection, + document, + filename, + prefix, + req, +}: { + collection: Collection + document: ResolvedUploadDocument + filename: string + prefix?: string + req: PayloadRequest +}): Promise { + return retrieveFileResponse({ + collection, + doc: document, + filename, + operation: 'transform', + prefix, + req, + }) +} diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts new file mode 100644 index 00000000000..b2746704d4d --- /dev/null +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./resolveUploadDocument.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveUploadDocument: vi.fn(), +})) + +vi.mock('./planTransformerPipeline.js', async (importOriginal) => ({ + ...(await importOriginal()), + planTransformerPipeline: vi.fn(), +})) + +vi.mock('../checkFileAccess.js', () => ({ + checkFileAccess: vi.fn(), +})) + +vi.mock('../endpoints/getFile.js', () => ({ + retrieveFileResponse: vi.fn(), +})) + +vi.mock('./getSourceFileResponse.js', () => ({ + getSourceFileResponse: vi.fn(), +})) + +vi.mock('./finalizeFileResponse.js', () => ({ + finalizeFileResponse: vi.fn(({ response }) => response), +})) + +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' +import type { UploadTransformer } from './types.js' + +import { Forbidden } from '../../errors/Forbidden.js' +import { NotFound } from '../../errors/NotFound.js' +import { checkFileAccess } from '../checkFileAccess.js' +import { retrieveFileResponse } from '../endpoints/getFile.js' +import { getSourceFileResponse } from './getSourceFileResponse.js' +import { handleDynamicFileRequest } from './handleDynamicFileRequest.js' +import { planTransformerPipeline } from './planTransformerPipeline.js' +import { resolveUploadDocument } from './resolveUploadDocument.js' + +// Filenames are only unique per storage prefix, so the unfiltered lookup can match a +// document the user can't read. That needs a prefix-aware storage adapter, which the +// local-disk integration suite can't reproduce. +const otherTenantDocument = { id: '2', filename: 'logo.png', mimeType: 'image/png', prefix: 'bob' } +const authorizedDocument = { id: '1', filename: 'logo.png', mimeType: 'image/png', prefix: 'alice' } + +const collection = { + config: { slug: 'media', access: { read: vi.fn() }, upload: {} }, +} as unknown as Collection + +const makeReq = (transformers: UploadTransformer[] = []): PayloadRequest => + ({ + payload: { + config: { upload: { transformers } }, + logger: { error: vi.fn() }, + }, + }) as unknown as PayloadRequest + +const actualPlanTransformerPipeline = ( + await vi.importActual( + './planTransformerPipeline.js', + ) +).planTransformerPipeline + +const events: string[] = [] + +const mockReadAccess = ({ plain, transform }: { plain: boolean; transform: boolean }) => { + vi.mocked(checkFileAccess).mockImplementation(async ({ req }) => { + const isTransform = req.fileTransform === true + + events.push(isTransform ? 'access:transform' : 'access:plain') + + if (!(isTransform ? transform : plain)) { + throw new Forbidden() + } + + return undefined + }) +} + +const makeTransformer = ({ canTransform }: { canTransform: boolean }): UploadTransformer => ({ + slug: 'test-transformer', + canTransform: vi.fn(({ req }) => { + events.push(req.fileTransform ? 'canTransform:flagged' : 'canTransform') + return canTransform + }), + handleRequest: vi.fn().mockImplementation(async ({ getSourceFile }) => ({ + response: await getSourceFile(), + status: 'complete', + })), + mimeTypes: ['image/*'], +}) + +describe('handleDynamicFileRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + vi.mocked(planTransformerPipeline).mockImplementation(actualPlanTransformerPipeline) + vi.mocked(resolveUploadDocument).mockResolvedValue(otherTenantDocument) + vi.mocked(checkFileAccess).mockResolvedValue(authorizedDocument) + vi.mocked(getSourceFileResponse).mockResolvedValue(new Response('source-bytes')) + vi.mocked(retrieveFileResponse).mockResolvedValue(new Response('original-bytes')) + }) + + it('should serve the access-checked document when the unfiltered lookup matched another document with the same filename', async () => { + vi.mocked(planTransformerPipeline).mockResolvedValue([]) + + await handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq() }) + + expect(retrieveFileResponse).toHaveBeenCalledWith( + expect.objectContaining({ doc: authorizedDocument }), + ) + }) + + it('should transform the access-checked document when the unfiltered lookup matched another document with the same filename', async () => { + const transformer: UploadTransformer = { + slug: 'test-transformer', + handleRequest: vi.fn().mockImplementation(async ({ getSourceFile }) => ({ + response: await getSourceFile(), + status: 'complete', + })), + mimeTypes: ['image/*'], + } + vi.mocked(planTransformerPipeline).mockResolvedValue([transformer]) + + await handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq() }) + + expect(getSourceFileResponse).toHaveBeenCalledWith( + expect.objectContaining({ document: authorizedDocument }), + ) + expect(transformer.handleRequest).toHaveBeenCalledWith( + expect.objectContaining({ documentID: authorizedDocument.id }), + ) + }) + + describe('access control ordering', () => { + beforeEach(() => { + vi.mocked(resolveUploadDocument).mockResolvedValue(authorizedDocument) + }) + + it('should not run canTransform or handleRequest when read access is denied in both modes', async () => { + mockReadAccess({ plain: false, transform: false }) + const transformer = makeTransformer({ canTransform: true }) + + await expect( + handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq([transformer]) }), + ).rejects.toBeInstanceOf(Forbidden) + + expect(transformer.canTransform).not.toHaveBeenCalled() + expect(transformer.handleRequest).not.toHaveBeenCalled() + expect(events).toEqual(['access:transform', 'access:plain']) + }) + + it('should run transform-aware access before canTransform and transform when allowed', async () => { + mockReadAccess({ plain: false, transform: true }) + const transformer = makeTransformer({ canTransform: true }) + + await handleDynamicFileRequest({ + collection, + filename: 'logo.png', + req: makeReq([transformer]), + }) + + expect(events).toEqual(['access:transform', 'canTransform']) + expect(transformer.handleRequest).toHaveBeenCalled() + }) + + it('should serve the original when transform-aware access is denied, ordinary read is allowed and no transformer applies', async () => { + mockReadAccess({ plain: true, transform: false }) + const transformer = makeTransformer({ canTransform: false }) + + await handleDynamicFileRequest({ + collection, + filename: 'logo.png', + req: makeReq([transformer]), + }) + + expect(events).toEqual(['access:transform', 'access:plain', 'canTransform']) + expect(retrieveFileResponse).toHaveBeenCalled() + }) + + it('should reject a transform when only ordinary read access is allowed', async () => { + mockReadAccess({ plain: true, transform: false }) + const transformer = makeTransformer({ canTransform: true }) + + await expect( + handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq([transformer]) }), + ).rejects.toBeInstanceOf(Forbidden) + + expect(transformer.handleRequest).not.toHaveBeenCalled() + }) + + it('should not serve the original when only transform-aware access is allowed and no transformer applies', async () => { + mockReadAccess({ plain: false, transform: true }) + const transformer = makeTransformer({ canTransform: false }) + + await expect( + handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq([transformer]) }), + ).rejects.toBeInstanceOf(Forbidden) + + expect(events).toEqual(['access:transform', 'canTransform', 'access:plain']) + expect(retrieveFileResponse).not.toHaveBeenCalled() + }) + + it('should use only an ordinary read access check when no transformer matches the MIME type', async () => { + mockReadAccess({ plain: true, transform: false }) + const transformer = { ...makeTransformer({ canTransform: true }), mimeTypes: ['video/*'] } + + await handleDynamicFileRequest({ + collection, + filename: 'logo.png', + req: makeReq([transformer]), + }) + + expect(events).toEqual(['access:plain']) + expect(transformer.canTransform).not.toHaveBeenCalled() + expect(retrieveFileResponse).toHaveBeenCalled() + }) + + it('should return not found for a missing file only when both access modes allow it', async () => { + vi.mocked(resolveUploadDocument).mockResolvedValue(undefined) + mockReadAccess({ plain: true, transform: true }) + + await expect( + handleDynamicFileRequest({ collection, filename: 'missing.png', req: makeReq() }), + ).rejects.toBeInstanceOf(NotFound) + + mockReadAccess({ plain: false, transform: true }) + + await expect( + handleDynamicFileRequest({ collection, filename: 'missing.png', req: makeReq() }), + ).rejects.toBeInstanceOf(Forbidden) + }) + }) +}) diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts new file mode 100644 index 00000000000..c210efb8075 --- /dev/null +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -0,0 +1,236 @@ +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' +import type { ResolvedUploadDocument } from './resolveUploadDocument.js' +import type { UploadTransformer } from './types.js' + +import { Forbidden } from '../../errors/Forbidden.js' +import { NotFound } from '../../errors/NotFound.js' +import { TransformerContractError } from '../../errors/TransformerContractError.js' +import { checkFileAccess } from '../checkFileAccess.js' +import { retrieveFileResponse } from '../endpoints/getFile.js' +import { createLazySourceGetter } from './createLazySourceGetter.js' +import { finalizeFileResponse } from './finalizeFileResponse.js' +import { getSourceFileResponse } from './getSourceFileResponse.js' +import { getCandidateTransformers, planTransformerPipeline } from './planTransformerPipeline.js' +import { getRequestedFile, resolveUploadDocument } from './resolveUploadDocument.js' +import { withFileTransformAccessContext } from './withFileTransformAccessContext.js' + +/** + * Orchestrates a dynamic file request end to end: resolve the document, plan the + * request-capable transformer pipeline from the requested file's authoritative + * MIME type (the primary file or the matched image size), enforce + * transform-aware read access, then run every eligible transformer in declaration + * order against a lazily-fetched source. Called from `getFileHandler` only when at + * least one transformer is configured — the zero-transformer path never reaches + * this function. + */ +export async function handleDynamicFileRequest({ + collection, + filename, + prefix, + req, +}: { + collection: Collection + filename: string + prefix?: string + req: PayloadRequest +}): Promise { + const resolvedDocument = await resolveUploadDocument({ collection, filename, prefix, req }) + + if (!resolvedDocument) { + // There's no mimeType to plan a pipeline from, so both access modes must pass + // before admitting the file doesn't exist — otherwise the status code would leak + // file existence to an access function keyed on req.fileTransform. + for (const isTransform of [true, false]) { + await withFileTransformAccessContext({ + callback: () => checkFileAccess({ collection, filename, prefix, req }), + isTransform, + req, + }) + } + throw new NotFound(req.t) + } + + const { document, pipeline } = await authorizeDocument({ + collection, + filename, + prefix, + req, + resolvedDocument, + }) + + const requestedFile = getRequestedFile({ document, filename }) + + const source = createLazySourceGetter({ + retrieve: () => getSourceFileResponse({ collection, document, filename, prefix, req }), + }) + + let currentResponse: Response | undefined + + try { + for (const transformer of pipeline) { + const stageSource = createLazySourceGetter({ + retrieve: async () => currentResponse ?? source.get(), + }) + + const result = await transformer.handleRequest!({ + collectionSlug: collection.config.slug, + documentID: document.id, + filename: requestedFile.filename, + getSourceFile: stageSource.get, + mimeType: requestedFile.mimeType, + req, + }) + + if (result.response) { + currentResponse = result.response + } else if (stageSource.wasCalled()) { + throw new TransformerContractError( + 'A transformer that consumes its source must return a response.', + ) + } + + if (result.status === 'complete') { + return finalizeFileResponse({ collection, req, response: currentResponse! }) + } + } + } catch (err) { + req.payload.logger.error({ err, msg: 'Error running the file transformer pipeline' }) + throw err + } + + if (currentResponse) { + return finalizeFileResponse({ collection, req, response: currentResponse }) + } + + // No transformer produced a response — serve the original file through the + // normal path (Range/ETag/redirect support, existing `modifyResponseHeaders` order). + return retrieveFileResponse({ collection, doc: document, filename, prefix, req }) +} + +function planRequestPipeline({ + collection, + document, + filename, + req, +}: { + collection: Collection + document: ResolvedUploadDocument + filename: string + req: PayloadRequest +}): Promise { + return planTransformerPipeline({ + args: { + collectionSlug: collection.config.slug, + documentID: document.id, + mimeType: getRequestedFile({ document, filename }).mimeType, + operation: 'request', + req, + }, + capability: 'handleRequest', + transformers: req.payload.config.upload.transformers, + }) +} + +type AccessResult = { document?: ResolvedUploadDocument; isAllowed: true } | { isAllowed: false } + +/** + * Decides which document is served and which pipeline runs, checking read access + * before any application-defined transformer code (`canTransform`) executes. + * + * 1. The first access mode comes from MIME matching alone: a transform-aware check + * when any transformer could handle the file, otherwise an ordinary read. + * 2. If the transform-aware check is denied, an ordinary read may still be allowed + * (e.g. public originals, signed-in-only variants), so that mode is tried next. + * 3. Only once a check has passed does `canTransform` plan the real pipeline. + * 4. The access mode the final pipeline requires must itself have passed — a + * "variants only" policy must not leak the original when no transformer applies. + * + * `resolveUploadDocument` is unfiltered, so without a `prefix` it can match a + * document the user can't read that shares the filename (filenames are only + * unique per prefix). Whenever `checkFileAccess` returns a document, that + * authorized document is the one served — storage adapters trust `doc` to + * resolve the object key. + */ +async function authorizeDocument({ + collection, + filename, + prefix, + req, + resolvedDocument, +}: { + collection: Collection + filename: string + prefix?: string + req: PayloadRequest + resolvedDocument: ResolvedUploadDocument +}): Promise<{ document: ResolvedUploadDocument; pipeline: UploadTransformer[] }> { + const accessResults = new Map>() + + const checkAccess = ({ isTransform }: { isTransform: boolean }): Promise => { + if (!accessResults.has(isTransform)) { + accessResults.set( + isTransform, + withFileTransformAccessContext({ + callback: () => checkFileAccess({ collection, filename, prefix, req }), + isTransform, + req, + }).then( + (document) => ({ + document: document as ResolvedUploadDocument | undefined, + isAllowed: true as const, + }), + (err: unknown) => { + if (err instanceof Forbidden) { + return { isAllowed: false as const } + } + + throw err + }, + ), + ) + } + + return accessResults.get(isTransform)! + } + + const hasCandidateTransformers = + getCandidateTransformers({ + capability: 'handleRequest', + mimeType: getRequestedFile({ document: resolvedDocument, filename }).mimeType, + transformers: req.payload.config.upload.transformers, + }).length > 0 + + let isTransformAccess = hasCandidateTransformers + let access = await checkAccess({ isTransform: isTransformAccess }) + + if (!access.isAllowed && isTransformAccess) { + isTransformAccess = false + access = await checkAccess({ isTransform: isTransformAccess }) + } + + if (!access.isAllowed) { + throw new Forbidden(req.t) + } + + const document = + access.document && access.document.id !== resolvedDocument.id + ? access.document + : resolvedDocument + + const pipeline = await planRequestPipeline({ collection, document, filename, req }) + const isTransform = pipeline.length > 0 + + if (isTransform !== isTransformAccess) { + const requiredAccess = await checkAccess({ isTransform }) + + if ( + !requiredAccess.isAllowed || + (requiredAccess.document && requiredAccess.document.id !== document.id) + ) { + throw new Forbidden(req.t) + } + } + + return { document, pipeline } +} diff --git a/packages/payload/src/uploads/transformers/matchesMimeType.spec.ts b/packages/payload/src/uploads/transformers/matchesMimeType.spec.ts new file mode 100644 index 00000000000..569ff5219f7 --- /dev/null +++ b/packages/payload/src/uploads/transformers/matchesMimeType.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { matchesMimeType } from './matchesMimeType.js' + +describe('matchesMimeType', () => { + it('should match an exact MIME type', () => { + expect(matchesMimeType({ mimeType: 'image/png', pattern: 'image/png' })).toBe(true) + }) + + it('should not match a different exact MIME type', () => { + expect(matchesMimeType({ mimeType: 'image/jpeg', pattern: 'image/png' })).toBe(false) + }) + + it('should match a category wildcard pattern against any subtype of that category', () => { + expect(matchesMimeType({ mimeType: 'image/png', pattern: 'image/*' })).toBe(true) + expect(matchesMimeType({ mimeType: 'image/jpeg', pattern: 'image/*' })).toBe(true) + }) + + it('should not match a category wildcard pattern against a different category', () => { + expect(matchesMimeType({ mimeType: 'video/mp4', pattern: 'image/*' })).toBe(false) + }) + + it('should match the universal wildcard against any well-formed MIME type', () => { + expect(matchesMimeType({ mimeType: 'video/mp4', pattern: '*/*' })).toBe(true) + expect(matchesMimeType({ mimeType: 'application/pdf', pattern: '*/*' })).toBe(true) + }) + + it('should normalize case before comparing', () => { + expect(matchesMimeType({ mimeType: 'IMAGE/PNG', pattern: 'image/png' })).toBe(true) + expect(matchesMimeType({ mimeType: 'image/png', pattern: 'IMAGE/*' })).toBe(true) + }) + + it('should normalize surrounding whitespace before comparing', () => { + expect(matchesMimeType({ mimeType: ' image/png ', pattern: ' image/png ' })).toBe(true) + }) + + it('should return false for a pattern with no slash', () => { + expect(matchesMimeType({ mimeType: 'image/png', pattern: 'image' })).toBe(false) + }) + + it('should return false for a subtype-only wildcard pattern (wildcard type with a concrete subtype)', () => { + expect(matchesMimeType({ mimeType: 'image/png', pattern: '*/png' })).toBe(false) + }) + + it('should return false for an empty pattern', () => { + expect(matchesMimeType({ mimeType: 'image/png', pattern: '' })).toBe(false) + }) + + it('should return false when the MIME type has no slash and the pattern is not the universal wildcard', () => { + expect(matchesMimeType({ mimeType: 'notamimetype', pattern: 'image/*' })).toBe(false) + }) + + it('should defensively return false rather than throw for non-string input', () => { + expect(matchesMimeType({ mimeType: undefined as unknown as string, pattern: 'image/*' })).toBe( + false, + ) + expect( + matchesMimeType({ mimeType: 'image/png', pattern: undefined as unknown as string }), + ).toBe(false) + }) +}) diff --git a/packages/payload/src/uploads/transformers/matchesMimeType.ts b/packages/payload/src/uploads/transformers/matchesMimeType.ts new file mode 100644 index 00000000000..61b13686ef7 --- /dev/null +++ b/packages/payload/src/uploads/transformers/matchesMimeType.ts @@ -0,0 +1,56 @@ +function normalize(value: string): string { + return value.trim().toLowerCase() +} + +/** + * Matches an authoritative MIME type against a transformer-declared MIME pattern: + * an exact value, a category wildcard (`image/*`), or the universal wildcard (matching + * every type and subtype). Defensive by design — malformed input returns `false` + * rather than throwing; startup validation owns rejecting malformed configured patterns. + */ +export function matchesMimeType({ + mimeType, + pattern, +}: { + mimeType: string + pattern: string +}): boolean { + if (typeof pattern !== 'string' || typeof mimeType !== 'string') { + return false + } + + const normalizedPattern = normalize(pattern) + const normalizedMimeType = normalize(mimeType) + + if (normalizedPattern === '*/*') { + return true + } + + const patternParts = normalizedPattern.split('/') + + if (patternParts.length !== 2 || patternParts.some((part) => part.length === 0)) { + return false + } + + const [patternType, patternSubtype] = patternParts + + // "*/*" is the only valid full wildcard — a wildcard type with a concrete + // subtype (e.g. "*/png") is invalid. + if (patternType === '*') { + return false + } + + const mimeParts = normalizedMimeType.split('/') + + if (mimeParts.length !== 2) { + return false + } + + const [mimeTypeSegment, mimeSubtypeSegment] = mimeParts + + if (patternType !== mimeTypeSegment) { + return false + } + + return patternSubtype === '*' || patternSubtype === mimeSubtypeSegment +} diff --git a/packages/payload/src/uploads/transformers/planTransformerPipeline.spec.ts b/packages/payload/src/uploads/transformers/planTransformerPipeline.spec.ts new file mode 100644 index 00000000000..038b8358df6 --- /dev/null +++ b/packages/payload/src/uploads/transformers/planTransformerPipeline.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { CanTransformArgs, UploadTransformer } from './types.js' + +import { planTransformerPipeline } from './planTransformerPipeline.js' + +const makeArgs = (overrides: Partial = {}): CanTransformArgs => ({ + collectionSlug: 'media', + mimeType: 'image/png', + operation: 'request', + req: {} as CanTransformArgs['req'], + ...overrides, +}) + +const makeTransformer = (overrides: Partial = {}): UploadTransformer => ({ + mimeTypes: ['image/*'], + slug: 'test-transformer', + ...overrides, +}) + +describe('planTransformerPipeline', () => { + it('should only include transformers that implement the requested capability', async () => { + const requestOnly = makeTransformer({ handleRequest: vi.fn(), slug: 'request-only' }) + const fileOnly = makeTransformer({ slug: 'file-only', transformFile: vi.fn() }) + const transformers = [requestOnly, fileOnly] + + const requestPipeline = await planTransformerPipeline({ + args: makeArgs(), + capability: 'handleRequest', + transformers, + }) + const filePipeline = await planTransformerPipeline({ + args: makeArgs({ operation: 'upload' }), + capability: 'transformFile', + transformers, + }) + + expect(requestPipeline).toEqual([requestOnly]) + expect(filePipeline).toEqual([fileOnly]) + }) + + it('should check the MIME type before calling canTransform', async () => { + const canTransform = vi.fn().mockResolvedValue(true) + const transformer = makeTransformer({ + canTransform, + handleRequest: vi.fn(), + mimeTypes: ['video/*'], + }) + + const pipeline = await planTransformerPipeline({ + args: makeArgs({ mimeType: 'image/png' }), + capability: 'handleRequest', + transformers: [transformer], + }) + + expect(pipeline).toEqual([]) + expect(canTransform).not.toHaveBeenCalled() + }) + + it('should reject planning without evaluating later transformers when canTransform throws', async () => { + const thirdCanTransform = vi.fn().mockResolvedValue(true) + const first = makeTransformer({ handleRequest: vi.fn(), slug: 'first' }) + const second = makeTransformer({ + canTransform: vi.fn().mockRejectedValue(new Error('boom')), + handleRequest: vi.fn(), + slug: 'second', + }) + const third = makeTransformer({ + canTransform: thirdCanTransform, + handleRequest: vi.fn(), + slug: 'third', + }) + + await expect( + planTransformerPipeline({ + args: makeArgs(), + capability: 'handleRequest', + transformers: [first, second, third], + }), + ).rejects.toThrow('boom') + + expect(thirdCanTransform).not.toHaveBeenCalled() + }) +}) diff --git a/packages/payload/src/uploads/transformers/planTransformerPipeline.ts b/packages/payload/src/uploads/transformers/planTransformerPipeline.ts new file mode 100644 index 00000000000..bf7ebb7047a --- /dev/null +++ b/packages/payload/src/uploads/transformers/planTransformerPipeline.ts @@ -0,0 +1,63 @@ +import type { CanTransformArgs, UploadTransformer } from './types.js' + +import { matchesMimeType } from './matchesMimeType.js' + +/** + * Builds the fixed, ordered list of transformers eligible for one upload or + * dynamic-request operation. Pure and side-effect-free beyond invoking each + * candidate's own `canTransform`: it never calls `transformFile`/`handleRequest` + * and performs no storage or access work. A thrown/rejected `canTransform` aborts + * planning immediately rather than being treated as `false`. + */ +export async function planTransformerPipeline({ + args, + capability, + transformers, +}: { + args: CanTransformArgs + capability: TransformerCapability + transformers: UploadTransformer[] +}): Promise { + const pipeline: UploadTransformer[] = [] + + for (const transformer of getCandidateTransformers({ + capability, + mimeType: args.mimeType, + transformers, + })) { + if (typeof transformer.canTransform === 'function') { + const isEligible = await transformer.canTransform(args) + + if (!isEligible) { + continue + } + } + + pipeline.push(transformer) + } + + return pipeline +} + +/** + * The transformers that implement `capability` and declare a MIME pattern matching + * `mimeType`, before `canTransform` narrows them. Runs no application-defined code, + * so it is safe to call before access control has been checked. + */ +export function getCandidateTransformers({ + capability, + mimeType, + transformers, +}: { + capability: TransformerCapability + mimeType: string + transformers: UploadTransformer[] +}): UploadTransformer[] { + return transformers.filter( + (transformer) => + typeof transformer[capability] === 'function' && + transformer.mimeTypes.some((pattern) => matchesMimeType({ mimeType, pattern })), + ) +} + +type TransformerCapability = 'handleRequest' | 'transformFile' diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts new file mode 100644 index 00000000000..e763e3a4049 --- /dev/null +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Collection } from '../../collections/config/types.js' +import type { PayloadRequest } from '../../types/index.js' + +import { getRequestedFile, resolveUploadDocument } from './resolveUploadDocument.js' + +describe('resolveUploadDocument', () => { + it('should reject path traversal in the filename without querying the database', async () => { + const findOne = vi.fn() + const req = { payload: { db: { findOne } }, t: vi.fn() } as unknown as PayloadRequest + const collection = { config: { slug: 'test-media', upload: {} } } as unknown as Collection + + await expect( + resolveUploadDocument({ collection, filename: '../etc/passwd', req }), + ).rejects.toThrow() + expect(findOne).not.toHaveBeenCalled() + }) +}) + +describe('getRequestedFile', () => { + const document = { + id: '1', + filename: 'logo.png', + mimeType: 'image/png', + sizes: { + card: { filename: 'logo-640x480.webp', mimeType: 'image/webp' }, + skipped: { filename: null, mimeType: null }, + }, + } + + it('should return the primary file when the primary filename is requested', () => { + expect(getRequestedFile({ document, filename: 'logo.png' })).toEqual({ + filename: 'logo.png', + mimeType: 'image/png', + }) + }) + + it('should return the matched image size filename and mimeType', () => { + expect(getRequestedFile({ document, filename: 'logo-640x480.webp' })).toEqual({ + filename: 'logo-640x480.webp', + mimeType: 'image/webp', + }) + }) + + it('should fall back to the primary file when no image size matches', () => { + expect(getRequestedFile({ document, filename: 'unknown.png' })).toEqual({ + filename: 'logo.png', + mimeType: 'image/png', + }) + }) +}) diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts new file mode 100644 index 00000000000..089c766419f --- /dev/null +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -0,0 +1,98 @@ +import type { Collection, TypeWithID } from '../../collections/config/types.js' +import type { PayloadRequest, Where } from '../../types/index.js' + +import { Forbidden } from '../../errors/Forbidden.js' + +export type ResolvedUploadDocument = { + filename: string + mimeType: string + sizes?: Record +} & TypeWithID + +/** + * The primary filename, or a configured legacy image size's filename, both match. + * Shared with `checkFileAccess.ts` so the two lookups can't drift apart. + */ +export function buildFilenameWhere({ + filename, + imageSizes, +}: { + filename: string + imageSizes?: { name: string }[] +}): Where { + const filenameCondition: Where = { + or: [{ filename: { equals: filename } }], + } + + imageSizes?.forEach(({ name }) => { + filenameCondition.or!.push({ + [`sizes.${name}.filename`]: { equals: filename }, + }) + }) + + return filenameCondition +} + +/** + * The `filename` and `mimeType` of the file a request actually targets: the + * primary file, or the legacy image size whose filename matched (a size can be + * stored in a different format than the primary file via `formatOptions`). + */ +export function getRequestedFile({ + document, + filename, +}: { + document: ResolvedUploadDocument + filename: string +}): { filename: string; mimeType: string } { + if (document.filename !== filename) { + const size = Object.values(document.sizes ?? {}).find((size) => size?.filename === filename) + + if (size?.filename && size.mimeType) { + return { filename: size.filename, mimeType: size.mimeType } + } + } + + return { filename: document.filename, mimeType: document.mimeType } +} + +/** + * Finds the upload document matching a filename (or one of its configured legacy + * image sizes) and an optional storage `prefix`, without applying any access-control + * filtering. Runs unconditionally — no `constraints.length` fast path — because + * it's only ever called when at least one transformer is configured, and pipeline + * planning needs the document's authoritative `mimeType` before access is enforced. + */ +export async function resolveUploadDocument({ + collection, + filename, + prefix, + req, +}: { + collection: Collection + filename: string + prefix?: string + req: PayloadRequest +}): Promise { + if (filename.includes('../') || filename.includes('..\\')) { + throw new Forbidden(req.t) + } + + const { config } = collection + + const constraints: Where[] = [ + buildFilenameWhere({ filename, imageSizes: config.upload.imageSizes }), + ] + + if (typeof prefix === 'string') { + constraints.push({ prefix: { equals: prefix } }) + } + + const doc = await req.payload.db.findOne({ + collection: config.slug, + req, + where: constraints.length > 1 ? { and: constraints } : constraints[0], + }) + + return (doc as null | ResolvedUploadDocument) ?? undefined +} diff --git a/packages/payload/src/uploads/transformers/transformUploadFile.spec.ts b/packages/payload/src/uploads/transformers/transformUploadFile.spec.ts new file mode 100644 index 00000000000..5aeef9e57e0 --- /dev/null +++ b/packages/payload/src/uploads/transformers/transformUploadFile.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PayloadRequest } from '../../types/index.js' +import type { UploadTransformer } from './types.js' + +import { transformUploadFile } from './transformUploadFile.js' + +const makeReq = (): PayloadRequest => ({}) as unknown as PayloadRequest + +const makeTransformer = (overrides: Partial = {}): UploadTransformer => ({ + mimeTypes: ['image/*'], + slug: 'test-transformer', + ...overrides, +}) + +describe('transformUploadFile', () => { + it('should replace the file and pass it to the next stage when a stage returns continue with a file', async () => { + const replacement = new File(['replaced'], 'logo.png') + const first = makeTransformer({ + slug: 'first', + transformFile: vi.fn().mockResolvedValue({ file: replacement, status: 'continue' }), + }) + const second = makeTransformer({ + slug: 'second', + transformFile: vi.fn().mockResolvedValue({ status: 'continue' }), + }) + + const result = await transformUploadFile({ + collectionSlug: 'media', + file: new File(['original'], 'logo.png'), + options: undefined, + pipeline: [first, second], + req: makeReq(), + }) + + expect(second.transformFile).toHaveBeenCalledWith( + expect.objectContaining({ file: replacement }), + ) + expect(result).toBe(replacement) + }) + + it('should replace the file and stop the pipeline when a stage returns complete', async () => { + const completeFile = new File(['done'], 'logo.png') + const first = makeTransformer({ + slug: 'first', + transformFile: vi.fn().mockResolvedValue({ file: completeFile, status: 'complete' }), + }) + const second = makeTransformer({ slug: 'second', transformFile: vi.fn() }) + + const result = await transformUploadFile({ + collectionSlug: 'media', + file: new File(['original'], 'logo.png'), + options: undefined, + pipeline: [first, second], + req: makeReq(), + }) + + expect(result).toBe(completeFile) + expect(second.transformFile).not.toHaveBeenCalled() + }) + + it('should propagate a thrown error without calling later stages', async () => { + const first = makeTransformer({ + slug: 'first', + transformFile: vi.fn().mockRejectedValue(new Error('transform failed')), + }) + const second = makeTransformer({ slug: 'second', transformFile: vi.fn() }) + + await expect( + transformUploadFile({ + collectionSlug: 'media', + file: new File(['original'], 'logo.png'), + options: undefined, + pipeline: [first, second], + req: makeReq(), + }), + ).rejects.toThrow('transform failed') + + expect(second.transformFile).not.toHaveBeenCalled() + }) +}) diff --git a/packages/payload/src/uploads/transformers/transformUploadFile.ts b/packages/payload/src/uploads/transformers/transformUploadFile.ts new file mode 100644 index 00000000000..532353a2162 --- /dev/null +++ b/packages/payload/src/uploads/transformers/transformUploadFile.ts @@ -0,0 +1,49 @@ +import type { PayloadRequest } from '../../types/index.js' +import type { UploadTransformer } from './types.js' + +/** + * Runs one file through every eligible `transformFile` stage in declaration + * order. Used both for the ordinary top-level upload call (`options: undefined`) + * and, via the private v4 bridge's `transform` callback, once per legacy task + * (the main file, crop output, or a named image size) with that task's own + * `options`. Retains only the current accumulator — never a history array — + * so passing the same `File` through unchanged never copies its buffer. + */ +export async function transformUploadFile({ + collectionSlug, + file, + options, + pipeline, + req, +}: { + collectionSlug: string + file: File + options: unknown + pipeline: UploadTransformer[] + req: PayloadRequest +}): Promise { + let accumulator = file + + for (const transformer of pipeline) { + if (typeof transformer.transformFile !== 'function') { + continue + } + + const result = await transformer.transformFile({ + collectionSlug, + file: accumulator, + options, + req, + }) + + if (result.file) { + accumulator = result.file + } + + if (result.status === 'complete') { + return accumulator + } + } + + return accumulator +} diff --git a/packages/payload/src/uploads/transformers/types.ts b/packages/payload/src/uploads/transformers/types.ts new file mode 100644 index 00000000000..f7be253c112 --- /dev/null +++ b/packages/payload/src/uploads/transformers/types.ts @@ -0,0 +1,80 @@ +import type { Config } from '../../config/types.js' +import type { PayloadRequest } from '../../types/index.js' + +export type CanTransformArgs = { + collectionSlug: string + documentID?: number | string + mimeType: string + operation: 'request' | 'upload' + req: PayloadRequest +} + +export type TransformFileArgs = { + collectionSlug: string + file: File + options: TOptions + req: PayloadRequest +} + +export type TransformFileResult = + | { + file: File + status: 'complete' + } + | { + file?: File + status: 'continue' + } + +export type HandleTransformRequestArgs = { + collectionSlug: string + documentID: number | string + filename: string + getSourceFile: () => Promise + mimeType: string + req: PayloadRequest +} + +export type HandleTransformRequestResult = + | { + response: Response + status: 'complete' + } + | { + response?: Response + status: 'continue' + } + +/** + * A capability adapter that can transform files during upload, handle dynamic + * file requests, or both. Configured only under `upload.transformers`. + */ +export type UploadTransformer = { + /** + * Inexpensive, side-effect-free routing predicate. Must not fetch the source, + * call an external service, or perform the transformation. + */ + canTransform?: (args: CanTransformArgs) => boolean | Promise + /** + * Handles one stage of a dynamic transformation request. + */ + handleRequest?: (args: HandleTransformRequestArgs) => Promise + /** + * Runs once during `buildConfig`, between the `plugins` loop and storage-adapter + * `init()`, for configuration validation and transformer-specific setup. + */ + init?: (config: Config) => Config | Promise + /** + * MIME patterns this transformer supports: exact values (`image/png`), a category + * wildcard (`image/*`), or the universal wildcard matching every type and subtype. + */ + mimeTypes: string[] + /** + * Must be unique across `upload.transformers`. + */ + slug: string + /** + * One-file-in, one-file-out upload processing primitive. Never writes to storage. + */ + transformFile?: (args: TransformFileArgs) => Promise +} diff --git a/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts new file mode 100644 index 00000000000..532229048f3 --- /dev/null +++ b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts @@ -0,0 +1,47 @@ +import type { PayloadRequest } from '../../types/index.js' +import type { UploadEdits } from '../types.js' + +/** + * Private v4 compatibility bridge, attached to a transformer object under this + * symbol. Lets the official Sharp package reproduce the current main-image and + * legacy `sizes` upload behavior without core exposing a general persisted- + * variants API. Never exported from `payload` — `payload/internal` only, and + * never documented for third-party use. + */ +export const uploadTransformerInternal = Symbol.for('payload.uploadTransformerInternal') + +export type UploadTransformTask = { + fieldPath: 'filename' | `sizes.${string}` + /** Source file for this task. Defaults to the original upload (e.g. pass the cropped main output so sizes derive from the crop). */ + file?: File + options: TOptions +} + +export type PreparedUploadTransformation = { + fieldPath: 'filename' | `sizes.${string}` + /** Omitted when this task was intentionally skipped (e.g. an image size too small to enlarge) — recorded with null metadata, matching the existing `sizes` shape. */ + file?: File + height?: number + mimeType?: string + width?: number +} + +export type UploadTransformerInternal = { + prepareUpload?: (args: { + collectionSlug: string + file: File + req: PayloadRequest + transform: (task: UploadTransformTask) => Promise + uploadEdits: UploadEdits + }) => Promise +} + +export type TransformerWithInternalBridge = { + [uploadTransformerInternal]?: UploadTransformerInternal +} + +export function getUploadTransformerInternal( + transformer: object, +): undefined | UploadTransformerInternal { + return (transformer as TransformerWithInternalBridge)[uploadTransformerInternal] +} diff --git a/packages/payload/src/uploads/transformers/validateTransformers.spec.ts b/packages/payload/src/uploads/transformers/validateTransformers.spec.ts new file mode 100644 index 00000000000..65cb80a41fb --- /dev/null +++ b/packages/payload/src/uploads/transformers/validateTransformers.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import type { UploadTransformer } from './types.js' + +import { validateTransformers } from './validateTransformers.js' + +const makeTransformer = (overrides: Partial = {}): UploadTransformer => ({ + mimeTypes: ['image/*'], + slug: 'test-transformer', + ...overrides, +}) + +describe('validateTransformers', () => { + it.each(['', ' '])('should throw when a transformer has an empty slug (%j)', (slug) => { + const transformer = makeTransformer({ slug }) + + expect(() => validateTransformers({ transformers: [transformer] })).toThrow(/slug/i) + }) + + it('should throw when a transformer declares no MIME types', () => { + const transformer = makeTransformer({ mimeTypes: [] }) + + expect(() => validateTransformers({ transformers: [transformer] })).toThrow(/mime/i) + }) + + it.each(['image', '*/png'])('should throw for the invalid MIME pattern %j', (mimeType) => { + const transformer = makeTransformer({ mimeTypes: [mimeType] }) + + expect(() => validateTransformers({ transformers: [transformer] })).toThrow(/mime/i) + }) + + it.each(['init', 'canTransform', 'transformFile', 'handleRequest'] as const)( + 'should throw when %s is present but not a function', + (capability) => { + const transformer = makeTransformer({ + [capability]: 'not-a-function', + } as Partial) + + expect(() => validateTransformers({ transformers: [transformer] })).toThrow( + new RegExp(capability, 'i'), + ) + }, + ) + + it('should throw and identify every duplicate slug, including non-adjacent duplicates', () => { + const transformers = [ + makeTransformer({ slug: 'a' }), + makeTransformer({ slug: 'b' }), + makeTransformer({ slug: 'a' }), + makeTransformer({ slug: 'c' }), + makeTransformer({ slug: 'b' }), + ] + + expect(() => validateTransformers({ transformers })).toThrow(/"a".*"b"|"b".*"a"/is) + }) + + it('should report multiple distinct problems across transformers in a single error', () => { + const transformers = [makeTransformer({ mimeTypes: [] }), makeTransformer({ slug: '' })] + + expect(() => validateTransformers({ transformers })).toThrow(/mime.*slug|slug.*mime/is) + }) +}) diff --git a/packages/payload/src/uploads/transformers/validateTransformers.ts b/packages/payload/src/uploads/transformers/validateTransformers.ts new file mode 100644 index 00000000000..cd8467c385c --- /dev/null +++ b/packages/payload/src/uploads/transformers/validateTransformers.ts @@ -0,0 +1,88 @@ +import type { UploadTransformer } from './types.js' + +const MIME_TOKEN_PATTERN = /^[\w!#$&^.+-]+$/ + +const CAPABILITY_KEYS = ['init', 'canTransform', 'transformFile', 'handleRequest'] as const + +function isValidMimeTypePattern(pattern: unknown): boolean { + if (typeof pattern !== 'string' || pattern.length === 0) { + return false + } + + if (pattern === '*/*') { + return true + } + + const [type, subtype] = pattern.split('/') + + if (type === undefined || subtype === undefined || pattern.split('/').length !== 2) { + return false + } + + if (!MIME_TOKEN_PATTERN.test(type)) { + return false + } + + return subtype === '*' || MIME_TOKEN_PATTERN.test(subtype) +} + +/** + * Validates `upload.transformers` at startup: unique, non-empty slugs; at least one + * well-formed MIME pattern per transformer; and every declared capability is a function. + * Called once for the authored list and again after every transformer `init()` runs, + * since `init()` may return a mutated transformers list. + */ +export function validateTransformers({ + transformers, +}: { + transformers: UploadTransformer[] +}): void { + const errors: string[] = [] + const slugCounts = new Map() + + transformers.forEach((transformer, index) => { + const label = `upload.transformers[${index}]` + const slug = transformer?.slug + + if (typeof slug !== 'string' || slug.trim().length === 0) { + errors.push(`${label} must have a non-empty \`slug\`.`) + } else { + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1) + } + + const identifier = typeof slug === 'string' && slug.length > 0 ? `"${slug}"` : label + + if (!Array.isArray(transformer?.mimeTypes) || transformer.mimeTypes.length === 0) { + errors.push(`Transformer ${identifier} must declare at least one MIME type in \`mimeTypes\`.`) + } else { + transformer.mimeTypes.forEach((mimeType) => { + if (!isValidMimeTypePattern(mimeType)) { + errors.push( + `Transformer ${identifier} has an invalid MIME type pattern: "${String(mimeType)}". ` + + `Use an exact value (e.g. "image/png"), a category wildcard (e.g. "image/*"), or "*/*".`, + ) + } + }) + } + + for (const capability of CAPABILITY_KEYS) { + const value = transformer?.[capability] + + if (value !== undefined && typeof value !== 'function') { + errors.push(`Transformer ${identifier}'s \`${capability}\` must be a function.`) + } + } + }) + + for (const [slug, count] of slugCounts) { + if (count > 1) { + errors.push( + `Duplicate upload transformer slug: "${slug}" is used ${count} times. Every transformer must have a unique \`slug\`.`, + ) + } + } + + if (errors.length > 0) { + throw new Error(`Invalid \`upload.transformers\` configuration:\n${errors.join('\n')}`) + } +} diff --git a/packages/payload/src/uploads/transformers/withFileTransformAccessContext.ts b/packages/payload/src/uploads/transformers/withFileTransformAccessContext.ts new file mode 100644 index 00000000000..3888156da34 --- /dev/null +++ b/packages/payload/src/uploads/transformers/withFileTransformAccessContext.ts @@ -0,0 +1,29 @@ +import type { PayloadRequest } from '../../types/index.js' + +/** + * Sets `req.fileTransform = true` for the duration of `callback()` when + * `isTransform` is `true`, then restores the previous value afterward, even + * if `callback()` throws. Ordinary reads (`isTransform: false`) never see the + * flag set. + */ +export async function withFileTransformAccessContext({ + callback, + isTransform, + req, +}: { + callback: () => Promise | T + isTransform: boolean + req: PayloadRequest +}): Promise { + const previousFileTransform = req.fileTransform + + if (isTransform) { + req.fileTransform = true + } + + try { + return await callback() + } finally { + req.fileTransform = previousFileTransform + } +} diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 29ab1627f0d..2a418ffcd5d 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -1,10 +1,7 @@ -import type { ResizeOptions, Sharp, SharpOptions } from 'sharp' - import type { CollectionConfig, TypeWithID } from '../collections/config/types.js' import type { PayloadComponent } from '../config/types.js' import type { UploadCollectionSlug } from '../index.js' import type { PayloadRequest } from '../types/index.js' -import type { WithMetadata } from './optionallyAppendMetadata.js' export type FileSize = { filename: null | string @@ -37,21 +34,6 @@ export type ProbedImageSize = { width: number } -/** - * Params sent to the sharp `toFormat()` function - * @link https://sharp.pixelplumbing.com/api-output#toformat - */ -export type ImageUploadFormatOptions = { - format: Parameters[0] - options?: Parameters[1] -} - -/** - * Params sent to the sharp trim() function - * @link https://sharp.pixelplumbing.com/api-resize#trim - */ -export type ImageUploadTrimOptions = Parameters[0] - export type GenerateImageName = (args: { extension: string height: number @@ -86,33 +68,12 @@ type ImageSizeBase = { name: string } -/** - * Image size options implemented by Payload's default Sharp image processor. - */ -export type SharpImageSizeOptions = { - /** - * @deprecated prefer position - */ - crop?: string // comes from sharp package - formatOptions?: ImageUploadFormatOptions - trimOptions?: ImageUploadTrimOptions - /** - * When an uploaded image is smaller than the defined image size, we have 3 options: - * - * `undefined | false | true` - * - * 1. `undefined` [default]: uploading images with smaller width AND height than the image size will return null - * 2. `false`: always enlarge images to the image size - * 3. `true`: if the image is smaller than the image size, return the original image - */ - withoutEnlargement?: ResizeOptions['withoutEnlargement'] -} & Omit - /** * Interface to be module-augmented by image processing providers. * - * When no provider is registered, ImageSize uses SharpImageSizeOptions. - * When providers are registered, ImageSize uses their registered options instead. + * When no provider is registered, ImageSize carries no processor-specific options. + * When one or more providers are registered (e.g. `@payloadcms/transformer-sharp`), + * ImageSize uses the union of their registered options instead. * * @example * declare module 'payload' { @@ -125,7 +86,7 @@ export type SharpImageSizeOptions = { export interface RegisteredImageSizeOptions {} type ImageSizeOptions = keyof RegisteredImageSizeOptions extends never - ? SharpImageSizeOptions + ? unknown : RegisteredImageSizeOptions[keyof RegisteredImageSizeOptions] export type ImageSize = ImageSizeBase & ImageSizeOptions @@ -159,6 +120,13 @@ type UploadFilePreviewMap = { [mimeTypePattern: string]: PayloadComponent } +/** + * Distinguishes an ordinary public file request (`read`) from Payload's internal + * retrieval of a stored file's bytes for a dynamic transformation (`transform`). + * Defaults to `read` when omitted, for third-party handler compatibility. + */ +export type FileHandlerOperation = 'read' | 'transform' + type Admin = { components?: { /** @@ -217,11 +185,6 @@ export type UploadConfig = { * @default true */ cacheTags?: boolean - /** - * Sharp constructor options to be passed to the uploaded file. - * @link https://sharp.pixelplumbing.com/api-constructor/#sharp - */ - constructorOptions?: SharpOptions /** * Enables cropping of images. * @default true @@ -269,10 +232,6 @@ export type UploadConfig = { * @default true */ focalPoint?: boolean - /** - * Format options for the uploaded file. Formatting image sizes needs to be done within each formatOptions individually. - */ - formatOptions?: ImageUploadFormatOptions /** * Custom handlers to run when a file is fetched. * @@ -280,7 +239,6 @@ export type UploadConfig = { * - If a handler returns null, the next handler will be run. * - If no handlers return a response the file will be returned by default. * - * @link https://sharp.pixelplumbing.com/api-output/#toformat * @default undefined */ handlers?: (( @@ -291,11 +249,22 @@ export type UploadConfig = { params: { collection: string filename: string + /** @default 'read' */ + operation?: FileHandlerOperation prefix?: string uploadReference?: unknown } }, ) => Promise | Promise | Response | void)[] + /** + * Whether a registered transformer performs additional processing on the main + * uploaded file beyond simple pass-through (e.g. resizing, format conversion, + * trimming). Written by a transformer's `init()`; not intended to be set + * directly in collection config. Used by the Admin UI to decide whether to + * show focal-point controls when no image sizes are configured. + * @internal + */ + hasImageAdjustments?: boolean /** * Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. */ @@ -304,7 +273,6 @@ export type UploadConfig = { * Set to `true` to prevent the admin UI having a way to remove an existing file while editing. */ hideRemoveFile?: boolean - imageSizes?: ImageSize[] /** * Restrict mimeTypes in the file picker. Array of valid mime types or mimetype wildcards * @example ['image/*', 'application/pdf'] @@ -328,12 +296,6 @@ export type UploadConfig = { allowList: AllowList } | false - /** - * Sharp resize options for the original image. - * @link https://sharp.pixelplumbing.com/api-resize#resize - * @default undefined - */ - resizeOptions?: ResizeOptions /** * Skip safe fetch when using server-side fetching for external files from these URLs. * @default false @@ -344,23 +306,11 @@ export type UploadConfig = { * @default undefined */ staticDir?: string - trimOptions?: ImageUploadTrimOptions /** * Adapter-provided upload instructions. * @internal */ uploadInstructions?: UploadInstructionsCapability - /** - * Optionally append metadata to the image during processing. - * - * Can be a boolean or a function. - * - * If true, metadata will be appended to the image. - * If false, no metadata will be appended. - * If a function, it will receive an object containing the metadata and should return a boolean indicating whether to append the metadata. - * @default false - */ - withMetadata?: WithMetadata } export type UploadInstructionsAccess = (args: { @@ -423,6 +373,12 @@ export type checkFileRestrictionsParams = { } export type SanitizedUploadConfig = { + /** + * The collection's image sizes, as written at startup by a registered transformer such as + * `sharpTransformer({ collections: { : { variants } } })`. Names and admin options + * only — not authored on the collection. + */ + imageSizes?: ImageSize[] staticDir: UploadConfig['staticDir'] } & UploadConfig diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 64283cc9da2..4a786c09f2e 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, Field, GroupField, TextField } from 'payload' +import type { CollectionConfig, Field, GroupField, SanitizedUploadConfig, TextField } from 'payload' import path from 'path' @@ -101,7 +101,14 @@ export const getFields = ({ } as TextField) } - if (typeof collection.upload === 'object' && collection.upload.imageSizes) { + // Storage adapters add these fields during their `init`, after transformers (e.g. Sharp) have + // written each collection's image sizes onto its upload config. + const imageSizes = + typeof collection.upload === 'object' + ? (collection.upload as SanitizedUploadConfig).imageSizes + : undefined + + if (imageSizes) { let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { @@ -124,7 +131,7 @@ export const getFields = ({ admin: { hidden: true, }, - fields: collection.upload.imageSizes.map((size) => { + fields: imageSizes.map((size) => { const existingSizeField = existingSizesField?.fields.find( (existingField) => 'name' in existingField && existingField.name === size.name, ) as GroupField diff --git a/packages/plugin-cloud-storage/src/types.ts b/packages/plugin-cloud-storage/src/types.ts index 23345697ebd..9a148500d75 100644 --- a/packages/plugin-cloud-storage/src/types.ts +++ b/packages/plugin-cloud-storage/src/types.ts @@ -2,6 +2,7 @@ import type { CollectionConfig, Field, FileData, + FileHandlerOperation, ImageSize, PayloadHandler, PayloadRequest, @@ -11,6 +12,8 @@ import type { UploadInstructionsCapability, } from 'payload' +export type { FileHandlerOperation } from 'payload' + export interface File { buffer: Buffer filename: string @@ -75,7 +78,13 @@ export type StaticHandler = ( args: { doc?: TypeWithID headers?: Headers - params: { collection: string; filename: string; prefix?: string; uploadReference?: unknown } + params: { + collection: string + filename: string + operation?: FileHandlerOperation + prefix?: string + uploadReference?: unknown + } }, ) => Promise | Response diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 4a00e980c90..fee15fbc575 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, PayloadRequest, TypeWithID, UploadConfig } from 'payload' +import type { CollectionConfig, PayloadRequest, SanitizedUploadConfig, TypeWithID } from 'payload' import { buildPrefixWithObjectKey } from './buildPrefixWithObjectKey.js' import { buildUploadStoragePathData } from './buildStoragePathData.js' @@ -64,7 +64,7 @@ export async function getFilePrefix({ } // Reads without a query prefix or read-access constraints skip the endpoint's document lookup. - const imageSizes = (collection?.upload as UploadConfig)?.imageSizes || [] + const imageSizes = (collection?.upload as SanitizedUploadConfig)?.imageSizes || [] const filenameClause = { or: [ diff --git a/packages/storage-azure/src/adapter.ts b/packages/storage-azure/src/adapter.ts index c6071ea7b03..76bed502544 100644 --- a/packages/storage-azure/src/adapter.ts +++ b/packages/storage-azure/src/adapter.ts @@ -78,7 +78,7 @@ export function createAzureAdapter({ return data }, - staticHandler: (req, { doc, headers, params: { filename, uploadReference } }) => + staticHandler: (req, { doc, headers, params: { filename, operation, uploadReference } }) => getFile({ client: getStorageClient(), collection, @@ -86,6 +86,7 @@ export function createAzureAdapter({ doc, filename, incomingHeaders: headers, + operation, req, uploadReference, useCompositePrefixes, diff --git a/packages/storage-azure/src/getFile.ts b/packages/storage-azure/src/getFile.ts index 359b49a6683..51267aba2c7 100644 --- a/packages/storage-azure/src/getFile.ts +++ b/packages/storage-azure/src/getFile.ts @@ -1,5 +1,5 @@ import type { BlobDownloadResponseParsed, ContainerClient } from '@azure/storage-blob' -import type { CollectionConfig, PayloadRequest, TypeWithID } from 'payload' +import type { CollectionConfig, FileHandlerOperation, PayloadRequest, TypeWithID } from 'payload' import type { Readable } from 'stream' import { RestError } from '@azure/storage-blob' @@ -16,6 +16,7 @@ interface GetFileArgs { doc?: TypeWithID filename: string incomingHeaders?: Headers + operation?: FileHandlerOperation req: PayloadRequest uploadReference?: unknown useCompositePrefixes?: boolean @@ -58,10 +59,12 @@ export async function getFile({ doc, filename, incomingHeaders, + operation = 'read', req, uploadReference, useCompositePrefixes = false, }: GetFileArgs): Promise { + const isTransformSource = operation === 'transform' let blob: BlobDownloadResponseParsed | undefined = undefined let streamed = false @@ -92,7 +95,6 @@ export async function getFile({ const blockBlobClient = client.getBlockBlobClient(storageFilePath) - // Get file size for range validation const properties = await blockBlobClient.getProperties() const fileSize = properties.contentLength @@ -100,8 +102,7 @@ export async function getFile({ return new Response('Internal Server Error', { status: 500 }) } - // Handle range request - const rangeHeader = req.headers.get('range') + const rangeHeader = isTransformSource ? null : req.headers.get('range') const rangeResult = getRangeRequestInfo({ fileSize, rangeHeader }) if (rangeResult.type === 'invalid') { @@ -111,7 +112,6 @@ export async function getFile({ }) } - // Download with range if partial blob = rangeResult.type === 'partial' ? await blockBlobClient.download( @@ -123,12 +123,10 @@ export async function getFile({ let headers = new Headers(incomingHeaders) - // Add range-related headers from the result for (const [key, value] of Object.entries(rangeResult.headers)) { headers.append(key, value) } - // Add Azure-specific headers headers.append('Content-Type', String(properties.contentType)) if (properties.etag) { headers.append('ETag', String(properties.etag)) @@ -142,6 +140,7 @@ export async function getFile({ const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') if ( + !isTransformSource && collection.upload && typeof collection.upload === 'object' && typeof collection.upload.modifyResponseHeaders === 'function' @@ -149,7 +148,7 @@ export async function getFile({ headers = collection.upload.modifyResponseHeaders({ headers }) || headers } - if (etagFromHeaders && etagFromHeaders === properties.etag) { + if (!isTransformSource && etagFromHeaders && etagFromHeaders === properties.etag) { return new Response(null, { headers, status: 304, diff --git a/packages/storage-gcs/src/adapter.ts b/packages/storage-gcs/src/adapter.ts index a7160eac8e9..abb31f647be 100644 --- a/packages/storage-gcs/src/adapter.ts +++ b/packages/storage-gcs/src/adapter.ts @@ -72,7 +72,7 @@ export function createGcsAdapter({ return data }, - staticHandler: (req, { doc, headers, params: { filename, uploadReference } }) => + staticHandler: (req, { doc, headers, params: { filename, operation, uploadReference } }) => getFile({ bucket, client: getStorageClient(), @@ -81,6 +81,7 @@ export function createGcsAdapter({ doc, filename, incomingHeaders: headers, + operation, req, uploadReference, useCompositePrefixes, diff --git a/packages/storage-gcs/src/getFile.ts b/packages/storage-gcs/src/getFile.ts index 2860962e636..04a76c37a11 100644 --- a/packages/storage-gcs/src/getFile.ts +++ b/packages/storage-gcs/src/getFile.ts @@ -1,5 +1,5 @@ import type { Storage } from '@google-cloud/storage' -import type { CollectionConfig, PayloadRequest, TypeWithID } from 'payload' +import type { CollectionConfig, FileHandlerOperation, PayloadRequest, TypeWithID } from 'payload' import { ApiError } from '@google-cloud/storage' import { @@ -16,6 +16,7 @@ interface GetFileArgs { doc?: TypeWithID filename: string incomingHeaders?: Headers + operation?: FileHandlerOperation req: PayloadRequest uploadReference?: unknown useCompositePrefixes?: boolean @@ -29,10 +30,13 @@ export async function getFile({ doc, filename, incomingHeaders, + operation = 'read', req, uploadReference, useCompositePrefixes = false, }: GetFileArgs): Promise { + const isTransformSource = operation === 'transform' + try { const docPrefix = await getDocPrefix({ collection, @@ -55,8 +59,7 @@ export async function getFile({ const [metadata] = await file.getMetadata() - // Handle range request - const rangeHeader = req.headers.get('range') + const rangeHeader = isTransformSource ? null : req.headers.get('range') const fileSize = Number(metadata.size) const rangeResult = getRangeRequestInfo({ fileSize, rangeHeader }) @@ -72,7 +75,6 @@ export async function getFile({ let headers = new Headers(incomingHeaders) - // Add range-related headers from the result for (const [key, value] of Object.entries(rangeResult.headers)) { headers.append(key, value) } @@ -86,6 +88,7 @@ export async function getFile({ } if ( + !isTransformSource && collection.upload && typeof collection.upload === 'object' && typeof collection.upload.modifyResponseHeaders === 'function' @@ -93,14 +96,13 @@ export async function getFile({ headers = collection.upload.modifyResponseHeaders({ headers }) || headers } - if (etagFromHeaders && etagFromHeaders === objectEtag) { + if (!isTransformSource && etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { headers, status: 304, }) } - // Manually create a ReadableStream for the web from a Node.js stream. const readableStream = new ReadableStream({ start(controller) { const streamOptions = diff --git a/packages/storage-r2/src/adapter.ts b/packages/storage-r2/src/adapter.ts index e1eec0cc9ac..62ffbcdbe7b 100644 --- a/packages/storage-r2/src/adapter.ts +++ b/packages/storage-r2/src/adapter.ts @@ -72,13 +72,14 @@ export function createR2Adapter({ storageFilePath, }), - staticHandler: (req, { doc, headers, params: { filename, uploadReference } }) => + staticHandler: (req, { doc, headers, params: { filename, operation, uploadReference } }) => getFile({ bucket, collection, doc, filename, incomingHeaders: headers, + operation, prefix, req, uploadReference, diff --git a/packages/storage-r2/src/getFile.spec.ts b/packages/storage-r2/src/getFile.spec.ts new file mode 100644 index 00000000000..10402025301 --- /dev/null +++ b/packages/storage-r2/src/getFile.spec.ts @@ -0,0 +1,79 @@ +import type { CollectionConfig, PayloadRequest } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@payloadcms/plugin-cloud-storage/utilities', () => ({ + buildStoragePathData: vi.fn(() => ({ storageFilePath: 'logo.png' })), + getFilePrefix: vi.fn().mockResolvedValue(''), +})) + +import { getFile } from './getFile.js' + +const LARGE_FILE_SIZE = 60 * 1024 * 1024 + +const makeBody = (): ReadableStream => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('image-bytes')) + controller.close() + }, + }) + +const makeBucket = () => ({ + get: vi.fn().mockResolvedValue({ + body: makeBody(), + etag: 'etag-1', + writeHttpMetadata: vi.fn((headers: Headers) => { + headers.set('Content-Type', 'image/png') + }), + }), + head: vi.fn().mockResolvedValue({ size: LARGE_FILE_SIZE }), +}) + +const makeReq = (): PayloadRequest => + ({ + headers: new Headers(), + payload: { logger: { error: vi.fn() } }, + }) as unknown as PayloadRequest + +const collection = {} as CollectionConfig + +describe('storage-r2 getFile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should preserve the existing empty-body guard for a large internal uploadReference read when operation is "read" (regression)', async () => { + const bucket = makeBucket() + + const response = await getFile({ + bucket: bucket as never, + collection, + filename: 'large.png', + prefix: '', + operation: 'read', + req: makeReq(), + uploadReference: { id: 'ref-1' }, + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + }) + + it('should not return an empty body for an object over 50MB when operation is "transform", even if uploadReference is present', async () => { + const bucket = makeBucket() + + const response = await getFile({ + bucket: bucket as never, + collection, + filename: 'large.png', + prefix: '', + operation: 'transform', + req: makeReq(), + uploadReference: { id: 'ref-1' }, + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('image-bytes') + }) +}) diff --git a/packages/storage-r2/src/getFile.ts b/packages/storage-r2/src/getFile.ts index 68676aee41e..f3a1a608c52 100644 --- a/packages/storage-r2/src/getFile.ts +++ b/packages/storage-r2/src/getFile.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, PayloadRequest, TypeWithID } from 'payload' +import type { CollectionConfig, FileHandlerOperation, PayloadRequest, TypeWithID } from 'payload' import { buildStoragePathData, @@ -14,6 +14,7 @@ interface GetFileArgs { doc?: TypeWithID filename: string incomingHeaders?: Headers + operation?: FileHandlerOperation prefix: string req: PayloadRequest uploadReference?: unknown @@ -28,11 +29,14 @@ export async function getFile({ doc, filename, incomingHeaders, + operation = 'read', prefix = '', req, uploadReference, useCompositePrefixes = false, }: GetFileArgs): Promise { + const isTransformSource = operation === 'transform' + try { const docPrefix = await getDocPrefix({ collection, @@ -59,13 +63,13 @@ export async function getFile({ const fileSize = headObj.size - // Don't return large file uploads back to the client, or the Worker will run out of memory - if (fileSize > 50 * 1024 * 1024 && uploadReference) { + // Don't return large file uploads back to the client, or the Worker will run out of memory. + // Skipped for `transform`, which needs the real bytes and would otherwise silently corrupt. + if (fileSize > 50 * 1024 * 1024 && uploadReference && !isTransformSource) { return new Response(null, { status: 200 }) } - // Handle range request - const rangeHeader = req.headers.get('range') + const rangeHeader = isTransformSource ? null : req.headers.get('range') const rangeResult = getRangeRequestInfo({ fileSize, rangeHeader }) if (rangeResult.type === 'invalid') { @@ -75,7 +79,6 @@ export async function getFile({ }) } - // Get object with range if needed // Due to https://github.com/cloudflare/workers-sdk/issues/6047 // We cannot send a Headers instance to Miniflare const obj = @@ -94,14 +97,11 @@ export async function getFile({ let headers = new Headers(incomingHeaders) - // Add range-related headers from the result for (const [headerKey, value] of Object.entries(rangeResult.headers)) { headers.append(headerKey, value) } - // Add R2-specific headers if (isMiniflare) { - // In development with Miniflare, manually set headers from httpMetadata const metadata = obj.httpMetadata if (metadata?.cacheControl) { headers.set('Cache-Control', metadata.cacheControl) @@ -122,8 +122,9 @@ export async function getFile({ obj.writeHttpMetadata(headers) } - // Apply a restrictive policy to XML-family responses served through Payload. const contentType = headers.get('Content-Type') + + // Apply a restrictive policy to XML-family responses served through Payload. if (isXmlMimeType(contentType)) { headers.set('Content-Security-Policy', uploadContentSecurityPolicy) } @@ -131,6 +132,7 @@ export async function getFile({ const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match') if ( + !isTransformSource && collection.upload && typeof collection.upload === 'object' && typeof collection.upload.modifyResponseHeaders === 'function' @@ -138,7 +140,7 @@ export async function getFile({ headers = collection.upload.modifyResponseHeaders({ headers }) || headers } - if (etagFromHeaders && etagFromHeaders === obj.etag) { + if (!isTransformSource && etagFromHeaders && etagFromHeaders === obj.etag) { return new Response(null, { headers, status: 304, diff --git a/packages/storage-s3/src/adapter.ts b/packages/storage-s3/src/adapter.ts index 48075123491..946602138a7 100644 --- a/packages/storage-s3/src/adapter.ts +++ b/packages/storage-s3/src/adapter.ts @@ -82,7 +82,10 @@ export function createS3Adapter({ return data }, - staticHandler: async (req, { doc, headers, params: { filename, uploadReference } }) => { + staticHandler: async ( + req, + { doc, headers, params: { filename, operation, uploadReference } }, + ) => { const { getFile } = await import('./getFile.js') return getFile({ bucket, @@ -92,6 +95,7 @@ export function createS3Adapter({ doc, filename, incomingHeaders: headers, + operation, req, signedDownloads, uploadReference, diff --git a/packages/storage-s3/src/getFile.ts b/packages/storage-s3/src/getFile.ts index 4cf615805f3..3e42d760275 100644 --- a/packages/storage-s3/src/getFile.ts +++ b/packages/storage-s3/src/getFile.ts @@ -1,5 +1,5 @@ import type * as AWS from '@aws-sdk/client-s3' -import type { CollectionConfig, PayloadRequest, TypeWithID } from 'payload' +import type { CollectionConfig, FileHandlerOperation, PayloadRequest, TypeWithID } from 'payload' import type { Readable } from 'stream' import { GetObjectCommand } from '@aws-sdk/client-s3' @@ -30,6 +30,7 @@ interface GetFileArgs { doc?: TypeWithID filename: string incomingHeaders?: Headers + operation?: FileHandlerOperation req: PayloadRequest signedDownloads: SignedDownloadsConfig uploadReference?: unknown @@ -72,11 +73,13 @@ export async function getFile({ doc, filename, incomingHeaders, + operation = 'read', req, signedDownloads, uploadReference, useCompositePrefixes = false, }: GetFileArgs): Promise { + const isTransformSource = operation === 'transform' let object: AWS.GetObjectOutput | undefined = undefined let streamed = false @@ -105,7 +108,7 @@ export async function getFile({ useCompositePrefixes, }) - if (signedDownloads && !uploadReference) { + if (signedDownloads && !uploadReference && !isTransformSource) { let useSignedURL = true if ( typeof signedDownloads === 'object' && @@ -125,7 +128,6 @@ export async function getFile({ } } - // Get file size first for range validation and to set Content-Length header before streaming const headObject = await client.headObject({ Bucket: bucket, Key: storageFilePath, @@ -136,8 +138,7 @@ export async function getFile({ return new Response('Internal Server Error', { status: 500 }) } - // Handle range request - const rangeHeader = req.headers.get('range') + const rangeHeader = isTransformSource ? null : req.headers.get('range') const rangeResult = getRangeRequestInfo({ fileSize, rangeHeader }) if (rangeResult.type === 'invalid') { @@ -154,7 +155,6 @@ export async function getFile({ let headers = new Headers(incomingHeaders) - // Add range-related headers from the result for (const [headerKey, value] of Object.entries(rangeResult.headers)) { headers.append(headerKey, value) } @@ -173,6 +173,7 @@ export async function getFile({ const objectEtag = headObject.ETag if ( + !isTransformSource && collection.upload && typeof collection.upload === 'object' && typeof collection.upload.modifyResponseHeaders === 'function' @@ -180,7 +181,7 @@ export async function getFile({ headers = collection.upload.modifyResponseHeaders({ headers }) || headers } - if (etagFromHeaders && etagFromHeaders === objectEtag) { + if (!isTransformSource && etagFromHeaders && etagFromHeaders === objectEtag) { return new Response(null, { headers, status: 304, diff --git a/packages/storage-vercel-blob/src/adapter.ts b/packages/storage-vercel-blob/src/adapter.ts index 0eed7911da1..35bbe684608 100644 --- a/packages/storage-vercel-blob/src/adapter.ts +++ b/packages/storage-vercel-blob/src/adapter.ts @@ -169,7 +169,7 @@ export function createVercelBlobAdapter({ return data }, - staticHandler: (req, { doc, headers, params: { filename, uploadReference } }) => + staticHandler: (req, { doc, headers, params: { filename, operation, uploadReference } }) => getFile({ baseUrl, cacheControlMaxAge, @@ -178,6 +178,7 @@ export function createVercelBlobAdapter({ doc, filename, incomingHeaders: headers, + operation, req, token, uploadReference, diff --git a/packages/storage-vercel-blob/src/getFile.spec.ts b/packages/storage-vercel-blob/src/getFile.spec.ts new file mode 100644 index 00000000000..e2f18a10898 --- /dev/null +++ b/packages/storage-vercel-blob/src/getFile.spec.ts @@ -0,0 +1,81 @@ +import type { CollectionConfig, PayloadRequest } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@payloadcms/plugin-cloud-storage/utilities', () => ({ + buildStoragePathData: vi.fn((args: { filename: string }) => ({ + storageFilePath: args.filename, + })), + getFilePrefix: vi.fn().mockResolvedValue(''), +})) + +vi.mock('@vercel/blob', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + head: vi.fn(), + } +}) + +vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('image-bytes', { status: 200 }))) + +import { head } from '@vercel/blob' + +import { getFile } from './getFile.js' + +const FILE_SIZE = 1000 + +const makeHeadResult = () => ({ + contentDisposition: 'inline', + contentType: 'image/png', + size: FILE_SIZE, + uploadedAt: new Date('2024-01-01T00:00:00.000Z'), +}) + +const makeReq = (): PayloadRequest => + ({ + headers: new Headers(), + payload: { logger: { error: vi.fn() } }, + }) as unknown as PayloadRequest + +const collection = {} as CollectionConfig + +describe('storage-vercel-blob getFile', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(head).mockResolvedValue(makeHeadResult() as never) + vi.mocked(fetch).mockResolvedValue(new Response('image-bytes', { status: 200 })) + }) + + it('should return 204 for a failed source fetch when operation is "read" (regression)', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })) + + const response = await getFile({ + baseUrl: 'https://blob.example.com', + cacheControlMaxAge: 3600, + collection, + filename: 'logo.png', + operation: 'read', + req: makeReq(), + token: 'test-token', + }) + + expect(response.status).toBe(204) + }) + + it('should propagate the real failure status for operation "transform" instead of masking it as 204', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })) + + const response = await getFile({ + baseUrl: 'https://blob.example.com', + cacheControlMaxAge: 3600, + collection, + filename: 'logo.png', + operation: 'transform', + req: makeReq(), + token: 'test-token', + }) + + expect(response.status).toBe(500) + }) +}) diff --git a/packages/storage-vercel-blob/src/getFile.ts b/packages/storage-vercel-blob/src/getFile.ts index 60e248c5f74..8f43ad5cf9e 100644 --- a/packages/storage-vercel-blob/src/getFile.ts +++ b/packages/storage-vercel-blob/src/getFile.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, PayloadRequest, TypeWithID } from 'payload' +import type { CollectionConfig, FileHandlerOperation, PayloadRequest, TypeWithID } from 'payload' import { getFilePrefix as getDocPrefix } from '@payloadcms/plugin-cloud-storage/utilities' import { BlobNotFoundError, head } from '@vercel/blob' @@ -14,6 +14,7 @@ interface GetFileArgs { doc?: TypeWithID filename: string incomingHeaders?: Headers + operation?: FileHandlerOperation req: PayloadRequest token: string uploadReference?: unknown @@ -28,11 +29,14 @@ export async function getFile({ doc, filename, incomingHeaders, + operation = 'read', req, token, uploadReference, useCompositePrefixes = false, }: GetFileArgs): Promise { + const isTransformSource = operation === 'transform' + try { const docPrefix = await getDocPrefix({ collection, @@ -58,8 +62,7 @@ export async function getFile({ const fileKeyForETag = fileUrl.replace(`${baseUrl}/`, '') const ETag = `"${fileKeyForETag}-${uploadedAtString}"` - // Handle range request - const rangeHeader = req.headers.get('range') + const rangeHeader = isTransformSource ? null : req.headers.get('range') const rangeResult = getRangeRequestInfo({ fileSize: size, rangeHeader }) if (rangeResult.type === 'invalid') { @@ -71,7 +74,6 @@ export async function getFile({ let headers = new Headers(incomingHeaders) - // Add range-related headers from the result for (const [key, value] of Object.entries(rangeResult.headers)) { headers.append(key, value) } @@ -87,6 +89,7 @@ export async function getFile({ } if ( + !isTransformSource && collection.upload && typeof collection.upload === 'object' && typeof collection.upload.modifyResponseHeaders === 'function' @@ -94,7 +97,7 @@ export async function getFile({ headers = collection.upload.modifyResponseHeaders({ headers }) || headers } - if (etagFromHeaders && etagFromHeaders === ETag) { + if (!isTransformSource && etagFromHeaders && etagFromHeaders === ETag) { return new Response(null, { headers, status: 304, @@ -112,6 +115,11 @@ export async function getFile({ }) if (!response.ok || !response.body) { + // A transformer needs the real failure status; masking it as 204 is only safe for a plain download. + if (isTransformSource) { + return new Response(null, { status: response.status, statusText: response.statusText }) + } + return new Response(null, { status: 204, statusText: 'No Content' }) } diff --git a/packages/ui/src/elements/FileManager/index.tsx b/packages/ui/src/elements/FileManager/index.tsx index 807338eda4a..c735fb0a762 100644 --- a/packages/ui/src/elements/FileManager/index.tsx +++ b/packages/ui/src/elements/FileManager/index.tsx @@ -99,10 +99,10 @@ export const FileManager: React.FC = ({ const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt const hasImageSizes = uploadConfig?.imageSizes?.length > 0 - const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) + const hasImageAdjustments = Boolean(uploadConfig?.hasImageAdjustments) const focalPointEnabled = uploadConfig?.focalPoint === true const { crop: showCrop = true, focalPoint = true } = uploadConfig - const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled) + const showFocalPoint = focalPoint && (hasImageSizes || hasImageAdjustments || focalPointEnabled) const selectedSizeData = selectedSize ? (data?.sizes?.[selectedSize] as Record) diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 0bf95190bd3..b676eb11043 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -114,7 +114,7 @@ export type UploadProps = { readonly customActions?: React.ReactNode[] readonly initialState?: FormState readonly onChange?: (file?: File) => void - readonly uploadConfig: SanitizedCollectionConfig['upload'] + readonly uploadConfig: Omit readonly UploadControls?: React.ReactNode } @@ -289,13 +289,13 @@ const UploadComponent: React.FC = (props) => { const canRemoveUpload = docPermissions?.update const hasImageSizes = uploadConfig?.imageSizes?.length > 0 - const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) + const hasImageAdjustments = Boolean(uploadConfig?.hasImageAdjustments) // Explicity check if set to true, default is undefined const focalPointEnabled = uploadConfig?.focalPoint === true const { crop: showCrop = true, focalPoint = true } = uploadConfig - const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled) + const showFocalPoint = focalPoint && (hasImageSizes || hasImageAdjustments || focalPointEnabled) const acceptMimeTypes = uploadConfig.mimeTypes?.join(', ') diff --git a/templates/_template/package.json b/templates/_template/package.json index dae87a4734d..c93f96b720b 100644 --- a/templates/_template/package.json +++ b/templates/_template/package.json @@ -21,14 +21,14 @@ "@payloadcms/db-mongodb": "latest", "@payloadcms/next": "latest", "@payloadcms/richtext-lexical": "latest", + "@payloadcms/transformer-sharp": "latest", "@payloadcms/ui": "latest", "cross-env": "10.1.0", "graphql": "^16.8.1", "next": "16.3.3", "payload": "latest", "react": "19.2.6", - "react-dom": "19.2.6", - "sharp": "0.35.4" + "react-dom": "19.2.6" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/templates/_template/src/payload.config.ts b/templates/_template/src/payload.config.ts index e8474cd36be..24506f2bb56 100644 --- a/templates/_template/src/payload.config.ts +++ b/templates/_template/src/payload.config.ts @@ -1,9 +1,9 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' -import sharp from 'sharp' import { Users } from './collections/Users' import { Media } from './collections/Media' @@ -27,6 +27,8 @@ export default buildConfig({ db: mongooseAdapter({ url: process.env.DATABASE_URL || '', }), - sharp, plugins: [], + upload: { + transformers: [sharpTransformer()], + }, }) diff --git a/templates/blank-tanstack/package.json b/templates/blank-tanstack/package.json index 097a6984aaf..dbfb3e28a4e 100644 --- a/templates/blank-tanstack/package.json +++ b/templates/blank-tanstack/package.json @@ -32,7 +32,6 @@ "payload": "workspace:*", "react": "19.2.6", "react-dom": "19.2.6", - "sharp": "0.35.4", "srvx": "0.11.21", "vite": "8.3.0" }, @@ -60,7 +59,6 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "sharp", "esbuild", "unrs-resolver" ] diff --git a/templates/blank-tanstack/src/payload.config.ts b/templates/blank-tanstack/src/payload.config.ts index c8e9c0013a8..21eb83597e4 100644 --- a/templates/blank-tanstack/src/payload.config.ts +++ b/templates/blank-tanstack/src/payload.config.ts @@ -4,7 +4,6 @@ import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' -import sharp from 'sharp' import { Users } from './collections/Users' import { Media } from './collections/Media' @@ -30,7 +29,6 @@ export default buildConfig({ db: mongooseAdapter({ url: process.env.DATABASE_URL || '', }), - sharp, localization: { locales: ['en'], fallback: true, diff --git a/templates/blank/package.json b/templates/blank/package.json index 38cf164b328..e2445564955 100644 --- a/templates/blank/package.json +++ b/templates/blank/package.json @@ -22,6 +22,7 @@ "@payloadcms/next": "workspace:*", "@payloadcms/plugin-mcp": "workspace:*", "@payloadcms/richtext-lexical": "workspace:*", + "@payloadcms/transformer-sharp": "workspace:*", "@payloadcms/ui": "workspace:*", "cross-env": "10.1.0", "dotenv": "16.4.7", @@ -29,8 +30,7 @@ "next": "16.3.3", "payload": "workspace:*", "react": "19.2.6", - "react-dom": "19.2.6", - "sharp": "0.35.4" + "react-dom": "19.2.6" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/templates/blank/src/payload.config.ts b/templates/blank/src/payload.config.ts index c8e9c0013a8..deb7ade121e 100644 --- a/templates/blank/src/payload.config.ts +++ b/templates/blank/src/payload.config.ts @@ -1,10 +1,10 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' import { mcpPlugin } from '@payloadcms/plugin-mcp' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' -import sharp from 'sharp' import { Users } from './collections/Users' import { Media } from './collections/Media' @@ -30,11 +30,13 @@ export default buildConfig({ db: mongooseAdapter({ url: process.env.DATABASE_URL || '', }), - sharp, localization: { locales: ['en'], fallback: true, defaultLocale: 'en', }, plugins: [mcpPlugin({})], + upload: { + transformers: [sharpTransformer()], + }, }) diff --git a/templates/plugin/dev/payload.config.ts b/templates/plugin/dev/payload.config.ts index 854022d9580..c6041502a01 100644 --- a/templates/plugin/dev/payload.config.ts +++ b/templates/plugin/dev/payload.config.ts @@ -1,10 +1,10 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import { MongoMemoryReplSet } from 'mongodb-memory-server' import path from 'path' import { buildConfig } from 'payload' import { myPlugin } from 'plugin-package-name-placeholder' -import sharp from 'sharp' import { fileURLToPath } from 'url' import { testEmailAdapter } from './helpers/testEmailAdapter.js' @@ -65,10 +65,12 @@ const buildConfigWithMemoryDB = async () => { }), ], secret: process.env.PAYLOAD_SECRET || 'test-secret_key', - sharp, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, + upload: { + transformers: [sharpTransformer()], + }, }) } diff --git a/templates/plugin/package.json b/templates/plugin/package.json index 6e883db51dd..51db6856f90 100644 --- a/templates/plugin/package.json +++ b/templates/plugin/package.json @@ -52,6 +52,7 @@ "@payloadcms/eslint-config": "3.28.0", "@payloadcms/next": "3.82.1", "@payloadcms/richtext-lexical": "3.82.1", + "@payloadcms/transformer-sharp": "3.82.1", "@payloadcms/ui": "3.82.1", "@playwright/test": "1.63.0", "@swc-node/register": "1.10.9", @@ -73,7 +74,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "rimraf": "3.0.2", - "sharp": "0.35.4", "sort-package-json": "^2.10.0", "typescript": "6.0.3", "vite-tsconfig-paths": "6.0.5", diff --git a/templates/website/package.json b/templates/website/package.json index 259402ced75..7f695e7851b 100644 --- a/templates/website/package.json +++ b/templates/website/package.json @@ -32,6 +32,7 @@ "@payloadcms/plugin-search": "workspace:*", "@payloadcms/plugin-seo": "workspace:*", "@payloadcms/richtext-lexical": "workspace:*", + "@payloadcms/transformer-sharp": "workspace:*", "@payloadcms/ui": "workspace:*", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-label": "^2.0.2", @@ -51,7 +52,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "7.71.1", - "sharp": "0.35.4", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/templates/website/src/collections/Media.ts b/templates/website/src/collections/Media.ts index 638eefe2786..c10e11617a6 100644 --- a/templates/website/src/collections/Media.ts +++ b/templates/website/src/collections/Media.ts @@ -44,39 +44,42 @@ export const Media: CollectionConfig = { // Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload staticDir: path.resolve(dirname, '../../public/media'), adminThumbnail: 'thumbnail', - focalPoint: true, - imageSizes: [ - { - name: 'thumbnail', - width: 300, - }, - { - name: 'square', - width: 500, - height: 500, - }, - { - name: 'small', - width: 600, - }, - { - name: 'medium', - width: 900, - }, - { - name: 'large', - width: 1400, - }, - { - name: 'xlarge', - width: 1920, - }, - { - name: 'og', - width: 1200, - height: 630, - crop: 'center', - }, - ], }, } + +export const mediaSharpOptions = { + focalPoint: true, + variants: [ + { + name: 'thumbnail', + width: 300, + }, + { + name: 'square', + width: 500, + height: 500, + }, + { + name: 'small', + width: 600, + }, + { + name: 'medium', + width: 900, + }, + { + name: 'large', + width: 1400, + }, + { + name: 'xlarge', + width: 1920, + }, + { + name: 'og', + width: 1200, + height: 630, + crop: 'center', + }, + ], +} diff --git a/templates/website/src/payload.config.ts b/templates/website/src/payload.config.ts index f75b1d20581..6b56f2f7248 100644 --- a/templates/website/src/payload.config.ts +++ b/templates/website/src/payload.config.ts @@ -1,11 +1,11 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' -import sharp from 'sharp' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import path from 'path' import { buildConfig, PayloadRequest } from 'payload' import { fileURLToPath } from 'url' import { Categories } from './collections/Categories' -import { Media } from './collections/Media' +import { Media, mediaSharpOptions } from './collections/Media' import { Pages } from './collections/Pages' import { Posts } from './collections/Posts' import { Users } from './collections/Users' @@ -86,10 +86,12 @@ export default buildConfig({ globals: [Header, Footer], plugins, secret: process.env.PAYLOAD_SECRET, - sharp, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, + upload: { + transformers: [sharpTransformer({ collections: { media: mediaSharpOptions } })], + }, jobs: { access: { run: ({ req }: { req: PayloadRequest }): boolean => { diff --git a/templates/with-postgres/package.json b/templates/with-postgres/package.json index ff092514495..4e18331ec97 100644 --- a/templates/with-postgres/package.json +++ b/templates/with-postgres/package.json @@ -22,14 +22,14 @@ "@payloadcms/db-postgres": "3.82.1", "@payloadcms/next": "3.82.1", "@payloadcms/richtext-lexical": "3.82.1", + "@payloadcms/transformer-sharp": "3.82.1", "@payloadcms/ui": "3.82.1", "cross-env": "10.1.0", "graphql": "^16.8.1", "next": "16.3.3", "payload": "3.82.1", "react": "19.2.6", - "react-dom": "19.2.6", - "sharp": "0.35.4" + "react-dom": "19.2.6" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/templates/with-postgres/src/payload.config.ts b/templates/with-postgres/src/payload.config.ts index debb2fd92e7..1054fa4464e 100644 --- a/templates/with-postgres/src/payload.config.ts +++ b/templates/with-postgres/src/payload.config.ts @@ -1,9 +1,9 @@ import { postgresAdapter } from '@payloadcms/db-postgres' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' -import sharp from 'sharp' import { Users } from './collections/Users' import { Media } from './collections/Media' @@ -29,6 +29,8 @@ export default buildConfig({ connectionString: process.env.DATABASE_URL || '', }, }), - sharp, plugins: [], + upload: { + transformers: [sharpTransformer()], + }, }) diff --git a/templates/with-vercel-website/package.json b/templates/with-vercel-website/package.json index 4fd3bec3cf4..5667162401d 100644 --- a/templates/with-vercel-website/package.json +++ b/templates/with-vercel-website/package.json @@ -34,6 +34,7 @@ "@payloadcms/plugin-seo": "3.82.1", "@payloadcms/richtext-lexical": "3.82.1", "@payloadcms/storage-vercel-blob": "3.82.1", + "@payloadcms/transformer-sharp": "3.82.1", "@payloadcms/ui": "3.82.1", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-label": "^2.0.2", @@ -53,7 +54,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "7.71.1", - "sharp": "0.35.4", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/templates/with-vercel-website/src/collections/Media.ts b/templates/with-vercel-website/src/collections/Media.ts index 638eefe2786..c10e11617a6 100644 --- a/templates/with-vercel-website/src/collections/Media.ts +++ b/templates/with-vercel-website/src/collections/Media.ts @@ -44,39 +44,42 @@ export const Media: CollectionConfig = { // Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload staticDir: path.resolve(dirname, '../../public/media'), adminThumbnail: 'thumbnail', - focalPoint: true, - imageSizes: [ - { - name: 'thumbnail', - width: 300, - }, - { - name: 'square', - width: 500, - height: 500, - }, - { - name: 'small', - width: 600, - }, - { - name: 'medium', - width: 900, - }, - { - name: 'large', - width: 1400, - }, - { - name: 'xlarge', - width: 1920, - }, - { - name: 'og', - width: 1200, - height: 630, - crop: 'center', - }, - ], }, } + +export const mediaSharpOptions = { + focalPoint: true, + variants: [ + { + name: 'thumbnail', + width: 300, + }, + { + name: 'square', + width: 500, + height: 500, + }, + { + name: 'small', + width: 600, + }, + { + name: 'medium', + width: 900, + }, + { + name: 'large', + width: 1400, + }, + { + name: 'xlarge', + width: 1920, + }, + { + name: 'og', + width: 1200, + height: 630, + crop: 'center', + }, + ], +} diff --git a/templates/with-vercel-website/src/payload.config.ts b/templates/with-vercel-website/src/payload.config.ts index 7ec681d5d09..1d447b4d84e 100644 --- a/templates/with-vercel-website/src/payload.config.ts +++ b/templates/with-vercel-website/src/payload.config.ts @@ -1,11 +1,11 @@ import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres' -import sharp from 'sharp' +import { sharpTransformer } from '@payloadcms/transformer-sharp' import path from 'path' import { buildConfig, PayloadRequest } from 'payload' import { fileURLToPath } from 'url' import { Categories } from './collections/Categories' -import { Media } from './collections/Media' +import { Media, mediaSharpOptions } from './collections/Media' import { Pages } from './collections/Pages' import { Posts } from './collections/Posts' import { Users } from './collections/Users' @@ -89,10 +89,12 @@ export default buildConfig({ plugins: [...plugins], globals: [Header, Footer], secret: process.env.PAYLOAD_SECRET, - sharp, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, + upload: { + transformers: [sharpTransformer({ collections: { media: mediaSharpOptions } })], + }, jobs: { access: { run: ({ req }: { req: PayloadRequest }): boolean => { From aa85e9ed485f0343abdcb893566a839626fb6d8a Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 30 Sep 2026 17:23:29 +0100 Subject: [PATCH 02/13] feat(transformer-sharp): add sharp file transformer package --- package.json | 3 +- packages/payload/package.json | 3 +- packages/transformer-sharp/.swcrc | 15 + packages/transformer-sharp/README.md | 105 +++++++ packages/transformer-sharp/package.json | 69 +++++ .../transformer-sharp/src/canResizeImage.ts | 7 + .../src/getImageResizeAction.ts | 76 +++++ .../src/handleRequest.spec.ts | 193 ++++++++++++ .../transformer-sharp/src/handleRequest.ts | 175 +++++++++++ packages/transformer-sharp/src/index.ts | 9 + .../src/initSharpCollections.spec.ts | 154 ++++++++++ .../src/initSharpCollections.ts | 121 ++++++++ .../src/mapWithBoundedConcurrency.spec.ts | 34 +++ .../src/mapWithBoundedConcurrency.ts | 33 ++ .../src/optionallyAppendMetadata.ts | 28 ++ .../src/parseDynamicResize.spec.ts | 85 ++++++ .../src/parseDynamicResize.ts | 125 ++++++++ .../src/prepareLegacyUpload.ts | 177 +++++++++++ .../src/sanitizeResizeConfig.ts | 21 ++ .../src/sharpTransformer.spec.ts | 48 +++ .../transformer-sharp/src/sharpTransformer.ts | 125 ++++++++ .../src/transformFile.spec.ts | 221 ++++++++++++++ .../transformer-sharp/src/transformFile.ts | 286 ++++++++++++++++++ packages/transformer-sharp/src/types.ts | 187 ++++++++++++ packages/transformer-sharp/tsconfig.json | 8 + pnpm-lock.yaml | 259 +++++++++++----- tsconfig.json | 3 + 27 files changed, 2483 insertions(+), 87 deletions(-) create mode 100644 packages/transformer-sharp/.swcrc create mode 100644 packages/transformer-sharp/README.md create mode 100644 packages/transformer-sharp/package.json create mode 100644 packages/transformer-sharp/src/canResizeImage.ts create mode 100644 packages/transformer-sharp/src/getImageResizeAction.ts create mode 100644 packages/transformer-sharp/src/handleRequest.spec.ts create mode 100644 packages/transformer-sharp/src/handleRequest.ts create mode 100644 packages/transformer-sharp/src/index.ts create mode 100644 packages/transformer-sharp/src/initSharpCollections.spec.ts create mode 100644 packages/transformer-sharp/src/initSharpCollections.ts create mode 100644 packages/transformer-sharp/src/mapWithBoundedConcurrency.spec.ts create mode 100644 packages/transformer-sharp/src/mapWithBoundedConcurrency.ts create mode 100644 packages/transformer-sharp/src/optionallyAppendMetadata.ts create mode 100644 packages/transformer-sharp/src/parseDynamicResize.spec.ts create mode 100644 packages/transformer-sharp/src/parseDynamicResize.ts create mode 100644 packages/transformer-sharp/src/prepareLegacyUpload.ts create mode 100644 packages/transformer-sharp/src/sanitizeResizeConfig.ts create mode 100644 packages/transformer-sharp/src/sharpTransformer.spec.ts create mode 100644 packages/transformer-sharp/src/sharpTransformer.ts create mode 100644 packages/transformer-sharp/src/transformFile.spec.ts create mode 100644 packages/transformer-sharp/src/transformFile.ts create mode 100644 packages/transformer-sharp/src/types.ts create mode 100644 packages/transformer-sharp/tsconfig.json diff --git a/package.json b/package.json index 008058e0b41..fc6fb8d8ac4 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "build:storage-vercel-blob": "turbo build --filter \"@payloadcms/storage-vercel-blob\"", "build:tests": "pnpm --filter payload-test-suite run typecheck", "build:tools": "turbo build --filter=./tools/*", + "build:transformer-sharp": "turbo build --filter \"@payloadcms/transformer-sharp\"", "build:translations": "turbo build --filter \"@payloadcms/translations\"", "build:ui": "turbo build --filter \"@payloadcms/ui\"", "clean": "turbo clean", @@ -206,7 +207,7 @@ "react": "catalog:", "react-dom": "catalog:", "rimraf": "6.0.1", - "sharp": "0.32.6", + "sharp": "0.35.4", "shelljs": "0.8.5", "slash": "3.0.0", "sort-package-json": "^2.10.0", diff --git a/packages/payload/package.json b/packages/payload/package.json index 986a183ac22..1975a12dbbd 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -169,8 +169,7 @@ "react-datepicker": "7.6.0", "rimraf": "6.0.1", "rollup": "4.59.0", - "rollup-plugin-dts": "6.2.3", - "sharp": "0.32.6" + "rollup-plugin-dts": "6.2.3" }, "peerDependencies": { "graphql": "^16.8.1" diff --git a/packages/transformer-sharp/.swcrc b/packages/transformer-sharp/.swcrc new file mode 100644 index 00000000000..14463f4b08b --- /dev/null +++ b/packages/transformer-sharp/.swcrc @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": true, + "jsc": { + "target": "esnext", + "parser": { + "syntax": "typescript", + "tsx": true, + "dts": true + } + }, + "module": { + "type": "es6" + } +} diff --git a/packages/transformer-sharp/README.md b/packages/transformer-sharp/README.md new file mode 100644 index 00000000000..ff62dad3b91 --- /dev/null +++ b/packages/transformer-sharp/README.md @@ -0,0 +1,105 @@ +# Sharp Transformer for Payload + +Payload's official [file transformer](https://payloadcms.com/docs/upload/transformers) for Sharp-based image processing: upload-time resizing/format conversion (the same behavior Payload has always provided) plus opt-in request-time dynamic resizing. + +## Installation + +```sh +pnpm add @payloadcms/transformer-sharp +``` + +## Usage + +Register `sharpTransformer()` under `upload.transformers` in your Payload Config: + +```ts +import { buildConfig } from 'payload' +import { sharpTransformer } from '@payloadcms/transformer-sharp' + +export default buildConfig({ + upload: { + transformers: [sharpTransformer()], + }, +}) +``` + +### Upload-time image processing + +Configure per-collection Sharp settings through `sharpTransformer({ collections })`: + +```ts +sharpTransformer({ + collections: { + media: { + imageSizes: [ + { name: 'thumbnail', width: 400, height: 300 }, + { name: 'card', width: 768, height: 1024 }, + ], + resizeOptions: { width: 2048, height: 2048, withoutEnlargement: true }, + formatOptions: { format: 'webp' }, + trimOptions: undefined, + constructorOptions: undefined, + withMetadata: false, + crop: true, + focalPoint: true, + }, + }, +}) +``` + +`imageSizes`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `imageSizes`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before. + +### Dynamic (request-time) resizing + +Dynamic resizing is **disabled by default** — resize query parameters are ignored and the original file is served. Enable it for every upload collection, or only the ones that need it: + +```ts +sharpTransformer({ dynamic: true }) +sharpTransformer({ dynamic: { collections: ['media'] } }) +``` + +Once enabled, requesting a stored image with recognized query parameters resizes it on the fly, without storing the result: + +``` +GET /api/media/file/photo.png?width=400 +GET /api/media/file/photo.png?width=400&height=300 +GET /api/media/file/photo.png?width=400&withoutEnlargement=true +``` + +Configure limits and defaults for these requests: + +```ts +sharpTransformer({ + dynamic: { + collections: ['media'], // omit to allow every upload collection + fit: 'cover', // default + position: 'center', // default + maxWidth: 4096, // default + maxHeight: 4096, // default + maxPixels: 16_777_216, // default + withoutEnlargement: false, // default + }, +}) +``` + +Resized output is not cached, and anyone who can read a file can request it at any size within these limits. Before enabling dynamic resizing on a public collection, see [Securing dynamic resizing](https://payloadcms.com/docs/upload/transformers#securing-dynamic-resizing) for how to restrict it with access control, tighten the limits, and cache the output. + +### Injecting a custom Sharp build + +```ts +import customSharp from 'sharp' + +sharpTransformer({ sharp: customSharp }) +``` + +See the [File Transformers](https://payloadcms.com/docs/upload/transformers) docs for the full transformer contract, access-control lifecycle, and non-goals (no caching, no CDN integration, no jobs queue). + +## Migrating from Payload 3.x + +If you previously configured `sharp`/`resizeOptions`/`imageSizes`/etc. directly on your Payload Config or collections, run: + +```sh +npx @payloadcms/codemod --transform migrate-sharp-to-transformer +``` + +See the [v4 migration guide](https://payloadcms.com/docs/migration-guide/v4#sharp-moved-out-of-core-into-payloadcmstransformer-sharp) for details. diff --git a/packages/transformer-sharp/package.json b/packages/transformer-sharp/package.json new file mode 100644 index 00000000000..38d93744a69 --- /dev/null +++ b/packages/transformer-sharp/package.json @@ -0,0 +1,69 @@ +{ + "name": "@payloadcms/transformer-sharp", + "version": "4.0.0-canary.14", + "description": "Official Sharp-based file transformer for Payload: request-time image resizing and upload-time image processing", + "homepage": "https://payloadcms.com", + "repository": { + "type": "git", + "url": "https://github.com/payloadcms/payload.git", + "directory": "packages/transformer-sharp" + }, + "license": "MIT", + "author": "Payload (https://payloadcms.com)", + "maintainers": [ + { + "name": "Payload", + "email": "info@payloadcms.com", + "url": "https://payloadcms.com" + } + ], + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm build:types && pnpm build:swc", + "build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build", + "build:debug": "pnpm build", + "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", + "build:types": "tsc --emitDeclarationOnly --outDir dist", + "clean": "rimraf -g {dist,*.tsbuildinfo}", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "file-type": "22.0.1", + "sanitize-filename": "1.6.4", + "sharp": "0.35.4" + }, + "devDependencies": { + "payload": "workspace:*" + }, + "peerDependencies": { + "payload": "workspace:*" + }, + "engines": { + "node": ">=24.15.0" + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + } +} diff --git a/packages/transformer-sharp/src/canResizeImage.ts b/packages/transformer-sharp/src/canResizeImage.ts new file mode 100644 index 00000000000..b3df32d1b3f --- /dev/null +++ b/packages/transformer-sharp/src/canResizeImage.ts @@ -0,0 +1,7 @@ +export function canResizeImage(mimeType: string): boolean { + return ( + ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/avif'].indexOf( + mimeType, + ) > -1 + ) +} diff --git a/packages/transformer-sharp/src/getImageResizeAction.ts b/packages/transformer-sharp/src/getImageResizeAction.ts new file mode 100644 index 00000000000..15f1bc2290b --- /dev/null +++ b/packages/transformer-sharp/src/getImageResizeAction.ts @@ -0,0 +1,76 @@ +import type { ImageSize, ProbedImageSize } from 'payload' + +import { isNumber } from 'payload/shared' + +/** + * Determine whether or not to resize the image. + * - resize using image config + * - resize using image config with focal adjustments + * - do not resize at all + * + * `imageResizeConfig.withoutEnlargement`: + * - undefined [default]: uploading images with smaller width AND height than the image size will return null + * - false: always enlarge images to the image size + * - true: if the image is smaller than the image size, return the original image + * + * `imageResizeConfig.withoutReduction`: + * - false [default]: always enlarge images to the image size + * - true: if the image is smaller than the image size, return the original image + * + * @return 'omit' | 'resize' | 'resizeWithFocalPoint' + */ +export const getImageResizeAction = ({ + dimensions: originalImage, + hasFocalPoint, + imageResizeConfig, +}: { + dimensions: ProbedImageSize + hasFocalPoint?: boolean + imageResizeConfig: ImageSize +}): 'omit' | 'resize' | 'resizeWithFocalPoint' => { + const { fit, withoutEnlargement, withoutReduction } = imageResizeConfig + const targetWidth = imageResizeConfig.width! + const targetHeight = imageResizeConfig.height! + + // prevent upscaling by default when x and y are both smaller than target image size + if (targetHeight && targetWidth) { + const originalImageIsSmallerXAndY = + originalImage.width < targetWidth && originalImage.height < targetHeight + if (withoutEnlargement === undefined && originalImageIsSmallerXAndY) { + return 'omit' // prevent image size from being enlarged + } + } + + if (withoutEnlargement === undefined && (!targetWidth || !targetHeight)) { + if ( + (targetWidth && originalImage.width < targetWidth) || + (targetHeight && originalImage.height < targetHeight) + ) { + return 'omit' + } + } + + const originalImageIsSmallerXOrY = + originalImage.width < targetWidth || originalImage.height < targetHeight + if (fit === 'contain' || fit === 'inside') { + return 'resize' + } + if (!isNumber(targetHeight) && !isNumber(targetWidth)) { + return 'resize' + } + + const targetAspectRatio = targetWidth / targetHeight + const originalAspectRatio = originalImage.width / originalImage.height + if (originalAspectRatio === targetAspectRatio) { + return 'resize' + } + + if (withoutEnlargement && originalImageIsSmallerXOrY) { + return 'resize' + } + if (withoutReduction && !originalImageIsSmallerXOrY) { + return 'resize' + } + + return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize' +} diff --git a/packages/transformer-sharp/src/handleRequest.spec.ts b/packages/transformer-sharp/src/handleRequest.spec.ts new file mode 100644 index 00000000000..cb0d4314842 --- /dev/null +++ b/packages/transformer-sharp/src/handleRequest.spec.ts @@ -0,0 +1,193 @@ +import type { PayloadRequest } from 'payload' + +import { readFileSync } from 'fs' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' +import { describe, expect, it, vi } from 'vitest' + +import { createHandleRequest } from './handleRequest.js' +import { resolveSharpDynamicDefaults } from './sharpTransformer.js' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) +// 200x200, 44 frames. +const animatedWebp = readFileSync(path.resolve(dirname, '../../../test/uploads/animated.webp')) + +const makeReq = ({ query = '' }: { query?: string } = {}): PayloadRequest => + ({ + headers: new Headers(), + method: 'GET', + payload: { logger: { error: vi.fn() } }, + searchParams: new URLSearchParams(query), + }) as unknown as PayloadRequest + +const makeSourceImage = async ({ + background = { b: 0, g: 128, r: 255 }, + format = 'png' as Parameters['toFormat']>[0], + height = 200, + width = 400, +} = {}): Promise => + sharp({ create: { background, channels: 3, height, width } }) + .toFormat(format) + .toBuffer() + +const resizeReal = async ({ + dynamicDefaults = resolveSharpDynamicDefaults(), + mimeType = 'image/png', + query, + sourceBuffer, +}: { + dynamicDefaults?: ReturnType + mimeType?: string + query: string + sourceBuffer: Buffer +}) => { + const handleRequest = createHandleRequest({ dynamicDefaults, sharpDependency: sharp }) + const getSourceFile = vi.fn().mockResolvedValue(new Response(sourceBuffer)) + + const result = await handleRequest({ + collectionSlug: 'media', + documentID: '1', + filename: 'logo.png', + getSourceFile, + mimeType, + req: makeReq({ query }), + }) + + return result +} + +const getOutputMetadata = async (result: Awaited>) => + sharp(Buffer.from(await result.response!.arrayBuffer())).metadata() + +describe('createHandleRequest', () => { + it('should reject a width-only request whose aspect-ratio-derived output exceeds maxPixels', async () => { + const sourceBuffer = await makeSourceImage({ height: 100, width: 10 }) + + // 10x100 source at width=100 renders 100x1000 = 100,000 pixels, 10x the limit. + const result = await resizeReal({ + dynamicDefaults: resolveSharpDynamicDefaults({ maxPixels: 10_000 }), + query: 'width=100', + sourceBuffer, + }) + + expect(result.response?.status).toBe(400) + }) + + it.each(['width=1000&height=1000', 'width=1000'])( + 'should count every frame of an animated source against maxPixels (%s)', + async (query) => { + // 1000x1000 per frame is within the 16,777,216 default, but across 44 frames + // Sharp would render 44,000,000 pixels. + const result = await resizeReal({ + mimeType: 'image/webp', + query, + sourceBuffer: animatedWebp, + }) + + expect(result.response?.status).toBe(400) + }, + ) + + it('should resize every frame of an animated source within maxPixels', async () => { + const metadata = await sharp( + Buffer.from( + await ( + await resizeReal({ + mimeType: 'image/webp', + query: 'width=100', + sourceBuffer: animatedWebp, + }) + ).response!.arrayBuffer(), + ), + { animated: true }, + ).metadata() + + expect(metadata.width).toBe(100) + expect(metadata.pages).toBe(44) + }) + + it('should apply the EXIF orientation of the source before resizing', async () => { + // Stored 400x200 but tagged orientation 6, so it displays as 200x400. + const sourceBuffer = await sharp(await makeSourceImage({ format: 'jpeg' })) + .withMetadata({ orientation: 6 }) + .toBuffer() + + const metadata = await getOutputMetadata( + await resizeReal({ mimeType: 'image/jpeg', query: 'width=100', sourceBuffer }), + ) + + expect(metadata.width).toBe(100) + expect(metadata.height).toBe(200) + }) + + it('should budget maxPixels against the EXIF-oriented source dimensions', async () => { + // Stored 100x10 but tagged orientation 6, so it displays as 10x100. + const sourceBuffer = await sharp( + await makeSourceImage({ format: 'jpeg', height: 10, width: 100 }), + ) + .withMetadata({ orientation: 6 }) + .toBuffer() + + // Displayed 10x100 at width=100 renders 100x1000 = 100,000 pixels, 10x the limit. + const result = await resizeReal({ + dynamicDefaults: resolveSharpDynamicDefaults({ maxPixels: 10_000 }), + mimeType: 'image/jpeg', + query: 'width=100', + sourceBuffer, + }) + + expect(result.response?.status).toBe(400) + }) + + it('should not upscale when withoutEnlargement is configured as the default', async () => { + const sourceBuffer = await makeSourceImage({ height: 100, width: 100 }) + + const metadata = await getOutputMetadata( + await resizeReal({ + dynamicDefaults: resolveSharpDynamicDefaults({ withoutEnlargement: true }), + query: 'width=300', + sourceBuffer, + }), + ) + + expect(metadata.width).toBe(100) + expect(metadata.height).toBe(100) + }) + + it('should allow a per-request withoutEnlargement=false to override a configured withoutEnlargement=true default', async () => { + const sourceBuffer = await makeSourceImage({ height: 100, width: 100 }) + + const metadata = await getOutputMetadata( + await resizeReal({ + dynamicDefaults: resolveSharpDynamicDefaults({ withoutEnlargement: true }), + query: 'width=300&withoutEnlargement=false', + sourceBuffer, + }), + ) + + expect(metadata.width).toBe(300) + }) + + it.each([ + ['jpeg', 'jpeg'], + ['png', 'png'], + ['webp', 'webp'], + ['tiff', 'tiff'], + ['gif', 'gif'], + // AVIF is stored in a HEIF container — Sharp reports its metadata format as + // "heif", not "avif", even though the MIME type and file extension are AVIF. + ['avif', 'heif'], + ] as const)( + 'should retain the source format (%s) in the resized output', + async (format, expectedMetadataFormat) => { + const sourceBuffer = await makeSourceImage({ format }) + + const metadata = await getOutputMetadata( + await resizeReal({ mimeType: `image/${format}`, query: 'width=100', sourceBuffer }), + ) + + expect(metadata.format).toBe(expectedMetadataFormat) + }, + ) +}) diff --git a/packages/transformer-sharp/src/handleRequest.ts b/packages/transformer-sharp/src/handleRequest.ts new file mode 100644 index 00000000000..3743d6b10d7 --- /dev/null +++ b/packages/transformer-sharp/src/handleRequest.ts @@ -0,0 +1,175 @@ +import type { HandleTransformRequestArgs, HandleTransformRequestResult } from 'payload' +import type { SharpOptions } from 'sharp' + +import type { SharpDependency, SharpDynamicDefaults } from './types.js' + +import { parseDynamicResize } from './parseDynamicResize.js' + +// Must match generateFileData.ts's allow-list — the only MIME types Sharp auto-detects multi-frame animation for. +const ANIMATED_MIME_TYPES = ['image/avif', 'image/gif', 'image/webp'] + +/** + * Builds the request-time `handleRequest` stage: parses and validates the v1 + * dynamic parameters (`width`, `height`, `withoutEnlargement`), retrieves the + * source exactly once — only after validation passes — and resizes it while + * preserving the source format. Unexpected Sharp errors are left to propagate + * uncaught; Payload's orchestrator logs and aborts the pipeline. + */ +export function createHandleRequest({ + dynamicDefaults, + sharpDependency, +}: { + dynamicDefaults: Required + sharpDependency: SharpDependency +}): (args: HandleTransformRequestArgs) => Promise { + return async ({ getSourceFile, mimeType, req }) => { + const parseResult = parseDynamicResize({ + limits: dynamicDefaults, + searchParams: req.searchParams ?? new URLSearchParams(), + }) + + if (!parseResult.isRouted) { + return { status: 'continue' } + } + + if (!parseResult.valid) { + return { + response: Response.json({ errors: [{ message: parseResult.error }] }, { status: 400 }), + status: 'complete', + } + } + + if (req.headers?.get('range')) { + return { response: new Response(null, { status: 416 }), status: 'complete' } + } + + const source = await getSourceFile() + + if (!source.ok) { + return { response: source, status: 'complete' } + } + + const sourceBuffer = Buffer.from(await source.arrayBuffer()) + + const isAnimated = ANIMATED_MIME_TYPES.includes(mimeType) + const sharpOptions: SharpOptions = isAnimated ? { animated: true } : {} + + const withoutEnlargement = parseResult.withoutEnlargement ?? dynamicDefaults.withoutEnlargement + + // The output size is only known once the source is probed: with a single dimension + // Sharp derives the other from the aspect ratio, `fit: 'outside'` can overflow the + // requested box along one axis, and an animated source is resized frame by frame, + // so its real cost is the per-frame output times the frame count. + if ( + isAnimated || + parseResult.width === undefined || + parseResult.height === undefined || + dynamicDefaults.fit === 'outside' + ) { + const metadata = await sharpDependency(sourceBuffer, sharpOptions).metadata() + const frameCount = isAnimated ? (metadata.pages ?? 1) : 1 + const frameHeight = metadata.pageHeight ?? metadata.height + // EXIF orientations 5-8 rotate the image by 90°, so `.rotate()` below swaps its axes. + const isRotatedQuarterTurn = [5, 6, 7, 8].includes(metadata.orientation!) + const output = getOutputDimensions({ + fit: dynamicDefaults.fit, + height: parseResult.height, + sourceHeight: isRotatedQuarterTurn ? metadata.width : frameHeight, + sourceWidth: isRotatedQuarterTurn ? frameHeight : metadata.width, + width: parseResult.width, + withoutEnlargement, + }) + + if ( + output && + (output.width > dynamicDefaults.maxWidth || + output.height > dynamicDefaults.maxHeight || + output.width * output.height * frameCount > dynamicDefaults.maxPixels) + ) { + const frameDescription = frameCount > 1 ? ` across ${frameCount} frames` : '' + + return { + response: Response.json( + { + errors: [ + { + message: `Requested dimensions (${output.width}x${output.height}${frameDescription}) exceed the configured maximum.`, + }, + ], + }, + { status: 400 }, + ), + status: 'complete', + } + } + } + + // Sharp drops the EXIF orientation tag on output, so apply it to the pixels first. + const resizedBuffer = await sharpDependency(sourceBuffer, sharpOptions) + .rotate() + .resize({ + fit: dynamicDefaults.fit, + height: parseResult.height, + position: dynamicDefaults.position, + width: parseResult.width, + withoutEnlargement, + }) + .toBuffer() + + const headers = new Headers() + headers.set('Content-Type', mimeType) + headers.set('Content-Length', String(resizedBuffer.length)) + + return { + response: new Response(req.method === 'HEAD' ? null : resizedBuffer, { + headers, + status: 200, + }), + status: 'continue', + } + } +} + +/** + * The per-frame output size. When both dimensions are requested with any `fit` other + * than `'outside'` this is the requested box, an upper bound (`fit: 'contain'`/`'inside'` + * or `withoutEnlargement` can render smaller), which is what a resource budget needs. + * `'outside'` scales to cover the box, so one axis can exceed it. + */ +function getOutputDimensions({ + fit, + height, + sourceHeight, + sourceWidth, + width, + withoutEnlargement, +}: { + fit: SharpDynamicDefaults['fit'] + height: number | undefined + sourceHeight: number | undefined + sourceWidth: number | undefined + width: number | undefined + withoutEnlargement: boolean +}): { height: number; width: number } | undefined { + const hasBothDimensions = width !== undefined && height !== undefined + + if (hasBothDimensions && fit !== 'outside') { + return { height, width } + } + + if (!sourceWidth || !sourceHeight) { + return undefined + } + + const scale = hasBothDimensions + ? Math.max(width / sourceWidth, height / sourceHeight) + : width !== undefined + ? width / sourceWidth + : height! / sourceHeight + const effectiveScale = withoutEnlargement ? Math.min(scale, 1) : scale + + return { + height: Math.round(sourceHeight * effectiveScale), + width: Math.round(sourceWidth * effectiveScale), + } +} diff --git a/packages/transformer-sharp/src/index.ts b/packages/transformer-sharp/src/index.ts new file mode 100644 index 00000000000..40e8d6ff7f9 --- /dev/null +++ b/packages/transformer-sharp/src/index.ts @@ -0,0 +1,9 @@ +export { sharpTransformer } from './sharpTransformer.js' +export type { + DynamicResizeParseResult, + SharpCollectionConfig, + SharpDependency, + SharpDynamicDefaults, + SharpDynamicOptions, + SharpTransformerOptions, +} from './types.js' diff --git a/packages/transformer-sharp/src/initSharpCollections.spec.ts b/packages/transformer-sharp/src/initSharpCollections.spec.ts new file mode 100644 index 00000000000..2a74b05bbb8 --- /dev/null +++ b/packages/transformer-sharp/src/initSharpCollections.spec.ts @@ -0,0 +1,154 @@ +import type { Config } from 'payload' + +import { describe, expect, it } from 'vitest' + +import type { SharpCollectionConfig } from './types.js' + +import { initSharpCollections } from './initSharpCollections.js' + +const makeConfig = (collections: Config['collections']): Config => + ({ + collections, + }) as unknown as Config + +const uploadCollection = ({ + slug, + upload = {}, +}: { + slug: string + upload?: boolean | Record +}) => ({ slug, upload }) as unknown as NonNullable[number] + +describe('initSharpCollections', () => { + it('should not throw when the config has no collections', () => { + const config = makeConfig(undefined) + + expect(() => initSharpCollections({ collections: {}, config })).not.toThrow() + }) + + it("should keep the collection's crop and focalPoint when Sharp doesn't set them", () => { + const config = makeConfig([ + uploadCollection({ slug: 'media', upload: { crop: false, focalPoint: false } }), + ]) + + const result = initSharpCollections({ collections: { media: {} }, config }) + + expect(result.collections?.[0]?.upload).toMatchObject({ crop: false, focalPoint: false }) + }) + + it("should let Sharp's crop and focalPoint override the collection's", () => { + const config = makeConfig([ + uploadCollection({ slug: 'media', upload: { crop: false, focalPoint: false } }), + ]) + + const result = initSharpCollections({ + collections: { media: { crop: true, focalPoint: true } }, + config, + }) + + expect(result.collections?.[0]?.upload).toMatchObject({ crop: true, focalPoint: true }) + }) + + it('should write variants as imageSizes onto a copy, leaving the authored collection untouched', () => { + const authoredUpload = { staticDir: 'media' } + const authoredCollection = uploadCollection({ slug: 'media', upload: authoredUpload }) + const config = makeConfig([authoredCollection]) + + const result = initSharpCollections({ + collections: { media: { variants: [{ name: 'thumbnail', width: 100 }] } }, + config, + }) + + expect(result.collections?.[0]?.upload).toMatchObject({ + imageSizes: [{ name: 'thumbnail' }], + staticDir: 'media', + }) + expect(authoredUpload).toEqual({ staticDir: 'media' }) + expect(result.collections?.[0]).not.toBe(authoredCollection) + }) + + it('should throw when a configured collection slug does not exist in the config', () => { + const config = makeConfig([uploadCollection({ slug: 'media' })]) + + expect(() => + initSharpCollections({ + collections: { missing: {} }, + config, + }), + ).toThrow(/"missing"/) + }) + + it('should throw when a configured collection is not upload-enabled', () => { + const config = makeConfig([uploadCollection({ slug: 'posts', upload: false })]) + + expect(() => + initSharpCollections({ + collections: { posts: {} }, + config, + }), + ).toThrow(/"posts"/) + }) + + it('should throw when variants has a duplicate name', () => { + const config = makeConfig([uploadCollection({ slug: 'media' })]) + + expect(() => + initSharpCollections({ + collections: { + media: { + variants: [ + { name: 'square', width: 100 }, + { name: 'square', width: 200 }, + ], + }, + }, + config, + }), + ).toThrow(/duplicate/i) + }) + + it('should throw when a variants entry uses a reserved field name', () => { + const config = makeConfig([uploadCollection({ slug: 'media' })]) + + expect(() => + initSharpCollections({ + collections: { + media: { + variants: [{ name: 'filename', width: 100 }], + }, + }, + config, + }), + ).toThrow(/reserved/i) + }) + + it('should allow a variants entry with neither width nor height (format-only/pass-through size)', () => { + const config = makeConfig([uploadCollection({ slug: 'media' })]) + + expect(() => + initSharpCollections({ + collections: { + media: { + variants: [{ name: 'noDimensions' }], + }, + }, + config, + }), + ).not.toThrow() + }) + + it('should throw when a variants entry is missing a name', () => { + const config = makeConfig([uploadCollection({ slug: 'media' })]) + + expect(() => + initSharpCollections({ + collections: { + media: { + variants: [{ width: 100 }] as unknown as SharpCollectionConfig['variants'], + }, + }, + config, + }), + ).toThrow(/name/i) + }) +}) diff --git a/packages/transformer-sharp/src/initSharpCollections.ts b/packages/transformer-sharp/src/initSharpCollections.ts new file mode 100644 index 00000000000..82e94710628 --- /dev/null +++ b/packages/transformer-sharp/src/initSharpCollections.ts @@ -0,0 +1,121 @@ +import type { Config, SanitizedUploadConfig } from 'payload' + +import type { SharpCollectionConfig } from './types.js' + +const RESERVED_IMAGE_SIZE_NAMES = [ + 'filename', + 'mimeType', + 'filesize', + 'width', + 'height', + 'url', + 'thumbnailURL', + 'sizes', + 'focalX', + 'focalY', +] + +/** + * Validates `sharpTransformer({ collections })` against the config's real + * collections, then writes a narrowed, Sharp-agnostic projection of + * `variants` (as `imageSizes`)/`crop`/`focalPoint`/`hasImageAdjustments` back onto each + * targeted collection's sanitized `upload` config, so core's own field + * generation and Admin UI keep working without knowing about Sharp. + */ +export function initSharpCollections({ + collections, + config, +}: { + collections: Partial> + config: Config +}): Config { + const errors: string[] = [] + + for (const [slug, sharpConfig] of Object.entries(collections)) { + if (!sharpConfig) { + continue + } + + const collection = config.collections?.find((candidate) => candidate.slug === slug) + + if (!collection) { + errors.push( + `sharpTransformer collections."${slug}" does not match any configured collection slug.`, + ) + continue + } + + if (!collection.upload) { + errors.push( + `sharpTransformer collections."${slug}" targets a collection whose \`upload\` option is not set. Enable uploads on "${slug}" first.`, + ) + continue + } + + const seenSizeNames = new Set() + + for (const size of sharpConfig.variants ?? []) { + if (typeof size.name !== 'string' || size.name.trim().length === 0) { + errors.push( + `sharpTransformer collections."${slug}".variants has an entry missing a valid \`name\`.`, + ) + continue + } + + if (seenSizeNames.has(size.name)) { + errors.push( + `sharpTransformer collections."${slug}".variants has a duplicate size name: "${size.name}".`, + ) + } + seenSizeNames.add(size.name) + + if (RESERVED_IMAGE_SIZE_NAMES.includes(size.name)) { + errors.push( + `sharpTransformer collections."${slug}".variants uses reserved name "${size.name}", which collides with a built-in upload field. Choose a different name.`, + ) + } + } + } + + if (errors.length > 0) { + throw new Error( + `Invalid \`sharpTransformer({ collections })\` configuration:\n${errors.join('\n')}`, + ) + } + + // Write onto copies, never the authored collection objects: a rebuilt config (e.g. on a dev + // reload) would otherwise see `upload.imageSizes` on the collection and reject it as legacy. + config.collections = config.collections?.map((collection) => { + const sharpConfig = collections[collection.slug] + + if (!sharpConfig || !collection.upload) { + return collection + } + + const authoredUpload = typeof collection.upload === 'object' ? collection.upload : {} + + const upload: Partial = { + ...authoredUpload, + // Sharp's setting wins when given; otherwise keep the collection's own, so an + // unset Sharp option can't silently re-enable a collection's `crop: false`. + crop: sharpConfig.crop ?? authoredUpload.crop, + focalPoint: sharpConfig.focalPoint ?? authoredUpload.focalPoint, + hasImageAdjustments: Boolean( + sharpConfig.resizeOptions || + sharpConfig.formatOptions || + sharpConfig.trimOptions || + sharpConfig.constructorOptions || + sharpConfig.withMetadata, + ), + imageSizes: sharpConfig.variants?.map(({ name, admin, generateImageName }) => ({ + name, + admin, + generateImageName, + })), + } + + return { ...collection, upload } + }) + + return config +} diff --git a/packages/transformer-sharp/src/mapWithBoundedConcurrency.spec.ts b/packages/transformer-sharp/src/mapWithBoundedConcurrency.spec.ts new file mode 100644 index 00000000000..c7c3ef9fbe0 --- /dev/null +++ b/packages/transformer-sharp/src/mapWithBoundedConcurrency.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { mapWithBoundedConcurrency } from './mapWithBoundedConcurrency.js' + +describe('mapWithBoundedConcurrency', () => { + it('should preserve result order regardless of completion order', async () => { + const items = [30, 10, 20] + + const results = await mapWithBoundedConcurrency(items, async (delay) => { + await new Promise((resolve) => setTimeout(resolve, delay)) + return delay + }) + + expect(results).toEqual([30, 10, 20]) + }) + + it('should never run more than the configured concurrency at once', async () => { + let inFlight = 0 + let maxInFlight = 0 + + await mapWithBoundedConcurrency( + Array.from({ length: 10 }, (_, i) => i), + async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 5)) + inFlight -= 1 + }, + 2, + ) + + expect(maxInFlight).toBeLessThanOrEqual(2) + }) +}) diff --git a/packages/transformer-sharp/src/mapWithBoundedConcurrency.ts b/packages/transformer-sharp/src/mapWithBoundedConcurrency.ts new file mode 100644 index 00000000000..73be00d2a12 --- /dev/null +++ b/packages/transformer-sharp/src/mapWithBoundedConcurrency.ts @@ -0,0 +1,33 @@ +const DEFAULT_CONCURRENCY = 4 + +/** + * Runs `mapper` over every item, at most `concurrency` items in flight at + * once. Results preserve `items`' order regardless of completion order — + * used so configured image sizes keep their declared metadata order. + */ +export async function mapWithBoundedConcurrency( + items: TItem[], + mapper: (item: TItem, index: number) => Promise, + concurrency: number = DEFAULT_CONCURRENCY, +): Promise { + const results: TResult[] = new Array(items.length) + let nextIndex = 0 + + async function runWorker(): Promise { + while (true) { + const currentIndex = nextIndex + nextIndex += 1 + + if (currentIndex >= items.length) { + return + } + + results[currentIndex] = await mapper(items[currentIndex]!, currentIndex) + } + } + + const workerCount = Math.min(concurrency, items.length) + await Promise.all(Array.from({ length: workerCount }, runWorker)) + + return results +} diff --git a/packages/transformer-sharp/src/optionallyAppendMetadata.ts b/packages/transformer-sharp/src/optionallyAppendMetadata.ts new file mode 100644 index 00000000000..b4b04e5fb97 --- /dev/null +++ b/packages/transformer-sharp/src/optionallyAppendMetadata.ts @@ -0,0 +1,28 @@ +import type { PayloadRequest } from 'payload' +import type { Sharp } from 'sharp' + +import type { WithMetadata } from './types.js' + +export async function optionallyAppendMetadata({ + req, + sharpFile, + withMetadata, +}: { + req: PayloadRequest + sharpFile: Sharp + withMetadata: undefined | WithMetadata +}): Promise { + const metadata = await sharpFile.metadata() + + if (withMetadata === true) { + return sharpFile.withMetadata() + } else if (typeof withMetadata === 'function') { + const useMetadata = await withMetadata({ metadata, req }) + + if (useMetadata) { + return sharpFile.withMetadata() + } + } + + return sharpFile +} diff --git a/packages/transformer-sharp/src/parseDynamicResize.spec.ts b/packages/transformer-sharp/src/parseDynamicResize.spec.ts new file mode 100644 index 00000000000..0ebc217a5e8 --- /dev/null +++ b/packages/transformer-sharp/src/parseDynamicResize.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import { parseDynamicResize } from './parseDynamicResize.js' + +const DEFAULT_LIMITS = { maxHeight: 4096, maxPixels: 16_777_216, maxWidth: 4096 } + +const parse = (query: string) => + parseDynamicResize({ limits: DEFAULT_LIMITS, searchParams: new URLSearchParams(query) }) + +describe('parseDynamicResize', () => { + it('should route a request containing only withoutEnlargement, but reject it as invalid', () => { + const result = parse('withoutEnlargement=true') + expect(result.isRouted).toBe(true) + expect(result).toMatchObject({ valid: false }) + }) + + it.each([true, false])( + 'should parse a valid withoutEnlargement=%s alongside width', + (withoutEnlargement) => { + expect(parse(`width=500&withoutEnlargement=${withoutEnlargement}`)).toEqual({ + height: undefined, + isRouted: true, + valid: true, + width: 500, + withoutEnlargement, + }) + }, + ) + + it('should reject an invalid withoutEnlargement value', () => { + const result = parse('width=500&withoutEnlargement=yes') + expect(result).toMatchObject({ isRouted: true, valid: false }) + }) + + it.each([ + 'width=500&width=600', + 'height=500&height=600', + 'width=500&withoutEnlargement=true&withoutEnlargement=false', + ])('should reject a repeated parameter: %s', (query) => { + expect(parse(query)).toMatchObject({ isRouted: true, valid: false }) + }) + + it.each(['abc', '12.5', '-5', '0', '', '5px', ' 5'])( + 'should reject a non-positive-integer width: %s', + (value) => { + const result = parse(`width=${encodeURIComponent(value)}`) + expect(result).toMatchObject({ isRouted: true, valid: false }) + }, + ) + + it('should reject an unsafe-integer width without throwing', () => { + const result = parse('width=99999999999999999999999999999999') + expect(result).toMatchObject({ isRouted: true, valid: false }) + }) + + it('should reject a width exceeding the configured maxWidth', () => { + const result = parse('width=5000') + expect(result).toMatchObject({ isRouted: true, valid: false }) + }) + + it('should reject a height exceeding the configured maxHeight', () => { + const result = parse('height=5000') + expect(result).toMatchObject({ isRouted: true, valid: false }) + }) + + it('should accept a width exactly at the configured maxWidth', () => { + expect(parse('width=4096')).toMatchObject({ isRouted: true, valid: true }) + }) + + it('should reject a width/height combination whose product exceeds maxPixels even when each dimension is individually within its own limit', () => { + const result = parseDynamicResize({ + limits: { maxHeight: 4096, maxPixels: 1000, maxWidth: 4096 }, + searchParams: new URLSearchParams('width=100&height=100'), + }) + expect(result).toMatchObject({ isRouted: true, valid: false }) + }) + + it('should accept a width/height combination within the configured maxPixels', () => { + const result = parseDynamicResize({ + limits: { maxHeight: 4096, maxPixels: 1000, maxWidth: 4096 }, + searchParams: new URLSearchParams('width=10&height=10'), + }) + expect(result).toMatchObject({ height: 10, isRouted: true, valid: true, width: 10 }) + }) +}) diff --git a/packages/transformer-sharp/src/parseDynamicResize.ts b/packages/transformer-sharp/src/parseDynamicResize.ts new file mode 100644 index 00000000000..f8e4013a99f --- /dev/null +++ b/packages/transformer-sharp/src/parseDynamicResize.ts @@ -0,0 +1,125 @@ +import type { DynamicResizeParseResult } from './types.js' + +const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/ + +function parsePositiveInteger(value: string): number | undefined { + if (!POSITIVE_INTEGER_PATTERN.test(value)) { + return undefined + } + + const parsed = Number(value) + + return Number.isSafeInteger(parsed) ? parsed : undefined +} + +/** + * Strictly parses the v1 dynamic resize query parameters (`width`, `height`, + * `withoutEnlargement`). The presence of any of them routes the request — + * a malformed combination is `valid: false` (mapped to `400`), not treated as + * an ordinary read. Every other query key (`draft`, `depth`, `where`, `prefix`, + * …) is ignored. + */ +export function parseDynamicResize({ + limits, + searchParams, +}: { + limits: { maxHeight: number; maxPixels: number; maxWidth: number } + searchParams: URLSearchParams +}): DynamicResizeParseResult { + const widthValues = searchParams.getAll('width') + const heightValues = searchParams.getAll('height') + const withoutEnlargementValues = searchParams.getAll('withoutEnlargement') + + const isRouted = + widthValues.length > 0 || heightValues.length > 0 || withoutEnlargementValues.length > 0 + + if (!isRouted) { + return { isRouted: false } + } + + if (widthValues.length > 1) { + return { error: '`width` may only be specified once.', isRouted: true, valid: false } + } + + if (heightValues.length > 1) { + return { error: '`height` may only be specified once.', isRouted: true, valid: false } + } + + if (withoutEnlargementValues.length > 1) { + return { + error: '`withoutEnlargement` may only be specified once.', + isRouted: true, + valid: false, + } + } + + if (widthValues.length === 0 && heightValues.length === 0) { + return { + error: 'At least one of `width` or `height` is required to resize.', + isRouted: true, + valid: false, + } + } + + let width: number | undefined + + if (widthValues.length === 1) { + width = parsePositiveInteger(widthValues[0]!) + + if (width === undefined) { + return { error: '`width` must be a positive integer.', isRouted: true, valid: false } + } + + if (width > limits.maxWidth) { + return { + error: `\`width\` exceeds the configured maximum of ${limits.maxWidth}.`, + isRouted: true, + valid: false, + } + } + } + + let height: number | undefined + + if (heightValues.length === 1) { + height = parsePositiveInteger(heightValues[0]!) + + if (height === undefined) { + return { error: '`height` must be a positive integer.', isRouted: true, valid: false } + } + + if (height > limits.maxHeight) { + return { + error: `\`height\` exceeds the configured maximum of ${limits.maxHeight}.`, + isRouted: true, + valid: false, + } + } + } + + if (width !== undefined && height !== undefined && width * height > limits.maxPixels) { + return { + error: `Requested dimensions exceed the configured maximum of ${limits.maxPixels} pixels.`, + isRouted: true, + valid: false, + } + } + + let withoutEnlargement: boolean | undefined + + if (withoutEnlargementValues.length === 1) { + const raw = withoutEnlargementValues[0] + + if (raw !== 'true' && raw !== 'false') { + return { + error: '`withoutEnlargement` must be "true" or "false".', + isRouted: true, + valid: false, + } + } + + withoutEnlargement = raw === 'true' + } + + return { height, isRouted: true, valid: true, width, withoutEnlargement } +} diff --git a/packages/transformer-sharp/src/prepareLegacyUpload.ts b/packages/transformer-sharp/src/prepareLegacyUpload.ts new file mode 100644 index 00000000000..306d8a72eed --- /dev/null +++ b/packages/transformer-sharp/src/prepareLegacyUpload.ts @@ -0,0 +1,177 @@ +import type { FocalPoint, ProbedImageSize } from 'payload' +import type { PreparedUploadTransformation, UploadTransformerInternal } from 'payload/internal' + +import { isNumber } from 'payload/shared' + +import type { SharpCollectionConfig, SharpDependency, SharpUploadTaskOptions } from './types.js' + +import { canResizeImage } from './canResizeImage.js' +import { getImageResizeAction } from './getImageResizeAction.js' +import { mapWithBoundedConcurrency } from './mapWithBoundedConcurrency.js' +import { sanitizeResizeConfig } from './sanitizeResizeConfig.js' + +/** + * Sharp throws on truncated/header-only files. Returns `undefined` instead — + * matching core's dependency-free probe — since unreadable dimensions shouldn't fail the upload. + */ +async function tryProbe(file: File, sharpDependency: SharpDependency) { + try { + const buffer = Buffer.from(await file.arrayBuffer()) + const metadata = await sharpDependency(buffer).metadata() + return metadata.width && metadata.height ? metadata : undefined + } catch { + return undefined + } +} + +/** + * Computes the legacy upload tasks (the main file, optionally cropped, and every + * configured legacy image size) and hands each to the injected `transform` callback + * exactly once. Reads the Sharp-owned config from `sharpTransformer({ collections })` + * at factory-construction time — not from the sanitized collection, which only gets + * the narrowed projection `init()` writes back. + */ +export function createPrepareLegacyUpload({ + collections, + sharpDependency, +}: { + collections: Partial> + sharpDependency: SharpDependency +}): NonNullable { + return async ({ collectionSlug, file, transform, uploadEdits }) => { + const collectionUpload = collections[collectionSlug] ?? {} + + const fileSupportsResize = canResizeImage(file.type) + + const cropData = + uploadEdits && typeof uploadEdits === 'object' && 'crop' in uploadEdits + ? uploadEdits.crop + : undefined + + let originalDimensions: ProbedImageSize | undefined + + if (fileSupportsResize) { + const originalMeta = await tryProbe(file, sharpDependency) + if (originalMeta) { + originalDimensions = { height: originalMeta.height, width: originalMeta.width } + } + } + + // Unreadable dimensions can't be cropped/resized — treat as non-resizable rather than failing the upload. + const canProcessAsImage = fileSupportsResize && originalDimensions !== undefined + + const crop = + cropData && originalDimensions + ? { + cropData, + heightInPixels: uploadEdits.heightInPixels!, + originalDimensions, + widthInPixels: uploadEdits.widthInPixels!, + } + : undefined + + const mainResultFile = await transform({ + fieldPath: 'filename', + options: { collectionUpload, crop, kind: 'main' } satisfies SharpUploadTaskOptions, + }) + + const mainResult = await describeResult({ + fieldPath: 'filename', + fileSupportsResize: canProcessAsImage, + resultFile: mainResultFile, + sharpDependency, + }) + + const results: PreparedUploadTransformation[] = [mainResult] + + // A cropped upload derives its sizes from the crop output, not the original. + const isCropped = Boolean(crop && mainResult.width && mainResult.height) + const sizeSourceFile = isCropped ? mainResultFile : undefined + const sizeSourceDimensions: ProbedImageSize | undefined = isCropped + ? { height: mainResult.height!, width: mainResult.width! } + : originalDimensions + + const focalPointEnabled = collectionUpload.focalPoint !== false + const variants = collectionUpload.variants + + if (canProcessAsImage && Array.isArray(variants) && sizeSourceDimensions) { + const focalPoint: FocalPoint | undefined = + focalPointEnabled && uploadEdits?.focalPoint + ? { + x: isNumber(uploadEdits.focalPoint.x) ? Math.round(uploadEdits.focalPoint.x) : 50, + y: isNumber(uploadEdits.focalPoint.y) ? Math.round(uploadEdits.focalPoint.y) : 50, + } + : undefined + + const sizeResults = await mapWithBoundedConcurrency(variants, async (rawConfig) => { + const imageResizeConfig = sanitizeResizeConfig(rawConfig) + const fieldPath = `sizes.${imageResizeConfig.name}` as const + + const resizeAction = getImageResizeAction({ + dimensions: sizeSourceDimensions, + hasFocalPoint: Boolean(focalPoint), + imageResizeConfig, + }) + + if (resizeAction === 'omit') { + return { fieldPath } satisfies PreparedUploadTransformation + } + + const sizeResultFile = await transform({ + fieldPath, + file: sizeSourceFile, + options: { + collectionUpload, + focalPoint: resizeAction === 'resizeWithFocalPoint' ? focalPoint : undefined, + imageResizeConfig, + kind: 'size', + originalDimensions: sizeSourceDimensions, + } satisfies SharpUploadTaskOptions, + }) + + return describeResult({ + fieldPath, + fileSupportsResize: true, + resultFile: sizeResultFile, + sharpDependency, + }) + }) + + results.push(...sizeResults) + } + + return results + } +} + +async function describeResult({ + fieldPath, + fileSupportsResize, + resultFile, + sharpDependency, +}: { + fieldPath: PreparedUploadTransformation['fieldPath'] + fileSupportsResize: boolean + resultFile: File + sharpDependency: SharpDependency +}): Promise { + if (!fileSupportsResize) { + return { fieldPath, file: resultFile, mimeType: resultFile.type } + } + + const meta = await tryProbe(resultFile, sharpDependency) + + if (!meta) { + return { fieldPath, file: resultFile, mimeType: resultFile.type } + } + + // `tryProbe` never sets `animated: true`, so `meta.height` is already the + // single-frame height — do not divide by page count here. + return { + fieldPath, + file: resultFile, + height: meta.height, + mimeType: resultFile.type, + width: meta.width, + } +} diff --git a/packages/transformer-sharp/src/sanitizeResizeConfig.ts b/packages/transformer-sharp/src/sanitizeResizeConfig.ts new file mode 100644 index 00000000000..5ae20a55f88 --- /dev/null +++ b/packages/transformer-sharp/src/sanitizeResizeConfig.ts @@ -0,0 +1,21 @@ +import type { ImageSize } from 'payload' + +/** + * Sanitize the resize config. If the resize config has the `withoutReduction` + * property set to true, the `fit` and `position` properties will be set to `contain` + * and `top left` respectively. + * + * @param resizeConfig - the resize config + * @returns a sanitized resize config + */ +export const sanitizeResizeConfig = (resizeConfig: ImageSize): ImageSize => { + if (resizeConfig.withoutReduction) { + return { + ...resizeConfig, + // Why fit `contain` should also be set to https://github.com/lovell/sharp/issues/3595 + fit: resizeConfig?.fit || 'contain', + position: resizeConfig?.position || 'left top', + } + } + return resizeConfig +} diff --git a/packages/transformer-sharp/src/sharpTransformer.spec.ts b/packages/transformer-sharp/src/sharpTransformer.spec.ts new file mode 100644 index 00000000000..45758e2aa25 --- /dev/null +++ b/packages/transformer-sharp/src/sharpTransformer.spec.ts @@ -0,0 +1,48 @@ +import type { Config, UploadCollectionSlug } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { resolveSharpDynamicDefaults, sharpTransformer } from './sharpTransformer.js' + +describe('sharpTransformer', () => { + it("should default mimeTypes to canResizeImage's allow-list exactly, excluding jxl", () => { + expect(sharpTransformer().mimeTypes).toEqual([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/tiff', + 'image/avif', + ]) + expect(sharpTransformer().mimeTypes).not.toContain('image/jxl') + }) + + it('should reject dynamic.collections that are unknown or not upload-enabled on init', () => { + const config = { + collections: [ + { slug: 'media', fields: [], upload: true }, + { slug: 'posts', fields: [] }, + ], + } as unknown as Config + const transformer = sharpTransformer({ + dynamic: { collections: ['posts', 'missing'] as unknown as UploadCollectionSlug[] }, + }) + + expect(() => transformer.init!(config)).toThrow( + /not an upload-enabled collection: "posts", "missing"/, + ) + }) +}) + +describe('resolveSharpDynamicDefaults', () => { + it('should default to fit=cover, position=center, maxWidth=4096, maxHeight=4096, maxPixels=16_777_216, withoutEnlargement=false', () => { + expect(resolveSharpDynamicDefaults()).toEqual({ + fit: 'cover', + maxHeight: 4096, + maxPixels: 16_777_216, + maxWidth: 4096, + position: 'center', + withoutEnlargement: false, + }) + }) +}) diff --git a/packages/transformer-sharp/src/sharpTransformer.ts b/packages/transformer-sharp/src/sharpTransformer.ts new file mode 100644 index 00000000000..1574037767d --- /dev/null +++ b/packages/transformer-sharp/src/sharpTransformer.ts @@ -0,0 +1,125 @@ +import type { Config, TransformFileArgs, TransformFileResult, UploadTransformer } from 'payload' +import type { TransformerWithInternalBridge } from 'payload/internal' + +import { uploadTransformerInternal } from 'payload/internal' +import bundledSharp from 'sharp' + +import type { SharpDynamicDefaults, SharpDynamicOptions, SharpTransformerOptions } from './types.js' + +import { createHandleRequest } from './handleRequest.js' +import { initSharpCollections } from './initSharpCollections.js' +import { parseDynamicResize } from './parseDynamicResize.js' +import { createPrepareLegacyUpload } from './prepareLegacyUpload.js' +import { createTransformFile } from './transformFile.js' + +const DEFAULT_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/tiff', + 'image/avif', +] + +export function resolveSharpDynamicDefaults( + overrides?: SharpDynamicDefaults, +): Required { + return { + fit: overrides?.fit ?? 'cover', + maxHeight: overrides?.maxHeight ?? 4096, + maxPixels: overrides?.maxPixels ?? 16_777_216, + maxWidth: overrides?.maxWidth ?? 4096, + position: overrides?.position ?? 'center', + withoutEnlargement: overrides?.withoutEnlargement ?? false, + } +} + +/** + * Normalizes the `dynamic` option: `false`/omitted disables request-time + * resizing, `true` enables it with defaults for every upload collection. + */ +function resolveSharpDynamicOptions( + dynamic: SharpTransformerOptions['dynamic'], +): false | SharpDynamicOptions { + if (!dynamic) { + return false + } + + return dynamic === true ? {} : dynamic +} + +/** + * Payload's official Sharp-based file transformer: upload-time image processing, + * plus opt-in (`dynamic`) request-time width/height/`withoutEnlargement` resizing. + */ +export function sharpTransformer( + options: SharpTransformerOptions = {}, +): TransformerWithInternalBridge & UploadTransformer { + const dynamicOptions = resolveSharpDynamicOptions(options.dynamic) + const dynamicDefaults = resolveSharpDynamicDefaults(dynamicOptions || undefined) + const sharpDependency = options.sharp ?? bundledSharp + const collections = options.collections ?? {} + + return { + slug: options.slug ?? 'sharp', + canTransform: (args) => { + // Upload-time eligibility is already decided by the MIME match that ran + // before `canTransform`; only dynamic request routing needs the query. + if (args.operation === 'upload') { + return true + } + + if ( + !dynamicOptions || + (dynamicOptions.collections && !dynamicOptions.collections.includes(args.collectionSlug)) + ) { + return false + } + + const result = parseDynamicResize({ + limits: dynamicDefaults, + searchParams: args.req.searchParams ?? new URLSearchParams(), + }) + + return result.isRouted + }, + handleRequest: createHandleRequest({ dynamicDefaults, sharpDependency }), + init: (config) => { + assertDynamicCollectionsExist({ config, dynamicOptions }) + + return initSharpCollections({ collections, config }) + }, + mimeTypes: DEFAULT_MIME_TYPES, + [uploadTransformerInternal]: { + prepareUpload: createPrepareLegacyUpload({ collections, sharpDependency }), + }, + // `options` here is always what this transformer computed via `prepareUpload`'s + // `transform` callback; the public contract's `unknown` just reflects that core never inspects it. + transformFile: createTransformFile({ sharpDependency }) as ( + args: TransformFileArgs, + ) => Promise, + } +} + +function assertDynamicCollectionsExist({ + config, + dynamicOptions, +}: { + config: Config + dynamicOptions: false | SharpDynamicOptions +}): void { + if (!dynamicOptions || !dynamicOptions.collections) { + return + } + + const invalidSlugs = dynamicOptions.collections.filter( + (slug) => + !config.collections?.some((collection) => collection.slug === slug && collection.upload), + ) + + if (invalidSlugs.length > 0) { + throw new Error( + `Invalid \`sharpTransformer({ dynamic: { collections } })\` configuration: not an upload-enabled collection: ${invalidSlugs.map((slug) => `"${slug}"`).join(', ')}.`, + ) + } +} diff --git a/packages/transformer-sharp/src/transformFile.spec.ts b/packages/transformer-sharp/src/transformFile.spec.ts new file mode 100644 index 00000000000..d5383a60ed9 --- /dev/null +++ b/packages/transformer-sharp/src/transformFile.spec.ts @@ -0,0 +1,221 @@ +import type { PayloadRequest } from 'payload' + +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' + +import type { SharpUploadTaskOptions } from './types.js' + +import { createTransformFile } from './transformFile.js' + +const makeReq = (): PayloadRequest => ({}) as PayloadRequest + +const toBuffer = async (file: File): Promise => Buffer.from(await file.arrayBuffer()) + +const makeImageBuffer = async ({ + background = { b: 0, g: 128, r: 255 }, + height, + width, +}: { + background?: { b: number; g: number; r: number } + height: number + width: number +}): Promise => + sharp({ create: { background, channels: 3, height, width } }) + .png() + .toBuffer() + +/** + * A two-colour image split along `splitAxis` (red/blue halves) — proves the + * focal-point extract region in `transformSize` lands where the math says, + * not just that the output dimensions are right. + */ +const makeTwoColorImage = async ({ + height, + splitAxis, + width, +}: { + height: number + splitAxis: 'x' | 'y' + width: number +}): Promise => { + const raw = Buffer.alloc(width * height * 3) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 3 + const isFirstHalf = splitAxis === 'x' ? x < width / 2 : y < height / 2 + + if (isFirstHalf) { + raw[idx] = 255 // red + } else { + raw[idx + 2] = 255 // blue + } + } + } + + return sharp(raw, { raw: { channels: 3, height, width } }) + .png() + .toBuffer() +} + +const sampleRawPixel = ({ + data, + info, + x, + y, +}: { + data: Buffer + info: { channels: number; width: number } + x: number + y: number +}): { b: number; g: number; r: number } => { + const idx = (y * info.width + x) * info.channels + return { b: data[idx + 2]!, g: data[idx + 1]!, r: data[idx]! } +} + +describe('createTransformFile', () => { + describe('main (transformMain)', () => { + it('should trim uniform-colour padding from the main file', async () => { + const square = await makeImageBuffer({ + background: { b: 0, g: 0, r: 0 }, + height: 20, + width: 20, + }) + const framed = await sharp({ + create: { background: { b: 255, g: 255, r: 255 }, channels: 3, height: 60, width: 60 }, + }) + .composite([{ input: square, left: 20, top: 20 }]) + .png() + .toBuffer() + const file = new File([framed], 'photo.png', { type: 'image/png' }) + const transformFile = createTransformFile({ sharpDependency: sharp }) + + const result = await transformFile({ + file, + options: { + collectionUpload: { trimOptions: {} }, + kind: 'main', + } satisfies SharpUploadTaskOptions, + req: makeReq(), + }) + + const metadata = await sharp(await toBuffer(result.file!)).metadata() + expect(metadata.width).toBe(20) + expect(metadata.height).toBe(20) + }) + + describe('crop', () => { + it('should re-apply resizeOptions to the crop output by default', async () => { + const buffer = await makeImageBuffer({ height: 100, width: 100 }) + const file = new File([buffer], 'photo.png', { type: 'image/png' }) + const transformFile = createTransformFile({ sharpDependency: sharp }) + + const result = await transformFile({ + file, + options: { + collectionUpload: { resizeOptions: { fit: 'cover', height: 20, width: 20 } }, + crop: { + cropData: { height: 40, unit: '%', width: 40, x: 25, y: 25 }, + heightInPixels: 40, + originalDimensions: { height: 100, width: 100 }, + widthInPixels: 40, + }, + kind: 'main', + } satisfies SharpUploadTaskOptions, + req: makeReq(), + }) + + const metadata = await sharp(await toBuffer(result.file!)).metadata() + expect(metadata.width).toBe(20) + expect(metadata.height).toBe(20) + }) + + it('should not re-apply resizeOptions to the crop output when withoutEnlargement is set', async () => { + const buffer = await makeImageBuffer({ height: 100, width: 100 }) + const file = new File([buffer], 'photo.png', { type: 'image/png' }) + const transformFile = createTransformFile({ sharpDependency: sharp }) + + const result = await transformFile({ + file, + options: { + collectionUpload: { + resizeOptions: { height: 20, width: 20, withoutEnlargement: true }, + }, + crop: { + cropData: { height: 40, unit: '%', width: 40, x: 25, y: 25 }, + heightInPixels: 40, + originalDimensions: { height: 100, width: 100 }, + widthInPixels: 40, + }, + kind: 'main', + } satisfies SharpUploadTaskOptions, + req: makeReq(), + }) + + const metadata = await sharp(await toBuffer(result.file!)).metadata() + expect(metadata.width).toBe(40) + expect(metadata.height).toBe(40) + }) + }) + }) + + describe('size (transformSize)', () => { + it('should extract the horizontally-correct focal-point region for a landscape original', async () => { + // 200x100 (2:1) forces `prioritizeHeight` (resize to 100x50); focal x:80 + // pushes the 50px-wide extract window against the clamped right edge, so + // a correct crop is entirely blue. + const buffer = await makeTwoColorImage({ height: 100, splitAxis: 'x', width: 200 }) + const file = new File([buffer], 'photo.png', { type: 'image/png' }) + const transformFile = createTransformFile({ sharpDependency: sharp }) + + const result = await transformFile({ + file, + options: { + collectionUpload: {}, + focalPoint: { x: 80, y: 50 }, + imageResizeConfig: { name: 'thumb', height: 50, width: 50 }, + kind: 'size', + originalDimensions: { height: 100, width: 200 }, + } satisfies SharpUploadTaskOptions, + req: makeReq(), + }) + + const { data, info } = await sharp(await toBuffer(result.file!)) + .raw() + .toBuffer({ resolveWithObject: true }) + expect(info.width).toBe(50) + expect(info.height).toBe(50) + expect(sampleRawPixel({ data, info, x: 10, y: 25 })).toEqual({ b: 255, g: 0, r: 0 }) + expect(sampleRawPixel({ data, info, x: 40, y: 25 })).toEqual({ b: 255, g: 0, r: 0 }) + }) + + it('should extract the vertically-correct focal-point region for a portrait original', async () => { + // 100x200 (1:2) forces the width-priority branch (resize to 50x100); focal + // y:80 pushes the 50px-tall extract window against the clamped bottom edge, + // so a correct crop is entirely blue. + const buffer = await makeTwoColorImage({ height: 200, splitAxis: 'y', width: 100 }) + const file = new File([buffer], 'photo.png', { type: 'image/png' }) + const transformFile = createTransformFile({ sharpDependency: sharp }) + + const result = await transformFile({ + file, + options: { + collectionUpload: {}, + focalPoint: { x: 50, y: 80 }, + imageResizeConfig: { name: 'thumb', height: 50, width: 50 }, + kind: 'size', + originalDimensions: { height: 200, width: 100 }, + } satisfies SharpUploadTaskOptions, + req: makeReq(), + }) + + const { data, info } = await sharp(await toBuffer(result.file!)) + .raw() + .toBuffer({ resolveWithObject: true }) + expect(info.width).toBe(50) + expect(info.height).toBe(50) + expect(sampleRawPixel({ data, info, x: 25, y: 10 })).toEqual({ b: 255, g: 0, r: 0 }) + expect(sampleRawPixel({ data, info, x: 25, y: 40 })).toEqual({ b: 255, g: 0, r: 0 }) + }) + }) +}) diff --git a/packages/transformer-sharp/src/transformFile.ts b/packages/transformer-sharp/src/transformFile.ts new file mode 100644 index 00000000000..eaa7e222f0a --- /dev/null +++ b/packages/transformer-sharp/src/transformFile.ts @@ -0,0 +1,286 @@ +import type { PayloadRequest, TransformFileArgs, TransformFileResult } from 'payload' +import type { ResizeOptions, SharpOptions } from 'sharp' + +import type { SharpDependency, SharpUploadTaskOptions } from './types.js' + +import { optionallyAppendMetadata } from './optionallyAppendMetadata.js' + +const ANIMATED_MIME_TYPES = ['image/avif', 'image/gif', 'image/webp'] + +const percentToPixel = (value: number, dimension: number) => Math.floor((value / 100) * dimension) + +async function toBuffer(file: File): Promise { + return Buffer.from(await file.arrayBuffer()) +} + +/** + * The Sharp package's public one-file-in/one-file-out upload primitive. + * `options.kind` selects which legacy task to run: the main file (optionally + * cropped, with `resizeOptions` re-applied to the crop output), or one named + * legacy image size. Never writes to storage. + */ +export function createTransformFile({ sharpDependency }: { sharpDependency: SharpDependency }) { + return async function transformFile({ + file, + options, + req, + }: TransformFileArgs): Promise { + if (options.kind === 'main') { + return transformMain({ file, options, req, sharpDependency }) + } + + return transformSize({ file, options, req, sharpDependency }) + } +} + +async function transformMain({ + file, + options, + req, + sharpDependency, +}: { + file: File + options: Extract + req: PayloadRequest + sharpDependency: SharpDependency +}): Promise { + const { collectionUpload, crop } = options + const { constructorOptions, formatOptions, resizeOptions, trimOptions, withMetadata } = + collectionUpload + + const fileIsAnimatedType = ANIMATED_MIME_TYPES.includes(file.type) + const fileHasAdjustments = Boolean( + resizeOptions || formatOptions || trimOptions || constructorOptions, + ) + + if (crop) { + return transformCrop({ + crop, + file, + fileIsAnimatedType, + req, + resizeOptions, + sharpDependency, + withMetadata, + }) + } + + if (!fileIsAnimatedType && !fileHasAdjustments) { + return { status: 'continue' } + } + + const buffer = await toBuffer(file) + const sharpOptions: SharpOptions = { ...constructorOptions } + + if (fileIsAnimatedType) { + sharpOptions.animated = true + } + + // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 + let sharpFile = sharpDependency(buffer, sharpOptions).rotate() + + if (resizeOptions) { + sharpFile = sharpFile.resize(resizeOptions) + } + if (formatOptions) { + sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options) + } + if (trimOptions) { + sharpFile = sharpFile.trim(trimOptions) + } + + sharpFile = await optionallyAppendMetadata({ req, sharpFile, withMetadata }) + const { data: outputData } = await sharpFile.toBuffer({ resolveWithObject: true }) + + return { + file: new File([outputData], file.name, { type: file.type }), + status: 'continue', + } +} + +async function transformCrop({ + crop, + file, + fileIsAnimatedType, + req, + resizeOptions, + sharpDependency, + withMetadata, +}: { + crop: NonNullable['crop']> + file: File + fileIsAnimatedType: boolean + req: PayloadRequest + resizeOptions: ResizeOptions | undefined + sharpDependency: SharpDependency + withMetadata: SharpUploadTaskOptions['collectionUpload']['withMetadata'] +}): Promise { + const { cropData, heightInPixels, originalDimensions, widthInPixels } = crop + const buffer = await toBuffer(file) + + const sharpOptions: SharpOptions = fileIsAnimatedType ? { animated: true } : {} + + const newWidth = Number(widthInPixels) + const newHeight = Number(heightInPixels) + const dimensionsChanged = + originalDimensions.width !== newWidth || originalDimensions.height !== newHeight + + let croppedBuffer: Buffer + + if (!dimensionsChanged) { + croppedBuffer = buffer + } else { + const formattedCropData = { + height: newHeight, + left: percentToPixel(cropData.x, originalDimensions.width), + top: percentToPixel(cropData.y, originalDimensions.height), + width: newWidth, + } + + let cropped = sharpDependency(buffer, sharpOptions).extract(formattedCropData) + cropped = await optionallyAppendMetadata({ req, sharpFile: cropped, withMetadata }) + const { data } = await cropped.toBuffer({ resolveWithObject: true }) + croppedBuffer = data + } + + if (resizeOptions && !resizeOptions.withoutEnlargement) { + const { data: resizedBuffer } = await sharpDependency(croppedBuffer) + .resize({ + fit: resizeOptions.fit || 'cover', + height: resizeOptions.height, + position: resizeOptions.position || 'center', + width: resizeOptions.width, + }) + .toBuffer({ resolveWithObject: true }) + + return { + file: new File([resizedBuffer], file.name, { type: file.type }), + status: 'continue', + } + } + + return { + file: new File([croppedBuffer], file.name, { type: file.type }), + status: 'continue', + } +} + +async function transformSize({ + file, + options, + req, + sharpDependency, +}: { + file: File + options: Extract + req: PayloadRequest + sharpDependency: SharpDependency +}): Promise { + const { collectionUpload, focalPoint, imageResizeConfig, originalDimensions } = options + const { withMetadata } = collectionUpload + + const fileIsAnimatedType = ANIMATED_MIME_TYPES.includes(file.type) + const buffer = await toBuffer(file) + const sharpOptions: SharpOptions = fileIsAnimatedType ? { animated: true } : {} + + const sharpBase = sharpDependency(buffer, sharpOptions).rotate() + const originalImageMeta = await sharpBase.metadata() + + let adjustedDimensions = { ...originalDimensions } + + if ([5, 6, 7, 8].includes(originalImageMeta.orientation!)) { + adjustedDimensions = { + height: originalDimensions.width, + width: originalDimensions.height, + } + } + + let resized = sharpBase.clone() + + if (focalPoint) { + let { height: resizeHeight, width: resizeWidth } = imageResizeConfig + + const originalAspectRatio = adjustedDimensions.width / adjustedDimensions.height + + if (resizeHeight && !resizeWidth) { + resizeWidth = Math.round(resizeHeight * originalAspectRatio) + } + if (resizeWidth && !resizeHeight) { + resizeHeight = Math.round(resizeWidth / originalAspectRatio) + } + if (!resizeHeight) { + resizeHeight = originalImageMeta.height + } + if (!resizeWidth) { + resizeWidth = originalImageMeta.width + } + + const resizeAspectRatio = resizeWidth / resizeHeight + const prioritizeHeight = resizeAspectRatio < originalAspectRatio + + resized = resized.resize({ + fastShrinkOnLoad: false, + height: prioritizeHeight ? resizeHeight : undefined, + width: prioritizeHeight ? undefined : resizeWidth, + }) + + const metadataAppended = await optionallyAppendMetadata({ + req, + sharpFile: resized, + withMetadata, + }) + const { info } = await metadataAppended.toBuffer({ resolveWithObject: true }) + + const resizedHeight = originalImageMeta.pages + ? info.height / originalImageMeta.pages + : info.height + const resizedWidth = info.width + + const halfResizeX = resizeWidth / 2 + const xFocalCenter = resizedWidth * (focalPoint.x / 100) + let leftBound = xFocalCenter - halfResizeX + if (xFocalCenter + halfResizeX > resizedWidth) { + leftBound = resizedWidth - resizeWidth + } + if (leftBound < 0) { + leftBound = 0 + } + + const halfResizeY = resizeHeight / 2 + const yFocalCenter = resizedHeight * (focalPoint.y / 100) + let topBound = yFocalCenter - halfResizeY + if (yFocalCenter + halfResizeY > resizedHeight) { + topBound = resizedHeight - resizeHeight + } + if (topBound < 0) { + topBound = 0 + } + + resized = resized.extract({ + height: resizeHeight, + left: Math.floor(leftBound), + top: Math.floor(topBound), + width: resizeWidth, + }) + } else { + resized = resized.resize(imageResizeConfig) + } + + if (imageResizeConfig.formatOptions) { + resized = resized.toFormat( + imageResizeConfig.formatOptions.format, + imageResizeConfig.formatOptions.options, + ) + } + if (imageResizeConfig.trimOptions) { + resized = resized.trim(imageResizeConfig.trimOptions) + } + + resized = await optionallyAppendMetadata({ req, sharpFile: resized, withMetadata }) + const { data: outputData } = await resized.toBuffer({ resolveWithObject: true }) + + return { + file: new File([outputData], file.name, { type: file.type }), + status: 'continue', + } +} diff --git a/packages/transformer-sharp/src/types.ts b/packages/transformer-sharp/src/types.ts new file mode 100644 index 00000000000..296b1d7ec1d --- /dev/null +++ b/packages/transformer-sharp/src/types.ts @@ -0,0 +1,187 @@ +import type { + FocalPoint, + ImageSize, + PayloadRequest, + ProbedImageSize, + UploadCollectionSlug, + UploadEdits, +} from 'payload' +import type { FitEnum, ResizeOptions, Sharp, Metadata as SharpMetadata, SharpOptions } from 'sharp' + +/** + * Params sent to the sharp `toFormat()` function + * @link https://sharp.pixelplumbing.com/api-output#toformat + */ +export type ImageUploadFormatOptions = { + format: Parameters[0] + options?: Parameters[1] +} + +/** + * Params sent to the sharp trim() function + * @link https://sharp.pixelplumbing.com/api-resize#trim + */ +export type ImageUploadTrimOptions = Parameters[0] + +export type WithMetadata = + | ((options: { metadata: SharpMetadata; req: PayloadRequest }) => Promise) + | boolean + +/** + * Image size options implemented by Payload's default Sharp image processor. + */ +export type SharpImageSizeOptions = { + /** + * @deprecated prefer position + */ + crop?: string // comes from sharp package + formatOptions?: ImageUploadFormatOptions + trimOptions?: ImageUploadTrimOptions + /** Image sizes generated for each upload, stored under the document's `sizes`. */ + variants?: ImageSize[] + /** + * When an uploaded image is smaller than the defined image size, we have 3 options: + * + * `undefined | false | true` + * + * 1. `undefined` [default]: uploading images with smaller width AND height than the image size will return null + * 2. `false`: always enlarge images to the image size + * 3. `true`: if the image is smaller than the image size, return the original image + */ + withoutEnlargement?: ResizeOptions['withoutEnlargement'] +} & Omit + +declare module 'payload' { + interface RegisteredImageSizeOptions { + sharp: SharpImageSizeOptions + } +} + +/** + * A collection's Sharp-owned upload-time settings, authored via + * `sharpTransformer({ collections: { : {...} } })`. `init()` writes a + * narrowed, Sharp-agnostic projection of `variants` (as `imageSizes`)/`crop`/`focalPoint` + * back onto the sanitized collection's `upload` config for core's own use (Admin UI, + * field generation); this richer shape is what the transformer itself reads. + */ +export type SharpCollectionConfig = { + constructorOptions?: SharpOptions + crop?: boolean + focalPoint?: boolean + formatOptions?: ImageUploadFormatOptions + resizeOptions?: ResizeOptions + trimOptions?: ImageUploadTrimOptions + /** Image sizes generated for each upload, stored under the document's `sizes`. */ + variants?: ImageSize[] + withMetadata?: WithMetadata +} + +/** + * Matches core's (soon-removed) `SharpDependency` shape, for advanced injection + * of a compatible custom Sharp build or version via `sharpTransformer({ sharp })`. + */ +export type SharpDependency = ( + input?: + | ArrayBuffer + | Buffer + | Float32Array + | Float64Array + | Int8Array + | Int16Array + | Int32Array + | string + | Uint8Array + | Uint8ClampedArray + | Uint16Array + | Uint32Array, + options?: SharpOptions, +) => Sharp + +export type SharpDynamicDefaults = { + /** @default 'cover' */ + fit?: keyof FitEnum + /** @default 4096 */ + maxHeight?: number + /** + * Total output pixels, counted across every frame of an animated image. + * @default 16_777_216 + */ + maxPixels?: number + /** @default 4096 */ + maxWidth?: number + /** @default 'center' */ + position?: ResizeOptions['position'] + /** @default false */ + withoutEnlargement?: boolean +} + +/** + * Enables request-time resizing and tunes its limits. `collections` restricts it + * to the listed upload collections; omit it to allow every upload collection. + */ +export type SharpDynamicOptions = { + collections?: UploadCollectionSlug[] +} & SharpDynamicDefaults + +/** + * The result of parsing a request's dynamic resize query parameters. + * `isRouted: false` means none of the recognized parameters were present at + * all — an ordinary file read, not a dynamic transformation attempt. + */ +export type DynamicResizeParseResult = + | { + error: string + isRouted: true + valid: false + } + | { + height?: number + isRouted: true + valid: true + width?: number + withoutEnlargement?: boolean + } + | { + isRouted: false + } + +export type SharpTransformerOptions = { + /** Per-collection upload-time image processing settings. */ + collections?: Partial> + /** + * Request-time resizing via `?width=`/`?height=` on the file endpoint. `true` + * enables it with default limits for every upload collection; an object enables + * it with custom limits, optionally restricted to specific collections. + * + * @default false + */ + dynamic?: boolean | SharpDynamicOptions + /** Inject a compatible custom Sharp build or version. Defaults to the bundled dependency. */ + sharp?: SharpDependency + /** @default 'sharp' */ + slug?: string +} + +/** + * `options` for the one-file-in/one-file-out `transformFile` primitive, computed + * by `prepareLegacyUpload` for each task it hands to the injected `transform` + * callback: the main file (optionally a crop), or one named legacy image size. + */ +export type SharpUploadTaskOptions = + | { + collectionUpload: SharpCollectionConfig + crop?: { + cropData: NonNullable + heightInPixels: number + originalDimensions: ProbedImageSize + widthInPixels: number + } + kind: 'main' + } + | { + collectionUpload: SharpCollectionConfig + focalPoint?: FocalPoint + imageResizeConfig: ImageSize + kind: 'size' + originalDimensions: ProbedImageSize + } diff --git a/packages/transformer-sharp/tsconfig.json b/packages/transformer-sharp/tsconfig.json new file mode 100644 index 00000000000..a387f7959d5 --- /dev/null +++ b/packages/transformer-sharp/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{ "path": "../payload" }], + "compilerOptions": { + // Do not include DOM and DOM.Iterable as this is a server-only package. + "lib": ["ES2022"], + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e11f88c2488..f012c5968f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,8 +203,8 @@ importers: specifier: 6.0.1 version: 6.0.1 sharp: - specifier: 0.32.6 - version: 0.32.6 + specifier: 0.35.4 + version: 0.35.4(@types/node@24.12.3) shelljs: specifier: 0.8.5 version: 0.8.5 @@ -1082,9 +1082,6 @@ importers: rollup-plugin-dts: specifier: 6.2.3 version: 6.2.3(@typescript/typescript6@6.0.2)(rollup@4.59.0) - sharp: - specifier: 0.32.6 - version: 0.32.6 packages/payload-cloud: dependencies: @@ -1723,6 +1720,22 @@ importers: specifier: '>=8.0.0' version: 8.1.0(@types/node@24.12.3)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + packages/transformer-sharp: + dependencies: + file-type: + specifier: 22.0.1 + version: 22.0.1 + sanitize-filename: + specifier: 1.6.4 + version: 1.6.4 + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@24.12.3) + devDependencies: + payload: + specifier: workspace:* + version: link:../payload + packages/translations: dependencies: date-fns: @@ -1898,6 +1911,9 @@ importers: '@payloadcms/richtext-lexical': specifier: workspace:* version: link:../../packages/richtext-lexical + '@payloadcms/transformer-sharp': + specifier: workspace:* + version: link:../../packages/transformer-sharp '@payloadcms/ui': specifier: workspace:* version: link:../../packages/ui @@ -1922,9 +1938,6 @@ importers: react-dom: specifier: 19.2.6 version: 19.2.6(react@19.2.6) - sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.3) devDependencies: '@playwright/test': specifier: 1.63.0 @@ -2013,9 +2026,6 @@ importers: react-dom: specifier: 19.2.6 version: 19.2.6(react@19.2.6) - sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.3) srvx: specifier: 0.11.21 version: 0.11.21 @@ -2318,6 +2328,9 @@ importers: '@payloadcms/richtext-lexical': specifier: workspace:* version: link:../../packages/richtext-lexical + '@payloadcms/transformer-sharp': + specifier: workspace:* + version: link:../../packages/transformer-sharp '@payloadcms/ui': specifier: workspace:* version: link:../../packages/ui @@ -2375,9 +2388,6 @@ importers: react-hook-form: specifier: 7.71.1 version: 7.71.1(react@19.2.6) - sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.3) tailwind-merge: specifier: ^3.4.0 version: 3.6.0 @@ -2602,6 +2612,9 @@ importers: '@payloadcms/tanstack-start': specifier: workspace:* version: link:../packages/tanstack-start + '@payloadcms/transformer-sharp': + specifier: workspace:* + version: link:../packages/transformer-sharp '@payloadcms/translations': specifier: workspace:* version: link:../packages/translations @@ -2726,8 +2739,8 @@ importers: specifier: ^0.0.1 version: 0.0.1 sharp: - specifier: 0.32.6 - version: 0.32.6 + specifier: 0.35.4 + version: 0.35.4(@types/node@24.12.3) shiki: specifier: ^4.0.2 version: 4.3.1 @@ -7252,30 +7265,60 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.1.4': resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.4': resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.1.4': resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.1.4': resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7283,6 +7326,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.1.4': resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7290,6 +7340,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.1.4': resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7297,6 +7354,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.4': resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7304,6 +7368,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.4': resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7311,6 +7382,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.1.4': resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7318,12 +7396,25 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.1.4': resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.1.4': resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7335,12 +7426,24 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.4': resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.11': resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} @@ -10046,13 +10149,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} @@ -12016,9 +12112,6 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} - is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -13266,9 +13359,6 @@ packages: resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} engines: {node: '>=10'} - node-addon-api@6.1.0: - resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} - node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -14536,10 +14626,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.32.6: - resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} - engines: {node: '>=14.15.0'} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -14608,9 +14694,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} @@ -15007,9 +15090,6 @@ packages: tar-fs@2.1.5: resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} - tar-fs@3.1.3: - resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} - tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -20715,39 +20795,75 @@ snapshots: '@rolldown/binding-android-arm64@1.1.4': optional: true + '@rolldown/binding-android-arm64@1.2.8': + optional: true + '@rolldown/binding-darwin-arm64@1.1.4': optional: true + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + '@rolldown/binding-darwin-x64@1.1.4': optional: true + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + '@rolldown/binding-freebsd-x64@1.1.4': optional: true + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.4': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.4': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.4': optional: true + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.4': optional: true + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + '@rolldown/binding-wasm32-wasi@1.1.4': dependencies: '@emnapi/core': 1.11.1 @@ -20758,9 +20874,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.1.4': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.4': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + '@rolldown/pluginutils@1.0.0-beta.11': {} '@rolldown/pluginutils@1.0.1': {} @@ -24363,16 +24485,6 @@ snapshots: color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 - - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - colord@2.9.3: {} colorette@2.0.20: {} @@ -26696,8 +26808,6 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.4: {} - is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -28057,8 +28167,6 @@ snapshots: dependencies: semver: 7.8.5 - node-addon-api@6.1.0: {} - node-addon-api@7.1.1: optional: true @@ -29072,6 +29180,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 optionalDependencies: '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 rollup-plugin-dts@6.2.3(@typescript/typescript6@6.0.2)(rollup@4.59.0): dependencies: @@ -29375,21 +29497,6 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.32.6: - dependencies: - color: 4.2.3 - detect-libc: 2.1.2 - node-addon-api: 6.1.0 - prebuild-install: 7.1.3 - semver: 7.8.5 - simple-get: 4.0.1 - tar-fs: 3.1.3 - tunnel-agent: 0.6.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -29523,10 +29630,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - simple-wcswidth@1.1.2: {} sirv@2.0.4: @@ -29981,18 +30084,6 @@ snapshots: pump: 3.0.4 tar-stream: 2.2.0 - tar-fs@3.1.3: - dependencies: - pump: 3.0.4 - tar-stream: 3.2.0 - optionalDependencies: - bare-fs: 4.7.3 - bare-path: 3.1.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - tar-stream@2.2.0: dependencies: bl: 4.1.0 diff --git a/tsconfig.json b/tsconfig.json index a1c0508b142..9c70448ae6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -77,6 +77,9 @@ { "path": "./packages/richtext-lexical" }, + { + "path": "./packages/transformer-sharp" + }, { "path": "./packages/translations" }, From 78bf69062859e8ce3ef60e761fa74ac83910b5f1 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 10:06:03 +0100 Subject: [PATCH 03/13] chore: address file transformer review feedback --- .../index.spec.ts | 56 +++++++++ .../migrate-sharp-to-transformer/index.ts | 119 +++++++++++++----- packages/payload/src/config/build.spec.ts | 20 +++ packages/payload/src/config/build.ts | 5 +- .../src/uploads/fetchAPI-stream-file/index.ts | 17 ++- .../uploads/getFileContentRequirement.spec.ts | 13 ++ .../src/uploads/getFileContentRequirement.ts | 12 +- .../getFileFromUploadInstructions.spec.ts | 52 ++++++++ .../uploads/getFileFromUploadInstructions.ts | 36 ++++++ .../handleDynamicFileRequest.spec.ts | 63 ++++++++++ .../transformers/handleDynamicFileRequest.ts | 49 +++++++- 11 files changed, 407 insertions(+), 35 deletions(-) diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts index 3a45dbd189e..94615835593 100644 --- a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.spec.ts @@ -106,6 +106,62 @@ export default buildConfig({ ) }) + it.each([ + ['a non-array transformers property', 'upload: {\n transformers: sharedTransformers,\n },'], + ['a spread in upload', 'upload: {\n ...sharedUpload,\n },'], + ['a non-inline upload object', 'upload: sharedUpload,'], + ])( + 'keeps the Sharp settings in place when sharpTransformer cannot be registered due to %s', + async (_, uploadPropertyText) => { + const input = `import sharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'media', + fields: [], + upload: { + imageSizes: [{ name: 'thumbnail', width: 400 }], + staticDir: 'media', + }, + }, + ], + sharp, + ${uploadPropertyText} +}) +` + const { notes, source } = await runTransformWithNotes({ input }) + + expect(source).toBe(input) + expect(notes).toContainEqual( + expect.stringContaining('then remove the migrated Sharp settings'), + ) + }, + ) + + it('uses the local alias of an existing sharpTransformer import', async () => { + const input = `import { sharpTransformer as st } from '@payloadcms/transformer-sharp' +import sharp from 'sharp' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + sharp, +}) +` + const result = await runTransform({ source: input, transform: migrateSharpToTransformer }) + + expect(result).toContain('transformers: [st({ sharp })]') + expect(result).toContain( + "import { sharpTransformer as st } from '@payloadcms/transformer-sharp'\n", + ) + expect(result).not.toMatch(/\bsharpTransformer\(/) + expect(await runTransform({ source: result, transform: migrateSharpToTransformer })).toBe( + result, + ) + }) + it('renames imageSizes to variants in an already-migrated sharpTransformer config', async () => { const input = `import { sharpTransformer } from '@payloadcms/transformer-sharp' import { buildConfig } from 'payload' diff --git a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts index 13052202256..d6b55305ddb 100644 --- a/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts +++ b/packages/codemod/src/transforms/migrate-sharp-to-transformer/index.ts @@ -98,6 +98,25 @@ const findBuildConfigLocalNames = (file: SourceFile): Set => { return localNames } +/** + * The local binding `sharpTransformer` is imported under in this file, honoring an alias such as + * `import { sharpTransformer as st }`. Falls back to `sharpTransformer` when it isn't imported yet. + */ +function getSharpTransformerLocalName(sourceFile: SourceFile): string { + const namedImports = sourceFile + .getImportDeclarations() + .filter((decl) => decl.getModuleSpecifierValue() === TRANSFORMER_MODULE) + .flatMap((decl) => decl.getNamedImports()) + .filter((named) => named.getName() === TRANSFORMER_NAME) + + const unaliasedImport = namedImports.find((named) => !named.getAliasNode()) + const aliasedImport = namedImports.find((named) => named.getAliasNode()) + + return unaliasedImport + ? TRANSFORMER_NAME + : (aliasedImport?.getAliasNode()?.getText() ?? TRANSFORMER_NAME) +} + function ensureSharpTransformerImport(sourceFile: SourceFile): void { const existing = sourceFile .getImportDeclarations() @@ -133,10 +152,18 @@ function ensureSharpTransformerImport(sourceFile: SourceFile): void { } } +type CollectionSharpEntry = { + /** `: { ... }` text for the `sharpTransformer({ collections })` map. */ + entryText: string + /** Moved properties, removed from the collection only once `sharpTransformer` is registered. */ + movedProps: (PropertyAssignment | ShorthandPropertyAssignment)[] + uploadObj: ObjectLiteralExpression +} + /** - * Extracts Sharp-owned fields off one collection's `upload` object literal into - * a `: { ... }` entry text for the `sharpTransformer({ collections })` map. - * Returns `undefined` when the collection has nothing to migrate. + * Reads Sharp-owned fields off one collection's `upload` object literal into + * a `: { ... }` entry text for the `sharpTransformer({ collections })` map, + * without modifying the collection. Returns `undefined` when the collection has nothing to migrate. */ function extractCollectionSharpEntry({ collectionObj, @@ -146,7 +173,7 @@ function extractCollectionSharpEntry({ collectionObj: ObjectLiteralExpression filePath: string notes: string[] -}): string | undefined { +}): CollectionSharpEntry | undefined { const slugProp = collectionObj.getProperty('slug') const slugAssignment = slugProp?.asKind(SyntaxKind.PropertyAssignment) const slugInitializer = slugAssignment?.getInitializer() @@ -178,6 +205,7 @@ function extractCollectionSharpEntry({ } const movedTexts: string[] = [] + const movedProps: CollectionSharpEntry['movedProps'] = [] for (const name of MOVED_UPLOAD_FIELDS) { const prop = uploadObj.getProperty(name) if (!prop) { @@ -188,7 +216,7 @@ function extractCollectionSharpEntry({ // where the `sharpTransformer` call is inserted, so it moves verbatim. if (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) { movedTexts.push(name === 'imageSizes' ? printAsVariants(prop) : prop.print()) - prop.remove() + movedProps.push(prop) continue } @@ -201,11 +229,6 @@ function extractCollectionSharpEntry({ return undefined } - // Avoid leaving `upload: {\n}` spread across two lines once every property moves out. - if (uploadObj.getProperties().length === 0) { - uploadObj.replaceWithText('{}') - } - const slugLiteralValue = Node.isStringLiteral(slugInitializer) ? slugInitializer.getLiteralValue() : undefined @@ -214,7 +237,18 @@ function extractCollectionSharpEntry({ ? slugLiteralValue : `[${slugInitializer.getText()}]` - return `${keyText}: { ${movedTexts.join(', ')} }` + return { entryText: `${keyText}: { ${movedTexts.join(', ')} }`, movedProps, uploadObj } +} + +function removeMovedCollectionProps({ movedProps, uploadObj }: CollectionSharpEntry): void { + for (const prop of movedProps) { + prop.remove() + } + + // Avoid leaving `upload: {\n}` spread across two lines once every property moves out. + if (uploadObj.getProperties().length === 0) { + uploadObj.replaceWithText('{}') + } } /** @@ -230,7 +264,7 @@ function extractSharpCollectionEntries({ configObj: ObjectLiteralExpression filePath: string notes: string[] -}): string[] { +}): CollectionSharpEntry[] { const collectionsProp = configObj.getProperty('collections') if (!collectionsProp || !Node.isPropertyAssignment(collectionsProp)) { return [] @@ -244,7 +278,7 @@ function extractSharpCollectionEntries({ return [] } - const entries: string[] = [] + const entries: CollectionSharpEntry[] = [] for (const el of arrayLiteral.getElements()) { if (!Node.isObjectLiteralExpression(el)) { @@ -278,9 +312,11 @@ function printAsVariants(prop: PropertyAssignment | ShorthandPropertyAssignment) function renameImageSizesInSharpTransformerCalls({ notes, sourceFile, + transformerLocalName, }: { notes: string[] sourceFile: SourceFile + transformerLocalName: string }): boolean { let hasChanged = false @@ -288,7 +324,7 @@ function renameImageSizesInSharpTransformerCalls({ .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const callee = call.getExpression() - return Node.isIdentifier(callee) && callee.getText() === TRANSFORMER_NAME + return Node.isIdentifier(callee) && callee.getText() === transformerLocalName }) for (const call of sharpTransformerCalls) { @@ -326,13 +362,19 @@ function renameImageSizesInSharpTransformerCalls({ return hasChanged } -function findSharpTransformerCall(transformersArray: ArrayLiteralExpression) { +function findSharpTransformerCall({ + transformerLocalName, + transformersArray, +}: { + transformerLocalName: string + transformersArray: ArrayLiteralExpression +}) { return transformersArray.getElements().find((el) => { if (!Node.isCallExpression(el)) { return false } const callee = el.getExpression() - return Node.isIdentifier(callee) && callee.getText() === TRANSFORMER_NAME + return Node.isIdentifier(callee) && callee.getText() === transformerLocalName }) } @@ -343,7 +385,9 @@ export const migrateSharpToTransformer: Transform = { const notes: string[] = [] for (const sourceFile of project.getSourceFiles()) { - if (renameImageSizesInSharpTransformerCalls({ notes, sourceFile })) { + const transformerLocalName = getSharpTransformerLocalName(sourceFile) + + if (renameImageSizesInSharpTransformerCalls({ notes, sourceFile, transformerLocalName })) { filesChanged.add(sourceFile.getFilePath()) } @@ -376,7 +420,13 @@ export const migrateSharpToTransformer: Transform = { ?.asKind(SyntaxKind.PropertyAssignment) ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) - if (existingTransformersArray && findSharpTransformerCall(existingTransformersArray)) { + if ( + existingTransformersArray && + findSharpTransformerCall({ + transformerLocalName, + transformersArray: existingTransformersArray, + }) + ) { // Already migrated, but a leftover `sharp` property would fail a later // type-check with no signal from this codemod — flag it here. if (configObj.getProperty('sharp')) { @@ -395,11 +445,6 @@ export const migrateSharpToTransformer: Transform = { sharpExpressionText = sharpProp.getName() } - // Remove `sharp` before extracting collection entries — later removals - // shift node positions, and ts-morph node references taken before a - // sibling removal can go stale. - sharpProp?.remove() - const collectionEntries = extractSharpCollectionEntries({ configObj, filePath: sourceFile.getFilePath(), @@ -417,10 +462,12 @@ export const migrateSharpToTransformer: Transform = { ) } if (collectionEntries.length > 0) { - transformerArgs.push(`collections: { ${collectionEntries.join(', ')} }`) + transformerArgs.push( + `collections: { ${collectionEntries.map((entry) => entry.entryText).join(', ')} }`, + ) } - const transformerCallText = `${TRANSFORMER_NAME}({ ${transformerArgs.join(', ')} })` + const transformerCallText = `${transformerLocalName}({ ${transformerArgs.join(', ')} })` // Adding a `transformers` property next to a non-array one, or after a spread // that may already set it, would create a duplicate key whose last value wins @@ -429,15 +476,21 @@ export const migrateSharpToTransformer: Transform = { ?.getProperties() .some((prop) => Node.isSpreadAssignment(prop)) + // The old settings are only removed once the replacement call is registered — + // otherwise the migrated config would silently lose its image processing. + let isRegistered = true + if (existingTransformersArray) { existingTransformersArray.addElement(transformerCallText) } else if (existingTransformersProp) { + isRegistered = false notes.push( - `${sourceFile.getFilePath()}: \`upload.transformers\` isn't an inline array — add \`${transformerCallText}\` to it manually.`, + `${sourceFile.getFilePath()}: \`upload.transformers\` isn't an inline array — add \`${transformerCallText}\` to it manually, then remove the migrated Sharp settings.`, ) } else if (uploadHasSpread) { + isRegistered = false notes.push( - `${sourceFile.getFilePath()}: \`upload\` contains a spread that may already set \`transformers\` — add \`${transformerCallText}\` to its transformers manually.`, + `${sourceFile.getFilePath()}: \`upload\` contains a spread that may already set \`transformers\` — add \`${transformerCallText}\` to its transformers manually, then remove the migrated Sharp settings.`, ) } else if (uploadObj) { uploadObj.addPropertyAssignment({ @@ -445,8 +498,9 @@ export const migrateSharpToTransformer: Transform = { initializer: `[${transformerCallText}]`, }) } else if (uploadProp) { + isRegistered = false notes.push( - `${sourceFile.getFilePath()}: \`upload\` isn't an inline object — add \`transformers: [${transformerCallText}]\` to it manually.`, + `${sourceFile.getFilePath()}: \`upload\` isn't an inline object — add \`transformers: [${transformerCallText}]\` to it manually, then remove the migrated Sharp settings.`, ) } else { configObj.addPropertyAssignment({ @@ -455,6 +509,15 @@ export const migrateSharpToTransformer: Transform = { }) } + if (!isRegistered) { + continue + } + + sharpProp?.remove() + for (const entry of collectionEntries) { + removeMovedCollectionProps(entry) + } + ensureSharpTransformerImport(sourceFile) filesChanged.add(sourceFile.getFilePath()) } diff --git a/packages/payload/src/config/build.spec.ts b/packages/payload/src/config/build.spec.ts index 0657e496761..51ef61f64ab 100644 --- a/packages/payload/src/config/build.spec.ts +++ b/packages/payload/src/config/build.spec.ts @@ -77,4 +77,24 @@ describe('buildConfig', () => { /sharp/i, ) }) + + it('should reject removed Sharp settings a plugin adds, before any transformer init runs', async () => { + const transformer: UploadTransformer = { + init: vi.fn((config: Config) => config), + mimeTypes: ['image/*'], + slug: 'test-transformer', + } + + const plugin = (config: Config): Config => ({ + ...config, + collections: [ + { slug: 'media', fields: [], upload: { resizeOptions: { width: 100 } } }, + ] as unknown as Config['collections'], + }) + + await expect( + buildConfig(makeConfig({ plugins: [plugin], upload: { transformers: [transformer] } })), + ).rejects.toThrow(/resizeOptions/) + expect(transformer.init).not.toHaveBeenCalled() + }) }) diff --git a/packages/payload/src/config/build.ts b/packages/payload/src/config/build.ts index b9625079e55..af907406aa0 100644 --- a/packages/payload/src/config/build.ts +++ b/packages/payload/src/config/build.ts @@ -10,8 +10,6 @@ import { sanitizeConfig } from './sanitize.js' * @returns Built and sanitized Payload Config */ export async function buildConfig(config: Config): Promise { - assertNoLegacySharpConfig({ config }) - if (Array.isArray(config.plugins)) { const sorted = [...config.plugins].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) @@ -20,6 +18,9 @@ export async function buildConfig(config: Config): Promise { } } + // Runs after plugins so removed Sharp settings a plugin adds are caught too. + assertNoLegacySharpConfig({ config }) + if (Array.isArray(config.upload?.transformers) && config.upload.transformers.length > 0) { validateTransformers({ transformers: config.upload.transformers }) diff --git a/packages/payload/src/uploads/fetchAPI-stream-file/index.ts b/packages/payload/src/uploads/fetchAPI-stream-file/index.ts index 3f4cd7640cd..a01a921ff4e 100644 --- a/packages/payload/src/uploads/fetchAPI-stream-file/index.ts +++ b/packages/payload/src/uploads/fetchAPI-stream-file/index.ts @@ -1,7 +1,16 @@ import fs from 'fs' -export function iteratorToStream(iterator: AsyncIterator) { +export function iteratorToStream({ + iterator, + onCancel, +}: { + iterator: AsyncIterator + onCancel?: () => void +}) { return new ReadableStream({ + cancel() { + onCancel?.() + }, async pull(controller) { const { done, value } = await iterator.next() if (done) { @@ -27,6 +36,10 @@ export function streamFile({ options?: { end?: number; start?: number } }): ReadableStream { const nodeStream = fs.createReadStream(filePath, options) - const data: ReadableStream = iteratorToStream(nodeStreamToIterator(nodeStream)) + // Destroying the read stream on cancel releases the file handle, even if nothing was read yet. + const data: ReadableStream = iteratorToStream({ + iterator: nodeStreamToIterator(nodeStream), + onCancel: () => nodeStream.destroy(), + }) return data } diff --git a/packages/payload/src/uploads/getFileContentRequirement.spec.ts b/packages/payload/src/uploads/getFileContentRequirement.spec.ts index 71785a19179..35cd0dc3433 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.spec.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.spec.ts @@ -144,4 +144,17 @@ describe('getFileContentRequirement', () => { }), ).toBe('full') }) + + it.each(['video/mp4', 'image/png'])( + 'requires the full file for %s when a transformFile transformer will process it', + (mimeType) => { + expect( + getFileContentRequirement({ + hasTransformFileStages: true, + mimeType, + uploadConfig: createUploadConfig(), + }), + ).toBe('full') + }, + ) }) diff --git a/packages/payload/src/uploads/getFileContentRequirement.ts b/packages/payload/src/uploads/getFileContentRequirement.ts index 2b473b072e2..a277fc8cc1d 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.ts @@ -19,7 +19,8 @@ export type FileContentRequirement = 'full' | 'header' | 'none' * actually reads instead of always being re-downloaded in full. * * - `full`: local storage needs the real bytes, or a registered file transformer will adjust - * the image (`upload.hasImageAdjustments`), or the file is animated, or additional image + * the image (`upload.hasImageAdjustments`), or a custom `transformFile` transformer will + * process the file (`hasTransformFileStages`), or the file is animated, or additional image * sizes will be generated from it, or the collection restricts mime types (which also runs * SVG/PDF content-safety checks that must see the whole file), or the request itself carries * a crop/resize edit (`uploadEdits`) that will be applied to the fetched bytes. @@ -28,6 +29,7 @@ export type FileContentRequirement = 'full' | 'header' | 'none' */ export function getFileContentRequirement({ hasSizeEdits, + hasTransformFileStages, mimeType, uploadConfig, }: { @@ -37,6 +39,12 @@ export function getFileContentRequirement({ * only require a header probe. */ hasSizeEdits?: boolean + /** + * Whether a registered transformer without its own content projection (anything other than a + * built-in bridge transformer like `sharpTransformer`) will run `transformFile` on this file. + * Such a transformer reads the whole file, so neither a header probe nor no content is enough. + */ + hasTransformFileStages?: boolean mimeType: string uploadConfig: SanitizedUploadConfig }): FileContentRequirement { @@ -47,7 +55,7 @@ export function getFileContentRequirement({ const hasMimeTypeAllowList = Array.isArray(uploadConfig.mimeTypes) && uploadConfig.mimeTypes.length > 0 - if (hasMimeTypeAllowList) { + if (hasMimeTypeAllowList || hasTransformFileStages) { return 'full' } diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts index 161d6f89a9a..7cac4104bc3 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts @@ -302,6 +302,58 @@ describe('getFileFromUploadInstructions', () => { expect(file.mimetype).toBe('video/mp4') }) + it('fetches the full file when a custom transformFile transformer matches its mime type', async () => { + const handler = vi.fn(async (handlerReq: PayloadRequest) => { + expect(handlerReq.headers.get('range')).toBeNull() + return new Response(MINIMAL_PNG, { headers: { 'Content-Type': 'image/png' } }) + }) + + const req = createReq([handler], {}) + req.payload.config.upload.transformers = [ + { slug: 'custom', mimeTypes: ['image/*'], transformFile: vi.fn() }, + ] + + const file = await getFileFromUploadInstructions({ + collectionSlug: 'media', + file: createUploadReferenceFile({ + filename: 'photo.png', + mimeType: 'image/png', + size: MINIMAL_PNG.length, + }), + req, + }) + + tempFilesToClean.push(file.tempFilePath!) + + expect(handler).toHaveBeenCalledTimes(1) + expect(file.tempFilePath).toBeDefined() + expect(fs.readFileSync(file.tempFilePath!).equals(MINIMAL_PNG)).toBe(true) + }) + + it('skips fetching when a transformFile transformer declines the upload in canTransform', async () => { + const handler = vi.fn(() => { + throw new Error('No-content handler was invoked') + }) + + const req = createReq([handler], {}) + req.payload.config.upload.transformers = [ + { + slug: 'custom', + canTransform: () => false, + mimeTypes: ['*/*'], + transformFile: vi.fn(), + }, + ] + + await getFileFromUploadInstructions({ + collectionSlug: 'media', + file: createUploadReferenceFile(), + req, + }) + + expect(handler).not.toHaveBeenCalled() + }) + it('fetches only a bounded header for an image with no configured adjustments', async () => { const handler = vi.fn(async (handlerReq: PayloadRequest) => { expect(handlerReq.headers.get('range')).toBe(`bytes=0-${HEADER_PROBE_BYTE_LENGTH - 1}`) diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.ts index 4ba359fca75..ce27766dafc 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.ts @@ -20,6 +20,8 @@ import { getFileContentRequirement, HEADER_PROBE_BYTE_LENGTH } from './getFileCo import { getImageSize } from './getImageSize.js' import { hasCropOrResizeEdit } from './hasCropOrResizeEdit.js' import { getStagedFile } from './stagedUpload.js' +import { planTransformerPipeline } from './transformers/planTransformerPipeline.js' +import { getUploadTransformerInternal } from './transformers/uploadTransformerBridge.js' export const getFileFromUploadInstructions = async ({ collectionSlug, @@ -118,6 +120,11 @@ export const getFileFromUploadInstructions = async ({ const contentRequirement = getFileContentRequirement({ hasSizeEdits: requestHasSizeEdits(req), + hasTransformFileStages: await hasTransformFileStages({ + collectionSlug, + mimeType: file.mimeType, + req, + }), mimeType: file.mimeType, uploadConfig, }) @@ -174,6 +181,35 @@ const requestHasSizeEdits = (req: PayloadRequest): boolean => { return hasCropOrResizeEdit(uploadEdits as UploadEdits) } +/** + * Whether a transformer that reads the whole file will run `transformFile` on this upload. A + * bridge transformer (e.g. `sharpTransformer`) is excluded: it projects what it needs onto the + * sanitized upload config (`hasImageAdjustments`, `imageSizes`) at startup instead. + */ +const hasTransformFileStages = async ({ + collectionSlug, + mimeType, + req, +}: { + collectionSlug: string + mimeType: string + req: PayloadRequest +}): Promise => { + const transformers = req.payload.config.upload?.transformers ?? [] + + if (transformers.length === 0) { + return false + } + + const pipeline = await planTransformerPipeline({ + args: { collectionSlug, mimeType, operation: 'upload', req }, + capability: 'transformFile', + transformers, + }) + + return pipeline.some((transformer) => !getUploadTransformerInternal(transformer)?.prepareUpload) +} + /** * Fetches only the first `HEADER_PROBE_BYTE_LENGTH` bytes of the upload (via a best-effort byte * range request) and uses them to probe an image's dimensions, without downloading the rest of diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts index b2746704d4d..acef4d9da8d 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.spec.ts @@ -134,6 +134,69 @@ describe('handleDynamicFileRequest', () => { ) }) + describe('failed pipeline cleanup', () => { + const makeTrackedSourceResponse = () => { + const onCancel = vi.fn() + const response = new Response( + new ReadableStream({ + cancel: onCancel, + pull: (controller) => controller.enqueue(new TextEncoder().encode('source-bytes')), + }), + ) + + return { onCancel, response } + } + + it('should close a source body the throwing transformer locked with its own reader', async () => { + const source = makeTrackedSourceResponse() + vi.mocked(getSourceFileResponse).mockResolvedValue(source.response) + + const transformer: UploadTransformer = { + slug: 'throwing-transformer', + handleRequest: vi.fn().mockImplementation(async ({ getSourceFile }) => { + const sourceResponse: Response = await getSourceFile() + await sourceResponse.body!.getReader().read() + throw new Error('transform failed') + }), + mimeTypes: ['image/*'], + } + vi.mocked(planTransformerPipeline).mockResolvedValue([transformer]) + + await expect( + handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq() }), + ).rejects.toThrow('transform failed') + + await vi.waitFor(() => expect(source.onCancel).toHaveBeenCalled()) + }) + + it('should cancel an earlier stage response no later stage read', async () => { + const stageOutput = makeTrackedSourceResponse() + + const transformers: UploadTransformer[] = [ + { + slug: 'first', + handleRequest: vi.fn().mockResolvedValue({ + response: stageOutput.response, + status: 'continue', + }), + mimeTypes: ['image/*'], + }, + { + slug: 'second', + handleRequest: vi.fn().mockRejectedValue(new Error('transform failed')), + mimeTypes: ['image/*'], + }, + ] + vi.mocked(planTransformerPipeline).mockResolvedValue(transformers) + + await expect( + handleDynamicFileRequest({ collection, filename: 'logo.png', req: makeReq() }), + ).rejects.toThrow('transform failed') + + expect(stageOutput.onCancel).toHaveBeenCalled() + }) + }) + describe('access control ordering', () => { beforeEach(() => { vi.mocked(resolveUploadDocument).mockResolvedValue(authorizedDocument) diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts index c210efb8075..4fd18e43566 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -66,11 +66,18 @@ export async function handleDynamicFileRequest({ }) let currentResponse: Response | undefined + // Aborted on failure to close every response body handed to a transformer, even one the + // transformer locked with its own reader before throwing. + const handedOutBodies = new AbortController() try { for (const transformer of pipeline) { const stageSource = createLazySourceGetter({ - retrieve: async () => currentResponse ?? source.get(), + retrieve: async () => + withAbortableBody({ + response: currentResponse ?? (await source.get()), + signal: handedOutBodies.signal, + }), }) const result = await transformer.handleRequest!({ @@ -96,6 +103,8 @@ export async function handleDynamicFileRequest({ } } catch (err) { req.payload.logger.error({ err, msg: 'Error running the file transformer pipeline' }) + handedOutBodies.abort(err) + await cancelUnusedBody(currentResponse) throw err } @@ -108,6 +117,44 @@ export async function handleDynamicFileRequest({ return retrieveFileResponse({ collection, doc: document, filename, prefix, req }) } +/** + * Re-wraps `response` so its body can be cancelled through `signal`, closing the underlying + * source stream (file handle, storage connection) regardless of who holds a reader on it. + */ +function withAbortableBody({ + response, + signal, +}: { + response: Response + signal: AbortSignal +}): Response { + if (!response.body) { + return response + } + + return new Response(response.body.pipeThrough(new TransformStream(), { signal }), { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }) +} + +/** + * Cancels a response body nothing has started reading. A locked body was handed to a transformer + * through `withAbortableBody`, and is closed by aborting its signal instead. + */ +async function cancelUnusedBody(response: Response | undefined): Promise { + if (!response?.body || response.body.locked) { + return + } + + try { + await response.body.cancel() + } catch { + // The pipeline error is what gets reported — a failure to cancel must not mask it. + } +} + function planRequestPipeline({ collection, document, From 563145d7d095b66303ee1729294ff02bc2c796bc Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 10:18:14 +0100 Subject: [PATCH 04/13] chore: rename sanitized upload.imageSizes to upload.variants --- docs/migration-guide/v4.mdx | 2 +- docs/upload/overview.mdx | 2 +- docs/upload/transformers.mdx | 2 +- packages/payload/src/collections/config/client.ts | 4 ++-- .../src/config/assertNoLegacySharpConfig.spec.ts | 8 ++++++++ .../src/config/assertNoLegacySharpConfig.ts | 13 ++++++++++--- packages/payload/src/uploads/checkFileAccess.ts | 2 +- .../payload/src/uploads/docWithFilenameExists.ts | 4 ++-- .../payload/src/uploads/generateFileData.spec.ts | 2 +- packages/payload/src/uploads/generateFileData.ts | 2 +- packages/payload/src/uploads/getBaseFields.ts | 8 ++++---- .../src/uploads/getFileContentRequirement.spec.ts | 8 ++++---- .../src/uploads/getFileContentRequirement.ts | 4 ++-- .../uploads/getFileFromUploadInstructions.spec.ts | 4 ++-- .../src/uploads/getFileFromUploadInstructions.ts | 2 +- .../uploads/transformers/resolveUploadDocument.ts | 10 ++++------ packages/payload/src/uploads/types.ts | 4 ++-- .../src/utilities/appendUploadSelectFields.ts | 4 ++-- .../telemetry/featureInfo/getFeatureInfo.ts | 2 +- .../plugin-cloud-storage/src/fields/getFields.ts | 8 ++++---- .../src/utilities/getFilePrefix.ts | 4 ++-- .../src/authorizeFileOverwrite.ts | 6 +++--- .../src/elements/FileManager/FilePreview/index.tsx | 4 ++-- packages/ui/src/elements/FileManager/index.tsx | 6 +++--- packages/ui/src/elements/MiniCarousel/index.tsx | 8 ++++---- packages/ui/src/elements/PreviewSizes/index.tsx | 14 +++++++------- packages/ui/src/elements/Upload/index.tsx | 10 +++++----- test/buildConfigWithDefaults.ts | 2 +- test/image-size-options/default/types.spec.ts | 6 +++++- .../collections/MediaHeaderOnlyWithSizes.ts | 4 ++-- test/storage-azure/client-uploads/int.spec.ts | 6 +++--- .../collections/MediaHeaderOnlyWithSizes.ts | 4 ++-- test/storage-s3/client-uploads/int.spec.ts | 6 +++--- 33 files changed, 96 insertions(+), 79 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 7a6b6a6344d..e2b00ca6698 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1854,7 +1854,7 @@ pnpm add @payloadcms/transformer-sharp Run `npx @payloadcms/codemod --transform migrate-sharp-to-transformer` to migrate automatically. The codemod moves a top-level `sharp` dependency and inline collection Sharp options — including `crop` and `focalPoint` — into `sharpTransformer({ collections })` (writing `imageSizes` as `variants`), appends to an existing `upload.transformers` array if you already have one, and leaves anything it can't safely rewrite in place with a note — for example, a `collections` array built by a function call, or a collection defined outside the `buildConfig` call. Review those manually. For a config that already registers `sharpTransformer`, it only renames `imageSizes` to `variants` inside the existing `sharpTransformer` call and flags a leftover top-level `sharp` property; it doesn't move any remaining collection options. -Your `variants`, `crop`, and `focalPoint` are still readable at `collection.upload.imageSizes`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.imageSizes`). `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel, generated types, and the stored `sizes` shape on your documents are unchanged. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. `imageSizes` can't be set on the collection at all. +Your `variants`, `crop`, and `focalPoint` are readable at `collection.upload.variants`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.variants`). Code that read the sanitized `upload.imageSizes` in 3.x must read `upload.variants` instead. `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel, generated types, and the stored `sizes` shape on your documents are unchanged. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither `imageSizes` nor `variants` can be set on the collection at all. If you inject a custom Sharp build or version, pass it the same way: diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 0a71b4fca5b..7cc07e70101 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -227,7 +227,7 @@ export default buildConfig({ }) ``` -`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.imageSizes`, so the rest of this section — and the generated `sizes` shape on your documents — is unaffected by where the configuration is authored. `imageSizes` can't be set on the collection itself. +`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.variants`, so the rest of this section — and the generated `sizes` shape on your documents — is unaffected by where the configuration is authored. Neither `imageSizes` nor `variants` can be set on the collection itself. #### Admin List View Options diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx index 8adf3f3ea34..a1fa2faf055 100644 --- a/docs/upload/transformers.mdx +++ b/docs/upload/transformers.mdx @@ -280,7 +280,7 @@ export default buildConfig({ }) ``` -At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.imageSizes`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. The `sizes` shape on your documents is unchanged. `imageSizes` exists only on the sanitized config: setting it on the collection itself fails the build. +At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.variants`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. The `sizes` shape on your documents is unchanged. `variants` exists only on the sanitized config: setting it (or the removed `imageSizes`) on the collection itself fails the build. See [Image Sizes](./overview#image-sizes) for what each Sharp-specific image size option does, and [Crop and Focal Point Selector](./overview#crop-and-focal-point-selector) for the Admin Panel behavior these settings control. diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index d79ff341d14..05f477768b1 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -296,8 +296,8 @@ export const createClientCollectionConfig = ({ continue } - if (uploadKey === 'imageSizes') { - clientCollection.upload.imageSizes = collection.upload.imageSizes?.map((size) => { + if (uploadKey === 'variants') { + clientCollection.upload.variants = collection.upload.variants?.map((size) => { const sanitizedSize = { ...size } if ('generateImageName' in sanitizedSize) { delete sanitizedSize.generateImageName diff --git a/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts b/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts index 44643ca8838..24d63741e06 100644 --- a/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts +++ b/packages/payload/src/config/assertNoLegacySharpConfig.spec.ts @@ -35,6 +35,14 @@ describe('assertNoLegacySharpConfig', () => { }, ) + it('should throw when a collection sets variants directly on its upload config', () => { + const config = { + collections: [{ slug: 'media', upload: { variants: [{ name: 'thumbnail' }] } }], + } as unknown as Config + + expect(() => assertNoLegacySharpConfig({ config })).toThrow(/upload\.variants/) + }) + it.each([ { collections: [{ slug: 'media', upload: { imageSizes: [{ name: 'thumbnail' }] } }] }, { diff --git a/packages/payload/src/config/assertNoLegacySharpConfig.ts b/packages/payload/src/config/assertNoLegacySharpConfig.ts index 24912c0f36b..9185de7c23f 100644 --- a/packages/payload/src/config/assertNoLegacySharpConfig.ts +++ b/packages/payload/src/config/assertNoLegacySharpConfig.ts @@ -22,9 +22,10 @@ const MIGRATION_HINT = * this check an app upgrading from 3.x would boot with no indication that image * resizing had stopped. * - * `upload.imageSizes` is included: image sizes are authored as - * `sharpTransformer({ collections: { : { variants } } })`, and a size declared on - * the collection itself would otherwise build without error while every upload skips it. + * `upload.imageSizes` is included, and `upload.variants` is rejected too: image sizes are + * authored as `sharpTransformer({ collections: { : { variants } } })`, which writes + * `upload.variants` onto the sanitized config. A size declared on the collection itself would + * otherwise build without error while every upload skips it. * * Every violation is collected and reported together, since a config can have * more than one and fixing them one at a time would take multiple build attempts. @@ -47,6 +48,12 @@ export function assertNoLegacySharpConfig({ config }: { config: Config }): void errors.push(`Collection "${collection.slug}" uses the removed \`upload.${field}\` option.`) } } + + if ('variants' in upload) { + errors.push( + `Collection "${collection.slug}" sets \`upload.variants\`, which only a file transformer writes. Declare variants on the transformer instead, e.g. \`sharpTransformer({ collections: { "${collection.slug}": { variants } } })\`.`, + ) + } } if (errors.length > 0) { diff --git a/packages/payload/src/uploads/checkFileAccess.ts b/packages/payload/src/uploads/checkFileAccess.ts index ab57b927b64..52edf85a11b 100644 --- a/packages/payload/src/uploads/checkFileAccess.ts +++ b/packages/payload/src/uploads/checkFileAccess.ts @@ -37,7 +37,7 @@ export const checkFileAccess = async ({ } if (constraints.length > 0) { - const filenameCondition = buildFilenameWhere({ filename, imageSizes: config.upload.imageSizes }) + const filenameCondition = buildFilenameWhere({ filename, variants: config.upload.variants }) const doc = await req.payload.db.findOne({ collection: config.slug, diff --git a/packages/payload/src/uploads/docWithFilenameExists.ts b/packages/payload/src/uploads/docWithFilenameExists.ts index c1535ceed5a..7bc13768dae 100644 --- a/packages/payload/src/uploads/docWithFilenameExists.ts +++ b/packages/payload/src/uploads/docWithFilenameExists.ts @@ -28,8 +28,8 @@ export const docWithFilenameExists = async ({ equals: filename, }, }, - ...(upload && typeof upload === 'object' && upload.imageSizes - ? upload.imageSizes.map(({ name }) => ({ + ...(upload && typeof upload === 'object' && upload.variants + ? upload.variants.map(({ name }) => ({ [`sizes.${name}.filename`]: { equals: filename }, })) : []), diff --git a/packages/payload/src/uploads/generateFileData.spec.ts b/packages/payload/src/uploads/generateFileData.spec.ts index 97ff9f2b70a..a5fda4cbaca 100644 --- a/packages/payload/src/uploads/generateFileData.spec.ts +++ b/packages/payload/src/uploads/generateFileData.spec.ts @@ -327,7 +327,7 @@ describe('generateFileData', () => { } as unknown as PayloadRequest await generateFileData({ - collection: createCollection({ imageSizes: [] }), + collection: createCollection({ variants: [] }), config: {} as SanitizedConfig, data: {}, operation: 'create', diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 246e173af41..f3a28c4ba53 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -466,7 +466,7 @@ export const generateFileData = async ({ req.payloadUploadSizes[sizeName] = sizeBuffer - const imageSizeConfig = collectionConfig.upload.imageSizes?.find( + const imageSizeConfig = collectionConfig.upload.variants?.find( (imageSize) => imageSize.name === sizeName, ) diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index e19cdbe66c6..36e0794a2cc 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -25,7 +25,7 @@ type Options = { } export const getBaseUploadFields = ({ collection, config }: Options): Field[] => { - // `imageSizes` only exists once a transformer (e.g. Sharp) has written it back during init. + // `variants` only exists once a transformer (e.g. Sharp) has written it back during init. const uploadOptions: Partial = typeof collection.upload === 'object' ? collection.upload : {} @@ -176,7 +176,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => // Add focal point fields if not disabled if ( uploadOptions.focalPoint !== false || - uploadOptions.imageSizes || + uploadOptions.variants || uploadOptions.hasImageAdjustments ) { uploadFields = uploadFields.concat( @@ -197,7 +197,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => mimeType.validate = mimeTypeValidator(uploadOptions.mimeTypes) } - if (uploadOptions.imageSizes) { + if (uploadOptions.variants) { uploadFields = uploadFields.concat([ { name: 'sizes', @@ -205,7 +205,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => admin: { hidden: true, }, - fields: uploadOptions.imageSizes.map((size) => ({ + fields: uploadOptions.variants.map((size) => ({ name: size.name, type: 'group', admin: { diff --git a/packages/payload/src/uploads/getFileContentRequirement.spec.ts b/packages/payload/src/uploads/getFileContentRequirement.spec.ts index 35cd0dc3433..7b6d4412747 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.spec.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.spec.ts @@ -71,20 +71,20 @@ describe('getFileContentRequirement', () => { ).toBe('full') }) - it('requires the full file for an image with imageSizes configured, even with no other adjustments', () => { + it('requires the full file for an image with variants configured, even with no other adjustments', () => { expect( getFileContentRequirement({ mimeType: 'image/png', - uploadConfig: createUploadConfig({ imageSizes: [{ name: 'thumbnail', width: 100 }] }), + uploadConfig: createUploadConfig({ variants: [{ name: 'thumbnail', width: 100 }] }), }), ).toBe('full') }) - it('requires only the header for an image with an empty imageSizes array', () => { + it('requires only the header for an image with an empty variants array', () => { expect( getFileContentRequirement({ mimeType: 'image/png', - uploadConfig: createUploadConfig({ imageSizes: [] }), + uploadConfig: createUploadConfig({ variants: [] }), }), ).toBe('header') }) diff --git a/packages/payload/src/uploads/getFileContentRequirement.ts b/packages/payload/src/uploads/getFileContentRequirement.ts index a277fc8cc1d..f1f5c5b3f73 100644 --- a/packages/payload/src/uploads/getFileContentRequirement.ts +++ b/packages/payload/src/uploads/getFileContentRequirement.ts @@ -60,13 +60,13 @@ export function getFileContentRequirement({ } const isResizableImage = canResizeImage(mimeType) - // `hasImageAdjustments` and `imageSizes` are the transformer-agnostic projection a file + // `hasImageAdjustments` and `variants` are the transformer-agnostic projection a file // transformer writes back onto the sanitized upload config at startup (see // `@payloadcms/transformer-sharp`'s `initSharpCollections`), so core can make this decision // without knowing which transformer is registered or how it is configured. const hasConfiguredAdjustments = Boolean( uploadConfig.hasImageAdjustments || - (Array.isArray(uploadConfig.imageSizes) && uploadConfig.imageSizes.length > 0), + (Array.isArray(uploadConfig.variants) && uploadConfig.variants.length > 0), ) if (hasSizeEdits || (isResizableImage && hasConfiguredAdjustments) || isAnimatedImage(mimeType)) { diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts index 7cac4104bc3..aa3b62c3721 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts @@ -171,7 +171,7 @@ describe('getFileFromUploadInstructions', () => { }), ) const customReq = createReq([customHandler], { - imageSizes: [{ height: 100, name: 'preview', width: 100 }], + variants: [{ height: 100, name: 'preview', width: 100 }], mimeTypes: ['image/*'], uploadInstructions: undefined, }) @@ -192,7 +192,7 @@ describe('getFileFromUploadInstructions', () => { const handler = vi.fn(async () => new Response('existing file', { status: 200 })) const req = createReq([handler], { - imageSizes: [{ height: 100, name: 'preview', width: 100 }], + variants: [{ height: 100, name: 'preview', width: 100 }], mimeTypes: ['image/*'], }) diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.ts index ce27766dafc..dc1b0a0d369 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.ts @@ -184,7 +184,7 @@ const requestHasSizeEdits = (req: PayloadRequest): boolean => { /** * Whether a transformer that reads the whole file will run `transformFile` on this upload. A * bridge transformer (e.g. `sharpTransformer`) is excluded: it projects what it needs onto the - * sanitized upload config (`hasImageAdjustments`, `imageSizes`) at startup instead. + * sanitized upload config (`hasImageAdjustments`, `variants`) at startup instead. */ const hasTransformFileStages = async ({ collectionSlug, diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts index 089c766419f..5511d580ef3 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -15,16 +15,16 @@ export type ResolvedUploadDocument = { */ export function buildFilenameWhere({ filename, - imageSizes, + variants, }: { filename: string - imageSizes?: { name: string }[] + variants?: { name: string }[] }): Where { const filenameCondition: Where = { or: [{ filename: { equals: filename } }], } - imageSizes?.forEach(({ name }) => { + variants?.forEach(({ name }) => { filenameCondition.or!.push({ [`sizes.${name}.filename`]: { equals: filename }, }) @@ -80,9 +80,7 @@ export async function resolveUploadDocument({ const { config } = collection - const constraints: Where[] = [ - buildFilenameWhere({ filename, imageSizes: config.upload.imageSizes }), - ] + const constraints: Where[] = [buildFilenameWhere({ filename, variants: config.upload.variants })] if (typeof prefix === 'string') { constraints.push({ prefix: { equals: prefix } }) diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 2a418ffcd5d..dbfbaec4976 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -373,13 +373,13 @@ export type checkFileRestrictionsParams = { } export type SanitizedUploadConfig = { + staticDir: UploadConfig['staticDir'] /** * The collection's image sizes, as written at startup by a registered transformer such as * `sharpTransformer({ collections: { : { variants } } })`. Names and admin options * only — not authored on the collection. */ - imageSizes?: ImageSize[] - staticDir: UploadConfig['staticDir'] + variants?: ImageSize[] } & UploadConfig export type File = { diff --git a/packages/payload/src/utilities/appendUploadSelectFields.ts b/packages/payload/src/utilities/appendUploadSelectFields.ts index 90617750ae8..ea9854d49a9 100644 --- a/packages/payload/src/utilities/appendUploadSelectFields.ts +++ b/packages/payload/src/utilities/appendUploadSelectFields.ts @@ -23,7 +23,7 @@ export const appendUploadSelectFields = ({ select.updatedAt = true } - if (collectionConfig.upload.imageSizes && collectionConfig.upload.imageSizes.length > 0) { + if (collectionConfig.upload.variants && collectionConfig.upload.variants.length > 0) { if ( collectionConfig.upload.adminThumbnail && typeof collectionConfig.upload.adminThumbnail === 'string' @@ -36,7 +36,7 @@ export const appendUploadSelectFields = ({ } } else { /** Only return image size properties that are required for thumbnails */ - select.sizes = collectionConfig.upload.imageSizes.reduce((acc, imageSizeConfig) => { + select.sizes = collectionConfig.upload.variants.reduce((acc, imageSizeConfig) => { return { ...acc, [imageSizeConfig.name]: { diff --git a/packages/payload/src/utilities/telemetry/featureInfo/getFeatureInfo.ts b/packages/payload/src/utilities/telemetry/featureInfo/getFeatureInfo.ts index 08a0d5254d4..82132654a40 100644 --- a/packages/payload/src/utilities/telemetry/featureInfo/getFeatureInfo.ts +++ b/packages/payload/src/utilities/telemetry/featureInfo/getFeatureInfo.ts @@ -247,7 +247,7 @@ const collectUploadAndMedia = (config: SanitizedConfig): FeatureInfo => { ({ upload }) => isObject(upload) && upload.focalPoint === false, ).length, imageSizesCollectionCount: collections.filter( - ({ upload }) => isObject(upload) && Boolean(upload.imageSizes?.length), + ({ upload }) => isObject(upload) && Boolean(upload.variants?.length), ).length, mimeRestrictedCollectionCount: collections.filter( ({ upload }) => isObject(upload) && Boolean(upload.mimeTypes?.length), diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 4a786c09f2e..5c63dfd1ff3 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -103,12 +103,12 @@ export const getFields = ({ // Storage adapters add these fields during their `init`, after transformers (e.g. Sharp) have // written each collection's image sizes onto its upload config. - const imageSizes = + const variants = typeof collection.upload === 'object' - ? (collection.upload as SanitizedUploadConfig).imageSizes + ? (collection.upload as SanitizedUploadConfig).variants : undefined - if (imageSizes) { + if (variants) { let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { @@ -131,7 +131,7 @@ export const getFields = ({ admin: { hidden: true, }, - fields: imageSizes.map((size) => { + fields: variants.map((size) => { const existingSizeField = existingSizesField?.fields.find( (existingField) => 'name' in existingField && existingField.name === size.name, ) as GroupField diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index fee15fbc575..4bdf5bc02c8 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -64,14 +64,14 @@ export async function getFilePrefix({ } // Reads without a query prefix or read-access constraints skip the endpoint's document lookup. - const imageSizes = (collection?.upload as SanitizedUploadConfig)?.imageSizes || [] + const variants = (collection?.upload as SanitizedUploadConfig)?.variants || [] const filenameClause = { or: [ { filename: { equals: filename }, }, - ...imageSizes.map((imageSize) => ({ + ...variants.map((imageSize) => ({ [`sizes.${imageSize.name}.filename`]: { equals: filename }, })), ], diff --git a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts index 431ae567295..9f1d682b9aa 100644 --- a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts +++ b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts @@ -62,13 +62,13 @@ async function findMatchingDocs({ return [] } - const imageSizes = + const variants = collection.config.upload && typeof collection.config.upload === 'object' - ? collection.config.upload.imageSizes || [] + ? collection.config.upload.variants || [] : [] const filenameQueries: Where[] = [ { filename: { equals: requestedFilename } }, - ...imageSizes.map(({ name }) => ({ + ...variants.map(({ name }) => ({ [`sizes.${name}.filename`]: { equals: requestedFilename }, })), ] diff --git a/packages/ui/src/elements/FileManager/FilePreview/index.tsx b/packages/ui/src/elements/FileManager/FilePreview/index.tsx index 6a954ab0bc1..bbdc940708e 100644 --- a/packages/ui/src/elements/FileManager/FilePreview/index.tsx +++ b/packages/ui/src/elements/FileManager/FilePreview/index.tsx @@ -35,7 +35,7 @@ export const FilePreview: React.FC = ({ }) => { const { t } = useTranslation() - const hasImageSizes = uploadConfig?.imageSizes?.length > 0 + const hasVariants = uploadConfig?.variants?.length > 0 const mimeType = (data?.mimeType as string) ?? '' const isImageFile = isImage(mimeType) const fileSrc = (selectedSizeData?.url ?? data?.thumbnailURL ?? data?.url ?? null) as @@ -90,7 +90,7 @@ export const FilePreview: React.FC = ({ return (
- {hasImageSizes && isImageFile && ( + {hasVariants && isImageFile && ( = ({ const acceptMimeTypes = uploadConfig.mimeTypes?.join(', ') const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt - const hasImageSizes = uploadConfig?.imageSizes?.length > 0 + const hasVariants = uploadConfig?.variants?.length > 0 const hasImageAdjustments = Boolean(uploadConfig?.hasImageAdjustments) const focalPointEnabled = uploadConfig?.focalPoint === true const { crop: showCrop = true, focalPoint = true } = uploadConfig - const showFocalPoint = focalPoint && (hasImageSizes || hasImageAdjustments || focalPointEnabled) + const showFocalPoint = focalPoint && (hasVariants || hasImageAdjustments || focalPointEnabled) const selectedSizeData = selectedSize ? (data?.sizes?.[selectedSize] as Record) @@ -294,7 +294,7 @@ export const FileManager: React.FC = ({ /> )} - {data && hasImageSizes && ( + {data && hasVariants && ( = ({ uploadConfig, }) => { const { t } = useTranslation() - const { imageSizes } = uploadConfig + const { variants } = uploadConfig const { sizes, url: originalUrl } = doc const orderedSizeKeys = React.useMemo(() => { - if (!imageSizes || imageSizes.length === 0) { + if (!variants || variants.length === 0) { return Object.keys(sizes || {}) } - return imageSizes.map(({ name }) => name).filter((name) => sizes?.[name]?.url) - }, [imageSizes, sizes]) + return variants.map(({ name }) => name).filter((name) => sizes?.[name]?.url) + }, [variants, sizes]) if (!originalUrl) { return null diff --git a/packages/ui/src/elements/PreviewSizes/index.tsx b/packages/ui/src/elements/PreviewSizes/index.tsx index 8d4dfdd0e77..b0003d0d59e 100644 --- a/packages/ui/src/elements/PreviewSizes/index.tsx +++ b/packages/ui/src/elements/PreviewSizes/index.tsx @@ -16,14 +16,14 @@ type FilesSizesWithUrl = { [key: string]: FileInfo } -const sortSizes = (sizes: FilesSizesWithUrl, imageSizes: SanitizedUploadConfig['imageSizes']) => { - if (!imageSizes || imageSizes.length === 0) { +const sortSizes = (sizes: FilesSizesWithUrl, variants: SanitizedUploadConfig['variants']) => { + if (!variants || variants.length === 0) { return sizes } const orderedSizes: FilesSizesWithUrl = {} - imageSizes.forEach(({ name }) => { + variants.forEach(({ name }) => { if (sizes[name]) { orderedSizes[name] = sizes[name] } @@ -85,13 +85,13 @@ export type PreviewSizesProps = { } export const PreviewSizes: React.FC = ({ doc, imageCacheTag, uploadConfig }) => { - const { imageSizes } = uploadConfig + const { variants } = uploadConfig const { sizes } = doc const alt = (doc as { alt?: string })?.alt || doc.filename || '' const [orderedSizes, setOrderedSizes] = useState(() => - sortSizes(sizes, imageSizes), + sortSizes(sizes, variants), ) const [selectedSize, setSelectedSize] = useState(null) @@ -104,8 +104,8 @@ export const PreviewSizes: React.FC = ({ doc, imageCacheTag, } } useEffect(() => { - setOrderedSizes(sortSizes(sizes, imageSizes)) - }, [sizes, imageSizes, imageCacheTag]) + setOrderedSizes(sortSizes(sizes, variants)) + }, [sizes, variants, imageCacheTag]) const mainPreviewSrc = selectedSize ? generateImageUrl(doc.sizes[selectedSize]) diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index b676eb11043..e1cc5305506 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -288,14 +288,14 @@ const UploadComponent: React.FC = (props) => { const canRemoveUpload = docPermissions?.update - const hasImageSizes = uploadConfig?.imageSizes?.length > 0 + const hasVariants = uploadConfig?.variants?.length > 0 const hasImageAdjustments = Boolean(uploadConfig?.hasImageAdjustments) // Explicity check if set to true, default is undefined const focalPointEnabled = uploadConfig?.focalPoint === true const { crop: showCrop = true, focalPoint = true } = uploadConfig - const showFocalPoint = focalPoint && (hasImageSizes || hasImageAdjustments || focalPointEnabled) + const showFocalPoint = focalPoint && (hasVariants || hasImageAdjustments || focalPointEnabled) const acceptMimeTypes = uploadConfig.mimeTypes?.join(', ') @@ -341,7 +341,7 @@ const UploadComponent: React.FC = (props) => { /> )} - {data && hasImageSizes && ( + {data && hasVariants && ( = (props) => { doc={data} enableAdjustments={showCrop || showFocalPoint} handleRemove={canRemoveUpload ? handleFileRemoval : undefined} - hasImageSizes={hasImageSizes} + hasImageSizes={hasVariants} hideRemoveFile={uploadConfig.hideRemoveFile} imageCacheTag={imageCacheTag} uploadConfig={uploadConfig} @@ -452,7 +452,7 @@ const UploadComponent: React.FC = (props) => {
diff --git a/test/buildConfigWithDefaults.ts b/test/buildConfigWithDefaults.ts index b940fb3033d..ff2126b0c44 100644 --- a/test/buildConfigWithDefaults.ts +++ b/test/buildConfigWithDefaults.ts @@ -199,7 +199,7 @@ export async function buildConfigWithDefaults({ } // Auto-register the Sharp transformer so every test suite keeps its existing - // upload-time image processing (imageSizes/resizeOptions/crop/focalPoint). + // upload-time image processing (variants/resizeOptions/crop/focalPoint). // Suites that need to configure `upload.transformers` themselves (e.g. to add // a custom slug or fixture transformers) set their own array, which wins here. if (!config.upload) { diff --git a/test/image-size-options/default/types.spec.ts b/test/image-size-options/default/types.spec.ts index 1ffb551f2b1..b9e51279898 100644 --- a/test/image-size-options/default/types.spec.ts +++ b/test/image-size-options/default/types.spec.ts @@ -3,7 +3,7 @@ import type { CollectionConfig, ImageSize, SanitizedUploadConfig } from 'payload import { describe, expect, test } from 'tstyche' type CollectionUploadConfig = Exclude, boolean> -type SanitizedImageSize = NonNullable[number] +type SanitizedImageSize = NonNullable[number] describe('default image size options', () => { test('should carry no processor-specific options when no provider is registered', () => { @@ -25,4 +25,8 @@ describe('default image size options', () => { test('should not allow imageSizes to be authored on a collection upload configuration', () => { expect<'imageSizes' extends keyof CollectionUploadConfig ? true : false>().type.toBe() }) + + test('should not allow variants to be authored on a collection upload configuration', () => { + expect<'variants' extends keyof CollectionUploadConfig ? true : false>().type.toBe() + }) }) diff --git a/test/storage-azure/client-uploads/collections/MediaHeaderOnlyWithSizes.ts b/test/storage-azure/client-uploads/collections/MediaHeaderOnlyWithSizes.ts index 4e42f5da411..d0288f44d9d 100644 --- a/test/storage-azure/client-uploads/collections/MediaHeaderOnlyWithSizes.ts +++ b/test/storage-azure/client-uploads/collections/MediaHeaderOnlyWithSizes.ts @@ -5,12 +5,12 @@ import { existsSync } from 'node:fs' export const mediaHeaderOnlyWithSizesSlug = 'media-header-only-with-sizes' /** - * Like `MediaHeaderOnly`, but with `imageSizes` configured (through `sharpTransformer`, which + * Like `MediaHeaderOnly`, but with `variants` configured (through `sharpTransformer`, which * projects them back onto this collection's sanitized `upload` config) and no other * adjustments - `getFileContentRequirement` must still choose the `'full'` content requirement * here, since the transformer needs the complete file to generate thumbnails from. A client * upload larger than `HEADER_PROBE_BYTE_LENGTH` is a regression test for a bug where the - * missing `imageSizes` check let this collection take the `'header'` path instead, handing a + * missing `variants` check let this collection take the `'header'` path instead, handing a * truncated buffer to the transformer and crashing. */ export const MediaHeaderOnlyWithSizes: CollectionConfig = { diff --git a/test/storage-azure/client-uploads/int.spec.ts b/test/storage-azure/client-uploads/int.spec.ts index f9510cc18da..2b9af05fd96 100644 --- a/test/storage-azure/client-uploads/int.spec.ts +++ b/test/storage-azure/client-uploads/int.spec.ts @@ -421,13 +421,13 @@ test.suite('@payloadcms/storage-azure clientUploads', { config: './config.ts' }, }) /** - * `media-header-only-with-sizes` has `imageSizes` configured but no `resizeOptions`, so a + * `media-header-only-with-sizes` has `variants` configured but no `resizeOptions`, so a * client upload larger than `HEADER_PROBE_BYTE_LENGTH` (1MB) is a regression test for a bug - * where `getFileContentRequirement` ignored `imageSizes` and chose the `'header'` content + * where `getFileContentRequirement` ignored `variants` and chose the `'header'` content * requirement anyway - handing `createImageSizes` a truncated buffer and crashing instead of * fetching the full file through the real Azure handler. */ - test.describe('imageSizes with a large upload (real Azure handler)', () => { + test.describe('variants with a large upload (real Azure handler)', () => { const createdIds: (number | string)[] = [] test.afterEach(async ({ payload }) => { diff --git a/test/storage-s3/client-uploads/collections/MediaHeaderOnlyWithSizes.ts b/test/storage-s3/client-uploads/collections/MediaHeaderOnlyWithSizes.ts index 2a17c8c82cd..e5d5d4ecb86 100644 --- a/test/storage-s3/client-uploads/collections/MediaHeaderOnlyWithSizes.ts +++ b/test/storage-s3/client-uploads/collections/MediaHeaderOnlyWithSizes.ts @@ -3,12 +3,12 @@ import type { CollectionConfig } from 'payload' import { mediaHeaderOnlyWithSizesSlug } from '../../shared.js' /** - * Like `MediaHeaderOnly`, but with `imageSizes` configured (through `sharpTransformer`, which + * Like `MediaHeaderOnly`, but with `variants` configured (through `sharpTransformer`, which * projects them back onto this collection's sanitized `upload` config) and no other * adjustments - `getFileContentRequirement` must still choose the `'full'` content requirement * here, since the transformer needs the complete file to generate thumbnails from. A client * upload larger than `HEADER_PROBE_BYTE_LENGTH` is a regression test for a bug where the - * missing `imageSizes` check let this collection take the `'header'` path instead, handing a + * missing `variants` check let this collection take the `'header'` path instead, handing a * truncated buffer to the transformer and crashing. */ export const MediaHeaderOnlyWithSizes: CollectionConfig = { diff --git a/test/storage-s3/client-uploads/int.spec.ts b/test/storage-s3/client-uploads/int.spec.ts index 5403bfcaf15..e55c4b21e7e 100644 --- a/test/storage-s3/client-uploads/int.spec.ts +++ b/test/storage-s3/client-uploads/int.spec.ts @@ -482,13 +482,13 @@ test.suite('@payloadcms/storage-s3 clientUploads', { config: './config.ts' }, () }) /** - * `media-header-only-with-sizes` has `imageSizes` configured but no `resizeOptions`, so a + * `media-header-only-with-sizes` has `variants` configured but no `resizeOptions`, so a * client upload larger than `HEADER_PROBE_BYTE_LENGTH` (1MB) is a regression test for a bug - * where `getFileContentRequirement` ignored `imageSizes` and chose the `'header'` content + * where `getFileContentRequirement` ignored `variants` and chose the `'header'` content * requirement anyway - handing `createImageSizes` a truncated buffer and crashing instead of * fetching the full file through the real S3 handler. */ - test.describe('imageSizes with a large upload (real S3 handler)', () => { + test.describe('variants with a large upload (real S3 handler)', () => { const createdIds: (number | string)[] = [] test.afterEach(async ({ payload }) => { From c835a39eb980953fd3f15b1312771dd4de3cd2ff Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 10:22:47 +0100 Subject: [PATCH 05/13] chore(transformer-sharp): write sanitized upload.variants instead of imageSizes --- packages/transformer-sharp/README.md | 4 ++-- packages/transformer-sharp/src/initSharpCollections.spec.ts | 4 ++-- packages/transformer-sharp/src/initSharpCollections.ts | 6 +++--- packages/transformer-sharp/src/types.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/transformer-sharp/README.md b/packages/transformer-sharp/README.md index ff62dad3b91..ea4caa434df 100644 --- a/packages/transformer-sharp/README.md +++ b/packages/transformer-sharp/README.md @@ -31,7 +31,7 @@ Configure per-collection Sharp settings through `sharpTransformer({ collections sharpTransformer({ collections: { media: { - imageSizes: [ + variants: [ { name: 'thumbnail', width: 400, height: 300 }, { name: 'card', width: 768, height: 1024 }, ], @@ -47,7 +47,7 @@ sharpTransformer({ }) ``` -`imageSizes`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `imageSizes`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before. +`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before. ### Dynamic (request-time) resizing diff --git a/packages/transformer-sharp/src/initSharpCollections.spec.ts b/packages/transformer-sharp/src/initSharpCollections.spec.ts index 2a74b05bbb8..eca1068679d 100644 --- a/packages/transformer-sharp/src/initSharpCollections.spec.ts +++ b/packages/transformer-sharp/src/initSharpCollections.spec.ts @@ -49,7 +49,7 @@ describe('initSharpCollections', () => { expect(result.collections?.[0]?.upload).toMatchObject({ crop: true, focalPoint: true }) }) - it('should write variants as imageSizes onto a copy, leaving the authored collection untouched', () => { + it('should write variants onto a copy, leaving the authored collection untouched', () => { const authoredUpload = { staticDir: 'media' } const authoredCollection = uploadCollection({ slug: 'media', upload: authoredUpload }) const config = makeConfig([authoredCollection]) @@ -60,7 +60,7 @@ describe('initSharpCollections', () => { }) expect(result.collections?.[0]?.upload).toMatchObject({ - imageSizes: [{ name: 'thumbnail' }], + variants: [{ name: 'thumbnail' }], staticDir: 'media', }) expect(authoredUpload).toEqual({ staticDir: 'media' }) diff --git a/packages/transformer-sharp/src/initSharpCollections.ts b/packages/transformer-sharp/src/initSharpCollections.ts index 82e94710628..c46bdb8fdd9 100644 --- a/packages/transformer-sharp/src/initSharpCollections.ts +++ b/packages/transformer-sharp/src/initSharpCollections.ts @@ -18,7 +18,7 @@ const RESERVED_IMAGE_SIZE_NAMES = [ /** * Validates `sharpTransformer({ collections })` against the config's real * collections, then writes a narrowed, Sharp-agnostic projection of - * `variants` (as `imageSizes`)/`crop`/`focalPoint`/`hasImageAdjustments` back onto each + * `variants`/`crop`/`focalPoint`/`hasImageAdjustments` back onto each * targeted collection's sanitized `upload` config, so core's own field * generation and Admin UI keep working without knowing about Sharp. */ @@ -84,7 +84,7 @@ export function initSharpCollections({ } // Write onto copies, never the authored collection objects: a rebuilt config (e.g. on a dev - // reload) would otherwise see `upload.imageSizes` on the collection and reject it as legacy. + // reload) would otherwise see `upload.variants` on the collection and reject it as authored. config.collections = config.collections?.map((collection) => { const sharpConfig = collections[collection.slug] @@ -107,7 +107,7 @@ export function initSharpCollections({ sharpConfig.constructorOptions || sharpConfig.withMetadata, ), - imageSizes: sharpConfig.variants?.map(({ name, admin, generateImageName }) => ({ + variants: sharpConfig.variants?.map(({ name, admin, generateImageName }) => ({ name, admin, generateImageName, diff --git a/packages/transformer-sharp/src/types.ts b/packages/transformer-sharp/src/types.ts index 296b1d7ec1d..144d2351b76 100644 --- a/packages/transformer-sharp/src/types.ts +++ b/packages/transformer-sharp/src/types.ts @@ -60,7 +60,7 @@ declare module 'payload' { /** * A collection's Sharp-owned upload-time settings, authored via * `sharpTransformer({ collections: { : {...} } })`. `init()` writes a - * narrowed, Sharp-agnostic projection of `variants` (as `imageSizes`)/`crop`/`focalPoint` + * narrowed, Sharp-agnostic projection of `variants`/`crop`/`focalPoint` * back onto the sanitized collection's `upload` config for core's own use (Admin UI, * field generation); this richer shape is what the transformer itself reads. */ From d41d54369cec13e94fdbfb8586730a709d70f728 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 10:36:34 +0100 Subject: [PATCH 06/13] chore(transformer-sharp): address review feedback --- .../src/initSharpCollections.spec.ts | 14 ++++ .../src/initSharpCollections.ts | 8 +-- .../src/prepareLegacyUpload.spec.ts | 66 +++++++++++++++++++ .../src/prepareLegacyUpload.ts | 8 ++- 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 packages/transformer-sharp/src/prepareLegacyUpload.spec.ts diff --git a/packages/transformer-sharp/src/initSharpCollections.spec.ts b/packages/transformer-sharp/src/initSharpCollections.spec.ts index eca1068679d..12171ec5bb5 100644 --- a/packages/transformer-sharp/src/initSharpCollections.spec.ts +++ b/packages/transformer-sharp/src/initSharpCollections.spec.ts @@ -67,6 +67,20 @@ describe('initSharpCollections', () => { expect(result.collections?.[0]).not.toBe(authoredCollection) }) + it("should return a new root config, leaving the caller's config untouched", () => { + const authoredCollections = [uploadCollection({ slug: 'media' })] + const config = makeConfig(authoredCollections) + + const result = initSharpCollections({ + collections: { media: { variants: [{ name: 'thumbnail', width: 100 }] } }, + config, + }) + + expect(result).not.toBe(config) + expect(config.collections).toBe(authoredCollections) + expect(config.collections?.[0]?.upload).toEqual({}) + }) + it('should throw when a configured collection slug does not exist in the config', () => { const config = makeConfig([uploadCollection({ slug: 'media' })]) diff --git a/packages/transformer-sharp/src/initSharpCollections.ts b/packages/transformer-sharp/src/initSharpCollections.ts index c46bdb8fdd9..88a9fe3a97e 100644 --- a/packages/transformer-sharp/src/initSharpCollections.ts +++ b/packages/transformer-sharp/src/initSharpCollections.ts @@ -83,9 +83,9 @@ export function initSharpCollections({ ) } - // Write onto copies, never the authored collection objects: a rebuilt config (e.g. on a dev - // reload) would otherwise see `upload.variants` on the collection and reject it as authored. - config.collections = config.collections?.map((collection) => { + // Write onto copies, never the caller's config or collection objects: a rebuilt config (e.g. on + // a dev reload) would otherwise see `upload.variants` on the collection and reject it as authored. + const sanitizedCollections = config.collections?.map((collection) => { const sharpConfig = collections[collection.slug] if (!sharpConfig || !collection.upload) { @@ -117,5 +117,5 @@ export function initSharpCollections({ return { ...collection, upload } }) - return config + return { ...config, collections: sanitizedCollections } } diff --git a/packages/transformer-sharp/src/prepareLegacyUpload.spec.ts b/packages/transformer-sharp/src/prepareLegacyUpload.spec.ts new file mode 100644 index 00000000000..ea771a03238 --- /dev/null +++ b/packages/transformer-sharp/src/prepareLegacyUpload.spec.ts @@ -0,0 +1,66 @@ +import type { PayloadRequest, UploadEdits } from 'payload' +import type { UploadTransformTask } from 'payload/internal' + +import { describe, expect, it, vi } from 'vitest' + +import type { SharpCollectionConfig, SharpDependency, SharpUploadTaskOptions } from './types.js' + +import { createPrepareLegacyUpload } from './prepareLegacyUpload.js' + +const sharpDependency = (() => ({ + metadata: async () => ({ height: 1000, width: 1000 }), +})) as unknown as SharpDependency + +const makeReq = ({ focalPoint }: { focalPoint?: boolean }): PayloadRequest => + ({ + payload: { collections: { media: { config: { upload: { focalPoint } } } } }, + }) as unknown as PayloadRequest + +const runPrepareUpload = async ({ + collectionFocalPoint, + sharpConfig, +}: { + collectionFocalPoint?: boolean + sharpConfig: SharpCollectionConfig +}) => { + const transform = vi.fn( + async (_task: UploadTransformTask) => new File(['x'], 'image.png', { type: 'image/png' }), + ) + + await createPrepareLegacyUpload({ collections: { media: sharpConfig }, sharpDependency })({ + collectionSlug: 'media', + file: new File(['x'], 'image.png', { type: 'image/png' }), + req: makeReq({ focalPoint: collectionFocalPoint }), + transform, + uploadEdits: { focalPoint: { x: 10, y: 90 } } as UploadEdits, + }) + + const sizeTask = transform.mock.calls + .map(([task]) => task.options as SharpUploadTaskOptions) + .find((options) => options.kind === 'size') + + return sizeTask?.kind === 'size' ? sizeTask.focalPoint : undefined +} + +const variants = [{ name: 'card', height: 200, width: 400 }] + +describe('createPrepareLegacyUpload', () => { + it('should not apply the focal point when Sharp leaves it unset and the collection sets focalPoint: false', async () => { + expect( + await runPrepareUpload({ collectionFocalPoint: false, sharpConfig: { variants } }), + ).toBeUndefined() + }) + + it("should let Sharp's focalPoint override the collection's", async () => { + expect( + await runPrepareUpload({ + collectionFocalPoint: false, + sharpConfig: { focalPoint: true, variants }, + }), + ).toEqual({ x: 10, y: 90 }) + }) + + it('should apply the focal point when neither Sharp nor the collection disables it', async () => { + expect(await runPrepareUpload({ sharpConfig: { variants } })).toEqual({ x: 10, y: 90 }) + }) +}) diff --git a/packages/transformer-sharp/src/prepareLegacyUpload.ts b/packages/transformer-sharp/src/prepareLegacyUpload.ts index 306d8a72eed..daaca3eb47c 100644 --- a/packages/transformer-sharp/src/prepareLegacyUpload.ts +++ b/packages/transformer-sharp/src/prepareLegacyUpload.ts @@ -38,7 +38,7 @@ export function createPrepareLegacyUpload({ collections: Partial> sharpDependency: SharpDependency }): NonNullable { - return async ({ collectionSlug, file, transform, uploadEdits }) => { + return async ({ collectionSlug, file, req, transform, uploadEdits }) => { const collectionUpload = collections[collectionSlug] ?? {} const fileSupportsResize = canResizeImage(file.type) @@ -91,7 +91,11 @@ export function createPrepareLegacyUpload({ ? { height: mainResult.height!, width: mainResult.width! } : originalDimensions - const focalPointEnabled = collectionUpload.focalPoint !== false + // Same precedence `init()` applies: Sharp's setting, else the collection's own `focalPoint`. + const effectiveFocalPoint = + collectionUpload.focalPoint ?? + req.payload.collections[collectionSlug]?.config.upload?.focalPoint + const focalPointEnabled = effectiveFocalPoint !== false const variants = collectionUpload.variants if (canProcessAsImage && Array.isArray(variants) && sizeSourceDimensions) { From 6191e3ae12dc43b4e93f24d4a323193da18a7985 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 13:53:47 +0100 Subject: [PATCH 07/13] feat!: rename stored upload sizes field to variants --- docs/fields/default-fields.mdx | 2 +- docs/migration-guide/v4.mdx | 40 +- docs/upload/overview.mdx | 4 +- docs/upload/transformers.mdx | 2 +- .../src/exports/migration-utils.ts | 1 + .../predefinedMigrations/sizes-to-variants.ts | 7 + .../db-mongodb/src/exports/migration-utils.ts | 1 + .../migrateSizesToVariants.ts | 117 +++++ .../predefinedMigrations/sizes-to-variants.ts | 7 + .../src/exports/migration-utils.ts | 1 + .../predefinedMigrations/sizes-to-variants.ts | 7 + .../db-sqlite/src/exports/migration-utils.ts | 1 + .../predefinedMigrations/sizes-to-variants.ts | 7 + .../src/exports/migration-utils.ts | 1 + .../predefinedMigrations/sizes-to-variants.ts | 7 + packages/drizzle/src/exports/postgres.ts | 1 + packages/drizzle/src/exports/sqlite.ts | 1 + packages/drizzle/src/index.ts | 1 + .../sizes-to-variants/index.ts | 113 +++++ .../sizes-to-variants/index.ts | 113 +++++ ...namicPredefinedSizesToVariantsMigration.ts | 31 ++ .../utilities/getSizesToVariantsRenames.ts | 148 +++++++ .../payload-cloud/src/hooks/afterDelete.ts | 2 +- .../src/utilities/getIncomingFiles.ts | 4 +- .../payload/src/collections/config/types.ts | 8 +- .../src/fields/config/reservedFieldNames.ts | 2 +- .../src/uploads/deleteAssociatedFiles.spec.ts | 6 +- .../src/uploads/deleteAssociatedFiles.ts | 4 +- .../src/uploads/docWithFilenameExists.ts | 2 +- .../payload/src/uploads/generateFileData.ts | 4 +- packages/payload/src/uploads/getBaseFields.ts | 14 +- .../getFileFromUploadInstructions.spec.ts | 2 +- .../src/uploads/sanitizeUploadData.spec.ts | 16 +- .../payload/src/uploads/sanitizeUploadData.ts | 22 +- .../resolveUploadDocument.spec.ts | 2 +- .../transformers/resolveUploadDocument.ts | 6 +- .../transformers/uploadTransformerBridge.ts | 8 +- packages/payload/src/uploads/types.ts | 2 +- .../src/utilities/appendUploadSelectFields.ts | 4 +- .../src/fields/getFields.ts | 4 +- .../src/hooks/afterChange.spec.ts | 4 +- .../src/hooks/afterChange.ts | 8 +- .../src/hooks/afterDelete.ts | 2 +- .../src/hooks/afterRead.ts | 2 +- .../src/hooks/beforeChange.ts | 4 +- .../src/utilities/getFilePrefix.ts | 2 +- .../src/utilities/getIncomingFiles.ts | 4 +- .../lexicalToHtml/async/converters/upload.ts | 6 +- .../lexicalToHtml/sync/converters/upload.ts | 6 +- .../converter/converters/upload.tsx | 8 +- packages/sdk/src/types.ts | 2 +- .../src/authorizeFileOverwrite.ts | 4 +- packages/transformer-sharp/README.md | 2 +- .../src/initSharpCollections.ts | 2 +- .../src/prepareLegacyUpload.ts | 2 +- packages/transformer-sharp/src/types.ts | 4 +- .../DraggableFileDetails/index.tsx | 2 +- .../elements/FileDetails/FileMeta/index.tsx | 2 +- .../FileDetails/StaticFileDetails/index.tsx | 2 +- .../ui/src/elements/FileDetails/index.tsx | 2 +- .../ui/src/elements/FileManager/index.tsx | 2 +- .../ui/src/elements/MiniCarousel/index.tsx | 4 +- .../ui/src/elements/PreviewSizes/index.tsx | 6 +- .../Table/DefaultCell/fields/File/index.tsx | 2 +- .../ui/src/fields/Upload/HasMany/index.tsx | 2 +- .../ui/src/fields/Upload/HasOne/index.tsx | 2 +- .../ui/src/utilities/filterOutUploadFields.ts | 2 +- .../HierarchyTable/RelatedNameCell.tsx | 2 +- templates/website/src/payload-types.ts | 4 +- .../website/src/utilities/generateMeta.ts | 2 +- .../with-vercel-website/src/payload-types.ts | 4 +- .../src/utilities/generateMeta.ts | 2 +- .../shared/animatedResizeParityTests.ts | 10 +- test/__helpers/shared/sdk/types.ts | 4 +- test/_community/payload-types.ts | 68 +-- test/a11y/payload-types.ts | 4 +- test/admin-bar/payload-types.ts | 4 +- test/admin/payload-types.ts | 4 +- test/benchmark-blocks/payload-types.ts | 4 +- test/email/payload-types.ts | 4 +- test/group-by/payload-types.ts | 4 +- test/lexical-mdx/payload-types.ts | 4 +- test/lexical/lexical.int.spec.ts | 6 +- test/payload-cloud/payload-types.ts | 4 +- test/plugin-cloud-storage/int.spec.ts | 50 +-- test/plugin-cloud-storage/payload-types.ts | 12 +- test/sizes-to-variants/config.ts | 41 ++ test/sizes-to-variants/int.spec.ts | 291 ++++++++++++ test/sizes-to-variants/payload-types.ts | 419 ++++++++++++++++++ test/sizes-to-variants/shared.ts | 2 + test/sizes-to-variants/tsconfig.json | 4 + test/storage-azure/client-uploads/int.spec.ts | 8 +- .../client-uploads/payload-types.ts | 8 +- test/storage-azure/int.spec.ts | 4 +- test/storage-azure/payload-types.ts | 4 +- .../streamingUploads.int.spec.ts | 6 +- test/storage-gcs/int.spec.ts | 4 +- test/storage-gcs/payload-types.ts | 4 +- test/storage-r2/payload-types.ts | 8 +- test/storage-s3/client-uploads/int.spec.ts | 6 +- .../client-uploads/payload-types.ts | 8 +- test/storage-s3/int.spec.ts | 10 +- test/storage-s3/payload-types.ts | 8 +- test/storage-s3/test-utils.ts | 6 +- .../client-uploads/payload-types.ts | 4 +- test/storage-vercel-blob/int.spec.ts | 8 +- test/storage-vercel-blob/payload-types.ts | 8 +- test/storage-vercel-blob/test-utils.ts | 4 +- test/uploads/e2e.spec.ts | 32 +- test/uploads/int.spec.ts | 224 +++++----- test/uploads/payload-types.ts | 76 ++-- 111 files changed, 1786 insertions(+), 422 deletions(-) create mode 100644 packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts create mode 100644 packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts create mode 100644 packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts create mode 100644 packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts create mode 100644 packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts create mode 100644 packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts create mode 100644 packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts create mode 100644 packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts create mode 100644 packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts create mode 100644 packages/drizzle/src/utilities/getSizesToVariantsRenames.ts create mode 100644 test/sizes-to-variants/config.ts create mode 100644 test/sizes-to-variants/int.spec.ts create mode 100644 test/sizes-to-variants/payload-types.ts create mode 100644 test/sizes-to-variants/shared.ts create mode 100644 test/sizes-to-variants/tsconfig.json diff --git a/docs/fields/default-fields.mdx b/docs/fields/default-fields.mdx index 804927adeaf..5965015ecb9 100644 --- a/docs/fields/default-fields.mdx +++ b/docs/fields/default-fields.mdx @@ -120,7 +120,7 @@ The following fields are added to [upload-enabled collections](../upload/overvie | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filename`, `mimeType`, `filesize`, `width`, `height`, `url`, `thumbnailURL` | Uploads are enabled. | | `focalX`, `focalY` | Uploads are enabled, unless `focalPoint` is `false` and no Sharp `variants` or image adjustment options (`resizeOptions`, `formatOptions`, and so on) are configured. | -| `sizes` | Sharp `variants` are configured. Matching fields nested under `sizes` are also merged recursively. | +| `variants` | Sharp `variants` are configured. Matching fields nested under `variants` are also merged recursively. | The `file` field is reserved and cannot be overridden. diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index e2b00ca6698..f511f4cbe19 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1854,7 +1854,7 @@ pnpm add @payloadcms/transformer-sharp Run `npx @payloadcms/codemod --transform migrate-sharp-to-transformer` to migrate automatically. The codemod moves a top-level `sharp` dependency and inline collection Sharp options — including `crop` and `focalPoint` — into `sharpTransformer({ collections })` (writing `imageSizes` as `variants`), appends to an existing `upload.transformers` array if you already have one, and leaves anything it can't safely rewrite in place with a note — for example, a `collections` array built by a function call, or a collection defined outside the `buildConfig` call. Review those manually. For a config that already registers `sharpTransformer`, it only renames `imageSizes` to `variants` inside the existing `sharpTransformer` call and flags a leftover top-level `sharp` property; it doesn't move any remaining collection options. -Your `variants`, `crop`, and `focalPoint` are readable at `collection.upload.variants`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.variants`). Code that read the sanitized `upload.imageSizes` in 3.x must read `upload.variants` instead. `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel, generated types, and the stored `sizes` shape on your documents are unchanged. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither `imageSizes` nor `variants` can be set on the collection at all. +Your `variants`, `crop`, and `focalPoint` are readable at `collection.upload.variants`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.variants`). Code that read the sanitized `upload.imageSizes` in 3.x must read `upload.variants` instead. `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel and the rest of core see the same configuration. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither `imageSizes` nor `variants` can be set on the collection at all. If you inject a custom Sharp build or version, pass it the same way: @@ -1865,6 +1865,44 @@ If you inject a custom Sharp build or version, pass it the same way: `sharpTransformer` can also resize images at request time (`/api/media/file/photo.png?width=400`). This is new in Payload 4 and **disabled by default**, so a migrated config keeps serving only the original file and your pre-generated `variants`. The codemod doesn't enable it. To opt in, pass `dynamic: true` or `dynamic: { collections: ['media'] }` — see [Dynamic (request-time) resizing](../upload/transformers#dynamic-request-time-resizing), and [Securing dynamic resizing](../upload/transformers#securing-dynamic-resizing) before enabling it on a publicly readable collection. +#### The stored `sizes` field on upload documents is renamed to `variants` + +The group field that holds each generated image size on an upload document is now `variants` instead of `sizes`. Every place that reads or queries it changes: + +```diff +- doc.sizes?.thumbnail?.url ++ doc.variants?.thumbnail?.url + +- where: { 'sizes.thumbnail.filename': { equals: 'photo-400x300.png' } } ++ where: { 'variants.thumbnail.filename': { equals: 'photo-400x300.png' } } + +- select: { sizes: { thumbnail: true } } ++ select: { variants: { thumbnail: true } } +``` + +GraphQL queries select `variants { thumbnail { url } }` instead of `sizes { thumbnail { url } }`, and regenerated types expose `variants?: { ... }` (and `variants?: T | { ... }` on the `*Select` types) in place of `sizes`. Update any frontend code that reads `media.sizes`, including values you pass to helpers such as `getBestFitFromSizes({ sizes: doc.variants })`. + +Existing documents must be migrated. Create the predefined migration for your database adapter and run it: + +```sh +# MongoDB +payload migrate:create --file @payloadcms/db-mongodb/sizes-to-variants +# Postgres +payload migrate:create --file @payloadcms/db-postgres/sizes-to-variants +# Vercel Postgres +payload migrate:create --file @payloadcms/db-vercel-postgres/sizes-to-variants +# SQLite +payload migrate:create --file @payloadcms/db-sqlite/sizes-to-variants +# Cloudflare D1 +payload migrate:create --file @payloadcms/db-d1-sqlite/sizes-to-variants + +payload migrate +``` + +**Postgres and SQLite (including Vercel Postgres and D1):** the columns are renamed in place from `sizes__` to `variants__`, on each upload collection's table and its versions table, along with their indexes. Use the predefined migration above **instead of** a plain `payload migrate:create` for this change. A generated migration would drop the `sizes_*` columns and add new `variants_*` columns, losing the stored image size data. The predefined migration also writes the schema snapshot, so the next generated migration starts from the renamed columns. + +On MongoDB the migration renames `sizes` (and `version.sizes` on versions) with `$rename` and re-creates the indexes on those fields under the new path. Run the migration before the upgraded app writes to upload documents. + #### The file endpoint changes once any transformer is registered Registering any transformer under `upload.transformers` — including `sharpTransformer()` with dynamic resizing off — routes every file request through the transformer-aware handler: diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 7cc07e70101..828e7712dd0 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -27,7 +27,7 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy **By simply enabling Upload functionality on a Collection, Payload will automatically transform your Collection into a robust file management / storage solution. The following modifications will be made:** -1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you declare image sizes as `variants` on [`sharpTransformer`](#image-sizes), a [`sizes`](#image-sizes) group will also be added containing auto-resized image sizes and filenames. +1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you declare image sizes as `variants` on [`sharpTransformer`](#image-sizes), a [`variants`](#image-sizes) group will also be added containing auto-resized image sizes and filenames. 1. The Admin Panel will modify its built-in `List` component to show a thumbnail for each upload within the List View 1. The Admin Panel will modify its `Edit` view(s) to add a new set of corresponding Upload UI which will allow for file upload 1. The `create`, `update`, and `delete` Collection operations will be modified to support file upload, re-upload, and deletion @@ -227,7 +227,7 @@ export default buildConfig({ }) ``` -`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.variants`, so the rest of this section — and the generated `sizes` shape on your documents — is unaffected by where the configuration is authored. Neither `imageSizes` nor `variants` can be set on the collection itself. +`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.variants`, so the rest of this section — and the generated `variants` shape on your documents — is unaffected by where the configuration is authored. Neither `imageSizes` nor `variants` can be set on the collection itself. #### Admin List View Options diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx index a1fa2faf055..7c556890383 100644 --- a/docs/upload/transformers.mdx +++ b/docs/upload/transformers.mdx @@ -280,7 +280,7 @@ export default buildConfig({ }) ``` -At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.variants`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. The `sizes` shape on your documents is unchanged. `variants` exists only on the sanitized config: setting it (or the removed `imageSizes`) on the collection itself fails the build. +At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.variants`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. Each generated size is stored on your documents under `variants.`. `variants` exists only on the sanitized config: setting it (or the removed `imageSizes`) on the collection itself fails the build. See [Image Sizes](./overview#image-sizes) for what each Sharp-specific image size option does, and [Crop and Focal Point Selector](./overview#crop-and-focal-point-selector) for the Admin Panel behavior these settings control. diff --git a/packages/db-d1-sqlite/src/exports/migration-utils.ts b/packages/db-d1-sqlite/src/exports/migration-utils.ts index 56f26820e39..f688337fd4f 100644 --- a/packages/db-d1-sqlite/src/exports/migration-utils.ts +++ b/packages/db-d1-sqlite/src/exports/migration-utils.ts @@ -1 +1,2 @@ export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' +export { migrateSqliteSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/sqlite' diff --git a/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..e89a6fbca64 --- /dev/null +++ b/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-d1-sqlite', +}) diff --git a/packages/db-mongodb/src/exports/migration-utils.ts b/packages/db-mongodb/src/exports/migration-utils.ts index adf041813bb..7ad8b480e00 100644 --- a/packages/db-mongodb/src/exports/migration-utils.ts +++ b/packages/db-mongodb/src/exports/migration-utils.ts @@ -3,4 +3,5 @@ export { migrateLocalizeStatus, } from '../predefinedMigrations/migrateLocalizeStatus.js' export { migrateRelationshipsV2_V3 } from '../predefinedMigrations/migrateRelationshipsV2_V3.js' +export { migrateSizesToVariants } from '../predefinedMigrations/migrateSizesToVariants.js' export { migrateVersionsV1_V2 } from '../predefinedMigrations/migrateVersionsV1_V2.js' diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts new file mode 100644 index 00000000000..3fa246d3204 --- /dev/null +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -0,0 +1,117 @@ +import type { Collection, IndexDescription } from 'mongodb' +import type { Payload, PayloadRequest } from 'payload' + +import type { MongooseAdapter } from '../index.js' + +import { getSession } from '../utilities/getSession.js' + +/** Mongo error codes for an index that already exists with the same key but different options. */ +const INDEX_CONFLICT_CODES = new Set([85, 86]) +/** Mongo error code for a collection that hasn't been created yet. */ +const NAMESPACE_NOT_FOUND_CODE = 26 + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes` field and + * `variants`, on each collection and its versions collection (`version.sizes`). Uses `$rename`, + * so no document is rewritten beyond the renamed key, and re-keys the indexes on those fields. + * Documents that are already renamed are left as they are, so it's safe to re-run. + */ +export async function migrateSizesToVariants({ + direction = 'up', + payload, + req, +}: { + direction?: 'down' | 'up' + payload: Payload + req?: Partial +}): Promise { + const adapter = payload.db as unknown as MongooseAdapter + const session = await getSession(adapter, req) + const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] + + for (const collection of payload.config.collections) { + if (!collection.upload) { + continue + } + + const targets: { collection: Collection; path: string }[] = [ + { collection: adapter.collections[collection.slug]!.collection, path: '' }, + ] + + if (collection.versions && adapter.versions[collection.slug]) { + targets.push({ collection: adapter.versions[collection.slug]!.collection, path: 'version.' }) + } + + for (const target of targets) { + const fromPath = `${target.path}${from}` + const toPath = `${target.path}${to}` + + const result = await target.collection.updateMany( + { [fromPath]: { $exists: true }, [toPath]: { $exists: false } }, + { $rename: { [fromPath]: toPath } }, + { session }, + ) + + // Index builds can't run inside a multi-document transaction, so they skip the session. + await rekeyIndexes({ collection: target.collection, fromPath, toPath }) + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${fromPath} on ${result.modifiedCount} document(s) in "${target.collection.collectionName}"`, + }) + } + } +} + +async function rekeyIndexes({ + collection, + fromPath, + toPath, +}: { + collection: Collection + fromPath: string + toPath: string +}): Promise { + let indexes: IndexDescription[] + + try { + indexes = await collection.indexes() + } catch (err) { + if ((err as { code?: number }).code === NAMESPACE_NOT_FOUND_CODE) { + return + } + + throw err + } + + for (const index of indexes) { + const keys = Object.keys(index.key) + + if (!keys.some((key) => key.startsWith(`${fromPath}.`))) { + continue + } + + const rekeyed = Object.fromEntries( + Object.entries(index.key).map(([key, value]) => [ + key.startsWith(`${fromPath}.`) ? `${toPath}${key.slice(fromPath.length)}` : key, + value, + ]), + ) + + try { + await collection.createIndex(rekeyed, { + ...(index.partialFilterExpression + ? { partialFilterExpression: index.partialFilterExpression } + : {}), + ...(index.sparse ? { sparse: true } : {}), + ...(index.unique ? { unique: true } : {}), + }) + } catch (err) { + // The current schema already created this index (e.g. at connect) with its own options. + if (!INDEX_CONFLICT_CODES.has((err as { code?: number }).code ?? 0)) { + throw err + } + } + + await collection.dropIndex(index.name!) + } +} diff --git a/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..98f5674df89 --- /dev/null +++ b/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +const imports = `import { migrateSizesToVariants } from '@payloadcms/db-mongodb/migration-utils'` +const upSQL = ` await migrateSizesToVariants({ payload, req }) +` +const downSQL = ` await migrateSizesToVariants({ direction: 'down', payload, req }) +` + +export { downSQL, imports, upSQL } diff --git a/packages/db-postgres/src/exports/migration-utils.ts b/packages/db-postgres/src/exports/migration-utils.ts index 5cc7c1543f3..5f23c9c3f16 100644 --- a/packages/db-postgres/src/exports/migration-utils.ts +++ b/packages/db-postgres/src/exports/migration-utils.ts @@ -1,3 +1,4 @@ export { migrateLocalizeStatus } from '../predefinedMigrations/migrateLocalizeStatus.js' export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' export { migratePostgresLocalizeStatus, migratePostgresV2toV3 } from '@payloadcms/drizzle/postgres' +export { migratePostgresSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/postgres' diff --git a/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..ea77d967159 --- /dev/null +++ b/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', +}) diff --git a/packages/db-sqlite/src/exports/migration-utils.ts b/packages/db-sqlite/src/exports/migration-utils.ts index 70068fb26d8..b2683900a03 100644 --- a/packages/db-sqlite/src/exports/migration-utils.ts +++ b/packages/db-sqlite/src/exports/migration-utils.ts @@ -1,3 +1,4 @@ export { migrateLocalizeStatus } from '../predefinedMigrations/migrateLocalizeStatus.js' export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' export { migrateSqliteLocalizeStatus } from '@payloadcms/drizzle/sqlite' +export { migrateSqliteSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/sqlite' diff --git a/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..532fa1a4b9c --- /dev/null +++ b/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-sqlite', +}) diff --git a/packages/db-vercel-postgres/src/exports/migration-utils.ts b/packages/db-vercel-postgres/src/exports/migration-utils.ts index f5b74e7fa2b..c68f2fe5428 100644 --- a/packages/db-vercel-postgres/src/exports/migration-utils.ts +++ b/packages/db-vercel-postgres/src/exports/migration-utils.ts @@ -1 +1,2 @@ export { migratePostgresV2toV3 } from '@payloadcms/drizzle/postgres' +export { migratePostgresSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/postgres' diff --git a/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..3b3afb50955 --- /dev/null +++ b/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-vercel-postgres', +}) diff --git a/packages/drizzle/src/exports/postgres.ts b/packages/drizzle/src/exports/postgres.ts index e23795b0be2..3867083dc07 100644 --- a/packages/drizzle/src/exports/postgres.ts +++ b/packages/drizzle/src/exports/postgres.ts @@ -10,6 +10,7 @@ export { execute } from '../postgres/execute.js' export { init } from '../postgres/init.js' export { insert } from '../postgres/insert.js' export { migratePostgresLocalizeStatus } from '../postgres/predefinedMigrations/localize-status/index.js' +export { migratePostgresSizesToVariants } from '../postgres/predefinedMigrations/sizes-to-variants/index.js' export { migratePostgresV2toV3 } from '../postgres/predefinedMigrations/v2-v3/index.js' export { assertOperatorHandlerExtensionsInstalled } from '../postgres/query/assertOperatorHandlerExtensionsInstalled.js' export { postgresUnaccent } from '../postgres/query/postgresUnaccent.js' diff --git a/packages/drizzle/src/exports/sqlite.ts b/packages/drizzle/src/exports/sqlite.ts index 2f829e7bd21..4e782256680 100644 --- a/packages/drizzle/src/exports/sqlite.ts +++ b/packages/drizzle/src/exports/sqlite.ts @@ -9,5 +9,6 @@ export { execute } from '../sqlite/execute.js' export { init } from '../sqlite/init.js' export { insert } from '../sqlite/insert.js' export { migrateSqliteLocalizeStatus } from '../sqlite/predefinedMigrations/localize-status/index.js' +export { migrateSqliteSizesToVariants } from '../sqlite/predefinedMigrations/sizes-to-variants/index.js' export { requireDrizzleKit } from '../sqlite/requireDrizzleKit.js' export * from '../sqlite/types.js' diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index 4be9cff1015..3dec8e6cb63 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -91,6 +91,7 @@ export { getBlocksToJsonMigrator, } from './utilities/blocksToJsonMigrator.js' export { buildCreateMigration } from './utilities/buildCreateMigration.js' +export { buildDynamicPredefinedSizesToVariantsMigration } from './utilities/buildDynamicPredefinedSizesToVariantsMigration.js' export { buildIndexName } from './utilities/buildIndexName.js' export { createSchemaGenerator } from './utilities/createSchemaGenerator.js' export { executeSchemaHooks } from './utilities/executeSchemaHooks.js' diff --git a/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts new file mode 100644 index 00000000000..c084ef4bea0 --- /dev/null +++ b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts @@ -0,0 +1,113 @@ +import type { Payload } from 'payload' + +import { sql } from 'drizzle-orm' + +import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' +import type { BasePostgresAdapter } from '../../types.js' + +import { getSizesToVariantsRenames } from '../../../utilities/getSizesToVariantsRenames.js' + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and + * `variants_*` (main and versions tables), renaming each column in place so no data is copied, + * then renames the indexes on those columns to what the current schema expects. Skips columns + * that are already renamed, so it's safe to re-run. + */ +export async function migratePostgresSizesToVariants({ + db, + direction = 'up', + payload, +}: { + db: any + direction?: SizesToVariantsDirection + payload: Payload +}): Promise { + const adapter = payload.db as unknown as BasePostgresAdapter + const schemaName = adapter.schemaName || 'public' + const plan = getSizesToVariantsRenames({ adapter, direction }) + + for (const { columns, indexes, tableName } of plan) { + const existingColumns = await getColumnNames({ db, schemaName, tableName }) + let renamedCount = 0 + + for (const { from, to } of columns) { + if (!existingColumns.has(from) || existingColumns.has(to)) { + continue + } + + await db.execute( + sql.raw(`ALTER TABLE "${schemaName}"."${tableName}" RENAME COLUMN "${from}" TO "${to}"`), + ) + renamedCount++ + } + + const existingIndexes = await getIndexes({ db, schemaName, tableName }) + + for (const index of indexes) { + const existingName = + index.from ?? + existingIndexes.find(({ columns }) => columns.join(',') === index.columns.join(','))?.name + + if ( + !existingName || + existingName === index.to || + !existingIndexes.some(({ name }) => name === existingName) || + existingIndexes.some(({ name }) => name === index.to) + ) { + continue + } + + await db.execute( + sql.raw(`ALTER INDEX "${schemaName}"."${existingName}" RENAME TO "${index.to}"`), + ) + } + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${renamedCount} column(s) on "${tableName}"`, + }) + } +} + +async function getColumnNames({ + db, + schemaName, + tableName, +}: { + db: any + schemaName: string + tableName: string +}): Promise> { + const result = await db.execute(sql` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ${schemaName} AND table_name = ${tableName} + `) + + return new Set(result.rows.map((row: { column_name: string }) => row.column_name)) +} + +async function getIndexes({ + db, + schemaName, + tableName, +}: { + db: any + schemaName: string + tableName: string +}): Promise<{ columns: string[]; name: string }[]> { + const result = await db.execute(sql` + SELECT i.relname AS name, array_agg(a.attname::text ORDER BY k.ord) AS columns + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_class i ON i.oid = ix.indexrelid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + WHERE n.nspname = ${schemaName} AND t.relname = ${tableName} + GROUP BY i.relname + `) + + return result.rows.map((row: { columns: string[]; name: string }) => ({ + name: row.name, + columns: row.columns, + })) +} diff --git a/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts new file mode 100644 index 00000000000..80d80cffe47 --- /dev/null +++ b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts @@ -0,0 +1,113 @@ +import type { Payload } from 'payload' + +import { sql } from 'drizzle-orm' + +import type { DrizzleAdapter } from '../../../types.js' +import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' + +import { getSizesToVariantsRenames } from '../../../utilities/getSizesToVariantsRenames.js' + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and + * `variants_*` (main and versions tables), renaming each column in place so no data is copied. + * SQLite can't rename an index, so each index on a renamed column is dropped and recreated under + * the name the current schema expects. Skips columns that are already renamed, so it's safe to + * re-run. + */ +export async function migrateSqliteSizesToVariants({ + db, + direction = 'up', + payload, +}: { + db: any + direction?: SizesToVariantsDirection + payload: Payload +}): Promise { + const adapter = payload.db as unknown as DrizzleAdapter + const plan = getSizesToVariantsRenames({ adapter, direction }) + + for (const { columns, indexes, tableName } of plan) { + const existingColumns = await getColumnNames({ db, tableName }) + let renamedCount = 0 + + for (const { from, to } of columns) { + if (!existingColumns.has(from) || existingColumns.has(to)) { + continue + } + + await db.run(sql.raw(`ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\``)) + renamedCount++ + } + + const existingIndexes = await getIndexes({ db, tableName }) + + for (const index of indexes) { + const existingName = + index.from ?? + existingIndexes.find(({ columns }) => columns.join(',') === index.columns.join(','))?.name + + if ( + !existingName || + existingName === index.to || + !existingIndexes.some(({ name }) => name === existingName) || + existingIndexes.some(({ name }) => name === index.to) + ) { + continue + } + + const columnList = index.columns.map((column) => `\`${column}\``).join(', ') + + await db.run(sql.raw(`DROP INDEX \`${existingName}\``)) + await db.run( + sql.raw( + `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX \`${index.to}\` ON \`${tableName}\` (${columnList})`, + ), + ) + } + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${renamedCount} column(s) on "${tableName}"`, + }) + } +} + +async function getColumnNames({ + db, + tableName, +}: { + db: any + tableName: string +}): Promise> { + const rows: { name: string }[] = await db.all( + sql.raw(`SELECT name FROM pragma_table_info('${tableName}')`), + ) + + return new Set(rows.map(({ name }) => name)) +} + +async function getIndexes({ + db, + tableName, +}: { + db: any + tableName: string +}): Promise<{ columns: string[]; name: string }[]> { + // `origin = 'c'` keeps only `CREATE INDEX` indexes, skipping SQLite's internal autoindexes. + const rows: { column_name: string; index_name: string; seqno: number }[] = await db.all( + sql.raw( + `SELECT il.name AS index_name, ii.name AS column_name, ii.seqno AS seqno + FROM pragma_index_list('${tableName}') AS il + JOIN pragma_index_info(il.name) AS ii + WHERE il.origin = 'c' + ORDER BY il.name, ii.seqno`, + ), + ) + + const indexes = new Map() + + for (const { column_name, index_name } of rows) { + indexes.set(index_name, [...(indexes.get(index_name) ?? []), column_name]) + } + + return [...indexes].map(([name, columns]) => ({ name, columns })) +} diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts new file mode 100644 index 00000000000..6ee053cf5ad --- /dev/null +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -0,0 +1,31 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { writeFileSync } from 'fs' + +import type { DrizzleAdapter } from '../types.js' + +/** + * Builds the `sizes-to-variants` predefined migration for a SQL adapter. The migration renames + * columns rather than letting drizzle-kit diff the schema (which would drop and re-add them, + * losing every stored image size), so it also writes the current schema snapshot: the next + * generated migration then diffs from the renamed columns instead of the legacy ones. + */ +export const buildDynamicPredefinedSizesToVariantsMigration = ({ + packageName, +}: { + packageName: string +}): DynamicMigrationTemplate => { + return async ({ filePath, payload }) => { + const adapter = payload.db as unknown as DrizzleAdapter + const { generateDrizzleJson } = adapter.requireDrizzleKit() + const drizzleJsonAfter = await generateDrizzleJson(adapter.schema) + + writeFileSync(`${filePath}.json`, JSON.stringify(drizzleJsonAfter, null, 2)) + + return { + downSQL: ` await migrateSizesToVariants({ db, direction: 'down', payload })`, + imports: `import { migrateSizesToVariants } from '${packageName}/migration-utils'`, + upSQL: ` await migrateSizesToVariants({ db, payload })`, + } + } +} diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts new file mode 100644 index 00000000000..62bac10f1ad --- /dev/null +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -0,0 +1,148 @@ +import toSnakeCase from 'to-snake-case' + +import type { DrizzleAdapter, RawIndex } from '../types.js' + +import { maxGeneratedIdentifierLength } from './validateIdentifierLength.js' + +export type SizesToVariantsDirection = 'down' | 'up' + +export type SizesToVariantsColumnRename = { + from: string + to: string +} + +export type SizesToVariantsIndexRename = { + /** Columns the index covers, named as they are after the column renames. */ + columns: string[] + /** The index name to rename from on `up` (unknown up front — looked up by column at runtime). */ + from?: string + to: string + unique: boolean +} + +export type SizesToVariantsTableRenames = { + columns: SizesToVariantsColumnRename[] + indexes: SizesToVariantsIndexRename[] + tableName: string +} + +const columnPrefixes = [ + { current: 'variants_', legacy: 'sizes_' }, + { current: 'version_variants_', legacy: 'version_sizes_' }, +] + +/** + * Plans the column and index renames that move every upload collection's stored image sizes + * between the legacy `sizes` group and `variants`. Reads the current (`variants`) schema the + * adapter built from the config, so it covers each collection's main and versions tables without + * re-deriving Payload's column naming. + * + * Index names come from the schema on `up`, and on `down` are rebuilt the way `buildIndexName` + * named them before the rename. The runner still has to locate the existing index by column on + * `up`, since a legacy name may have been de-duplicated with a numeric suffix. + */ +export function getSizesToVariantsRenames({ + adapter, + direction, +}: { + adapter: DrizzleAdapter + direction: SizesToVariantsDirection +}): SizesToVariantsTableRenames[] { + const plan: SizesToVariantsTableRenames[] = [] + + for (const collection of adapter.payload.config.collections) { + if (!collection.upload) { + continue + } + + const tableNames = [adapter.tableNameMap.get(toSnakeCase(collection.slug))] + + if (collection.versions) { + tableNames.push( + adapter.tableNameMap.get(`_${toSnakeCase(collection.slug)}${adapter.versionsSuffix}`), + ) + } + + for (const tableName of tableNames) { + const rawTable = tableName ? adapter.rawTables[tableName] : undefined + + if (!tableName || !rawTable) { + continue + } + + const columns: SizesToVariantsColumnRename[] = [] + + for (const { name } of Object.values(rawTable.columns)) { + const prefix = columnPrefixes.find(({ current }) => name.startsWith(current)) + + if (!prefix) { + continue + } + + const legacyName = `${prefix.legacy}${name.slice(prefix.current.length)}` + + columns.push( + direction === 'up' ? { from: legacyName, to: name } : { from: name, to: legacyName }, + ) + } + + if (columns.length === 0) { + continue + } + + const indexes = Object.values(rawTable.indexes ?? {}) + .map((index) => planIndexRename({ columns, direction, index, tableName })) + .filter((index): index is SizesToVariantsIndexRename => Boolean(index)) + + plan.push({ columns, indexes, tableName }) + } + } + + return plan +} + +function planIndexRename({ + columns, + direction, + index, + tableName, +}: { + columns: SizesToVariantsColumnRename[] + direction: SizesToVariantsDirection + index: RawIndex + tableName: string +}): SizesToVariantsIndexRename | undefined { + const currentColumns = [index.on].flat() + const renamedColumns = currentColumns.map((column) => + columns.find((rename) => (direction === 'up' ? rename.to : rename.from) === column), + ) + + if (renamedColumns.every((rename) => !rename)) { + return undefined + } + + const unique = Boolean(index.unique) + + if (direction === 'up') { + return { columns: currentColumns, to: index.name, unique } + } + + const legacyColumns = currentColumns.map((column, i) => renamedColumns[i]?.to ?? column) + + return { + columns: legacyColumns, + from: index.name, + to: buildLegacyIndexName(`${tableName}_${legacyColumns.join('_')}`), + unique, + } +} + +/** Mirrors `buildIndexName` for an index that didn't collide, without registering the name. */ +function buildLegacyIndexName(name: string): string { + const suffix = '_idx' + const indexName = `${name}${suffix}` + + return indexName.length > maxGeneratedIdentifierLength + ? `${name.slice(0, maxGeneratedIdentifierLength - suffix.length)}${suffix}` + : indexName +} diff --git a/packages/payload-cloud/src/hooks/afterDelete.ts b/packages/payload-cloud/src/hooks/afterDelete.ts index 968c960f72c..a8a626929b9 100644 --- a/packages/payload-cloud/src/hooks/afterDelete.ts +++ b/packages/payload-cloud/src/hooks/afterDelete.ts @@ -18,7 +18,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename || '', - ...Object.values(doc?.sizes || []) + ...Object.values(doc?.variants || []) .map((resizedFileData) => resizedFileData.filename) .filter((filename): filename is string => filename !== null), ] diff --git a/packages/payload-cloud/src/utilities/getIncomingFiles.ts b/packages/payload-cloud/src/utilities/getIncomingFiles.ts index 3b8ea59e945..1bf7b659a86 100644 --- a/packages/payload-cloud/src/utilities/getIncomingFiles.ts +++ b/packages/payload-cloud/src/utilities/getIncomingFiles.ts @@ -24,8 +24,8 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.sizes) { - Object.entries(data.sizes).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (req.payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 021987bbde1..4dcbee4e0af 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -137,9 +137,9 @@ export type RequiredDataFromCollectionSlug = * The id field is optional since it's auto-generated */ export type DraftDataFromCollection = Partial< - Omit + Omit > & - Partial> + Partial> export type DraftDataFromCollectionSlug = DraftDataFromCollection< DataFromCollectionSlug @@ -150,9 +150,9 @@ export type DraftDataFromCollectionSlug = DraftDat * When querying drafts, required fields may be null/undefined as validation is skipped, but system fields like id are always present */ export type QueryDraftDataFromCollection = Partial< - Omit + Omit > & - Partial> & + Partial> & Pick export type QueryDraftDataFromCollectionSlug = diff --git a/packages/payload/src/fields/config/reservedFieldNames.ts b/packages/payload/src/fields/config/reservedFieldNames.ts index 8dd7f32267b..1bdaa45511e 100644 --- a/packages/payload/src/fields/config/reservedFieldNames.ts +++ b/packages/payload/src/fields/config/reservedFieldNames.ts @@ -37,7 +37,7 @@ export const reservedBaseUploadFieldNames = [ 'url', 'focalX', 'focalY', - 'sizes', */ + 'variants', */ ] /** diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts b/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts index 2ba6eafaa7f..991fadde354 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts @@ -62,7 +62,7 @@ describe('deleteAssociatedFiles', () => { deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: path.join('..', generatedFilename) }, }, }), @@ -86,7 +86,7 @@ describe('deleteAssociatedFiles', () => { await deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: generatedFilename }, }, }), @@ -104,7 +104,7 @@ describe('deleteAssociatedFiles', () => { await deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: null }, }, }), diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.ts b/packages/payload/src/uploads/deleteAssociatedFiles.ts index 5767b9aade9..0e01cf69bf2 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.ts @@ -43,8 +43,8 @@ export const deleteAssociatedFiles: (args: Args) => Promise = async ({ throw new ErrorDeletingFile(req.t) } - if (doc.sizes) { - const sizes: FileData[] = Object.values(doc.sizes) + if (doc.variants) { + const sizes: FileData[] = Object.values(doc.variants) // Since forEach will not wait until unlink is finished it could // happen that two operations will try to delete the same file. // To avoid this it is recommended to use "sync" instead diff --git a/packages/payload/src/uploads/docWithFilenameExists.ts b/packages/payload/src/uploads/docWithFilenameExists.ts index 7bc13768dae..dd2557deaf1 100644 --- a/packages/payload/src/uploads/docWithFilenameExists.ts +++ b/packages/payload/src/uploads/docWithFilenameExists.ts @@ -30,7 +30,7 @@ export const docWithFilenameExists = async ({ }, ...(upload && typeof upload === 'object' && upload.variants ? upload.variants.map(({ name }) => ({ - [`sizes.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, })) : []), ], diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index f3a28c4ba53..e11778a3b01 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -445,7 +445,7 @@ export const generateFileData = async ({ const { name: baseName, ext: baseExt } = parseFilename(fsSafeName) for (const result of sizeResults) { - const sizeName = result.fieldPath.slice('sizes.'.length) + const sizeName = result.fieldPath.slice('variants.'.length) if (!result.file) { sizes[sizeName] = { @@ -502,7 +502,7 @@ export const generateFileData = async ({ }) } - fileData.sizes = sizes + fileData.variants = sizes } } catch (err) { req.payload.logger.error(err) diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 36e0794a2cc..85c8638e6e7 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -61,13 +61,13 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => config, filename: typeof adminThumbnail === 'string' - ? (originalDoc.sizes?.[adminThumbnail]?.filename as string) + ? (originalDoc.variants?.[adminThumbnail]?.filename as string) : undefined, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: typeof adminThumbnail === 'string' - ? (originalDoc.sizes?.[adminThumbnail]?.url as string) + ? (originalDoc.variants?.[adminThumbnail]?.url as string) : undefined, }) }, @@ -200,7 +200,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => if (uploadOptions.variants) { uploadFields = uploadFields.concat([ { - name: 'sizes', + name: 'variants', type: 'group', admin: { hidden: true, @@ -226,8 +226,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.sizes?.[size.name]?.filename || - originalDoc?.sizes?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -239,8 +239,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.sizes?.[size.name]?.filename || - originalDoc?.sizes?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: true, serverURL: req.payload.config.serverURL, urlOrPath: value, diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts index aa3b62c3721..cde6aadacef 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts @@ -232,7 +232,7 @@ describe('getFileFromUploadInstructions', () => { where: { or: [ { filename: { equals: 'preview.png' } }, - { 'sizes.preview.filename': { equals: 'preview.png' } }, + { 'variants.preview.filename': { equals: 'preview.png' } }, ], }, }), diff --git a/packages/payload/src/uploads/sanitizeUploadData.spec.ts b/packages/payload/src/uploads/sanitizeUploadData.spec.ts index c60716a4399..becac498509 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.spec.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.spec.ts @@ -16,7 +16,7 @@ describe('sanitizeUploadData', () => { getExternalUploadSource({ alt: 'Alternative text', filename: 'remote.png', - sizes: { thumbnail: { filename: 'submitted-thumbnail.png' } }, + variants: { thumbnail: { filename: 'submitted-thumbnail.png' } }, url: 'https://example.com/remote.png', }), ).toEqual({ @@ -32,7 +32,7 @@ describe('sanitizeUploadData', () => { alt: 'Updated alternative text', filename: 'submitted.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', height: 100, @@ -99,7 +99,7 @@ describe('sanitizeUploadData', () => { { filename: 'C:\\submitted.png' }, { filename: '\\\\server\\submitted.png' }, { filename: 'images/../submitted.png' }, - { sizes: { thumbnail: { filename: '..\\submitted-thumbnail.png' } } }, + { variants: { thumbnail: { filename: '..\\submitted-thumbnail.png' } } }, ])('should reject an invalid submitted filename', (data) => { expect(() => sanitizeUploadData(data, 'update')).toThrow('Invalid filename.') }) @@ -113,7 +113,7 @@ describe('mergeUploadDataWithDocument', () => { { filename: 'stored.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, @@ -127,7 +127,7 @@ describe('mergeUploadDataWithDocument', () => { alt: 'Updated alternative text', filename: 'stored.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, @@ -144,13 +144,13 @@ describe('mergeUploadDataWithDocument', () => { { filename: 'hook.png', prefix: 'hook-prefix', - sizes: { thumbnail: { filename: 'hook-thumbnail.png' } }, + variants: { thumbnail: { filename: 'hook-thumbnail.png' } }, url: '/api/media/file/hook.png', }, { filename: 'stored.png', prefix: 'stored-prefix', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, width: 100 }, }, url: '/api/media/file/stored.png', @@ -159,7 +159,7 @@ describe('mergeUploadDataWithDocument', () => { ).toEqual({ filename: 'hook.png', prefix: 'hook-prefix', - sizes: { + variants: { thumbnail: { filename: 'hook-thumbnail.png', height: 100, width: 100 }, }, url: '/api/media/file/hook.png', diff --git a/packages/payload/src/uploads/sanitizeUploadData.ts b/packages/payload/src/uploads/sanitizeUploadData.ts index 8ca26b2e514..49758a8340c 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.ts @@ -114,8 +114,8 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { validateFilename(data.filename) - if (isRecord(data.sizes)) { - for (const sizeData of Object.values(data.sizes)) { + if (isRecord(data.variants)) { + for (const sizeData of Object.values(data.variants)) { if (isRecord(sizeData)) { validateFilename(sizeData.filename) } @@ -124,7 +124,7 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { const sanitizedData: Record = { ...data } delete sanitizedData.filename - delete sanitizedData.sizes + delete sanitizedData.variants delete sanitizedData.url // Server-owned; never accepted from the caller. delete sanitizedData._objectKey @@ -138,7 +138,7 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { } // Restores only the identity fields that select/serve a stored object (`filename`, `prefix`, -// `url`, `sizes`). Other generated-but-derived fields (`thumbnailURL`, `filesize`, `height`, +// `url`, `variants`). Other generated-but-derived fields (`thumbnailURL`, `filesize`, `height`, // `width`, `mimeType`, `focalX`/`focalY`) are intentionally left as submitted: `focalX`/`focalY` // are user-editable crop input, and the rest are regenerated by generateFileData when a file is // written. Compare with restoreUploadDataFromDocument (version restore), which re-binds the full @@ -160,20 +160,20 @@ export const mergeUploadDataWithDocument = ( } } - const documentSizes = getDocumentProperty(document, 'sizes', options) - if (!hasOwnProperty(data, 'sizes') && hasOwnProperty(document, 'sizes')) { - mergedData.sizes = documentSizes - } else if (isRecord(data.sizes) && isRecord(documentSizes)) { + const documentSizes = getDocumentProperty(document, 'variants', options) + if (!hasOwnProperty(data, 'variants') && hasOwnProperty(document, 'variants')) { + mergedData.variants = documentSizes + } else if (isRecord(data.variants) && isRecord(documentSizes)) { const mergedSizes: Record = { ...documentSizes } - for (const [sizeName, sizeData] of Object.entries(data.sizes)) { + for (const [sizeName, sizeData] of Object.entries(data.variants)) { const documentSize = documentSizes[sizeName] mergedSizes[sizeName] = isRecord(sizeData) && isRecord(documentSize) ? { ...documentSize, ...sizeData } : sizeData } - mergedData.sizes = mergedSizes + mergedData.variants = mergedSizes } return mergedData as T @@ -188,7 +188,7 @@ const uploadDerivedProperties = [ 'height', 'mimeType', 'prefix', - 'sizes', + 'variants', 'thumbnailURL', 'url', 'width', diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts index e763e3a4049..6f4dd166c9c 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts @@ -23,7 +23,7 @@ describe('getRequestedFile', () => { id: '1', filename: 'logo.png', mimeType: 'image/png', - sizes: { + variants: { card: { filename: 'logo-640x480.webp', mimeType: 'image/webp' }, skipped: { filename: null, mimeType: null }, }, diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts index 5511d580ef3..527f9e57b8b 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -6,7 +6,7 @@ import { Forbidden } from '../../errors/Forbidden.js' export type ResolvedUploadDocument = { filename: string mimeType: string - sizes?: Record + variants?: Record } & TypeWithID /** @@ -26,7 +26,7 @@ export function buildFilenameWhere({ variants?.forEach(({ name }) => { filenameCondition.or!.push({ - [`sizes.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, }) }) @@ -46,7 +46,7 @@ export function getRequestedFile({ filename: string }): { filename: string; mimeType: string } { if (document.filename !== filename) { - const size = Object.values(document.sizes ?? {}).find((size) => size?.filename === filename) + const size = Object.values(document.variants ?? {}).find((size) => size?.filename === filename) if (size?.filename && size.mimeType) { return { filename: size.filename, mimeType: size.mimeType } diff --git a/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts index 532229048f3..a7047ef582a 100644 --- a/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts +++ b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts @@ -4,22 +4,22 @@ import type { UploadEdits } from '../types.js' /** * Private v4 compatibility bridge, attached to a transformer object under this * symbol. Lets the official Sharp package reproduce the current main-image and - * legacy `sizes` upload behavior without core exposing a general persisted- + * legacy `variants` upload behavior without core exposing a general persisted- * variants API. Never exported from `payload` — `payload/internal` only, and * never documented for third-party use. */ export const uploadTransformerInternal = Symbol.for('payload.uploadTransformerInternal') export type UploadTransformTask = { - fieldPath: 'filename' | `sizes.${string}` + fieldPath: 'filename' | `variants.${string}` /** Source file for this task. Defaults to the original upload (e.g. pass the cropped main output so sizes derive from the crop). */ file?: File options: TOptions } export type PreparedUploadTransformation = { - fieldPath: 'filename' | `sizes.${string}` - /** Omitted when this task was intentionally skipped (e.g. an image size too small to enlarge) — recorded with null metadata, matching the existing `sizes` shape. */ + fieldPath: 'filename' | `variants.${string}` + /** Omitted when this task was intentionally skipped (e.g. an image size too small to enlarge) — recorded with null metadata, matching the existing `variants` shape. */ file?: File height?: number mimeType?: string diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index dbfbaec4976..6e283eb15cd 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -23,9 +23,9 @@ export type FileData = { focalY?: number height: number mimeType: string - sizes: FileSizes tempFilePath?: string url?: string + variants: FileSizes width: number } diff --git a/packages/payload/src/utilities/appendUploadSelectFields.ts b/packages/payload/src/utilities/appendUploadSelectFields.ts index ea9854d49a9..443f1eee961 100644 --- a/packages/payload/src/utilities/appendUploadSelectFields.ts +++ b/packages/payload/src/utilities/appendUploadSelectFields.ts @@ -29,14 +29,14 @@ export const appendUploadSelectFields = ({ typeof collectionConfig.upload.adminThumbnail === 'string' ) { /** Only return image size properties that are required to generate the adminThumbnailURL */ - select.sizes = { + select.variants = { [collectionConfig.upload.adminThumbnail]: { filename: true, }, } } else { /** Only return image size properties that are required for thumbnails */ - select.sizes = collectionConfig.upload.variants.reduce((acc, imageSizeConfig) => { + select.variants = collectionConfig.upload.variants.reduce((acc, imageSizeConfig) => { return { ...acc, [imageSizeConfig.name]: { diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 5c63dfd1ff3..623b7a11262 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -112,7 +112,7 @@ export const getFields = ({ let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { - if ('name' in existingField && existingField.name === 'sizes') { + if ('name' in existingField && existingField.name === 'variants') { existingSizesFieldIndex = i return true } @@ -126,7 +126,7 @@ export const getFields = ({ const sizesField: Field = { ...(existingSizesField || {}), - name: 'sizes', + name: 'variants', type: 'group', admin: { hidden: true, diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts index dbca2963db4..19003db0257 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts @@ -22,7 +22,7 @@ describe('upload replacement cleanup', () => { id: 1, filename: 'file.png', mimeType: 'image/png', - sizes: { thumbnail: { filename: 'thumb.png' } }, + variants: { thumbnail: { filename: 'thumb.png' } }, prefix: newPrefix, } @@ -34,7 +34,7 @@ describe('upload replacement cleanup', () => { id: 1, filename: 'file.png', mimeType: 'image/png', - sizes: { thumbnail: { filename: 'thumb.png' } }, + variants: { thumbnail: { filename: 'thumb.png' } }, prefix: oldPrefix, }, operation: 'update', diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index cf69ccf727e..545afba8c2b 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -125,9 +125,9 @@ export const getAfterChangeHook = filesToDelete.push(previousDoc.filename) } - if (typeof previousDoc.sizes === 'object') { + if (typeof previousDoc.variants === 'object') { filesToDelete = filesToDelete.concat( - Object.values(previousDoc?.sizes || []).map( + Object.values(previousDoc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ) @@ -140,8 +140,8 @@ export const getAfterChangeHook = if (typeof newFileData.filename === 'string') { newFilenames.add(newFileData.filename) } - if (typeof newFileData.sizes === 'object') { - for (const size of Object.values(newFileData.sizes || {})) { + if (typeof newFileData.variants === 'object') { + for (const size of Object.values(newFileData.variants || {})) { if (size?.filename && typeof size.filename === 'string') { newFilenames.add(size.filename) } diff --git a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts index 03d674f3812..fae6144eb00 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts @@ -28,7 +28,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename, - ...Object.values(doc?.sizes || []).map( + ...Object.values(doc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ] diff --git a/packages/plugin-cloud-storage/src/hooks/afterRead.ts b/packages/plugin-cloud-storage/src/hooks/afterRead.ts index 0de552ae75f..fef51fd3b3e 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterRead.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterRead.ts @@ -30,7 +30,7 @@ const getObjectFolder = (data: unknown): string => { export const getAfterReadHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => async ({ data, value }) => { - const filename = size ? data?.sizes?.[size.name]?.filename : data?.filename + const filename = size ? data?.variants?.[size.name]?.filename : data?.filename const prefix = data?.prefix // Direct-serve URLs encode the full location; the proxy resolves `_objectKey` server-side. const objectFolder = getObjectFolder(data) diff --git a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts index e8fa571c60c..c7eecba2f8f 100644 --- a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts @@ -30,9 +30,9 @@ const getObjectFolder = (data: unknown, originalDoc: unknown): string => { export const getBeforeChangeHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => async ({ data, originalDoc, value }) => { - const newFilename = size ? data?.sizes?.[size.name]?.filename : data?.filename + const newFilename = size ? data?.variants?.[size.name]?.filename : data?.filename const originalFilename = size - ? originalDoc?.sizes?.[size.name]?.filename + ? originalDoc?.variants?.[size.name]?.filename : originalDoc?.filename const filename = newFilename || originalFilename const prefix = getObjectFolder(data, originalDoc) diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 4bdf5bc02c8..8fecb1520e2 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -72,7 +72,7 @@ export async function getFilePrefix({ filename: { equals: filename }, }, ...variants.map((imageSize) => ({ - [`sizes.${imageSize.name}.filename`]: { equals: filename }, + [`variants.${imageSize.name}.filename`]: { equals: filename }, })), ], } diff --git a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts index 146982e7f23..3ab4d06c965 100644 --- a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts +++ b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts @@ -33,8 +33,8 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.sizes) { - Object.entries(data.sizes).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts index 75ed09ff54b..bbf3eca442b 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts @@ -41,7 +41,7 @@ export const UploadHTMLConverterAsync: HTMLConvertersAsync } // 2) If image has no different sizes, return a simple - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return ` // 3) If image has different sizes, build a element with tags let pictureHTML = '' - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize if ( !imageSize || diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts index d63b8807ed4..4b1896fc552 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts @@ -35,7 +35,7 @@ export const UploadHTMLConverter: HTMLConverters = { } // 2) If image has no different sizes, return a simple - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return ` = { // 3) If image has different sizes, build a element with tags let pictureHTML = '' - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize if ( !imageSize || diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx index d9ba171cce9..e4381b90941 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx @@ -32,7 +32,7 @@ export const UploadJSXConverter: JSXConverters = { /** * If the upload is a simple image with no different sizes, return a simple img tag */ - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return {alt} } @@ -41,9 +41,9 @@ export const UploadJSXConverter: JSXConverters = { */ const pictureJSX: React.ReactNode[] = [] - // Iterate through each size in the data.sizes object - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + // Iterate through each size in the data.variants object + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize // Skip if any property of the size object is null if ( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ab2c8b067cb..bed2d2121ac 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -65,7 +65,7 @@ export type TransformGlobalWithSelect< > : T['globals'][TSlug] -type SystemFields = 'createdAt' | 'id' | 'sizes' | 'updatedAt' +type SystemFields = 'createdAt' | 'id' | 'updatedAt' | 'variants' export type RequiredDataFromCollection = Omit & Partial & TData, SystemFields>> diff --git a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts index 9f1d682b9aa..78b129f2e56 100644 --- a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts +++ b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts @@ -28,7 +28,7 @@ function getDocumentFileKeys({ doc: OwnerDocument useCompositePrefixes: boolean }): string[] { - return [doc.filename, ...Object.values(doc.sizes || {}).map((size) => size?.filename)] + return [doc.filename, ...Object.values(doc.variants || {}).map((size) => size?.filename)] .filter((filename): filename is string => typeof filename === 'string') .map( (filename) => @@ -69,7 +69,7 @@ async function findMatchingDocs({ const filenameQueries: Where[] = [ { filename: { equals: requestedFilename } }, ...variants.map(({ name }) => ({ - [`sizes.${name}.filename`]: { equals: requestedFilename }, + [`variants.${name}.filename`]: { equals: requestedFilename }, })), ] const result = await req.payload.find({ diff --git a/packages/transformer-sharp/README.md b/packages/transformer-sharp/README.md index ea4caa434df..1cc31e8427b 100644 --- a/packages/transformer-sharp/README.md +++ b/packages/transformer-sharp/README.md @@ -47,7 +47,7 @@ sharpTransformer({ }) ``` -`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before. +`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel and generated types pick them up. Generated files are stored under each document's `variants` field. ### Dynamic (request-time) resizing diff --git a/packages/transformer-sharp/src/initSharpCollections.ts b/packages/transformer-sharp/src/initSharpCollections.ts index 88a9fe3a97e..09ca403fcb4 100644 --- a/packages/transformer-sharp/src/initSharpCollections.ts +++ b/packages/transformer-sharp/src/initSharpCollections.ts @@ -10,7 +10,7 @@ const RESERVED_IMAGE_SIZE_NAMES = [ 'height', 'url', 'thumbnailURL', - 'sizes', + 'variants', 'focalX', 'focalY', ] diff --git a/packages/transformer-sharp/src/prepareLegacyUpload.ts b/packages/transformer-sharp/src/prepareLegacyUpload.ts index daaca3eb47c..b1cbecae6dd 100644 --- a/packages/transformer-sharp/src/prepareLegacyUpload.ts +++ b/packages/transformer-sharp/src/prepareLegacyUpload.ts @@ -109,7 +109,7 @@ export function createPrepareLegacyUpload({ const sizeResults = await mapWithBoundedConcurrency(variants, async (rawConfig) => { const imageResizeConfig = sanitizeResizeConfig(rawConfig) - const fieldPath = `sizes.${imageResizeConfig.name}` as const + const fieldPath = `variants.${imageResizeConfig.name}` as const const resizeAction = getImageResizeAction({ dimensions: sizeSourceDimensions, diff --git a/packages/transformer-sharp/src/types.ts b/packages/transformer-sharp/src/types.ts index 144d2351b76..535e8fa67e1 100644 --- a/packages/transformer-sharp/src/types.ts +++ b/packages/transformer-sharp/src/types.ts @@ -37,7 +37,7 @@ export type SharpImageSizeOptions = { crop?: string // comes from sharp package formatOptions?: ImageUploadFormatOptions trimOptions?: ImageUploadTrimOptions - /** Image sizes generated for each upload, stored under the document's `sizes`. */ + /** Image sizes generated for each upload, stored under the document's `variants`. */ variants?: ImageSize[] /** * When an uploaded image is smaller than the defined image size, we have 3 options: @@ -71,7 +71,7 @@ export type SharpCollectionConfig = { formatOptions?: ImageUploadFormatOptions resizeOptions?: ResizeOptions trimOptions?: ImageUploadTrimOptions - /** Image sizes generated for each upload, stored under the document's `sizes`. */ + /** Image sizes generated for each upload, stored under the document's `variants`. */ variants?: ImageSize[] withMetadata?: WithMetadata } diff --git a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx index b4e2d853f40..197dc1f1a0a 100644 --- a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx @@ -18,7 +18,7 @@ export type DraggableFileDetailsProps = { collectionSlug: string customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean hasImageSizes?: boolean diff --git a/packages/ui/src/elements/FileDetails/FileMeta/index.tsx b/packages/ui/src/elements/FileDetails/FileMeta/index.tsx index d410660534e..176eaa6d52e 100644 --- a/packages/ui/src/elements/FileDetails/FileMeta/index.tsx +++ b/packages/ui/src/elements/FileDetails/FileMeta/index.tsx @@ -7,8 +7,8 @@ export type FileMetaProps = { filesize: number height?: number mimeType: string - sizes?: unknown url: string + variants?: unknown width?: number } diff --git a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx index 045d6597b07..e5c2e472003 100644 --- a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx @@ -14,7 +14,7 @@ import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload' export type StaticFileDetailsProps = { customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean handleRemove?: () => void diff --git a/packages/ui/src/elements/FileDetails/index.tsx b/packages/ui/src/elements/FileDetails/index.tsx index 9e9df479c93..db6f4c257b8 100644 --- a/packages/ui/src/elements/FileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/index.tsx @@ -10,7 +10,7 @@ type SharedFileDetailsProps = { collectionSlug: string customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean hasImageSizes?: boolean diff --git a/packages/ui/src/elements/FileManager/index.tsx b/packages/ui/src/elements/FileManager/index.tsx index 7c0bdc2d4bd..376d3d98223 100644 --- a/packages/ui/src/elements/FileManager/index.tsx +++ b/packages/ui/src/elements/FileManager/index.tsx @@ -105,7 +105,7 @@ export const FileManager: React.FC = ({ const showFocalPoint = focalPoint && (hasVariants || hasImageAdjustments || focalPointEnabled) const selectedSizeData = selectedSize - ? (data?.sizes?.[selectedSize] as Record) + ? (data?.variants?.[selectedSize] as Record) : null const sidePanelFileSrc = (selectedSizeData?.url ?? data?.thumbnailURL ?? data?.url ?? null) as | null diff --git a/packages/ui/src/elements/MiniCarousel/index.tsx b/packages/ui/src/elements/MiniCarousel/index.tsx index a56cc3dd37b..a84f50629df 100644 --- a/packages/ui/src/elements/MiniCarousel/index.tsx +++ b/packages/ui/src/elements/MiniCarousel/index.tsx @@ -43,8 +43,8 @@ const MiniCarouselItem: React.FC = ({ export type MiniCarouselProps = { doc: { - sizes?: FileSizes url?: string + variants?: FileSizes } & Data imageCacheTag?: false | string onSelect: (sizeKey: null | string) => void @@ -61,7 +61,7 @@ export const MiniCarousel: React.FC = ({ }) => { const { t } = useTranslation() const { variants } = uploadConfig - const { sizes, url: originalUrl } = doc + const { url: originalUrl, variants: sizes } = doc const orderedSizeKeys = React.useMemo(() => { if (!variants || variants.length === 0) { diff --git a/packages/ui/src/elements/PreviewSizes/index.tsx b/packages/ui/src/elements/PreviewSizes/index.tsx index b0003d0d59e..11f87baf8e3 100644 --- a/packages/ui/src/elements/PreviewSizes/index.tsx +++ b/packages/ui/src/elements/PreviewSizes/index.tsx @@ -78,7 +78,7 @@ const PreviewSizeCard: React.FC = ({ export type PreviewSizesProps = { doc: { - sizes?: FilesSizesWithUrl + variants?: FilesSizesWithUrl } & Data imageCacheTag?: false | string uploadConfig: SanitizedCollectionConfig['upload'] @@ -86,7 +86,7 @@ export type PreviewSizesProps = { export const PreviewSizes: React.FC = ({ doc, imageCacheTag, uploadConfig }) => { const { variants } = uploadConfig - const { sizes } = doc + const { variants: sizes } = doc const alt = (doc as { alt?: string })?.alt || doc.filename || '' @@ -108,7 +108,7 @@ export const PreviewSizes: React.FC = ({ doc, imageCacheTag, }, [sizes, variants, imageCacheTag]) const mainPreviewSrc = selectedSize - ? generateImageUrl(doc.sizes[selectedSize]) + ? generateImageUrl(doc.variants[selectedSize]) : generateImageUrl(doc) const originalImage = useMemo( diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx index 0abfe207963..a909a66ef52 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx @@ -36,7 +36,7 @@ export const FileCell: React.FC = ({ if (isFileImage) { fileSrc = getBestFitFromSizes({ - sizes: rowData?.sizes, + sizes: rowData?.variants, thumbnailURL: rowData?.thumbnailURL, url: rowData?.url, width: rowData?.width, diff --git a/packages/ui/src/fields/Upload/HasMany/index.tsx b/packages/ui/src/fields/Upload/HasMany/index.tsx index 5647633e841..570bbad982b 100644 --- a/packages/ui/src/fields/Upload/HasMany/index.tsx +++ b/packages/ui/src/fields/Upload/HasMany/index.tsx @@ -102,7 +102,7 @@ export function UploadComponentHasMany(props: Props) { if (isImage(value.mimeType)) { thumbnailSrc = getBestFitFromSizes({ - sizes: value.sizes, + sizes: value.variants, thumbnailURL: thumbnailSrc, url: src, width: value.width, diff --git a/packages/ui/src/fields/Upload/HasOne/index.tsx b/packages/ui/src/fields/Upload/HasOne/index.tsx index 8b1789d53a2..6e0056cb36b 100644 --- a/packages/ui/src/fields/Upload/HasOne/index.tsx +++ b/packages/ui/src/fields/Upload/HasOne/index.tsx @@ -62,7 +62,7 @@ export function UploadComponentHasOne(props: Props) { if (isImage(value.mimeType)) { thumbnailSrc = getBestFitFromSizes({ - sizes: value.sizes, + sizes: value.variants, thumbnailURL: thumbnailSrc, url: src, width: value.width, diff --git a/packages/ui/src/utilities/filterOutUploadFields.ts b/packages/ui/src/utilities/filterOutUploadFields.ts index 25c2f4301bd..ec345b1b6bf 100644 --- a/packages/ui/src/utilities/filterOutUploadFields.ts +++ b/packages/ui/src/utilities/filterOutUploadFields.ts @@ -13,7 +13,7 @@ export const filterOutUploadFields = (fields: FieldWithPathClient[]): FieldWithP 'url', 'focalX', 'focalY', - 'sizes', + 'variants', ] return fields.filter((field) => !baseUploadFieldNames.includes('name' in field && field.name)) diff --git a/packages/ui/src/views/HierarchyList/HierarchyTable/RelatedNameCell.tsx b/packages/ui/src/views/HierarchyList/HierarchyTable/RelatedNameCell.tsx index 3df0e7eae41..3c7a38895d3 100644 --- a/packages/ui/src/views/HierarchyList/HierarchyTable/RelatedNameCell.tsx +++ b/packages/ui/src/views/HierarchyList/HierarchyTable/RelatedNameCell.tsx @@ -37,7 +37,7 @@ const RelatedDocIcon = ({ collectionSlug, row }: RelatedDocIconProps) => { if (isFileImage) { thumbnailSrc = getBestFitFromSizes({ - sizes: row.sizes as Record, + sizes: row.variants as Record, thumbnailURL: row.thumbnailURL as string, url: row.url as string, width: row.width as number, diff --git a/templates/website/src/payload-types.ts b/templates/website/src/payload-types.ts index 55295b55282..e764ff34bc3 100644 --- a/templates/website/src/payload-types.ts +++ b/templates/website/src/payload-types.ts @@ -320,7 +320,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1175,7 +1175,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/templates/website/src/utilities/generateMeta.ts b/templates/website/src/utilities/generateMeta.ts index 7395c87d05d..367572bc440 100644 --- a/templates/website/src/utilities/generateMeta.ts +++ b/templates/website/src/utilities/generateMeta.ts @@ -11,7 +11,7 @@ const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => { let url = serverUrl + '/website-template-OG.webp' if (image && typeof image === 'object' && 'url' in image) { - const ogUrl = image.sizes?.og?.url + const ogUrl = image.variants?.og?.url url = ogUrl ? serverUrl + ogUrl : serverUrl + image.url } diff --git a/templates/with-vercel-website/src/payload-types.ts b/templates/with-vercel-website/src/payload-types.ts index a4f063aedc8..831d8c25d46 100644 --- a/templates/with-vercel-website/src/payload-types.ts +++ b/templates/with-vercel-website/src/payload-types.ts @@ -304,7 +304,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1238,7 +1238,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/templates/with-vercel-website/src/utilities/generateMeta.ts b/templates/with-vercel-website/src/utilities/generateMeta.ts index 7395c87d05d..367572bc440 100644 --- a/templates/with-vercel-website/src/utilities/generateMeta.ts +++ b/templates/with-vercel-website/src/utilities/generateMeta.ts @@ -11,7 +11,7 @@ const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => { let url = serverUrl + '/website-template-OG.webp' if (image && typeof image === 'object' && 'url' in image) { - const ogUrl = image.sizes?.og?.url + const ogUrl = image.variants?.og?.url url = ogUrl ? serverUrl + ogUrl : serverUrl + image.url } diff --git a/test/__helpers/shared/animatedResizeParityTests.ts b/test/__helpers/shared/animatedResizeParityTests.ts index 46664a97d35..ff117514062 100644 --- a/test/__helpers/shared/animatedResizeParityTests.ts +++ b/test/__helpers/shared/animatedResizeParityTests.ts @@ -17,7 +17,7 @@ type SizeExpectation = { type AnimatedUploadResult = { height: null | number id: number | string - sizes: Record + variants: Record width: null | number } @@ -53,8 +53,8 @@ export function runAnimatedResizeReportsPerFrameDimensionsTest({ expect(result.width).toBe(mainDimensions.width) for (const size of sizes) { - expect(result.sizes?.[size.name]?.height).toBe(size.height) - expect(result.sizes?.[size.name]?.width).toBe(size.width) + expect(result.variants?.[size.name]?.height).toBe(size.height) + expect(result.variants?.[size.name]?.width).toBe(size.width) } }) }) @@ -87,8 +87,8 @@ export function runAnimatedFocalPointResizeStaysValidTest({ } as unknown as Parameters[0] const result = (await payload.create(createArgs)) as unknown as AnimatedUploadResult - expect(result.sizes?.[size.name]?.height).toBe(size.height) - expect(result.sizes?.[size.name]?.width).toBe(size.width) + expect(result.variants?.[size.name]?.height).toBe(size.height) + expect(result.variants?.[size.name]?.width).toBe(size.width) }) }) } diff --git a/test/__helpers/shared/sdk/types.ts b/test/__helpers/shared/sdk/types.ts index b4246f805ba..0b1890e9285 100644 --- a/test/__helpers/shared/sdk/types.ts +++ b/test/__helpers/shared/sdk/types.ts @@ -4,8 +4,8 @@ import type { DeepPartial, MarkOptional } from 'ts-essentials' type CollectionDoc = { createdAt?: string id?: number | string - sizes?: unknown updatedAt?: string + variants?: unknown } type BaseTypes = { @@ -48,7 +48,7 @@ export type CreateArgs< collection: TSlug data: MarkOptional< TGeneratedTypes['collections'][TSlug], - 'createdAt' | 'id' | 'sizes' | 'updatedAt' + 'createdAt' | 'id' | 'updatedAt' | 'variants' > depth?: number disableTransaction?: boolean diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 5bd0c17ee63..efc9d83db84 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -62,24 +62,24 @@ export type SupportedTimezones = | 'Pacific/Fiji'; /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_C0124FAC". + * via the `definition` "LexicalNodes_FCB16F2F". */ -export type LexicalNodes_C0124FAC = +export type LexicalNodes_FCB16F2F = | SerializedTextNode | SerializedTabNode | SerializedLineBreakNode - | SerializedParagraphNode + | SerializedParagraphNode | SerializedHorizontalRuleNode | SerializedUploadNode<'media'> - | SerializedQuoteNode + | SerializedQuoteNode | SerializedRelationshipNode< 'posts' | 'payload-kv' | 'users' | 'payload-locked-documents' | 'payload-preferences' | 'payload-migrations' > - | SerializedAutoLinkNode - | SerializedLinkNode - | SerializedListNode - | SerializedListItemNode - | SerializedHeadingNode; + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode + | SerializedHeadingNode; export interface Config { auth: { @@ -106,7 +106,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; fallbackLocale: null; globals: { @@ -150,16 +150,16 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: string; + id: number; title?: string | null; - content?: LexicalRichText | null; + content?: LexicalRichText | null; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -169,14 +169,14 @@ export interface Post { * via the `definition` "users". */ export interface User { - id: string; + id: number; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -203,14 +203,14 @@ export interface User { * via the `definition` "media". */ export interface Media { - id: string; + id: number; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -223,7 +223,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -255,7 +255,7 @@ export interface Media { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -272,24 +272,24 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'media'; - value: string | Media; + value: number | Media; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -299,10 +299,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -322,7 +322,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -358,7 +358,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: @@ -463,15 +463,15 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: string; + id: number; globalText?: string | null; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/a11y/payload-types.ts b/test/a11y/payload-types.ts index 11508cebfd8..c48a7c686c0 100644 --- a/test/a11y/payload-types.ts +++ b/test/a11y/payload-types.ts @@ -346,7 +346,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -565,7 +565,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/admin-bar/payload-types.ts b/test/admin-bar/payload-types.ts index de9a71888c9..2192c3bdcd1 100644 --- a/test/admin-bar/payload-types.ts +++ b/test/admin-bar/payload-types.ts @@ -199,7 +199,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -334,7 +334,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index cc2626f5210..dbedcd899fd 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -338,7 +338,7 @@ export interface Upload { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1401,7 +1401,7 @@ export interface UploadsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/benchmark-blocks/payload-types.ts b/test/benchmark-blocks/payload-types.ts index 7b6d7a2d25e..8e3e9af67e6 100644 --- a/test/benchmark-blocks/payload-types.ts +++ b/test/benchmark-blocks/payload-types.ts @@ -35046,7 +35046,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -35796,7 +35796,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/email/payload-types.ts b/test/email/payload-types.ts index 093019eca3a..a79885d26da 100644 --- a/test/email/payload-types.ts +++ b/test/email/payload-types.ts @@ -170,7 +170,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -340,7 +340,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/group-by/payload-types.ts b/test/group-by/payload-types.ts index d3fd3607972..177759c8b65 100644 --- a/test/group-by/payload-types.ts +++ b/test/group-by/payload-types.ts @@ -253,7 +253,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -540,7 +540,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/lexical-mdx/payload-types.ts b/test/lexical-mdx/payload-types.ts index b27a1678ee7..5af380f1ea7 100644 --- a/test/lexical-mdx/payload-types.ts +++ b/test/lexical-mdx/payload-types.ts @@ -286,7 +286,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -445,7 +445,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/lexical/lexical.int.spec.ts b/test/lexical/lexical.int.spec.ts index 86e26579fb1..5b4873392d9 100644 --- a/test/lexical/lexical.int.spec.ts +++ b/test/lexical/lexical.int.spec.ts @@ -1308,7 +1308,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'test.pdf', height: 100, mimeType: 'application/pdf', - sizes: {}, + variants: {}, url: '/uploads/test.pdf', width: 100, }, @@ -1372,7 +1372,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'photo.jpg', height: 600, mimeType: 'image/jpeg', - sizes: {}, + variants: {}, url: '/uploads/photo.jpg', width: 800, }, @@ -1562,7 +1562,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'photo.jpg', height: 600, mimeType: 'image/jpeg', - sizes: { + variants: { thumbnail: { filename: 'photo-thumb.jpg', filesize: 1000, diff --git a/test/payload-cloud/payload-types.ts b/test/payload-cloud/payload-types.ts index ee3e38d0386..69e51644593 100644 --- a/test/payload-cloud/payload-types.ts +++ b/test/payload-cloud/payload-types.ts @@ -208,7 +208,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -344,7 +344,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/plugin-cloud-storage/int.spec.ts b/test/plugin-cloud-storage/int.spec.ts index 900c55a2b1b..db420870b48 100644 --- a/test/plugin-cloud-storage/int.spec.ts +++ b/test/plugin-cloud-storage/int.spec.ts @@ -54,9 +54,9 @@ async function verifyUploads({ id: uploadId, collection: collectionSlug, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const sizes = uploadData.sizes ?? {} + const sizes = uploadData.variants ?? {} const fileKeys = Object.keys(sizes).map((key) => { const entry = sizes[key] if (!entry) { @@ -407,11 +407,11 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => collection: mediaSlug, overrideAccess: true, }) - expect(apiResponse.sizes).toBeTruthy() + expect(apiResponse.variants).toBeTruthy() - const apiSizeKeys = Object.keys(apiResponse.sizes || {}) + const apiSizeKeys = Object.keys(apiResponse.variants || {}) for (const sizeKey of apiSizeKeys) { - const size = apiResponse.sizes?.[sizeKey as keyof typeof apiResponse.sizes] + const size = apiResponse.variants?.[sizeKey as keyof typeof apiResponse.variants] if (!size) { continue } @@ -427,16 +427,16 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const dbRecord = rawDbData as unknown as { filename: string - sizes: Record + variants: Record url: string } type SizeData = { filename: string; url: string } - const sizeKeys = Object.keys(dbRecord.sizes) + const sizeKeys = Object.keys(dbRecord.variants) expect(sizeKeys.length).toBeGreaterThan(0) for (const sizeKey of sizeKeys) { - const size: SizeData = dbRecord.sizes[sizeKey] as SizeData + const size: SizeData = dbRecord.variants[sizeKey] as SizeData expect(size.url).not.toEqual(`/api/${mediaSlug}/file/${dbRecord.filename}`) expect(size.url).toEqual(`/api/${mediaSlug}/file/${size.filename}`) } @@ -454,7 +454,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(upload.url).toEqual( `/api/${mediaWithPrefixSlug}/file/${upload.filename}?prefix=${prefix}`, ) - expect((upload as any).sizes).toBeFalsy() + expect((upload as any).variants).toBeFalsy() const rawDbData = await payload.db.findOne({ collection: mediaWithPrefixSlug, @@ -470,7 +470,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(dbRecord.filename).toEqual(upload.filename) expect(dbRecord.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${upload.filename}`) - expect((rawDbData as any)?.sizes).toBeFalsy() + expect((rawDbData as any)?.variants).toBeFalsy() }) test('should use custom generateFileURL in beforeChange when disablePayloadAccessControl is true', async ({ @@ -594,14 +594,14 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const originalFilenames = [ upload.filename, - ...Object.values(upload.sizes || {}).map((size) => size?.filename), + ...Object.values(upload.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') const updateResponse = await restClient.PATCH(`/${testMetadataSlug}/${upload.id}`, { body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted-prefix', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', }, @@ -638,14 +638,14 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const originalFilenames = [ upload.filename, - ...Object.values(upload.sizes || {}).map((size) => size?.filename), + ...Object.values(upload.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') const metadataUpdateResponse = await restClient.PATCH(`/${testMetadataSlug}/${upload.id}`, { body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted-prefix', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', }, @@ -663,7 +663,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(preservedUpload.filename).toBe(upload.filename) expect(preservedUpload.prefix).toBe('test-metadata') - expect(preservedUpload.sizes).toEqual(upload.sizes) + expect(preservedUpload.variants).toEqual(upload.variants) recordedCleanupTargets.length = 0 @@ -693,7 +693,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => }) const replacementFilenames = [ replacement.filename, - ...Object.values(replacement.sizes || {}).map((size) => size?.filename), + ...Object.values(replacement.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') expect(replacement.filename).not.toBe('submitted.png') @@ -743,7 +743,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(saved.filename).toBeTruthy() expect(saved.filename).not.toBe(original.filename) expect(saved.mimeType).toBe('image/webp') - expect(saved.sizes?.thumbnail?.filename).toBeTruthy() + expect(saved.variants?.thumbnail?.filename).toBeTruthy() expect([...uploadedTestFiles.values()]).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -752,8 +752,8 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => prefix: 'test-metadata', }), expect.objectContaining({ - filename: saved.sizes?.thumbnail?.filename, - mimeType: saved.sizes?.thumbnail?.mimeType, + filename: saved.variants?.thumbnail?.filename, + mimeType: saved.variants?.thumbnail?.mimeType, prefix: 'test-metadata', }), ]), @@ -878,7 +878,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const { doc } = await createResponse.json<{ doc: { id: number | string - sizes: { thumbnail: { filename: string } } + variants: { thumbnail: { filename: string } } storageProvider: string } }>() @@ -887,7 +887,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(createResponse.status).toBe(201) expect(doc.storageProvider).toBe('test-adapter') - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.filename).toBeTruthy() }) test('should persist metadata on update operations', async ({ payload }) => { @@ -1051,12 +1051,12 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => })) as unknown as { filename: string id: number | string - sizes: Record + variants: Record } createdIDs.push(initial.id) - const initialSizeKeys = Object.values(initial.sizes ?? {}) + const initialSizeKeys = Object.values(initial.variants ?? {}) .map((s) => s?.filename) .filter((f): f is string => typeof f === 'string') @@ -1078,13 +1078,13 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => overwriteExistingFiles: true, })) as unknown as { filename: string - sizes: Record + variants: Record } // Filenames should match because overwriteExistingFiles is enabled. expect(updated.filename).toBe(initial.filename) - const updatedSizeKeys = Object.values(updated.sizes ?? {}) + const updatedSizeKeys = Object.values(updated.variants ?? {}) .map((s) => s?.filename) .filter((f): f is string => typeof f === 'string') diff --git a/test/plugin-cloud-storage/payload-types.ts b/test/plugin-cloud-storage/payload-types.ts index 024c160506d..1bc987463d4 100644 --- a/test/plugin-cloud-storage/payload-types.ts +++ b/test/plugin-cloud-storage/payload-types.ts @@ -165,7 +165,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -358,7 +358,7 @@ export interface MediaWithOverwrite { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -498,7 +498,7 @@ export interface TestMetadatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -640,7 +640,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -770,7 +770,7 @@ export interface MediaWithOverwriteSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -882,7 +882,7 @@ export interface TestMetadataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/sizes-to-variants/config.ts b/test/sizes-to-variants/config.ts new file mode 100644 index 00000000000..cd58f974a4e --- /dev/null +++ b/test/sizes-to-variants/config.ts @@ -0,0 +1,41 @@ +import { sharpTransformer } from '@payloadcms/transformer-sharp' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + config: { + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + { + slug: mediaSlug, + fields: [], + upload: { + staticDir: path.resolve(dirname, 'media'), + }, + versions: true, + }, + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, + }, + sharp, + }), + ], + }, + }, + suite: 'sizes-to-variants', +}) diff --git a/test/sizes-to-variants/int.spec.ts b/test/sizes-to-variants/int.spec.ts new file mode 100644 index 00000000000..09c4665ee97 --- /dev/null +++ b/test/sizes-to-variants/int.spec.ts @@ -0,0 +1,291 @@ +import type { Payload } from 'payload' + +import { migrateSizesToVariants as migrateMongoSizesToVariants } from '@payloadcms/db-mongodb/migration-utils' +import { migrateSizesToVariants as migratePostgresSizesToVariants } from '@payloadcms/db-postgres/migration-utils' +import { migrateSizesToVariants as migrateSqliteSizesToVariants } from '@payloadcms/db-sqlite/migration-utils' +import { sql } from 'drizzle-orm' +import fs from 'fs' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' +import { expect } from 'vitest' + +import { test } from '../__helpers/int/vitest.js' +import { mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const variantFilenameColumn = `variants_${variantName}_filename` +const legacyFilenameColumn = `sizes_${variantName}_filename` + +const createMedia = async ({ payload }: { payload: Payload }) => { + const doc = await payload.create({ + collection: mediaSlug, + data: {}, + filePath: path.resolve(dirname, '../uploads/image.png'), + overrideAccess: true, + }) + + const variantFilename = doc.variants?.[variantName]?.filename + + if (!variantFilename) { + throw new Error(`Expected the "${variantName}" variant to be generated`) + } + + return { id: doc.id, filename: doc.filename!, variantFilename } +} + +/** The index the current schema expects on a table's variant filename column. */ +const getExpectedIndexName = ({ + payload, + tableName, +}: { + payload: Payload + tableName: string +}): string | undefined => { + const rawTable = (payload.db as any).rawTables[tableName] + + return Object.values<{ name: string; on: string | string[] }>(rawTable.indexes ?? {}).find( + (index) => [index.on].flat().some((column) => column.endsWith(variantFilenameColumn)), + )?.name +} + +test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { + test.afterEach(() => { + fs.rmSync(path.resolve(dirname, 'media'), { force: true, recursive: true }) + }) + + test('should not return the legacy sizes field', async ({ payload }) => { + const { id } = await createMedia({ payload }) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc).not.toHaveProperty('sizes') + }) + + test.options.describe( + 'PostgreSQL', + { db: (adapter) => adapter.startsWith('postgres') || adapter.startsWith('vercel-postgres') }, + () => { + const getColumns = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const schemaName = (payload.db as any).schemaName || 'public' + const result = await (payload.db as any).drizzle.execute(sql` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ${schemaName} AND table_name = ${tableName} + `) + + return result.rows.map((row: { column_name: string }) => row.column_name) as string[] + } + + const getIndexNames = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const schemaName = (payload.db as any).schemaName || 'public' + const result = await (payload.db as any).drizzle.execute(sql` + SELECT indexname FROM pg_indexes WHERE schemaname = ${schemaName} AND tablename = ${tableName} + `) + + return result.rows.map((row: { indexname: string }) => row.indexname) as string[] + } + + const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => + migratePostgresSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) + + test('should move legacy sizes columns to variants without losing data', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'down', payload }) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${legacyFilenameColumn}`, + ) + + await migrate({ direction: 'up', payload }) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(variantFilenameColumn) + expect(columns).not.toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${variantFilenameColumn}`, + ) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should rename the variant indexes to the names the current schema expects', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexName = getExpectedIndexName({ payload, tableName: mediaSlug }) + + expect(expectedIndexName).toBeTruthy() + + await migrate({ direction: 'down', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toContain( + expectedIndexName, + ) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(expectedIndexName) + }) + + test('should be safe to re-run once the columns are renamed', async ({ payload }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'up', payload }) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + }, + ) + + test.options.describe('SQLite', { db: (adapter) => adapter.startsWith('sqlite') }, () => { + const getColumns = async ({ payload, tableName }: { payload: Payload; tableName: string }) => { + const rows: { name: string }[] = await (payload.db as any).drizzle.all( + sql.raw(`SELECT name FROM pragma_table_info('${tableName}')`), + ) + + return rows.map(({ name }) => name) + } + + const getIndexNames = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const rows: { name: string }[] = await (payload.db as any).drizzle.all( + sql.raw(`SELECT name FROM pragma_index_list('${tableName}')`), + ) + + return rows.map(({ name }) => name) + } + + const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => + migrateSqliteSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) + + test('should move legacy sizes columns to variants without losing data', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'down', payload }) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${legacyFilenameColumn}`, + ) + + await migrate({ direction: 'up', payload }) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(variantFilenameColumn) + expect(columns).not.toContain(legacyFilenameColumn) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should recreate the variant indexes under the names the current schema expects', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexName = getExpectedIndexName({ payload, tableName: mediaSlug }) + + expect(expectedIndexName).toBeTruthy() + + await migrate({ direction: 'down', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toContain( + expectedIndexName, + ) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(expectedIndexName) + }) + }) + + test.options.describe('MongoDB', { db: 'mongo' }, () => { + test.beforeEach(async () => { + // Let Mongo finish the index builds the fixture reset triggers before indexes are re-keyed. + await wait(1000) + }) + + const getRawDocument = ({ filename, payload }: { filename: string; payload: Payload }) => + (payload.db as any).collections[mediaSlug].collection.findOne({ filename }) + + const getIndexKeys = async ({ payload }: { payload: Payload }) => { + const indexes: { key: Record }[] = await (payload.db as any).collections[ + mediaSlug + ].collection.indexes() + + return indexes.flatMap(({ key }) => Object.keys(key)) + } + + test('should move a legacy sizes field to variants without losing data', async ({ + payload, + }) => { + const { id, filename, variantFilename } = await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const legacyDocument = await getRawDocument({ filename, payload }) + + expect(legacyDocument.sizes?.[variantName]?.filename).toBe(variantFilename) + expect(legacyDocument.variants).toBeUndefined() + + await migrateMongoSizesToVariants({ payload }) + + const migratedDocument = await getRawDocument({ filename, payload }) + + expect(migratedDocument.sizes).toBeUndefined() + expect(migratedDocument.variants?.[variantName]?.filename).toBe(variantFilename) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should re-key indexes on the legacy sizes field', async ({ payload }) => { + await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + expect(await getIndexKeys({ payload })).toContain(`sizes.${variantName}.filename`) + + await migrateMongoSizesToVariants({ payload }) + + const keys = await getIndexKeys({ payload }) + + expect(keys).toContain(`variants.${variantName}.filename`) + expect(keys).not.toContain(`sizes.${variantName}.filename`) + }) + }) +}) diff --git a/test/sizes-to-variants/payload-types.ts b/test/sizes-to-variants/payload-types.ts new file mode 100644 index 00000000000..e8702daee4e --- /dev/null +++ b/test/sizes-to-variants/payload-types.ts @@ -0,0 +1,419 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + media: Media; + 'payload-kv': PayloadKv; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + media: MediaSelect | MediaSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + fallbackLocale: null; + globals: {}; + globalsSelect: {}; + locale: null; + widgets: { + collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; + }; + user: User; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + resetPasswordRequestedAt?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; + collection: 'users'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: string; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | 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; + variants?: { + thumbnail?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) + | ({ + relationTo: 'media'; + value: string | Media; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + resetPasswordRequestedAt?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + variants?: + | T + | { + thumbnail?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: 'users' | 'media'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: ('users' | 'media')[] | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/sizes-to-variants/shared.ts b/test/sizes-to-variants/shared.ts new file mode 100644 index 00000000000..f7a673d039f --- /dev/null +++ b/test/sizes-to-variants/shared.ts @@ -0,0 +1,2 @@ +export const mediaSlug = 'media' +export const variantName = 'thumbnail' diff --git a/test/sizes-to-variants/tsconfig.json b/test/sizes-to-variants/tsconfig.json new file mode 100644 index 00000000000..b29da4479f9 --- /dev/null +++ b/test/sizes-to-variants/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/test/storage-azure/client-uploads/int.spec.ts b/test/storage-azure/client-uploads/int.spec.ts index 2b9af05fd96..5d1215ab4a8 100644 --- a/test/storage-azure/client-uploads/int.spec.ts +++ b/test/storage-azure/client-uploads/int.spec.ts @@ -134,7 +134,7 @@ test.suite('@payloadcms/storage-azure clientUploads', { config: './config.ts' }, filename: 'legacy-original.png', mimeType: 'image/png', prefix, - sizes: { thumbnail: { filename: sizeFilename, mimeType: 'image/png' } }, + variants: { thumbnail: { filename: sizeFilename, mimeType: 'image/png' } }, }, }) const collection = payload.collections[mediaHeaderOnlyWithSizesSlug].config @@ -464,9 +464,9 @@ test.suite('@payloadcms/storage-azure clientUploads', { config: './config.ts' }, expect(doc.filesize).toBe(file.length) expect(doc.mimeType).toBe('image/jpeg') - expect(doc.sizes.thumbnail.width).toBe(400) - expect(doc.sizes.thumbnail.height).toBe(300) - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.width).toBe(400) + expect(doc.variants.thumbnail.height).toBe(300) + expect(doc.variants.thumbnail.filename).toBeTruthy() expect(downloadSpy).toHaveBeenCalledTimes(1) diff --git a/test/storage-azure/client-uploads/payload-types.ts b/test/storage-azure/client-uploads/payload-types.ts index e69aa2bc7ec..b48a225a98b 100644 --- a/test/storage-azure/client-uploads/payload-types.ts +++ b/test/storage-azure/client-uploads/payload-types.ts @@ -146,7 +146,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -239,7 +239,7 @@ export interface MediaHeaderOnlyWithSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -383,7 +383,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -478,7 +478,7 @@ export interface MediaHeaderOnlyWithSizesSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-azure/int.spec.ts b/test/storage-azure/int.spec.ts index 133b5b85f8f..acc9a06b8b5 100644 --- a/test/storage-azure/int.spec.ts +++ b/test/storage-azure/int.spec.ts @@ -131,9 +131,9 @@ test.suite('@payloadcms/storage-azure', { config: './config.ts', resetBetweenTes collection: collectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.values(uploadData.sizes || {}).map(({ filename: rawFilename }) => + const fileKeys = Object.values(uploadData.variants || {}).map(({ filename: rawFilename }) => prefix ? `${prefix}/${rawFilename}` : rawFilename, ) diff --git a/test/storage-azure/payload-types.ts b/test/storage-azure/payload-types.ts index 14e9f53c3a6..cb4fb99fd69 100644 --- a/test/storage-azure/payload-types.ts +++ b/test/storage-azure/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -363,7 +363,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-azure/streamingUploads.int.spec.ts b/test/storage-azure/streamingUploads.int.spec.ts index 3e3876d6360..53663d71a47 100644 --- a/test/storage-azure/streamingUploads.int.spec.ts +++ b/test/storage-azure/streamingUploads.int.spec.ts @@ -109,10 +109,10 @@ test.suite('@payloadcms/storage-azure streamingUploads', { config: './config.ts' collection: collectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => { - const rawFilename = uploadData.sizes[key].filename + const fileKeys = Object.keys(uploadData.variants || {}).map((key) => { + const rawFilename = uploadData.variants[key].filename return prefix ? `${prefix}/${rawFilename}` : rawFilename }) diff --git a/test/storage-gcs/int.spec.ts b/test/storage-gcs/int.spec.ts index 880b81efb13..8daed38ed66 100644 --- a/test/storage-gcs/int.spec.ts +++ b/test/storage-gcs/int.spec.ts @@ -60,9 +60,9 @@ test.suite('@payloadcms/storage-gcs', { config: './config.ts', resetBetweenTests id: uploadId, collection: collectionSlug as 'media', overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.values(uploadData.sizes || {}).map(({ filename: rawFilename }) => + const fileKeys = Object.values(uploadData.variants || {}).map(({ filename: rawFilename }) => filePrefix ? `${filePrefix}/${rawFilename}` : rawFilename, ) diff --git a/test/storage-gcs/payload-types.ts b/test/storage-gcs/payload-types.ts index 14e9f53c3a6..cb4fb99fd69 100644 --- a/test/storage-gcs/payload-types.ts +++ b/test/storage-gcs/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -363,7 +363,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-r2/payload-types.ts b/test/storage-r2/payload-types.ts index 915d03ccc56..36dd1529dba 100644 --- a/test/storage-r2/payload-types.ts +++ b/test/storage-r2/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -260,7 +260,7 @@ export interface MediaClient { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -382,7 +382,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -449,7 +449,7 @@ export interface MediaClientSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-s3/client-uploads/int.spec.ts b/test/storage-s3/client-uploads/int.spec.ts index e55c4b21e7e..3ef3d3b2bc4 100644 --- a/test/storage-s3/client-uploads/int.spec.ts +++ b/test/storage-s3/client-uploads/int.spec.ts @@ -539,9 +539,9 @@ test.suite('@payloadcms/storage-s3 clientUploads', { config: './config.ts' }, () expect(doc.filesize).toBe(file.length) expect(doc.mimeType).toBe('image/jpeg') - expect(doc.sizes.thumbnail.width).toBe(400) - expect(doc.sizes.thumbnail.height).toBe(300) - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.width).toBe(400) + expect(doc.variants.thumbnail.height).toBe(300) + expect(doc.variants.thumbnail.filename).toBeTruthy() }, 60000) }) diff --git a/test/storage-s3/client-uploads/payload-types.ts b/test/storage-s3/client-uploads/payload-types.ts index a6d75b613eb..c7683fdbfe7 100644 --- a/test/storage-s3/client-uploads/payload-types.ts +++ b/test/storage-s3/client-uploads/payload-types.ts @@ -146,7 +146,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -229,7 +229,7 @@ export interface MediaHeaderOnlyWithSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -373,7 +373,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -458,7 +458,7 @@ export interface MediaHeaderOnlyWithSizesSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-s3/int.spec.ts b/test/storage-s3/int.spec.ts index c4c3939ee71..77ac44ddcf6 100644 --- a/test/storage-s3/int.spec.ts +++ b/test/storage-s3/int.spec.ts @@ -224,8 +224,8 @@ test.suite('@payloadcms/storage-s3', { config: './config.ts' }, () => { expect(upload.id).toBeTruthy() // Verify image sizes URLs are returned correctly - expect(upload.sizes?.thumbnail?.url).toContain(process.env.S3_ENDPOINT) - expect(upload.sizes?.thumbnail?.url).toContain(getTestBucketName()) + expect(upload.variants?.thumbnail?.url).toContain(process.env.S3_ENDPOINT) + expect(upload.variants?.thumbnail?.url).toContain(getTestBucketName()) // CRITICAL: Verify that image size URLs are also stored as full S3 URLs in the database const dbDoc = await payload.db.findOne({ @@ -238,9 +238,9 @@ test.suite('@payloadcms/storage-s3', { config: './config.ts' }, () => { }) expect(dbDoc).toBeDefined() - expect(dbDoc.sizes.thumbnail.url).toContain(process.env.S3_ENDPOINT) - expect(dbDoc.sizes.thumbnail.url).toContain(getTestBucketName()) - expect(dbDoc.sizes.thumbnail.url).not.toMatch(/^\/api\//) + expect(dbDoc.variants.thumbnail.url).toContain(process.env.S3_ENDPOINT) + expect(dbDoc.variants.thumbnail.url).toContain(getTestBucketName()) + expect(dbDoc.variants.thumbnail.url).not.toMatch(/^\/api\//) await payload.delete({ collection: mediaWithDirectAccessSlug, diff --git a/test/storage-s3/payload-types.ts b/test/storage-s3/payload-types.ts index e2d9604660e..c85ff1422f1 100644 --- a/test/storage-s3/payload-types.ts +++ b/test/storage-s3/payload-types.ts @@ -157,7 +157,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -267,7 +267,7 @@ export interface MediaWithDirectAccess { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -478,7 +478,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -546,7 +546,7 @@ export interface MediaWithDirectAccessSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-s3/test-utils.ts b/test/storage-s3/test-utils.ts index 5622ab264f3..36836b09b8f 100644 --- a/test/storage-s3/test-utils.ts +++ b/test/storage-s3/test-utils.ts @@ -69,10 +69,10 @@ export async function verifyUploads({ collection: collectionSlug as CollectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => { - const rawFilename = uploadData?.sizes?.[key]?.filename + const fileKeys = Object.keys(uploadData.variants || {}).map((key) => { + const rawFilename = uploadData?.variants?.[key]?.filename return prefix ? `${prefix}/${rawFilename}` : rawFilename }) diff --git a/test/storage-vercel-blob/client-uploads/payload-types.ts b/test/storage-vercel-blob/client-uploads/payload-types.ts index cdcdaf0eba2..e73bacb0598 100644 --- a/test/storage-vercel-blob/client-uploads/payload-types.ts +++ b/test/storage-vercel-blob/client-uploads/payload-types.ts @@ -142,7 +142,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -315,7 +315,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-vercel-blob/int.spec.ts b/test/storage-vercel-blob/int.spec.ts index 660921c12f7..b25e39dba6a 100644 --- a/test/storage-vercel-blob/int.spec.ts +++ b/test/storage-vercel-blob/int.spec.ts @@ -166,16 +166,16 @@ test.suite('@payloadcms/storage-vercel-blob', { config: './config.ts' }, () => { overrideAccess: true, }) - expect(upload.sizes?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) - expect(upload.sizes?.thumbnail?.url).not.toMatch(/^\/api\//) + expect(upload.variants?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) + expect(upload.variants?.thumbnail?.url).not.toMatch(/^\/api\//) const dbDoc = await payload.db.findOne({ collection: mediaWithDirectAccessSlug, where: { id: { equals: upload.id } }, }) - expect(dbDoc?.sizes?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) - expect(dbDoc?.sizes?.thumbnail?.url).not.toMatch(/^\/api\//) + expect(dbDoc?.variants?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) + expect(dbDoc?.variants?.thumbnail?.url).not.toMatch(/^\/api\//) }) test('should return direct blob URL with encoded filename for file with spaces', async ({ diff --git a/test/storage-vercel-blob/payload-types.ts b/test/storage-vercel-blob/payload-types.ts index cbb340fa4d4..3192d7712ef 100644 --- a/test/storage-vercel-blob/payload-types.ts +++ b/test/storage-vercel-blob/payload-types.ts @@ -155,7 +155,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -265,7 +265,7 @@ export interface MediaWithDirectAccess { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -444,7 +444,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -512,7 +512,7 @@ export interface MediaWithDirectAccessSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-vercel-blob/test-utils.ts b/test/storage-vercel-blob/test-utils.ts index cdad6ba0345..0e3af333ef9 100644 --- a/test/storage-vercel-blob/test-utils.ts +++ b/test/storage-vercel-blob/test-utils.ts @@ -26,11 +26,11 @@ export async function verifyUploads({ collection: collectionSlug as CollectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } const { blobs } = await list() - const filenames = Object.values(uploadData.sizes || {}).map((s) => s.filename) + const filenames = Object.values(uploadData.variants || {}).map((s) => s.filename) filenames.push(uploadData.filename) for (const fn of filenames) { diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 83ea40f20c6..f76bc8da907 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -814,7 +814,7 @@ describe('Uploads', () => { slug: animatedTypeMedia, auth: true, }) - expect(doc.sizes.squareSmall.filename).toMatch(/480x480\.webp$/) + expect(doc.variants.squareSmall.filename).toMatch(/480x480\.webp$/) }) test('should show resized images', async () => { @@ -834,20 +834,20 @@ describe('Uploads', () => { // The per-size detail drawer was replaced by the inline carousel, which no longer surfaces // per-size dimensions/formats; verify the generated sizes via the API instead. - const { sizes } = pngDoc! + const { variants } = pngDoc! - expect(sizes!.maintainedAspectRatio).toMatchObject({ height: 1024, width: 1024 }) - expect(sizes!.differentFormatFromMainImage!.mimeType).toBe('image/jpeg') - expect(sizes!.maintainedImageSize).toMatchObject({ height: 1600, width: 1600 }) - expect(sizes!.maintainedImageSizeWithNewFormat).toMatchObject({ + expect(variants!.maintainedAspectRatio).toMatchObject({ height: 1024, width: 1024 }) + expect(variants!.differentFormatFromMainImage!.mimeType).toBe('image/jpeg') + expect(variants!.maintainedImageSize).toMatchObject({ height: 1600, width: 1600 }) + expect(variants!.maintainedImageSizeWithNewFormat).toMatchObject({ height: 1600, mimeType: 'image/jpeg', width: 1600, }) - expect(sizes!.accidentalSameSize).toMatchObject({ height: 80, width: 320 }) - expect(sizes!.tablet).toMatchObject({ height: 480, width: 640 }) - expect(sizes!.mobile).toMatchObject({ height: 240, width: 320 }) - expect(sizes!.icon).toMatchObject({ height: 16, width: 16 }) + expect(variants!.accidentalSameSize).toMatchObject({ height: 80, width: 320 }) + expect(variants!.tablet).toMatchObject({ height: 480, width: 640 }) + expect(variants!.mobile).toMatchObject({ height: 240, width: 320 }) + expect(variants!.icon).toMatchObject({ height: 16, width: 16 }) }) test('should resize and show tiff images', async () => { @@ -898,7 +898,7 @@ describe('Uploads', () => { slug: customFileNameMediaSlug, auth: true, }) - expect(doc.sizes.custom.filename).toBe('custom-500x500.png') + expect(doc.variants.custom.filename).toBe('custom-500x500.png') }) test('should show draft uploads in the relation list', async () => { @@ -1239,7 +1239,7 @@ describe('Uploads', () => { const acceptableFileSizes = [2989] await expect - .poll(() => acceptableFileSizes.includes(mediaDoc.sizes.sizeOne.filesize)) + .poll(() => acceptableFileSizes.includes(mediaDoc.variants.sizeOne.filesize)) .toBe(true) }) @@ -1260,7 +1260,7 @@ describe('Uploads', () => { const acceptableFileSizes = [2424, 2445] await expect - .poll(() => acceptableFileSizes.includes(mediaDoc.sizes.sizeTwo.filesize)) + .poll(() => acceptableFileSizes.includes(mediaDoc.variants.sizeTwo.filesize)) .toBe(true) }) @@ -1282,7 +1282,7 @@ describe('Uploads', () => { // without metadata appended, the jpeg image filesize would be 2424 await expect - .poll(() => acceptableFileSizesForJPEG.includes(jpegMediaDoc.sizes.sizeThree.filesize)) + .poll(() => acceptableFileSizesForJPEG.includes(jpegMediaDoc.variants.sizeThree.filesize)) .toBe(true) await gotoAndWaitForForm(page, withOnlyJPEGMetadataURL.create) @@ -1299,7 +1299,7 @@ describe('Uploads', () => { }) // With metadata, the animated image filesize would be 218762 - await expect.poll(() => webpMediaDoc.sizes.sizeThree.filesize).toBe(211638) + await expect.poll(() => webpMediaDoc.variants.sizeThree.filesize).toBe(211638) }) test('should show custom upload component', async () => { @@ -2425,7 +2425,7 @@ describe('Uploads', () => { }) // without focal point update this generated size was equal to 1736 - await expect.poll(() => redDoc.sizes.focalTest.filesize).toBe(1586) + await expect.poll(() => redDoc.variants.focalTest.filesize).toBe(1586) }) test('should resize image after crop if resizeOptions defined', async () => { diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 8b024be5b3e..a87c4b43e75 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -309,10 +309,10 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: body: JSON.stringify({ filename: restrictedDoc.filename, prefix: 'restricted', - sizes: { + variants: { thumbnail: { - ...readableDoc.sizes.thumbnail, - filename: restrictedDoc.sizes.thumbnail.filename, + ...readableDoc.variants.thumbnail, + filename: restrictedDoc.variants.thumbnail.filename, }, }, url: restrictedDoc.url, @@ -330,7 +330,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).toBe(readableDoc.filename) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBe(readableDoc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(readableDoc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(readableDoc.url) const fileResponse = await restClient.GET( @@ -359,7 +359,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const sizeFilenameResponse = await restClient.PATCH(`/${fileAccessMediaSlug}/${doc.id}`, { body: JSON.stringify({ - sizes: { thumbnail: { filename: '..\\invalid-thumbnail.png' } }, + variants: { thumbnail: { filename: '..\\invalid-thumbnail.png' } }, }), }) expect(sizeFilenameResponse.status).toBe(400) @@ -392,7 +392,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.focalX).toBe(75) expect(updatedDoc.focalY).toBe(25) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBe(doc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(doc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(doc.url) }) @@ -420,7 +420,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted', - sizes: { thumbnail: { filename: 'submitted-thumbnail.png' } }, + variants: { thumbnail: { filename: 'submitted-thumbnail.png' } }, url: '/api/file-access-media/file/submitted.png', }), query: { where: { id: { in: docs.map(({ id }) => id) } } }, @@ -437,7 +437,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).toBe(doc.filename) expect(updatedDoc.prefix).toBe(doc.prefix) expect(updatedDoc.requestMetadata).toContain('PATCH:application/json:') - expect(updatedDoc.sizes.thumbnail.filename).toBe(doc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(doc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(doc.url) } }) @@ -553,7 +553,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).not.toBe(doc.filename) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBeTruthy() + expect(updatedDoc.variants.thumbnail.filename).toBeTruthy() }) test('should replace a file using its submitted prefix', async ({ payload, restClient }) => { @@ -590,7 +590,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).not.toBe(doc.filename) expect(updatedDoc.prefix).toBe('replacement') - expect(updatedDoc.sizes.thumbnail.filename).toBeTruthy() + expect(updatedDoc.variants.thumbnail.filename).toBeTruthy() }) test('should retain current file data when restoring a version', async ({ @@ -649,7 +649,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: height: 1, mimeType: 'image/jpeg', prefix: otherDoc.prefix, - sizes: otherDoc.sizes, + variants: otherDoc.variants, thumbnailURL: '/version-thumbnail.jpg', url: { en: otherDoc.url, es: '/otro.png', fr: '/autre.png' }, width: 1, @@ -678,7 +678,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(restoredDoc.height).toBe(currentDoc.height) expect(restoredDoc.mimeType).toBe(currentDoc.mimeType) expect(restoredDoc.prefix).toBe(currentDoc.prefix) - expect(restoredDoc.sizes).toEqual(currentDoc.sizes) + expect(restoredDoc.variants).toEqual(currentDoc.variants) expect(restoredDoc.thumbnailURL).toBe(currentDoc.thumbnailURL) expect(restoredDoc.url).toBe(currentDoc.url) expect(restoredDoc.width).toBe(currentDoc.width) @@ -736,7 +736,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(doc.alt).toBe('Staged image') expect(doc.filename).toBe('staged-image.png') expect(doc.width).toBeDefined() - expect(doc.sizes.tablet.filename).toBeDefined() + expect(doc.variants.tablet.filename).toBeDefined() await payload.delete({ id: doc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -900,29 +900,29 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(201) - const { sizes } = doc + const { variants } = doc const expectedPath = path.join(dirname, './media') // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) expect( - await fileExists(path.join(expectedPath, sizes.maintainedAspectRatio.filename)), + await fileExists(path.join(expectedPath, variants.maintainedAspectRatio.filename)), ).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.tablet.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.mobile.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.icon.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.tablet.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.mobile.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.icon.filename))).toBe(true) // Check api response expect(doc.mimeType).toEqual('image/png') expect(doc.focalX).toEqual(50) expect(doc.focalY).toEqual(50) - expect(sizes.maintainedAspectRatio.url).toContain('/api/media/file/image') - expect(sizes.maintainedAspectRatio.url).toContain('.png') - expect(sizes.maintainedAspectRatio.width).toEqual(1024) - expect(sizes.maintainedAspectRatio.height).toEqual(1024) - expect(sizes).toHaveProperty('tablet') - expect(sizes).toHaveProperty('mobile') - expect(sizes).toHaveProperty('icon') + expect(variants.maintainedAspectRatio.url).toContain('/api/media/file/image') + expect(variants.maintainedAspectRatio.url).toContain('.png') + expect(variants.maintainedAspectRatio.width).toEqual(1024) + expect(variants.maintainedAspectRatio.height).toEqual(1024) + expect(variants).toHaveProperty('tablet') + expect(variants).toHaveProperty('mobile') + expect(variants).toHaveProperty('icon') }) test('creates from a remote source without reusing submitted file identity', async ({ @@ -1004,13 +1004,13 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(mediaDoc.url).not.toContain(' ') // Check that size URLs are also properly encoded - expect(mediaDoc.sizes?.tablet?.url).toBeDefined() - expect(mediaDoc.sizes?.tablet?.url).toContain('%20') - expect(mediaDoc.sizes?.tablet?.url).not.toContain(' ') + expect(mediaDoc.variants?.tablet?.url).toBeDefined() + expect(mediaDoc.variants?.tablet?.url).toContain('%20') + expect(mediaDoc.variants?.tablet?.url).not.toContain(' ') - expect(mediaDoc.sizes?.icon?.url).toBeDefined() - expect(mediaDoc.sizes?.icon?.url).toContain('%20') - expect(mediaDoc.sizes?.icon?.url).not.toContain(' ') + expect(mediaDoc.variants?.icon?.url).toBeDefined() + expect(mediaDoc.variants?.icon?.url).toContain('%20') + expect(mediaDoc.variants?.icon?.url).not.toContain(' ') await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -1037,7 +1037,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check api response expect(doc.mimeType).toEqual('image/svg+xml') - expect(doc.sizes.maintainedAspectRatio.url).toBeFalsy() + expect(doc.variants.maintainedAspectRatio.url).toBeFalsy() expect(doc.width).toBeDefined() expect(doc.height).toBeDefined() }) @@ -1104,11 +1104,11 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false) - expect(await fileExists(path.join(expectedPath, doc.sizes.icon.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, doc.variants.icon.filename))).toBe(true) // Check api response - expect(doc.sizes.tablet.filename).toBeNull() - expect(doc.sizes.icon.filename).toBeDefined() + expect(doc.variants.tablet.filename).toBeNull() + expect(doc.variants.icon.filename).toBeDefined() }) test('should not set url on image sizes that cannot be generated', async ({ @@ -1132,12 +1132,12 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(201) // Check ungenerated sizes are empty, including the URL - expect(doc.sizes.tablet.filename).toBeNull() - expect(doc.sizes.tablet.width).toBeNull() - expect(doc.sizes.tablet.height).toBeNull() - expect(doc.sizes.tablet.mimeType).toBeNull() - expect(doc.sizes.tablet.filesize).toBeNull() - expect(doc.sizes.tablet.url).toBeNull() + expect(doc.variants.tablet.filename).toBeNull() + expect(doc.variants.tablet.width).toBeNull() + expect(doc.variants.tablet.height).toBeNull() + expect(doc.variants.tablet.mimeType).toBeNull() + expect(doc.variants.tablet.filesize).toBeNull() + expect(doc.variants.tablet.url).toBeNull() // Also verify the database const dbDoc = await payload.db.findOne({ @@ -1145,7 +1145,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: where: { id: { equals: doc.id } }, }) - expect(dbDoc.sizes.tablet.url).toBeNull() + expect(dbDoc.variants.tablet.url).toBeNull() }) test('creates images from a different format', async ({ restClient }) => { @@ -1168,15 +1168,15 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, doc.sizes.tablet.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, doc.variants.tablet.filename))).toBe(true) // Check api response expect(doc.filename).toContain('.png') expect(doc.mimeType).toEqual('image/png') - expect(doc.sizes.maintainedAspectRatio.filename).toContain('.png') - expect(doc.sizes.maintainedAspectRatio.mimeType).toContain('image/png') - expect(doc.sizes.differentFormatFromMainImage.filename).toContain('.jpg') - expect(doc.sizes.differentFormatFromMainImage.mimeType).toContain('image/jpeg') + expect(doc.variants.maintainedAspectRatio.filename).toContain('.png') + expect(doc.variants.maintainedAspectRatio.mimeType).toContain('image/png') + expect(doc.variants.differentFormatFromMainImage.filename).toContain('.jpg') + expect(doc.variants.differentFormatFromMainImage.mimeType).toContain('image/jpeg') }) test('creates media without storing a file', async ({ restClient }) => { @@ -1279,9 +1279,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const response = await restClient.PATCH(`/${mediaSlug}/${mediaDoc.id}`, { body: JSON.stringify({ filename: `archive/../${mediaDoc.filename}`, - sizes: { + variants: { icon: { - filename: `archive/../${mediaDoc.sizes.icon.filename}`, + filename: `archive/../${mediaDoc.variants.icon.filename}`, }, }, }), @@ -1296,7 +1296,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: })) as unknown as Media expect(unchangedDoc.filename).toBe(mediaDoc.filename) - expect(unchangedDoc.sizes.icon.filename).toBe(mediaDoc.sizes.icon.filename) + expect(unchangedDoc.variants.icon.filename).toBe(mediaDoc.variants.icon.filename) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -1331,7 +1331,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check that previously existing files were removed expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false) - expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false) + expect(await fileExists(path.join(expectedPath, mediaDoc.variants.icon.filename))).toBe( + false, + ) }) test('should replace image and delete old files - where query', async ({ @@ -1374,7 +1376,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check that previously existing files were removed expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false) - expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false) + expect(await fileExists(path.join(expectedPath, mediaDoc.variants.icon.filename))).toBe( + false, + ) }) }) test.describe('delete', () => { @@ -1484,7 +1488,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: })) as unknown as Media try { - const size = mediaDoc.sizes!.differentFormatFromMainImage! + const size = mediaDoc.variants!.differentFormatFromMainImage! expect(size.mimeType).toBe('image/jpeg') @@ -1681,7 +1685,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const svgFilePath = path.resolve(dirname, './svgWithXml.svg') const fileBuffer = fs.readFileSync(svgFilePath) - // SVGs cannot be resized, so sizes.small should have null fields + // SVGs cannot be resized, so variants.small should have null fields const doc = await payload.create({ collection: adminThumbnailSizeSlug as CollectionSlug, data: {}, @@ -2039,8 +2043,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: overrideAccess: true, })) as unknown as Media - expect(doc.sizes.icon).toBeDefined() - expect(doc.sizes.tablet.width).toBeNull() + expect(doc.variants.icon).toBeDefined() + expect(doc.variants.tablet.width).toBeNull() }) test('should remove sizes that do not pertain to the new image - where query', async ({ @@ -2067,8 +2071,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: }, })) as unknown as { docs: Media[] } - expect(doc.docs[0].sizes.icon).toBeDefined() - expect(doc.docs[0].sizes.tablet.width).toBeNull() + expect(doc.docs[0].variants.icon).toBeDefined() + expect(doc.docs[0].variants.tablet.width).toBeNull() }) test('should allow removing file from upload relationship field - by ID', async ({ @@ -3046,9 +3050,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(200) expect(doc).toMatchObject({ height: 800, width: 800 }) - expect(doc.sizes?.maintainedImageSize).toMatchObject({ height: 800, width: 800 }) + expect(doc.variants?.maintainedImageSize).toMatchObject({ height: 800, width: 800 }) - const sizePath = path.join(dirname, './media', doc.sizes!.maintainedImageSize!.filename!) + const sizePath = path.join(dirname, './media', doc.variants!.maintainedImageSize!.filename!) await expect(sharp(sizePath).metadata()).resolves.toMatchObject({ height: 800, @@ -3073,32 +3077,32 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge const expectedPath = path.join(dirname, './media/enlarge') // Check for files expect(await fileExists(path.join(expectedPath, small.name))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.resizedLarger.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.resizedSmaller.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( - true, - ) - expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + expect(await fileExists(path.join(expectedPath, variants.resizedLarger.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.resizedSmaller.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.accidentalSameSize.filename))).toBe( true, ) + expect( + await fileExists(path.join(expectedPath, variants.sameSizeWithNewFormat.filename)), + ).toBe(true) // Check api response - expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') - expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') + expect(variants.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') + expect(variants.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') - expect(sizes.resizedLarger.mimeType).toBe('image/png') - expect(sizes.resizedLarger.filename).toBe('small-640x480.png') + expect(variants.resizedLarger.mimeType).toBe('image/png') + expect(variants.resizedLarger.filename).toBe('small-640x480.png') - expect(sizes.resizedSmaller.mimeType).toBe('image/png') - expect(sizes.resizedSmaller.filename).toBe('small-180x50.png') + expect(variants.resizedSmaller.mimeType).toBe('image/png') + expect(variants.resizedSmaller.filename).toBe('small-180x50.png') - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.accidentalSameSize.filename).toBe('small-320x80.png') await payload.delete({ id: result.id, @@ -3122,16 +3126,16 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result + const { variants } = result const expectedPath = path.join(dirname, './media/enlarge') // Check for files - expect(await fileExists(path.join(expectedPath, sizes.widthLowerHeightLarger.filename))).toBe( - true, - ) + expect( + await fileExists(path.join(expectedPath, variants.widthLowerHeightLarger.filename)), + ).toBe(true) // Check api response - expect(sizes.widthLowerHeightLarger.mimeType).toBe('image/png') - expect(sizes.widthLowerHeightLarger.filename).toBe('small-300x300.png') + expect(variants.widthLowerHeightLarger.mimeType).toBe('image/png') + expect(variants.widthLowerHeightLarger.filename).toBe('small-300x300.png') await payload.delete({ id: result.id, collection: enlargeSlug, @@ -3153,32 +3157,32 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge const expectedPath = path.join(dirname, './media/reduce') // Check for files expect(await fileExists(path.join(expectedPath, small.name))).toBe(true) expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false) expect(await fileExists(path.join(expectedPath, 'small-180x50.png'))).toBe(false) - expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( - true, - ) - expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + expect(await fileExists(path.join(expectedPath, variants.accidentalSameSize.filename))).toBe( true, ) + expect( + await fileExists(path.join(expectedPath, variants.sameSizeWithNewFormat.filename)), + ).toBe(true) // Check api response - expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') - expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') + expect(variants.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') + expect(variants.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') - expect(sizes.resizedLarger.mimeType).toBeNull() - expect(sizes.resizedLarger.filename).toBeNull() + expect(variants.resizedLarger.mimeType).toBeNull() + expect(variants.resizedLarger.filename).toBeNull() - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.resizedSmaller.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.resizedSmaller.filename).toBe('small-320x80.png') - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.accidentalSameSize.filename).toBe('small-320x80.png') await payload.delete({ id: result.id, collection: reduceSlug, overrideAccess: true }) }) @@ -3197,9 +3201,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge - expect(sizes.undefinedHeightWithoutEnlargement).toMatchObject({ + expect(variants.undefinedHeightWithoutEnlargement).toMatchObject({ filename: null, filesize: null, height: null, @@ -3390,8 +3394,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // payload.find should return full URLs with serverURL prefix (through afterRead hooks) expect(mediaDoc.url).toContain('http://local-images:3000') - expect(mediaDoc.sizes?.tablet?.url).toContain('http://local-images:3000') - expect(mediaDoc.sizes?.icon?.url).toContain('http://local-images:3000') + expect(mediaDoc.variants?.tablet?.url).toContain('http://local-images:3000') + expect(mediaDoc.variants?.icon?.url).toContain('http://local-images:3000') // Direct database query should return relative URLs (no hooks applied) const dbDoc = (await payload.db.findOne({ @@ -3409,13 +3413,13 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) - expect(dbDoc.sizes?.icon?.url).toBeDefined() - expect(dbDoc.sizes?.icon?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.icon?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.icon?.url).toBeDefined() + expect(dbDoc.variants?.icon?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.icon?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) } finally { @@ -3478,9 +3482,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) await payload.delete({ id: duplicatedDoc.id, collection: mediaSlug, overrideAccess: true }) @@ -3544,9 +3548,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) } finally { diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 72539c7e4c6..4d59f61cc84 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -404,7 +404,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { maintainedAspectRatio?: { url?: string | null; width?: number | null; @@ -708,7 +708,7 @@ export interface AdminThumbnailSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -781,7 +781,7 @@ export interface GifResize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -829,7 +829,7 @@ export interface FilenameCompoundIndex { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -899,7 +899,7 @@ export interface ObjectFit { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { fitContain?: { url?: string | null; width?: number | null; @@ -959,7 +959,7 @@ export interface WithMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeOne?: { url?: string | null; width?: number | null; @@ -995,7 +995,7 @@ export interface WithoutMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeTwo?: { url?: string | null; width?: number | null; @@ -1031,7 +1031,7 @@ export interface WithOnlyJpegMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeThree?: { url?: string | null; width?: number | null; @@ -1067,7 +1067,7 @@ export interface CropOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { focalTest?: { url?: string | null; width?: number | null; @@ -1119,7 +1119,7 @@ export interface FocalOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { focalTest?: { url?: string | null; width?: number | null; @@ -1171,7 +1171,7 @@ export interface ImageSizesOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeOne?: { url?: string | null; width?: number | null; @@ -1475,7 +1475,7 @@ export interface AnimatedTypeMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { squareSmall?: { url?: string | null; width?: number | null; @@ -1543,7 +1543,7 @@ export interface Enlarge { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { accidentalSameSize?: { url?: string | null; width?: number | null; @@ -1645,7 +1645,7 @@ export interface Reduce { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { accidentalSameSize?: { url?: string | null; width?: number | null; @@ -1705,7 +1705,7 @@ export interface MediaTrim { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { trimNumber?: { url?: string | null; width?: number | null; @@ -1757,7 +1757,7 @@ export interface CustomFileNameMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { custom?: { url?: string | null; width?: number | null; @@ -2557,7 +2557,7 @@ export interface MediaWithImageSizeAdminProp { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { one?: { url?: string | null; width?: number | null; @@ -2647,7 +2647,7 @@ export interface FileAccessMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -2716,7 +2716,7 @@ export interface MediaWithField { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -3302,7 +3302,7 @@ export interface GifResizeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -3346,7 +3346,7 @@ export interface FilenameCompoundIndexSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -3408,7 +3408,7 @@ export interface ObjectFitSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { fitContain?: @@ -3471,7 +3471,7 @@ export interface WithMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeOne?: @@ -3504,7 +3504,7 @@ export interface WithoutMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeTwo?: @@ -3537,7 +3537,7 @@ export interface WithOnlyJpegMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeThree?: @@ -3570,7 +3570,7 @@ export interface CropOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { focalTest?: @@ -3623,7 +3623,7 @@ export interface FocalOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { focalTest?: @@ -3676,7 +3676,7 @@ export interface ImageSizesOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeOne?: @@ -3740,7 +3740,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { maintainedAspectRatio?: @@ -4094,7 +4094,7 @@ export interface AnimatedTypeMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { squareSmall?: @@ -4167,7 +4167,7 @@ export interface EnlargeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { accidentalSameSize?: @@ -4269,7 +4269,7 @@ export interface ReduceSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { accidentalSameSize?: @@ -4332,7 +4332,7 @@ export interface MediaTrimSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { trimNumber?: @@ -4385,7 +4385,7 @@ export interface CustomFileNameMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { custom?: @@ -4558,7 +4558,7 @@ export interface AdminThumbnailSizeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -5098,7 +5098,7 @@ export interface MediaWithImageSizeAdminPropsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { one?: @@ -5184,7 +5184,7 @@ export interface FileAccessMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: @@ -5256,7 +5256,7 @@ export interface MediaWithFieldsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: From 3806aea7faafa1134e3b528275c35591c172b75e Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 15:38:13 +0100 Subject: [PATCH 08/13] chore: keep 3.x sizes storage behind deprecated upload.legacySizes --- docs/migration-guide/v4.mdx | 16 +- docs/upload/overview.mdx | 33 ++-- .../migrateSizesToVariants.ts | 6 + ...namicPredefinedSizesToVariantsMigration.ts | 5 + .../utilities/getSizesToVariantsRenames.ts | 16 ++ .../payload-cloud/src/hooks/afterDelete.ts | 4 +- .../src/utilities/getIncomingFiles.ts | 8 +- .../src/collections/operations/create.ts | 5 +- .../collections/operations/restoreVersion.ts | 8 +- .../src/collections/operations/update.ts | 6 +- .../src/collections/operations/updateByID.ts | 6 +- packages/payload/src/config/types.ts | 11 ++ packages/payload/src/index.ts | 5 + .../src/uploads/checkFileAccess.spec.ts | 1 + .../payload/src/uploads/checkFileAccess.ts | 7 +- .../src/uploads/deleteAssociatedFiles.ts | 8 +- .../src/uploads/docWithFilenameExists.ts | 5 +- .../payload/src/uploads/generateFileData.ts | 5 +- packages/payload/src/uploads/getBaseFields.ts | 52 +++++- .../src/uploads/getUploadVariantsFieldName.ts | 40 +++++ .../payload/src/uploads/sanitizeUploadData.ts | 34 ++-- .../transformers/handleDynamicFileRequest.ts | 19 ++- .../transformers/resolveUploadDocument.ts | 31 +++- .../src/fields/getFields.ts | 16 +- .../src/hooks/afterChange.spec.ts | 2 +- .../src/hooks/afterChange.ts | 20 ++- .../src/hooks/afterDelete.ts | 4 +- .../src/hooks/afterRead.ts | 8 +- .../src/hooks/beforeChange.ts | 11 +- packages/plugin-cloud-storage/src/plugin.ts | 4 + .../src/utilities/getFilePrefix.ts | 7 +- .../src/utilities/getIncomingFiles.ts | 8 +- .../buildSizesToVariantsConfig.ts | 54 ++++++ test/sizes-to-variants/config.ts | 42 +---- test/sizes-to-variants/legacySizes.config.ts | 7 + .../sizes-to-variants/legacySizes.int.spec.ts | 161 ++++++++++++++++++ 36 files changed, 562 insertions(+), 113 deletions(-) create mode 100644 packages/payload/src/uploads/getUploadVariantsFieldName.ts create mode 100644 test/sizes-to-variants/buildSizesToVariantsConfig.ts create mode 100644 test/sizes-to-variants/legacySizes.config.ts create mode 100644 test/sizes-to-variants/legacySizes.int.spec.ts diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index f511f4cbe19..9ce912c8f96 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1882,7 +1882,21 @@ The group field that holds each generated image size on an upload document is no GraphQL queries select `variants { thumbnail { url } }` instead of `sizes { thumbnail { url } }`, and regenerated types expose `variants?: { ... }` (and `variants?: T | { ... }` on the `*Select` types) in place of `sizes`. Update any frontend code that reads `media.sizes`, including values you pass to helpers such as `getBestFitFromSizes({ sizes: doc.variants })`. -Existing documents must be migrated. Create the predefined migration for your database adapter and run it: +To upgrade without migrating yet, set `upload.legacySizes: true` in your Payload config. Every upload collection then keeps storing generated files under the 3.x `sizes` field, so your existing database works as-is, and `variants` becomes a read-only virtual alias of it. Documents return both `sizes` and `variants`, `where`, `select`, and `sort` work on either path, and both appear in the generated types and GraphQL schema. Writes to `variants` are ignored. The option is deprecated and will be removed in the next major version: + +```ts +buildConfig({ + upload: { + legacySizes: true, + }, +}) +``` + +Plugins and custom code that read stored upload data directly, or query the database without the Local API, should use `getStoredUploadVariants({ config, doc })` and `getUploadVariantsFieldName({ config })` from `payload` so they work in both modes. + +Once your frontends read `variants`, remove `upload.legacySizes` and run the migration below. The migration refuses to run while `upload.legacySizes` is on. + +Without `upload.legacySizes`, existing documents must be migrated. Create the predefined migration for your database adapter and run it: ```sh # MongoDB diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 828e7712dd0..7a300542ebf 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -107,22 +107,23 @@ _An asterisk denotes that an option is required._ Upload options are specifiable on a Collection by Collection basis, you can also control app wide options by passing your base Payload Config an `upload` property containing an object supportive of all `Busboy` configuration options. -| Option | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | -| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | -| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | -| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | -| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | -| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | -| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | -| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | -| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | -| **`transformers`** | An ordered array of [file transformers](./transformers), such as `sharpTransformer()`, that process uploads and serve dynamically-transformed files. Registering any transformer changes the file endpoint — see [File Transformers](./transformers#registering-a-transformer-changes-the-file-endpoint). | -| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | -| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | -| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | -| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | +| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | +| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | +| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | +| **`legacySizes`** | Deprecated. Set to `true` to keep storing generated files under the 3.x `sizes` field, so a database written by Payload 3 works without the `sizes-to-variants` migration. `variants` is then a read-only virtual alias of `sizes`, and both are returned and queryable. [More](../migration-guide/v4#the-stored-sizes-field-on-upload-documents-is-renamed-to-variants) | +| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | +| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | +| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | +| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | +| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | +| **`transformers`** | An ordered array of [file transformers](./transformers), such as `sharpTransformer()`, that process uploads and serve dynamically-transformed files. Registering any transformer changes the file endpoint — see [File Transformers](./transformers#registering-a-transformer-changes-the-file-endpoint). | +| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | +| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | +| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | +| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | [Click here](https://github.com/mscdex/busboy#api) for more documentation about what you can control with `Busboy`. diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts index 3fa246d3204..326f1b7b261 100644 --- a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -25,6 +25,12 @@ export async function migrateSizesToVariants({ payload: Payload req?: Partial }): Promise { + if (direction === 'up' && payload.config.upload?.legacySizes) { + throw new Error( + 'The sizes-to-variants migration moves stored variants away from `sizes`, which `upload.legacySizes` still reads from. Remove `upload.legacySizes` from your config before running it.', + ) + } + const adapter = payload.db as unknown as MongooseAdapter const session = await getSession(adapter, req) const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts index 6ee053cf5ad..15b8ea72cae 100644 --- a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -4,6 +4,8 @@ import { writeFileSync } from 'fs' import type { DrizzleAdapter } from '../types.js' +import { assertLegacySizesDisabled } from './getSizesToVariantsRenames.js' + /** * Builds the `sizes-to-variants` predefined migration for a SQL adapter. The migration renames * columns rather than letting drizzle-kit diff the schema (which would drop and re-add them, @@ -17,6 +19,9 @@ export const buildDynamicPredefinedSizesToVariantsMigration = ({ }): DynamicMigrationTemplate => { return async ({ filePath, payload }) => { const adapter = payload.db as unknown as DrizzleAdapter + + assertLegacySizesDisabled({ adapter }) + const { generateDrizzleJson } = adapter.requireDrizzleKit() const drizzleJsonAfter = await generateDrizzleJson(adapter.schema) diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts index 62bac10f1ad..27a1626b586 100644 --- a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -48,6 +48,10 @@ export function getSizesToVariantsRenames({ adapter: DrizzleAdapter direction: SizesToVariantsDirection }): SizesToVariantsTableRenames[] { + if (direction === 'up') { + assertLegacySizesDisabled({ adapter }) + } + const plan: SizesToVariantsTableRenames[] = [] for (const collection of adapter.payload.config.collections) { @@ -101,6 +105,18 @@ export function getSizesToVariantsRenames({ return plan } +/** + * With `upload.legacySizes` on, variants stay stored under `sizes`, so the current schema has no + * `variants_*` columns to rename to, and the generated snapshot would describe the wrong layout. + */ +export function assertLegacySizesDisabled({ adapter }: { adapter: DrizzleAdapter }): void { + if (adapter.payload.config.upload?.legacySizes) { + throw new Error( + 'The sizes-to-variants migration moves stored variants away from `sizes`, which `upload.legacySizes` still reads from. Remove `upload.legacySizes` from your config before running it.', + ) + } +} + function planIndexRename({ columns, direction, diff --git a/packages/payload-cloud/src/hooks/afterDelete.ts b/packages/payload-cloud/src/hooks/afterDelete.ts index a8a626929b9..d4a421c9324 100644 --- a/packages/payload-cloud/src/hooks/afterDelete.ts +++ b/packages/payload-cloud/src/hooks/afterDelete.ts @@ -1,5 +1,7 @@ import type { CollectionAfterDeleteHook, CollectionConfig, FileData, TypeWithID } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { TypeWithPrefix } from '../types.js' import { createKey } from '../utilities/createKey.js' @@ -18,7 +20,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename || '', - ...Object.values(doc?.variants || []) + ...Object.values(getStoredUploadVariants({ config: req.payload.config, doc }) ?? {}) .map((resizedFileData) => resizedFileData.filename) .filter((filename): filename is string => filename !== null), ] diff --git a/packages/payload-cloud/src/utilities/getIncomingFiles.ts b/packages/payload-cloud/src/utilities/getIncomingFiles.ts index 1bf7b659a86..0ba2c4f3e18 100644 --- a/packages/payload-cloud/src/utilities/getIncomingFiles.ts +++ b/packages/payload-cloud/src/utilities/getIncomingFiles.ts @@ -1,5 +1,7 @@ import type { FileData, PayloadRequest } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { File } from '../types.js' export function getIncomingFiles({ @@ -24,8 +26,10 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.variants) { - Object.entries(data.variants).forEach(([key, resizedFileData]) => { + const variants = getStoredUploadVariants({ config: req.payload.config, doc: data }) + + if (variants) { + Object.entries(variants).forEach(([key, resizedFileData]) => { if (req.payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 0999e8688ff..7cf8ac0dcf0 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -28,6 +28,7 @@ import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { saveVersion } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' +import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getExternalUploadSource, getUploadDestination, @@ -92,7 +93,9 @@ export const createOperation = async < if (args.collection.config.upload && !args.overrideAccess) { externalUploadSource = getExternalUploadSource(args.data) const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const sanitizedData = sanitizeUploadData(args.data, 'create') + const sanitizedData = sanitizeUploadData(args.data, 'create', { + variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), + }) args = { ...args, diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 58fab9a115c..acf88dca9f7 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -19,6 +19,7 @@ import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, restoreUploadDataFromDocument, @@ -186,6 +187,7 @@ export const restoreVersionOperation = async < }) // originalDoc with hoisted localized data + const variantsFieldName = getUploadVariantsFieldName({ config: payload.config }) const validationLocale = payload.config.localization ? payload.config.localization.defaultLocale : locale! @@ -206,8 +208,9 @@ export const restoreVersionOperation = async < if (collectionConfig.upload && !overrideAccess) { versionToRestoreWithLocales = restoreUploadDataFromDocument( - sanitizeUploadData(versionToRestoreWithLocales, 'update'), + sanitizeUploadData(versionToRestoreWithLocales, 'update', { variantsFieldName }), prevDocWithLocales, + { variantsFieldName }, ) } @@ -228,11 +231,12 @@ export const restoreVersionOperation = async < if (collectionConfig.upload && !overrideAccess) { prevVersionDoc = restoreUploadDataFromDocument( - sanitizeUploadData(prevVersionDoc, 'update'), + sanitizeUploadData(prevVersionDoc, 'update', { variantsFieldName }), prevDocWithLocales, { locale: validationLocale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), + variantsFieldName, }, ) } diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 90becee4aa1..cbcb23ac6bf 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -23,6 +23,7 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { APIError } from '../../errors/index.js' import { type CollectionSlug, type FindOptions } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' +import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, getUploadDestination, @@ -98,7 +99,9 @@ export const updateOperation = async < if (args.collection.config.upload && !args.overrideAccess) { const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const data = sanitizeUploadData(args.data, 'update') + const data = sanitizeUploadData(args.data, 'update', { + variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), + }) args = { ...args, @@ -365,6 +368,7 @@ export const updateOperation = async < : undefined : locale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), + variantsFieldName: getUploadVariantsFieldName({ config }), }), operation: 'update', originalDoc: docWithLocales, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 5374643709c..d1862a5d4bb 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -22,6 +22,7 @@ import { combineQueries } from '../../database/combineQueries.js' import { APIError, Forbidden, NotFound } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple, type FindOptions } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' +import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, getUploadDestination, @@ -80,7 +81,9 @@ export const updateByIDOperation = async < if (args.collection.config.upload && !args.overrideAccess) { const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const data = sanitizeUploadData(args.data, 'update') + const data = sanitizeUploadData(args.data, 'update', { + variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), + }) args = { ...args, @@ -267,6 +270,7 @@ export const updateByIDOperation = async < : undefined : locale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), + variantsFieldName: getUploadVariantsFieldName({ config }), }) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 1a89152844b..d2bb18547cd 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -872,6 +872,17 @@ export type FetchAPIFileUploadOptions = { } & Partial export type GlobalUploadConfig = { + /** + * Keep storing every upload collection's generated variants under the 3.x `sizes` field, so a + * database written by Payload 3 works without the `sizes-to-variants` migration. `variants` is + * then a read-only virtual alias of `sizes`: documents return both, and `where`, `select` and + * `sort` work on either path. Remove this flag before running the migration. + * + * @deprecated Move frontends to `variants`, remove this flag, and run the `sizes-to-variants` + * migration. Will be removed in the next major version. + * @default false + */ + legacySizes?: boolean /** * Ordered list of file transformers. Every eligible transformer joins the pipeline * for a given upload or dynamic request in declaration order. Slugs must be unique. diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 354c63e6e0a..f252bb686c2 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1998,6 +1998,11 @@ export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' export { generatePayloadFileURL } from './uploads/generatePayloadFileURL.js' export { getFileByPath } from './uploads/getFileByPath.js' +export { + getStoredUploadVariants, + getUploadVariantsFieldName, + type UploadVariantsFieldName, +} from './uploads/getUploadVariantsFieldName.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' export type * from './uploads/transformers/types.js' export type * from './uploads/types.js' diff --git a/packages/payload/src/uploads/checkFileAccess.spec.ts b/packages/payload/src/uploads/checkFileAccess.spec.ts index c1dacedebf7..f099362519c 100644 --- a/packages/payload/src/uploads/checkFileAccess.spec.ts +++ b/packages/payload/src/uploads/checkFileAccess.spec.ts @@ -27,6 +27,7 @@ const makeReq = (findOne: ReturnType): PayloadRequest => ({ t: vi.fn(), payload: { + config: {}, db: { findOne }, }, }) as unknown as PayloadRequest diff --git a/packages/payload/src/uploads/checkFileAccess.ts b/packages/payload/src/uploads/checkFileAccess.ts index 52edf85a11b..91d3992fad2 100644 --- a/packages/payload/src/uploads/checkFileAccess.ts +++ b/packages/payload/src/uploads/checkFileAccess.ts @@ -3,6 +3,7 @@ import type { PayloadRequest, Where } from '../types/index.js' import { executeAccess } from '../auth/executeAccess.js' import { Forbidden } from '../errors/Forbidden.js' +import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { buildFilenameWhere } from './transformers/resolveUploadDocument.js' export const checkFileAccess = async ({ @@ -37,7 +38,11 @@ export const checkFileAccess = async ({ } if (constraints.length > 0) { - const filenameCondition = buildFilenameWhere({ filename, variants: config.upload.variants }) + const filenameCondition = buildFilenameWhere({ + filename, + variants: config.upload.variants, + variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), + }) const doc = await req.payload.db.findOne({ collection: config.slug, diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.ts b/packages/payload/src/uploads/deleteAssociatedFiles.ts index 0e01cf69bf2..7129646ba21 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.ts @@ -9,6 +9,7 @@ import type { FileData, FileToSave } from './types.js' import { APIError, ErrorDeletingFile } from '../errors/index.js' import { fileExists } from './fileExists.js' +import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' type Args = { collectionConfig: SanitizedCollectionConfig @@ -21,6 +22,7 @@ type Args = { export const deleteAssociatedFiles: (args: Args) => Promise = async ({ collectionConfig, + config, doc, files = [], overrideDelete, @@ -43,8 +45,10 @@ export const deleteAssociatedFiles: (args: Args) => Promise = async ({ throw new ErrorDeletingFile(req.t) } - if (doc.variants) { - const sizes: FileData[] = Object.values(doc.variants) + const variants = doc[getUploadVariantsFieldName({ config })] + + if (variants && typeof variants === 'object') { + const sizes = Object.values(variants) as FileData[] // Since forEach will not wait until unlink is finished it could // happen that two operations will try to delete the same file. // To avoid this it is recommended to use "sync" instead diff --git a/packages/payload/src/uploads/docWithFilenameExists.ts b/packages/payload/src/uploads/docWithFilenameExists.ts index dd2557deaf1..0eeb7938cc1 100644 --- a/packages/payload/src/uploads/docWithFilenameExists.ts +++ b/packages/payload/src/uploads/docWithFilenameExists.ts @@ -1,5 +1,7 @@ import type { PayloadRequest, Where } from '../types/index.js' +import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' + type Args = { collectionSlug: string filename: string @@ -21,6 +23,7 @@ export const docWithFilenameExists = async ({ const hasPrefixField = (collection?.fields ?? []).some( (field) => 'name' in field && field.name === 'prefix', ) + const variantsFieldName = getUploadVariantsFieldName({ config: req.payload.config }) const filenameCondition: Where = { or: [ { @@ -30,7 +33,7 @@ export const docWithFilenameExists = async ({ }, ...(upload && typeof upload === 'object' && upload.variants ? upload.variants.map(({ name }) => ({ - [`variants.${name}.filename`]: { equals: filename }, + [`${variantsFieldName}.${name}.filename`]: { equals: filename }, })) : []), ], diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index e11778a3b01..5df02a18c71 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -18,6 +18,7 @@ import { getFileByPath } from './getFileByPath.js' import { getFileExtension, getSanitizedUploadFilename } from './getFileTypeIdentity.js' import { getImageSize } from './getImageSize.js' import { getSafeFileName } from './getSafeFilename.js' +import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { hasCropOrResizeEdit } from './hasCropOrResizeEdit.js' import { hasFullFileContents } from './hasFullFileContents.js' import { isProcessableImage } from './isProcessableImage.js' @@ -502,7 +503,9 @@ export const generateFileData = async ({ }) } - fileData.variants = sizes + ;(fileData as Record)[ + getUploadVariantsFieldName({ config: req.payload.config }) + ] = sizes } } catch (err) { req.payload.logger.error(err) diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 85c8638e6e7..60c1f147652 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -4,6 +4,7 @@ import type { Field } from '../fields/config/types.js' import type { SanitizedUploadConfig } from './types.js' import { generateFilePathOrURL } from './generateFilePathOrURL.js' +import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { mimeTypeValidator } from './mimeTypeValidator.js' import { validateUploadFilename } from './validateUploadFilename.js' @@ -25,6 +26,7 @@ type Options = { } export const getBaseUploadFields = ({ collection, config }: Options): Field[] => { + const variantsFieldName = getUploadVariantsFieldName({ config }) // `variants` only exists once a transformer (e.g. Sharp) has written it back during init. const uploadOptions: Partial = typeof collection.upload === 'object' ? collection.upload : {} @@ -61,13 +63,13 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => config, filename: typeof adminThumbnail === 'string' - ? (originalDoc.variants?.[adminThumbnail]?.filename as string) + ? (originalDoc[variantsFieldName]?.[adminThumbnail]?.filename as string) : undefined, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: typeof adminThumbnail === 'string' - ? (originalDoc.variants?.[adminThumbnail]?.url as string) + ? (originalDoc[variantsFieldName]?.[adminThumbnail]?.url as string) : undefined, }) }, @@ -200,7 +202,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => if (uploadOptions.variants) { uploadFields = uploadFields.concat([ { - name: 'variants', + name: variantsFieldName, type: 'group', admin: { hidden: true, @@ -226,8 +228,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.variants?.[size.name]?.filename || - originalDoc?.variants?.[size.name]?.filename, + data?.[variantsFieldName]?.[size.name]?.filename || + originalDoc?.[variantsFieldName]?.[size.name]?.filename, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -239,8 +241,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.variants?.[size.name]?.filename || - originalDoc?.variants?.[size.name]?.filename, + data?.[variantsFieldName]?.[size.name]?.filename || + originalDoc?.[variantsFieldName]?.[size.name]?.filename, relative: true, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -290,6 +292,42 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => label: ({ t }) => t('upload:sizes'), }, ]) + + // With `legacySizes`, variants stay stored under the 3.x `sizes` field so an unmigrated + // database keeps working, and `variants` is the read-only alias of it. + if (variantsFieldName === 'sizes') { + uploadFields.push({ + name: 'variants', + type: 'group', + admin: { + hidden: true, + }, + // Same shape as `sizes`, without its hooks or indexes: the value is copied from `sizes` on + // read, and `where`/`select`/`sort` paths are rewritten to it. + fields: uploadOptions.variants.map((size) => ({ + name: size.name, + type: 'group', + fields: [url, width, height, mimeType, filesize, filename].map(toLegacySizesAliasField), + })), + virtual: 'sizes', + }) + } } return uploadFields } + +/** A plain copy of a base upload field for the read-only `variants` alias. */ +const toLegacySizesAliasField = (field: Field): Field => { + const { + hooks: _hooks, + index: _index, + unique: _unique, + ...aliasField + } = field as { + hooks?: unknown + index?: boolean + unique?: boolean + } & Field + + return aliasField as Field +} diff --git a/packages/payload/src/uploads/getUploadVariantsFieldName.ts b/packages/payload/src/uploads/getUploadVariantsFieldName.ts new file mode 100644 index 00000000000..c462fc601a2 --- /dev/null +++ b/packages/payload/src/uploads/getUploadVariantsFieldName.ts @@ -0,0 +1,40 @@ +import type { Config, SanitizedConfig } from '../config/types.js' +import type { FileSizes } from './types.js' + +export type UploadVariantsFieldName = 'sizes' | 'variants' + +/** + * The name upload documents store their generated variants under. With the deprecated + * `upload.legacySizes` flag on, that's still the 3.x `sizes` field, so a database written by 3.x + * works unmigrated, and `variants` is a read-only virtual alias of it. Code that reads or writes + * stored upload data, or queries the database directly, must use this name rather than + * hard-coding either one. + */ +export function getUploadVariantsFieldName({ + config, +}: { + config: Pick +}): UploadVariantsFieldName { + return config.upload?.legacySizes ? 'sizes' : 'variants' +} + +/** + * The generated variants stored on an upload document, read from wherever + * `getUploadVariantsFieldName` says they're stored. Works on raw database documents and on read + * results alike. + */ +export function getStoredUploadVariants({ + config, + doc, +}: { + config: Pick + doc: unknown +}): FileSizes | undefined { + if (!doc || typeof doc !== 'object') { + return undefined + } + + const variants = (doc as Record)[getUploadVariantsFieldName({ config })] + + return variants && typeof variants === 'object' ? (variants as FileSizes) : undefined +} diff --git a/packages/payload/src/uploads/sanitizeUploadData.ts b/packages/payload/src/uploads/sanitizeUploadData.ts index 49758a8340c..bbf333be557 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.ts @@ -1,6 +1,7 @@ import path from 'path' import type { FlattenedField } from '../fields/config/types.js' +import type { UploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { APIError } from '../errors/APIError.js' @@ -14,6 +15,8 @@ export type ExternalUploadSource = { type UploadDataOptions = { locale?: string localizedProperties?: Set + /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ + variantsFieldName?: UploadVariantsFieldName } const hasOwnProperty = (value: Record, key: string): boolean => @@ -107,15 +110,21 @@ const validateFilename = (filename: unknown): void => { } } -export const sanitizeUploadData = (data: T, operation: Operation): T => { +export const sanitizeUploadData = ( + data: T, + operation: Operation, + { variantsFieldName = 'variants' }: Pick = {}, +): T => { if (!isRecord(data)) { return data } validateFilename(data.filename) - if (isRecord(data.variants)) { - for (const sizeData of Object.values(data.variants)) { + const submittedVariants = data[variantsFieldName] + + if (isRecord(submittedVariants)) { + for (const sizeData of Object.values(submittedVariants)) { if (isRecord(sizeData)) { validateFilename(sizeData.filename) } @@ -124,7 +133,7 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { const sanitizedData: Record = { ...data } delete sanitizedData.filename - delete sanitizedData.variants + delete sanitizedData[variantsFieldName] delete sanitizedData.url // Server-owned; never accepted from the caller. delete sanitizedData._objectKey @@ -160,20 +169,22 @@ export const mergeUploadDataWithDocument = ( } } - const documentSizes = getDocumentProperty(document, 'variants', options) - if (!hasOwnProperty(data, 'variants') && hasOwnProperty(document, 'variants')) { - mergedData.variants = documentSizes - } else if (isRecord(data.variants) && isRecord(documentSizes)) { + const variantsFieldName = options.variantsFieldName ?? 'variants' + const submittedVariants = data[variantsFieldName] + const documentSizes = getDocumentProperty(document, variantsFieldName, options) + if (!hasOwnProperty(data, variantsFieldName) && hasOwnProperty(document, variantsFieldName)) { + mergedData[variantsFieldName] = documentSizes + } else if (isRecord(submittedVariants) && isRecord(documentSizes)) { const mergedSizes: Record = { ...documentSizes } - for (const [sizeName, sizeData] of Object.entries(data.variants)) { + for (const [sizeName, sizeData] of Object.entries(submittedVariants)) { const documentSize = documentSizes[sizeName] mergedSizes[sizeName] = isRecord(sizeData) && isRecord(documentSize) ? { ...documentSize, ...sizeData } : sizeData } - mergedData.variants = mergedSizes + mergedData[variantsFieldName] = mergedSizes } return mergedData as T @@ -188,7 +199,6 @@ const uploadDerivedProperties = [ 'height', 'mimeType', 'prefix', - 'variants', 'thumbnailURL', 'url', 'width', @@ -204,7 +214,7 @@ export const restoreUploadDataFromDocument = ( } const restoredData: Record = { ...data } - for (const property of uploadDerivedProperties) { + for (const property of [...uploadDerivedProperties, options.variantsFieldName ?? 'variants']) { if (hasOwnProperty(document, property)) { restoredData[property] = getDocumentProperty(document, property, options) } else { diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts index 4fd18e43566..c910d40940b 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -8,6 +8,7 @@ import { NotFound } from '../../errors/NotFound.js' import { TransformerContractError } from '../../errors/TransformerContractError.js' import { checkFileAccess } from '../checkFileAccess.js' import { retrieveFileResponse } from '../endpoints/getFile.js' +import { getUploadVariantsFieldName } from '../getUploadVariantsFieldName.js' import { createLazySourceGetter } from './createLazySourceGetter.js' import { finalizeFileResponse } from './finalizeFileResponse.js' import { getSourceFileResponse } from './getSourceFileResponse.js' @@ -59,7 +60,11 @@ export async function handleDynamicFileRequest({ resolvedDocument, }) - const requestedFile = getRequestedFile({ document, filename }) + const requestedFile = getRequestedFile({ + document, + filename, + variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), + }) const source = createLazySourceGetter({ retrieve: () => getSourceFileResponse({ collection, document, filename, prefix, req }), @@ -170,7 +175,11 @@ function planRequestPipeline({ args: { collectionSlug: collection.config.slug, documentID: document.id, - mimeType: getRequestedFile({ document, filename }).mimeType, + mimeType: getRequestedFile({ + document, + filename, + variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), + }).mimeType, operation: 'request', req, }, @@ -244,7 +253,11 @@ async function authorizeDocument({ const hasCandidateTransformers = getCandidateTransformers({ capability: 'handleRequest', - mimeType: getRequestedFile({ document: resolvedDocument, filename }).mimeType, + mimeType: getRequestedFile({ + document: resolvedDocument, + filename, + variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), + }).mimeType, transformers: req.payload.config.upload.transformers, }).length > 0 diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts index 527f9e57b8b..7f4d23921b5 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -1,13 +1,20 @@ import type { Collection, TypeWithID } from '../../collections/config/types.js' import type { PayloadRequest, Where } from '../../types/index.js' +import type { UploadVariantsFieldName } from '../getUploadVariantsFieldName.js' import { Forbidden } from '../../errors/Forbidden.js' +import { getUploadVariantsFieldName } from '../getUploadVariantsFieldName.js' export type ResolvedUploadDocument = { filename: string mimeType: string - variants?: Record -} & TypeWithID +} & Partial< + Record< + UploadVariantsFieldName, + Record + > +> & + TypeWithID /** * The primary filename, or a configured legacy image size's filename, both match. @@ -16,9 +23,12 @@ export type ResolvedUploadDocument = { export function buildFilenameWhere({ filename, variants, + variantsFieldName = 'variants', }: { filename: string variants?: { name: string }[] + /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ + variantsFieldName?: UploadVariantsFieldName }): Where { const filenameCondition: Where = { or: [{ filename: { equals: filename } }], @@ -26,7 +36,7 @@ export function buildFilenameWhere({ variants?.forEach(({ name }) => { filenameCondition.or!.push({ - [`variants.${name}.filename`]: { equals: filename }, + [`${variantsFieldName}.${name}.filename`]: { equals: filename }, }) }) @@ -41,12 +51,17 @@ export function buildFilenameWhere({ export function getRequestedFile({ document, filename, + variantsFieldName = 'variants', }: { document: ResolvedUploadDocument filename: string + /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ + variantsFieldName?: UploadVariantsFieldName }): { filename: string; mimeType: string } { if (document.filename !== filename) { - const size = Object.values(document.variants ?? {}).find((size) => size?.filename === filename) + const size = Object.values(document[variantsFieldName] ?? {}).find( + (size) => size?.filename === filename, + ) if (size?.filename && size.mimeType) { return { filename: size.filename, mimeType: size.mimeType } @@ -80,7 +95,13 @@ export async function resolveUploadDocument({ const { config } = collection - const constraints: Where[] = [buildFilenameWhere({ filename, variants: config.upload.variants })] + const constraints: Where[] = [ + buildFilenameWhere({ + filename, + variants: config.upload.variants, + variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), + }), + ] if (typeof prefix === 'string') { constraints.push({ prefix: { equals: prefix } }) diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 623b7a11262..6eeaabb3b3a 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -1,4 +1,11 @@ -import type { CollectionConfig, Field, GroupField, SanitizedUploadConfig, TextField } from 'payload' +import type { + CollectionConfig, + Field, + GroupField, + SanitizedUploadConfig, + TextField, + UploadVariantsFieldName, +} from 'payload' import path from 'path' @@ -19,6 +26,8 @@ interface Args { * document field holds only the document-level segment. */ useCompositePrefixes?: boolean + /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ + variantsFieldName?: UploadVariantsFieldName } export const getFields = ({ @@ -28,6 +37,7 @@ export const getFields = ({ generateFileURL, prefix, useCompositePrefixes = false, + variantsFieldName = 'variants', }: Args): Field[] => { const baseURLField: TextField = { name: 'url', @@ -112,7 +122,7 @@ export const getFields = ({ let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { - if ('name' in existingField && existingField.name === 'variants') { + if ('name' in existingField && existingField.name === variantsFieldName) { existingSizesFieldIndex = i return true } @@ -126,7 +136,7 @@ export const getFields = ({ const sizesField: Field = { ...(existingSizesField || {}), - name: 'variants', + name: variantsFieldName, type: 'group', admin: { hidden: true, diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts index 19003db0257..cb8918732b7 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts @@ -41,7 +41,7 @@ describe('upload replacement cleanup', () => { req: { context: {}, file: { data: Buffer.from('file'), size: 4 }, - payload: { logger: { error: vi.fn() } }, + payload: { config: {}, logger: { error: vi.fn() } }, }, } as never) diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index 545afba8c2b..81914f0a935 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -1,6 +1,6 @@ import type { CollectionAfterChangeHook, CollectionConfig, FileData, TypeWithID } from 'payload' -import { deepMergeWithSourceArrays } from 'payload' +import { deepMergeWithSourceArrays, getStoredUploadVariants } from 'payload' import type { GeneratedAdapter } from '../types.js' @@ -125,9 +125,14 @@ export const getAfterChangeHook = filesToDelete.push(previousDoc.filename) } - if (typeof previousDoc.variants === 'object') { + const previousVariants = getStoredUploadVariants({ + config: req.payload.config, + doc: previousDoc, + }) + + if (previousVariants) { filesToDelete = filesToDelete.concat( - Object.values(previousDoc?.variants || []).map( + Object.values(previousVariants).map( (resizedFileData) => resizedFileData?.filename as string, ), ) @@ -140,8 +145,13 @@ export const getAfterChangeHook = if (typeof newFileData.filename === 'string') { newFilenames.add(newFileData.filename) } - if (typeof newFileData.variants === 'object') { - for (const size of Object.values(newFileData.variants || {})) { + const newVariants = getStoredUploadVariants({ + config: req.payload.config, + doc: newFileData, + }) + + if (newVariants) { + for (const size of Object.values(newVariants)) { if (size?.filename && typeof size.filename === 'string') { newFilenames.add(size.filename) } diff --git a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts index fae6144eb00..c65d8c3355a 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts @@ -1,5 +1,7 @@ import type { CollectionAfterDeleteHook, CollectionConfig, FileData, TypeWithID } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { GeneratedAdapter, TypeWithPrefix } from '../types.js' import { buildPrefixWithObjectKey } from '../utilities/buildPrefixWithObjectKey.js' @@ -28,7 +30,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename, - ...Object.values(doc?.variants || []).map( + ...Object.values(getStoredUploadVariants({ config: req.payload.config, doc }) ?? {}).map( (resizedFileData) => resizedFileData?.filename as string, ), ] diff --git a/packages/plugin-cloud-storage/src/hooks/afterRead.ts b/packages/plugin-cloud-storage/src/hooks/afterRead.ts index fef51fd3b3e..71f823021fc 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterRead.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterRead.ts @@ -1,5 +1,7 @@ import type { CollectionConfig, FieldHook, ImageSize } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { GeneratedAdapter, GenerateFileURL } from '../types.js' import { sanitizePrefix } from '../utilities/sanitizePrefix.js' @@ -29,8 +31,10 @@ const getObjectFolder = (data: unknown): string => { export const getAfterReadHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => - async ({ data, value }) => { - const filename = size ? data?.variants?.[size.name]?.filename : data?.filename + async ({ data, req, value }) => { + const filename = size + ? getStoredUploadVariants({ config: req.payload.config, doc: data })?.[size.name]?.filename + : data?.filename const prefix = data?.prefix // Direct-serve URLs encode the full location; the proxy resolves `_objectKey` server-side. const objectFolder = getObjectFolder(data) diff --git a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts index c7eecba2f8f..690a7b16d32 100644 --- a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts @@ -1,5 +1,7 @@ import type { CollectionConfig, FieldHook, ImageSize } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { GeneratedAdapter, GenerateFileURL } from '../types.js' import { sanitizePrefix } from '../utilities/sanitizePrefix.js' @@ -29,10 +31,13 @@ const getObjectFolder = (data: unknown, originalDoc: unknown): string => { export const getBeforeChangeHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => - async ({ data, originalDoc, value }) => { - const newFilename = size ? data?.variants?.[size.name]?.filename : data?.filename + async ({ data, originalDoc, req, value }) => { + const newFilename = size + ? getStoredUploadVariants({ config: req.payload.config, doc: data })?.[size.name]?.filename + : data?.filename const originalFilename = size - ? originalDoc?.variants?.[size.name]?.filename + ? getStoredUploadVariants({ config: req.payload.config, doc: originalDoc })?.[size.name] + ?.filename : originalDoc?.filename const filename = newFilename || originalFilename const prefix = getObjectFolder(data, originalDoc) diff --git a/packages/plugin-cloud-storage/src/plugin.ts b/packages/plugin-cloud-storage/src/plugin.ts index 80be802d0ae..cf0e7a13c41 100644 --- a/packages/plugin-cloud-storage/src/plugin.ts +++ b/packages/plugin-cloud-storage/src/plugin.ts @@ -1,5 +1,7 @@ import type { Config } from 'payload' +import { getUploadVariantsFieldName } from 'payload' + import type { Adapter, AllowList, PluginOptions } from './types.js' import { getFields } from './fields/getFields.js' @@ -47,6 +49,7 @@ export const cloudStoragePlugin = generateFileURL: options.generateFileURL, prefix: options.prefix, useCompositePrefixes, + variantsFieldName: getUploadVariantsFieldName({ config: incomingConfig }), }) return { @@ -131,6 +134,7 @@ export const cloudStoragePlugin = generateFileURL: options.generateFileURL, prefix: options.prefix, useCompositePrefixes, + variantsFieldName: getUploadVariantsFieldName({ config: incomingConfig }), }) const handlers = [ diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 8fecb1520e2..86ca99553bb 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -1,5 +1,7 @@ import type { CollectionConfig, PayloadRequest, SanitizedUploadConfig, TypeWithID } from 'payload' +import { getUploadVariantsFieldName } from 'payload' + import { buildPrefixWithObjectKey } from './buildPrefixWithObjectKey.js' import { buildUploadStoragePathData } from './buildStoragePathData.js' import { sanitizePrefix } from './sanitizePrefix.js' @@ -72,7 +74,10 @@ export async function getFilePrefix({ filename: { equals: filename }, }, ...variants.map((imageSize) => ({ - [`variants.${imageSize.name}.filename`]: { equals: filename }, + [`${getUploadVariantsFieldName({ config: req.payload.config })}.${imageSize.name}.filename`]: + { + equals: filename, + }, })), ], } diff --git a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts index 3ab4d06c965..75a068b8a64 100644 --- a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts +++ b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts @@ -1,5 +1,7 @@ import type { FileData, PayloadRequest } from 'payload' +import { getStoredUploadVariants } from 'payload' + import type { File } from '../types.js' interface CloudStorageContext { @@ -33,8 +35,10 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.variants) { - Object.entries(data.variants).forEach(([key, resizedFileData]) => { + const variants = getStoredUploadVariants({ config: req.payload.config, doc: data }) + + if (variants) { + Object.entries(variants).forEach(([key, resizedFileData]) => { if (payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/test/sizes-to-variants/buildSizesToVariantsConfig.ts b/test/sizes-to-variants/buildSizesToVariantsConfig.ts new file mode 100644 index 00000000000..d705c1720e9 --- /dev/null +++ b/test/sizes-to-variants/buildSizesToVariantsConfig.ts @@ -0,0 +1,54 @@ +import { sharpTransformer } from '@payloadcms/transformer-sharp' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const buildSizesToVariantsConfig = ({ + legacySizes, + staticDirName, + suite, +}: { + legacySizes: boolean + staticDirName: string + suite: string +}) => + buildConfigWithDefaults({ + config: { + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + { + slug: mediaSlug, + access: { + read: () => true, + }, + fields: [], + upload: { + staticDir: path.resolve(dirname, staticDirName), + }, + versions: true, + }, + ], + upload: { + legacySizes, + transformers: [ + sharpTransformer({ + collections: { + [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, + }, + sharp, + }), + ], + }, + }, + suite, + }) diff --git a/test/sizes-to-variants/config.ts b/test/sizes-to-variants/config.ts index cd58f974a4e..5f702b91cf3 100644 --- a/test/sizes-to-variants/config.ts +++ b/test/sizes-to-variants/config.ts @@ -1,41 +1,7 @@ -import { sharpTransformer } from '@payloadcms/transformer-sharp' -import path from 'path' -import sharp from 'sharp' -import { fileURLToPath } from 'url' +import { buildSizesToVariantsConfig } from './buildSizesToVariantsConfig.js' -import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { mediaSlug, variantName } from './shared.js' - -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) - -export default buildConfigWithDefaults({ - config: { - collections: [ - { - slug: 'users', - auth: true, - fields: [], - }, - { - slug: mediaSlug, - fields: [], - upload: { - staticDir: path.resolve(dirname, 'media'), - }, - versions: true, - }, - ], - upload: { - transformers: [ - sharpTransformer({ - collections: { - [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, - }, - sharp, - }), - ], - }, - }, +export default buildSizesToVariantsConfig({ + legacySizes: false, + staticDirName: 'media', suite: 'sizes-to-variants', }) diff --git a/test/sizes-to-variants/legacySizes.config.ts b/test/sizes-to-variants/legacySizes.config.ts new file mode 100644 index 00000000000..99b4e2daedb --- /dev/null +++ b/test/sizes-to-variants/legacySizes.config.ts @@ -0,0 +1,7 @@ +import { buildSizesToVariantsConfig } from './buildSizesToVariantsConfig.js' + +export default buildSizesToVariantsConfig({ + legacySizes: true, + staticDirName: 'media-legacy-sizes', + suite: 'sizes-to-variants-legacy-sizes', +}) diff --git a/test/sizes-to-variants/legacySizes.int.spec.ts b/test/sizes-to-variants/legacySizes.int.spec.ts new file mode 100644 index 00000000000..e1db9fbff76 --- /dev/null +++ b/test/sizes-to-variants/legacySizes.int.spec.ts @@ -0,0 +1,161 @@ +import type { Payload } from 'payload' + +import { migrateSizesToVariants as migrateMongoSizesToVariants } from '@payloadcms/db-mongodb/migration-utils' +import { migrateSizesToVariants as migratePostgresSizesToVariants } from '@payloadcms/db-postgres/migration-utils' +import { migrateSizesToVariants as migrateSqliteSizesToVariants } from '@payloadcms/db-sqlite/migration-utils' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { expect } from 'vitest' + +import { test } from '../__helpers/int/vitest.js' +import { mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +const staticDir = path.resolve(dirname, 'media-legacy-sizes') + +const createMedia = async ({ payload }: { payload: Payload }) => { + const doc = await payload.create({ + collection: mediaSlug, + data: {}, + filePath: path.resolve(dirname, '../uploads/image.png'), + overrideAccess: true, + }) + + const variantFilename = doc.variants?.[variantName]?.filename + + if (!variantFilename) { + throw new Error(`Expected the "${variantName}" variant to be generated`) + } + + return { doc, variantFilename } +} + +test.suite('legacySizes', { config: './legacySizes.config.ts' }, () => { + test.afterEach(() => { + fs.rmSync(staticDir, { force: true, recursive: true }) + }) + + test('should return the stored sizes and the variants alias with the same data', async ({ + payload, + }) => { + const { doc } = await createMedia({ payload }) + + const found = await payload.findByID({ + id: doc.id, + collection: mediaSlug, + overrideAccess: true, + }) + + expect(found.sizes?.[variantName]?.url).toBeTruthy() + expect(found.variants).toEqual(found.sizes) + }) + + test('should resolve where queries on both sizes and variants paths', async ({ payload }) => { + const { doc, variantFilename } = await createMedia({ payload }) + + for (const fieldName of ['sizes', 'variants']) { + const { docs } = await payload.find({ + collection: mediaSlug, + overrideAccess: true, + where: { [`${fieldName}.${variantName}.filename`]: { equals: variantFilename } }, + }) + + expect(docs.map(({ id }) => id)).toEqual([doc.id]) + } + }) + + test('should resolve a variants select', async ({ payload }) => { + const { doc } = await createMedia({ payload }) + + const found = await payload.findByID({ + id: doc.id, + collection: mediaSlug, + overrideAccess: true, + select: { variants: true }, + }) + + expect(found.variants?.[variantName]?.filename).toBeTruthy() + expect(found).not.toHaveProperty('filename') + }) + + test('should serve a generated variant file by its filename', async ({ payload, restClient }) => { + const { variantFilename } = await createMedia({ payload }) + + const response = await restClient.GET(`/${mediaSlug}/file/${variantFilename}`) + + expect(response.status).toBe(200) + }) + + test('should delete the generated variant files with the document', async ({ payload }) => { + const { doc, variantFilename } = await createMedia({ payload }) + + expect(fs.existsSync(path.resolve(staticDir, variantFilename))).toBe(true) + + await payload.delete({ id: doc.id, collection: mediaSlug, overrideAccess: true }) + + expect(fs.existsSync(path.resolve(staticDir, variantFilename))).toBe(false) + }) + + test('should not store writes to the variants alias', async ({ payload }) => { + const { doc, variantFilename } = await createMedia({ payload }) + + await payload.update({ + id: doc.id, + collection: mediaSlug, + data: { variants: { [variantName]: { filename: 'ignored.png' } } } as never, + overrideAccess: true, + }) + + const found = await payload.findByID({ + id: doc.id, + collection: mediaSlug, + overrideAccess: true, + }) + + expect(found.sizes?.[variantName]?.filename).toBe(variantFilename) + expect(found.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test.options.describe('MongoDB', { db: 'mongo' }, () => { + test('should store variants under the legacy sizes field', async ({ payload }) => { + const { doc, variantFilename } = await createMedia({ payload }) + + const rawDocument = await (payload.db as any).collections[mediaSlug].collection.findOne({ + filename: doc.filename, + }) + + expect(rawDocument.sizes?.[variantName]?.filename).toBe(variantFilename) + expect(rawDocument.variants).toBeUndefined() + }) + + test('should refuse to run the Mongo migration while legacySizes is on', async ({ + payload, + }) => { + await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow(/legacySizes/) + }) + }) + + test.options.describe('SQL', { db: 'drizzle' }, () => { + test('should keep the legacy sizes columns in the schema', ({ payload }) => { + const columnNames = Object.values<{ name: string }>( + (payload.db as any).rawTables[mediaSlug].columns, + ).map(({ name }) => name) + + expect(columnNames).toContain(`sizes_${variantName}_filename`) + expect(columnNames).not.toContain(`variants_${variantName}_filename`) + }) + + test('should refuse to run the SQL migration while legacySizes is on', async ({ payload }) => { + const migrate = + (payload.db as any).name === 'sqlite' + ? migrateSqliteSizesToVariants + : migratePostgresSizesToVariants + + await expect(migrate({ db: (payload.db as any).drizzle, payload })).rejects.toThrow( + /legacySizes/, + ) + }) + }) +}) From 04871d4c8a18582a1cca488e2fbc0f3575f8fbbb Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 15:56:41 +0100 Subject: [PATCH 09/13] chore: add payload config to storage-r2 unit test mocks --- .../storage-r2/src/handleMultiPartUpload.createOnly.spec.ts | 1 + packages/storage-r2/src/handleMultiPartUpload.spec.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts b/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts index 901fb120faa..9bdbc4a385a 100644 --- a/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts +++ b/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts @@ -18,6 +18,7 @@ describe('getHandleMultiPartUpload', () => { } as unknown as R2Bucket const req = { payload: { + config: {}, collections: { media: { config: { access: {}, upload: true } }, }, diff --git a/packages/storage-r2/src/handleMultiPartUpload.spec.ts b/packages/storage-r2/src/handleMultiPartUpload.spec.ts index e3f77593305..4c4ca6b9f6d 100644 --- a/packages/storage-r2/src/handleMultiPartUpload.spec.ts +++ b/packages/storage-r2/src/handleMultiPartUpload.spec.ts @@ -10,6 +10,7 @@ import { getHandleMultiPartUpload } from './handleMultiPartUpload.js' const createRequest = (filename: string, mimeType: string) => ({ payload: { + config: {}, collections: { media: { config: { @@ -33,6 +34,7 @@ const createContinuationRequest = ({ multipartKey }: { multipartKey: string }) = arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), user: { id: 'user' }, payload: { + config: {}, collections: { media: { config: { slug: 'media', access: {}, upload: { staticDir: '/tmp' } } }, }, @@ -130,6 +132,7 @@ describe('getHandleMultiPartUpload', () => { } as unknown as R2Bucket const req = { payload: { + config: {}, collections: { media: { config: { access: {}, upload: true } }, }, From 01c3686c1db27037ff11b13d381445ee65f05917 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 17:12:36 +0100 Subject: [PATCH 10/13] chore: remove upload.legacySizes --- docs/migration-guide/v4.mdx | 16 +- docs/upload/overview.mdx | 33 ++-- .../migrateSizesToVariants.ts | 6 - ...namicPredefinedSizesToVariantsMigration.ts | 5 - .../utilities/getSizesToVariantsRenames.ts | 16 -- .../payload-cloud/src/hooks/afterDelete.ts | 4 +- .../src/utilities/getIncomingFiles.ts | 8 +- .../src/collections/operations/create.ts | 5 +- .../collections/operations/restoreVersion.ts | 8 +- .../src/collections/operations/update.ts | 6 +- .../src/collections/operations/updateByID.ts | 6 +- packages/payload/src/config/types.ts | 11 -- packages/payload/src/index.ts | 5 - .../src/uploads/checkFileAccess.spec.ts | 1 - .../payload/src/uploads/checkFileAccess.ts | 7 +- .../src/uploads/deleteAssociatedFiles.ts | 8 +- .../src/uploads/docWithFilenameExists.ts | 5 +- .../payload/src/uploads/generateFileData.ts | 5 +- packages/payload/src/uploads/getBaseFields.ts | 52 +----- .../src/uploads/getUploadVariantsFieldName.ts | 40 ----- .../payload/src/uploads/sanitizeUploadData.ts | 34 ++-- .../transformers/handleDynamicFileRequest.ts | 19 +-- .../transformers/resolveUploadDocument.ts | 31 +--- .../src/fields/getFields.ts | 16 +- .../src/hooks/afterChange.spec.ts | 2 +- .../src/hooks/afterChange.ts | 20 +-- .../src/hooks/afterDelete.ts | 4 +- .../src/hooks/afterRead.ts | 8 +- .../src/hooks/beforeChange.ts | 11 +- packages/plugin-cloud-storage/src/plugin.ts | 4 - .../src/utilities/getFilePrefix.ts | 7 +- .../src/utilities/getIncomingFiles.ts | 8 +- .../handleMultiPartUpload.createOnly.spec.ts | 1 - .../src/handleMultiPartUpload.spec.ts | 3 - .../buildSizesToVariantsConfig.ts | 54 ------ test/sizes-to-variants/config.ts | 42 ++++- test/sizes-to-variants/legacySizes.config.ts | 7 - .../sizes-to-variants/legacySizes.int.spec.ts | 161 ------------------ 38 files changed, 113 insertions(+), 566 deletions(-) delete mode 100644 packages/payload/src/uploads/getUploadVariantsFieldName.ts delete mode 100644 test/sizes-to-variants/buildSizesToVariantsConfig.ts delete mode 100644 test/sizes-to-variants/legacySizes.config.ts delete mode 100644 test/sizes-to-variants/legacySizes.int.spec.ts diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 9ce912c8f96..f511f4cbe19 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1882,21 +1882,7 @@ The group field that holds each generated image size on an upload document is no GraphQL queries select `variants { thumbnail { url } }` instead of `sizes { thumbnail { url } }`, and regenerated types expose `variants?: { ... }` (and `variants?: T | { ... }` on the `*Select` types) in place of `sizes`. Update any frontend code that reads `media.sizes`, including values you pass to helpers such as `getBestFitFromSizes({ sizes: doc.variants })`. -To upgrade without migrating yet, set `upload.legacySizes: true` in your Payload config. Every upload collection then keeps storing generated files under the 3.x `sizes` field, so your existing database works as-is, and `variants` becomes a read-only virtual alias of it. Documents return both `sizes` and `variants`, `where`, `select`, and `sort` work on either path, and both appear in the generated types and GraphQL schema. Writes to `variants` are ignored. The option is deprecated and will be removed in the next major version: - -```ts -buildConfig({ - upload: { - legacySizes: true, - }, -}) -``` - -Plugins and custom code that read stored upload data directly, or query the database without the Local API, should use `getStoredUploadVariants({ config, doc })` and `getUploadVariantsFieldName({ config })` from `payload` so they work in both modes. - -Once your frontends read `variants`, remove `upload.legacySizes` and run the migration below. The migration refuses to run while `upload.legacySizes` is on. - -Without `upload.legacySizes`, existing documents must be migrated. Create the predefined migration for your database adapter and run it: +Existing documents must be migrated. Create the predefined migration for your database adapter and run it: ```sh # MongoDB diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 7a300542ebf..828e7712dd0 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -107,23 +107,22 @@ _An asterisk denotes that an option is required._ Upload options are specifiable on a Collection by Collection basis, you can also control app wide options by passing your base Payload Config an `upload` property containing an object supportive of all `Busboy` configuration options. -| Option | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | -| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | -| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | -| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | -| **`legacySizes`** | Deprecated. Set to `true` to keep storing generated files under the 3.x `sizes` field, so a database written by Payload 3 works without the `sizes-to-variants` migration. `variants` is then a read-only virtual alias of `sizes`, and both are returned and queryable. [More](../migration-guide/v4#the-stored-sizes-field-on-upload-documents-is-renamed-to-variants) | -| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | -| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | -| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | -| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | -| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | -| **`transformers`** | An ordered array of [file transformers](./transformers), such as `sharpTransformer()`, that process uploads and serve dynamically-transformed files. Registering any transformer changes the file endpoint — see [File Transformers](./transformers#registering-a-transformer-changes-the-file-endpoint). | -| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | -| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | -| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | -| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `true`. | +| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. | +| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. | +| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. | +| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. | +| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. | +| **`requestSizeLimit`** | Maximum size in bytes for the complete raw multipart request, including files, fields, headers, and boundaries. Accepts a non-negative safe integer or `Infinity`. Defaults to 50 MiB. Set to `Infinity` to disable this limit. | +| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. | +| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. | +| **`transformers`** | An ordered array of [file transformers](./transformers), such as `sharpTransformer()`, that process uploads and serve dynamically-transformed files. Registering any transformer changes the file endpoint — see [File Transformers](./transformers#registering-a-transformer-changes-the-file-endpoint). | +| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'tmp'` in the current working directory. Supports absolute paths. | +| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. | +| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. | +| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. | [Click here](https://github.com/mscdex/busboy#api) for more documentation about what you can control with `Busboy`. diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts index 326f1b7b261..3fa246d3204 100644 --- a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -25,12 +25,6 @@ export async function migrateSizesToVariants({ payload: Payload req?: Partial }): Promise { - if (direction === 'up' && payload.config.upload?.legacySizes) { - throw new Error( - 'The sizes-to-variants migration moves stored variants away from `sizes`, which `upload.legacySizes` still reads from. Remove `upload.legacySizes` from your config before running it.', - ) - } - const adapter = payload.db as unknown as MongooseAdapter const session = await getSession(adapter, req) const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts index 15b8ea72cae..6ee053cf5ad 100644 --- a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -4,8 +4,6 @@ import { writeFileSync } from 'fs' import type { DrizzleAdapter } from '../types.js' -import { assertLegacySizesDisabled } from './getSizesToVariantsRenames.js' - /** * Builds the `sizes-to-variants` predefined migration for a SQL adapter. The migration renames * columns rather than letting drizzle-kit diff the schema (which would drop and re-add them, @@ -19,9 +17,6 @@ export const buildDynamicPredefinedSizesToVariantsMigration = ({ }): DynamicMigrationTemplate => { return async ({ filePath, payload }) => { const adapter = payload.db as unknown as DrizzleAdapter - - assertLegacySizesDisabled({ adapter }) - const { generateDrizzleJson } = adapter.requireDrizzleKit() const drizzleJsonAfter = await generateDrizzleJson(adapter.schema) diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts index 27a1626b586..62bac10f1ad 100644 --- a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -48,10 +48,6 @@ export function getSizesToVariantsRenames({ adapter: DrizzleAdapter direction: SizesToVariantsDirection }): SizesToVariantsTableRenames[] { - if (direction === 'up') { - assertLegacySizesDisabled({ adapter }) - } - const plan: SizesToVariantsTableRenames[] = [] for (const collection of adapter.payload.config.collections) { @@ -105,18 +101,6 @@ export function getSizesToVariantsRenames({ return plan } -/** - * With `upload.legacySizes` on, variants stay stored under `sizes`, so the current schema has no - * `variants_*` columns to rename to, and the generated snapshot would describe the wrong layout. - */ -export function assertLegacySizesDisabled({ adapter }: { adapter: DrizzleAdapter }): void { - if (adapter.payload.config.upload?.legacySizes) { - throw new Error( - 'The sizes-to-variants migration moves stored variants away from `sizes`, which `upload.legacySizes` still reads from. Remove `upload.legacySizes` from your config before running it.', - ) - } -} - function planIndexRename({ columns, direction, diff --git a/packages/payload-cloud/src/hooks/afterDelete.ts b/packages/payload-cloud/src/hooks/afterDelete.ts index d4a421c9324..a8a626929b9 100644 --- a/packages/payload-cloud/src/hooks/afterDelete.ts +++ b/packages/payload-cloud/src/hooks/afterDelete.ts @@ -1,7 +1,5 @@ import type { CollectionAfterDeleteHook, CollectionConfig, FileData, TypeWithID } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { TypeWithPrefix } from '../types.js' import { createKey } from '../utilities/createKey.js' @@ -20,7 +18,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename || '', - ...Object.values(getStoredUploadVariants({ config: req.payload.config, doc }) ?? {}) + ...Object.values(doc?.variants || []) .map((resizedFileData) => resizedFileData.filename) .filter((filename): filename is string => filename !== null), ] diff --git a/packages/payload-cloud/src/utilities/getIncomingFiles.ts b/packages/payload-cloud/src/utilities/getIncomingFiles.ts index 0ba2c4f3e18..1bf7b659a86 100644 --- a/packages/payload-cloud/src/utilities/getIncomingFiles.ts +++ b/packages/payload-cloud/src/utilities/getIncomingFiles.ts @@ -1,7 +1,5 @@ import type { FileData, PayloadRequest } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { File } from '../types.js' export function getIncomingFiles({ @@ -26,10 +24,8 @@ export function getIncomingFiles({ files = [mainFile] - const variants = getStoredUploadVariants({ config: req.payload.config, doc: data }) - - if (variants) { - Object.entries(variants).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (req.payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 7cf8ac0dcf0..0999e8688ff 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -28,7 +28,6 @@ import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { saveVersion } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' -import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getExternalUploadSource, getUploadDestination, @@ -93,9 +92,7 @@ export const createOperation = async < if (args.collection.config.upload && !args.overrideAccess) { externalUploadSource = getExternalUploadSource(args.data) const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const sanitizedData = sanitizeUploadData(args.data, 'create', { - variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), - }) + const sanitizedData = sanitizeUploadData(args.data, 'create') args = { ...args, diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index acf88dca9f7..58fab9a115c 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -19,7 +19,6 @@ import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' -import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, restoreUploadDataFromDocument, @@ -187,7 +186,6 @@ export const restoreVersionOperation = async < }) // originalDoc with hoisted localized data - const variantsFieldName = getUploadVariantsFieldName({ config: payload.config }) const validationLocale = payload.config.localization ? payload.config.localization.defaultLocale : locale! @@ -208,9 +206,8 @@ export const restoreVersionOperation = async < if (collectionConfig.upload && !overrideAccess) { versionToRestoreWithLocales = restoreUploadDataFromDocument( - sanitizeUploadData(versionToRestoreWithLocales, 'update', { variantsFieldName }), + sanitizeUploadData(versionToRestoreWithLocales, 'update'), prevDocWithLocales, - { variantsFieldName }, ) } @@ -231,12 +228,11 @@ export const restoreVersionOperation = async < if (collectionConfig.upload && !overrideAccess) { prevVersionDoc = restoreUploadDataFromDocument( - sanitizeUploadData(prevVersionDoc, 'update', { variantsFieldName }), + sanitizeUploadData(prevVersionDoc, 'update'), prevDocWithLocales, { locale: validationLocale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), - variantsFieldName, }, ) } diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index cbcb23ac6bf..90becee4aa1 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -23,7 +23,6 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { APIError } from '../../errors/index.js' import { type CollectionSlug, type FindOptions } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' -import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, getUploadDestination, @@ -99,9 +98,7 @@ export const updateOperation = async < if (args.collection.config.upload && !args.overrideAccess) { const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const data = sanitizeUploadData(args.data, 'update', { - variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), - }) + const data = sanitizeUploadData(args.data, 'update') args = { ...args, @@ -368,7 +365,6 @@ export const updateOperation = async < : undefined : locale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), - variantsFieldName: getUploadVariantsFieldName({ config }), }), operation: 'update', originalDoc: docWithLocales, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index d1862a5d4bb..5374643709c 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -22,7 +22,6 @@ import { combineQueries } from '../../database/combineQueries.js' import { APIError, Forbidden, NotFound } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple, type FindOptions } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' -import { getUploadVariantsFieldName } from '../../uploads/getUploadVariantsFieldName.js' import { getLocalizedUploadProperties, getUploadDestination, @@ -81,9 +80,7 @@ export const updateByIDOperation = async < if (args.collection.config.upload && !args.overrideAccess) { const { objectKey, prefix } = getUploadDestination({ data: args.data, file: args.req.file }) - const data = sanitizeUploadData(args.data, 'update', { - variantsFieldName: getUploadVariantsFieldName({ config: args.req.payload.config }), - }) + const data = sanitizeUploadData(args.data, 'update') args = { ...args, @@ -270,7 +267,6 @@ export const updateByIDOperation = async < : undefined : locale, localizedProperties: getLocalizedUploadProperties(collectionConfig.flattenedFields), - variantsFieldName: getUploadVariantsFieldName({ config }), }) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index d2bb18547cd..1a89152844b 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -872,17 +872,6 @@ export type FetchAPIFileUploadOptions = { } & Partial export type GlobalUploadConfig = { - /** - * Keep storing every upload collection's generated variants under the 3.x `sizes` field, so a - * database written by Payload 3 works without the `sizes-to-variants` migration. `variants` is - * then a read-only virtual alias of `sizes`: documents return both, and `where`, `select` and - * `sort` work on either path. Remove this flag before running the migration. - * - * @deprecated Move frontends to `variants`, remove this flag, and run the `sizes-to-variants` - * migration. Will be removed in the next major version. - * @default false - */ - legacySizes?: boolean /** * Ordered list of file transformers. Every eligible transformer joins the pipeline * for a given upload or dynamic request in declaration order. Slugs must be unique. diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f252bb686c2..354c63e6e0a 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1998,11 +1998,6 @@ export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' export { generatePayloadFileURL } from './uploads/generatePayloadFileURL.js' export { getFileByPath } from './uploads/getFileByPath.js' -export { - getStoredUploadVariants, - getUploadVariantsFieldName, - type UploadVariantsFieldName, -} from './uploads/getUploadVariantsFieldName.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' export type * from './uploads/transformers/types.js' export type * from './uploads/types.js' diff --git a/packages/payload/src/uploads/checkFileAccess.spec.ts b/packages/payload/src/uploads/checkFileAccess.spec.ts index f099362519c..c1dacedebf7 100644 --- a/packages/payload/src/uploads/checkFileAccess.spec.ts +++ b/packages/payload/src/uploads/checkFileAccess.spec.ts @@ -27,7 +27,6 @@ const makeReq = (findOne: ReturnType): PayloadRequest => ({ t: vi.fn(), payload: { - config: {}, db: { findOne }, }, }) as unknown as PayloadRequest diff --git a/packages/payload/src/uploads/checkFileAccess.ts b/packages/payload/src/uploads/checkFileAccess.ts index 91d3992fad2..52edf85a11b 100644 --- a/packages/payload/src/uploads/checkFileAccess.ts +++ b/packages/payload/src/uploads/checkFileAccess.ts @@ -3,7 +3,6 @@ import type { PayloadRequest, Where } from '../types/index.js' import { executeAccess } from '../auth/executeAccess.js' import { Forbidden } from '../errors/Forbidden.js' -import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { buildFilenameWhere } from './transformers/resolveUploadDocument.js' export const checkFileAccess = async ({ @@ -38,11 +37,7 @@ export const checkFileAccess = async ({ } if (constraints.length > 0) { - const filenameCondition = buildFilenameWhere({ - filename, - variants: config.upload.variants, - variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), - }) + const filenameCondition = buildFilenameWhere({ filename, variants: config.upload.variants }) const doc = await req.payload.db.findOne({ collection: config.slug, diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.ts b/packages/payload/src/uploads/deleteAssociatedFiles.ts index 7129646ba21..0e01cf69bf2 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.ts @@ -9,7 +9,6 @@ import type { FileData, FileToSave } from './types.js' import { APIError, ErrorDeletingFile } from '../errors/index.js' import { fileExists } from './fileExists.js' -import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' type Args = { collectionConfig: SanitizedCollectionConfig @@ -22,7 +21,6 @@ type Args = { export const deleteAssociatedFiles: (args: Args) => Promise = async ({ collectionConfig, - config, doc, files = [], overrideDelete, @@ -45,10 +43,8 @@ export const deleteAssociatedFiles: (args: Args) => Promise = async ({ throw new ErrorDeletingFile(req.t) } - const variants = doc[getUploadVariantsFieldName({ config })] - - if (variants && typeof variants === 'object') { - const sizes = Object.values(variants) as FileData[] + if (doc.variants) { + const sizes: FileData[] = Object.values(doc.variants) // Since forEach will not wait until unlink is finished it could // happen that two operations will try to delete the same file. // To avoid this it is recommended to use "sync" instead diff --git a/packages/payload/src/uploads/docWithFilenameExists.ts b/packages/payload/src/uploads/docWithFilenameExists.ts index 0eeb7938cc1..dd2557deaf1 100644 --- a/packages/payload/src/uploads/docWithFilenameExists.ts +++ b/packages/payload/src/uploads/docWithFilenameExists.ts @@ -1,7 +1,5 @@ import type { PayloadRequest, Where } from '../types/index.js' -import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' - type Args = { collectionSlug: string filename: string @@ -23,7 +21,6 @@ export const docWithFilenameExists = async ({ const hasPrefixField = (collection?.fields ?? []).some( (field) => 'name' in field && field.name === 'prefix', ) - const variantsFieldName = getUploadVariantsFieldName({ config: req.payload.config }) const filenameCondition: Where = { or: [ { @@ -33,7 +30,7 @@ export const docWithFilenameExists = async ({ }, ...(upload && typeof upload === 'object' && upload.variants ? upload.variants.map(({ name }) => ({ - [`${variantsFieldName}.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, })) : []), ], diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 5df02a18c71..e11778a3b01 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -18,7 +18,6 @@ import { getFileByPath } from './getFileByPath.js' import { getFileExtension, getSanitizedUploadFilename } from './getFileTypeIdentity.js' import { getImageSize } from './getImageSize.js' import { getSafeFileName } from './getSafeFilename.js' -import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { hasCropOrResizeEdit } from './hasCropOrResizeEdit.js' import { hasFullFileContents } from './hasFullFileContents.js' import { isProcessableImage } from './isProcessableImage.js' @@ -503,9 +502,7 @@ export const generateFileData = async ({ }) } - ;(fileData as Record)[ - getUploadVariantsFieldName({ config: req.payload.config }) - ] = sizes + fileData.variants = sizes } } catch (err) { req.payload.logger.error(err) diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 60c1f147652..85c8638e6e7 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -4,7 +4,6 @@ import type { Field } from '../fields/config/types.js' import type { SanitizedUploadConfig } from './types.js' import { generateFilePathOrURL } from './generateFilePathOrURL.js' -import { getUploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { mimeTypeValidator } from './mimeTypeValidator.js' import { validateUploadFilename } from './validateUploadFilename.js' @@ -26,7 +25,6 @@ type Options = { } export const getBaseUploadFields = ({ collection, config }: Options): Field[] => { - const variantsFieldName = getUploadVariantsFieldName({ config }) // `variants` only exists once a transformer (e.g. Sharp) has written it back during init. const uploadOptions: Partial = typeof collection.upload === 'object' ? collection.upload : {} @@ -63,13 +61,13 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => config, filename: typeof adminThumbnail === 'string' - ? (originalDoc[variantsFieldName]?.[adminThumbnail]?.filename as string) + ? (originalDoc.variants?.[adminThumbnail]?.filename as string) : undefined, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: typeof adminThumbnail === 'string' - ? (originalDoc[variantsFieldName]?.[adminThumbnail]?.url as string) + ? (originalDoc.variants?.[adminThumbnail]?.url as string) : undefined, }) }, @@ -202,7 +200,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => if (uploadOptions.variants) { uploadFields = uploadFields.concat([ { - name: variantsFieldName, + name: 'variants', type: 'group', admin: { hidden: true, @@ -228,8 +226,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.[variantsFieldName]?.[size.name]?.filename || - originalDoc?.[variantsFieldName]?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -241,8 +239,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.[variantsFieldName]?.[size.name]?.filename || - originalDoc?.[variantsFieldName]?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: true, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -292,42 +290,6 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => label: ({ t }) => t('upload:sizes'), }, ]) - - // With `legacySizes`, variants stay stored under the 3.x `sizes` field so an unmigrated - // database keeps working, and `variants` is the read-only alias of it. - if (variantsFieldName === 'sizes') { - uploadFields.push({ - name: 'variants', - type: 'group', - admin: { - hidden: true, - }, - // Same shape as `sizes`, without its hooks or indexes: the value is copied from `sizes` on - // read, and `where`/`select`/`sort` paths are rewritten to it. - fields: uploadOptions.variants.map((size) => ({ - name: size.name, - type: 'group', - fields: [url, width, height, mimeType, filesize, filename].map(toLegacySizesAliasField), - })), - virtual: 'sizes', - }) - } } return uploadFields } - -/** A plain copy of a base upload field for the read-only `variants` alias. */ -const toLegacySizesAliasField = (field: Field): Field => { - const { - hooks: _hooks, - index: _index, - unique: _unique, - ...aliasField - } = field as { - hooks?: unknown - index?: boolean - unique?: boolean - } & Field - - return aliasField as Field -} diff --git a/packages/payload/src/uploads/getUploadVariantsFieldName.ts b/packages/payload/src/uploads/getUploadVariantsFieldName.ts deleted file mode 100644 index c462fc601a2..00000000000 --- a/packages/payload/src/uploads/getUploadVariantsFieldName.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Config, SanitizedConfig } from '../config/types.js' -import type { FileSizes } from './types.js' - -export type UploadVariantsFieldName = 'sizes' | 'variants' - -/** - * The name upload documents store their generated variants under. With the deprecated - * `upload.legacySizes` flag on, that's still the 3.x `sizes` field, so a database written by 3.x - * works unmigrated, and `variants` is a read-only virtual alias of it. Code that reads or writes - * stored upload data, or queries the database directly, must use this name rather than - * hard-coding either one. - */ -export function getUploadVariantsFieldName({ - config, -}: { - config: Pick -}): UploadVariantsFieldName { - return config.upload?.legacySizes ? 'sizes' : 'variants' -} - -/** - * The generated variants stored on an upload document, read from wherever - * `getUploadVariantsFieldName` says they're stored. Works on raw database documents and on read - * results alike. - */ -export function getStoredUploadVariants({ - config, - doc, -}: { - config: Pick - doc: unknown -}): FileSizes | undefined { - if (!doc || typeof doc !== 'object') { - return undefined - } - - const variants = (doc as Record)[getUploadVariantsFieldName({ config })] - - return variants && typeof variants === 'object' ? (variants as FileSizes) : undefined -} diff --git a/packages/payload/src/uploads/sanitizeUploadData.ts b/packages/payload/src/uploads/sanitizeUploadData.ts index bbf333be557..49758a8340c 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.ts @@ -1,7 +1,6 @@ import path from 'path' import type { FlattenedField } from '../fields/config/types.js' -import type { UploadVariantsFieldName } from './getUploadVariantsFieldName.js' import { APIError } from '../errors/APIError.js' @@ -15,8 +14,6 @@ export type ExternalUploadSource = { type UploadDataOptions = { locale?: string localizedProperties?: Set - /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ - variantsFieldName?: UploadVariantsFieldName } const hasOwnProperty = (value: Record, key: string): boolean => @@ -110,21 +107,15 @@ const validateFilename = (filename: unknown): void => { } } -export const sanitizeUploadData = ( - data: T, - operation: Operation, - { variantsFieldName = 'variants' }: Pick = {}, -): T => { +export const sanitizeUploadData = (data: T, operation: Operation): T => { if (!isRecord(data)) { return data } validateFilename(data.filename) - const submittedVariants = data[variantsFieldName] - - if (isRecord(submittedVariants)) { - for (const sizeData of Object.values(submittedVariants)) { + if (isRecord(data.variants)) { + for (const sizeData of Object.values(data.variants)) { if (isRecord(sizeData)) { validateFilename(sizeData.filename) } @@ -133,7 +124,7 @@ export const sanitizeUploadData = ( const sanitizedData: Record = { ...data } delete sanitizedData.filename - delete sanitizedData[variantsFieldName] + delete sanitizedData.variants delete sanitizedData.url // Server-owned; never accepted from the caller. delete sanitizedData._objectKey @@ -169,22 +160,20 @@ export const mergeUploadDataWithDocument = ( } } - const variantsFieldName = options.variantsFieldName ?? 'variants' - const submittedVariants = data[variantsFieldName] - const documentSizes = getDocumentProperty(document, variantsFieldName, options) - if (!hasOwnProperty(data, variantsFieldName) && hasOwnProperty(document, variantsFieldName)) { - mergedData[variantsFieldName] = documentSizes - } else if (isRecord(submittedVariants) && isRecord(documentSizes)) { + const documentSizes = getDocumentProperty(document, 'variants', options) + if (!hasOwnProperty(data, 'variants') && hasOwnProperty(document, 'variants')) { + mergedData.variants = documentSizes + } else if (isRecord(data.variants) && isRecord(documentSizes)) { const mergedSizes: Record = { ...documentSizes } - for (const [sizeName, sizeData] of Object.entries(submittedVariants)) { + for (const [sizeName, sizeData] of Object.entries(data.variants)) { const documentSize = documentSizes[sizeName] mergedSizes[sizeName] = isRecord(sizeData) && isRecord(documentSize) ? { ...documentSize, ...sizeData } : sizeData } - mergedData[variantsFieldName] = mergedSizes + mergedData.variants = mergedSizes } return mergedData as T @@ -199,6 +188,7 @@ const uploadDerivedProperties = [ 'height', 'mimeType', 'prefix', + 'variants', 'thumbnailURL', 'url', 'width', @@ -214,7 +204,7 @@ export const restoreUploadDataFromDocument = ( } const restoredData: Record = { ...data } - for (const property of [...uploadDerivedProperties, options.variantsFieldName ?? 'variants']) { + for (const property of uploadDerivedProperties) { if (hasOwnProperty(document, property)) { restoredData[property] = getDocumentProperty(document, property, options) } else { diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts index c910d40940b..4fd18e43566 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -8,7 +8,6 @@ import { NotFound } from '../../errors/NotFound.js' import { TransformerContractError } from '../../errors/TransformerContractError.js' import { checkFileAccess } from '../checkFileAccess.js' import { retrieveFileResponse } from '../endpoints/getFile.js' -import { getUploadVariantsFieldName } from '../getUploadVariantsFieldName.js' import { createLazySourceGetter } from './createLazySourceGetter.js' import { finalizeFileResponse } from './finalizeFileResponse.js' import { getSourceFileResponse } from './getSourceFileResponse.js' @@ -60,11 +59,7 @@ export async function handleDynamicFileRequest({ resolvedDocument, }) - const requestedFile = getRequestedFile({ - document, - filename, - variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), - }) + const requestedFile = getRequestedFile({ document, filename }) const source = createLazySourceGetter({ retrieve: () => getSourceFileResponse({ collection, document, filename, prefix, req }), @@ -175,11 +170,7 @@ function planRequestPipeline({ args: { collectionSlug: collection.config.slug, documentID: document.id, - mimeType: getRequestedFile({ - document, - filename, - variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), - }).mimeType, + mimeType: getRequestedFile({ document, filename }).mimeType, operation: 'request', req, }, @@ -253,11 +244,7 @@ async function authorizeDocument({ const hasCandidateTransformers = getCandidateTransformers({ capability: 'handleRequest', - mimeType: getRequestedFile({ - document: resolvedDocument, - filename, - variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), - }).mimeType, + mimeType: getRequestedFile({ document: resolvedDocument, filename }).mimeType, transformers: req.payload.config.upload.transformers, }).length > 0 diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts index 7f4d23921b5..527f9e57b8b 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -1,20 +1,13 @@ import type { Collection, TypeWithID } from '../../collections/config/types.js' import type { PayloadRequest, Where } from '../../types/index.js' -import type { UploadVariantsFieldName } from '../getUploadVariantsFieldName.js' import { Forbidden } from '../../errors/Forbidden.js' -import { getUploadVariantsFieldName } from '../getUploadVariantsFieldName.js' export type ResolvedUploadDocument = { filename: string mimeType: string -} & Partial< - Record< - UploadVariantsFieldName, - Record - > -> & - TypeWithID + variants?: Record +} & TypeWithID /** * The primary filename, or a configured legacy image size's filename, both match. @@ -23,12 +16,9 @@ export type ResolvedUploadDocument = { export function buildFilenameWhere({ filename, variants, - variantsFieldName = 'variants', }: { filename: string variants?: { name: string }[] - /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ - variantsFieldName?: UploadVariantsFieldName }): Where { const filenameCondition: Where = { or: [{ filename: { equals: filename } }], @@ -36,7 +26,7 @@ export function buildFilenameWhere({ variants?.forEach(({ name }) => { filenameCondition.or!.push({ - [`${variantsFieldName}.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, }) }) @@ -51,17 +41,12 @@ export function buildFilenameWhere({ export function getRequestedFile({ document, filename, - variantsFieldName = 'variants', }: { document: ResolvedUploadDocument filename: string - /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ - variantsFieldName?: UploadVariantsFieldName }): { filename: string; mimeType: string } { if (document.filename !== filename) { - const size = Object.values(document[variantsFieldName] ?? {}).find( - (size) => size?.filename === filename, - ) + const size = Object.values(document.variants ?? {}).find((size) => size?.filename === filename) if (size?.filename && size.mimeType) { return { filename: size.filename, mimeType: size.mimeType } @@ -95,13 +80,7 @@ export async function resolveUploadDocument({ const { config } = collection - const constraints: Where[] = [ - buildFilenameWhere({ - filename, - variants: config.upload.variants, - variantsFieldName: getUploadVariantsFieldName({ config: req.payload.config }), - }), - ] + const constraints: Where[] = [buildFilenameWhere({ filename, variants: config.upload.variants })] if (typeof prefix === 'string') { constraints.push({ prefix: { equals: prefix } }) diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 6eeaabb3b3a..623b7a11262 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -1,11 +1,4 @@ -import type { - CollectionConfig, - Field, - GroupField, - SanitizedUploadConfig, - TextField, - UploadVariantsFieldName, -} from 'payload' +import type { CollectionConfig, Field, GroupField, SanitizedUploadConfig, TextField } from 'payload' import path from 'path' @@ -26,8 +19,6 @@ interface Args { * document field holds only the document-level segment. */ useCompositePrefixes?: boolean - /** Where generated variants are stored; see `getUploadVariantsFieldName`. */ - variantsFieldName?: UploadVariantsFieldName } export const getFields = ({ @@ -37,7 +28,6 @@ export const getFields = ({ generateFileURL, prefix, useCompositePrefixes = false, - variantsFieldName = 'variants', }: Args): Field[] => { const baseURLField: TextField = { name: 'url', @@ -122,7 +112,7 @@ export const getFields = ({ let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { - if ('name' in existingField && existingField.name === variantsFieldName) { + if ('name' in existingField && existingField.name === 'variants') { existingSizesFieldIndex = i return true } @@ -136,7 +126,7 @@ export const getFields = ({ const sizesField: Field = { ...(existingSizesField || {}), - name: variantsFieldName, + name: 'variants', type: 'group', admin: { hidden: true, diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts index cb8918732b7..19003db0257 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts @@ -41,7 +41,7 @@ describe('upload replacement cleanup', () => { req: { context: {}, file: { data: Buffer.from('file'), size: 4 }, - payload: { config: {}, logger: { error: vi.fn() } }, + payload: { logger: { error: vi.fn() } }, }, } as never) diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index 81914f0a935..545afba8c2b 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -1,6 +1,6 @@ import type { CollectionAfterChangeHook, CollectionConfig, FileData, TypeWithID } from 'payload' -import { deepMergeWithSourceArrays, getStoredUploadVariants } from 'payload' +import { deepMergeWithSourceArrays } from 'payload' import type { GeneratedAdapter } from '../types.js' @@ -125,14 +125,9 @@ export const getAfterChangeHook = filesToDelete.push(previousDoc.filename) } - const previousVariants = getStoredUploadVariants({ - config: req.payload.config, - doc: previousDoc, - }) - - if (previousVariants) { + if (typeof previousDoc.variants === 'object') { filesToDelete = filesToDelete.concat( - Object.values(previousVariants).map( + Object.values(previousDoc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ) @@ -145,13 +140,8 @@ export const getAfterChangeHook = if (typeof newFileData.filename === 'string') { newFilenames.add(newFileData.filename) } - const newVariants = getStoredUploadVariants({ - config: req.payload.config, - doc: newFileData, - }) - - if (newVariants) { - for (const size of Object.values(newVariants)) { + if (typeof newFileData.variants === 'object') { + for (const size of Object.values(newFileData.variants || {})) { if (size?.filename && typeof size.filename === 'string') { newFilenames.add(size.filename) } diff --git a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts index c65d8c3355a..fae6144eb00 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts @@ -1,7 +1,5 @@ import type { CollectionAfterDeleteHook, CollectionConfig, FileData, TypeWithID } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { GeneratedAdapter, TypeWithPrefix } from '../types.js' import { buildPrefixWithObjectKey } from '../utilities/buildPrefixWithObjectKey.js' @@ -30,7 +28,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename, - ...Object.values(getStoredUploadVariants({ config: req.payload.config, doc }) ?? {}).map( + ...Object.values(doc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ] diff --git a/packages/plugin-cloud-storage/src/hooks/afterRead.ts b/packages/plugin-cloud-storage/src/hooks/afterRead.ts index 71f823021fc..fef51fd3b3e 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterRead.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterRead.ts @@ -1,7 +1,5 @@ import type { CollectionConfig, FieldHook, ImageSize } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { GeneratedAdapter, GenerateFileURL } from '../types.js' import { sanitizePrefix } from '../utilities/sanitizePrefix.js' @@ -31,10 +29,8 @@ const getObjectFolder = (data: unknown): string => { export const getAfterReadHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => - async ({ data, req, value }) => { - const filename = size - ? getStoredUploadVariants({ config: req.payload.config, doc: data })?.[size.name]?.filename - : data?.filename + async ({ data, value }) => { + const filename = size ? data?.variants?.[size.name]?.filename : data?.filename const prefix = data?.prefix // Direct-serve URLs encode the full location; the proxy resolves `_objectKey` server-side. const objectFolder = getObjectFolder(data) diff --git a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts index 690a7b16d32..c7eecba2f8f 100644 --- a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts @@ -1,7 +1,5 @@ import type { CollectionConfig, FieldHook, ImageSize } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { GeneratedAdapter, GenerateFileURL } from '../types.js' import { sanitizePrefix } from '../utilities/sanitizePrefix.js' @@ -31,13 +29,10 @@ const getObjectFolder = (data: unknown, originalDoc: unknown): string => { export const getBeforeChangeHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => - async ({ data, originalDoc, req, value }) => { - const newFilename = size - ? getStoredUploadVariants({ config: req.payload.config, doc: data })?.[size.name]?.filename - : data?.filename + async ({ data, originalDoc, value }) => { + const newFilename = size ? data?.variants?.[size.name]?.filename : data?.filename const originalFilename = size - ? getStoredUploadVariants({ config: req.payload.config, doc: originalDoc })?.[size.name] - ?.filename + ? originalDoc?.variants?.[size.name]?.filename : originalDoc?.filename const filename = newFilename || originalFilename const prefix = getObjectFolder(data, originalDoc) diff --git a/packages/plugin-cloud-storage/src/plugin.ts b/packages/plugin-cloud-storage/src/plugin.ts index cf0e7a13c41..80be802d0ae 100644 --- a/packages/plugin-cloud-storage/src/plugin.ts +++ b/packages/plugin-cloud-storage/src/plugin.ts @@ -1,7 +1,5 @@ import type { Config } from 'payload' -import { getUploadVariantsFieldName } from 'payload' - import type { Adapter, AllowList, PluginOptions } from './types.js' import { getFields } from './fields/getFields.js' @@ -49,7 +47,6 @@ export const cloudStoragePlugin = generateFileURL: options.generateFileURL, prefix: options.prefix, useCompositePrefixes, - variantsFieldName: getUploadVariantsFieldName({ config: incomingConfig }), }) return { @@ -134,7 +131,6 @@ export const cloudStoragePlugin = generateFileURL: options.generateFileURL, prefix: options.prefix, useCompositePrefixes, - variantsFieldName: getUploadVariantsFieldName({ config: incomingConfig }), }) const handlers = [ diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 86ca99553bb..8fecb1520e2 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -1,7 +1,5 @@ import type { CollectionConfig, PayloadRequest, SanitizedUploadConfig, TypeWithID } from 'payload' -import { getUploadVariantsFieldName } from 'payload' - import { buildPrefixWithObjectKey } from './buildPrefixWithObjectKey.js' import { buildUploadStoragePathData } from './buildStoragePathData.js' import { sanitizePrefix } from './sanitizePrefix.js' @@ -74,10 +72,7 @@ export async function getFilePrefix({ filename: { equals: filename }, }, ...variants.map((imageSize) => ({ - [`${getUploadVariantsFieldName({ config: req.payload.config })}.${imageSize.name}.filename`]: - { - equals: filename, - }, + [`variants.${imageSize.name}.filename`]: { equals: filename }, })), ], } diff --git a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts index 75a068b8a64..3ab4d06c965 100644 --- a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts +++ b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts @@ -1,7 +1,5 @@ import type { FileData, PayloadRequest } from 'payload' -import { getStoredUploadVariants } from 'payload' - import type { File } from '../types.js' interface CloudStorageContext { @@ -35,10 +33,8 @@ export function getIncomingFiles({ files = [mainFile] - const variants = getStoredUploadVariants({ config: req.payload.config, doc: data }) - - if (variants) { - Object.entries(variants).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts b/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts index 9bdbc4a385a..901fb120faa 100644 --- a/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts +++ b/packages/storage-r2/src/handleMultiPartUpload.createOnly.spec.ts @@ -18,7 +18,6 @@ describe('getHandleMultiPartUpload', () => { } as unknown as R2Bucket const req = { payload: { - config: {}, collections: { media: { config: { access: {}, upload: true } }, }, diff --git a/packages/storage-r2/src/handleMultiPartUpload.spec.ts b/packages/storage-r2/src/handleMultiPartUpload.spec.ts index 4c4ca6b9f6d..e3f77593305 100644 --- a/packages/storage-r2/src/handleMultiPartUpload.spec.ts +++ b/packages/storage-r2/src/handleMultiPartUpload.spec.ts @@ -10,7 +10,6 @@ import { getHandleMultiPartUpload } from './handleMultiPartUpload.js' const createRequest = (filename: string, mimeType: string) => ({ payload: { - config: {}, collections: { media: { config: { @@ -34,7 +33,6 @@ const createContinuationRequest = ({ multipartKey }: { multipartKey: string }) = arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), user: { id: 'user' }, payload: { - config: {}, collections: { media: { config: { slug: 'media', access: {}, upload: { staticDir: '/tmp' } } }, }, @@ -132,7 +130,6 @@ describe('getHandleMultiPartUpload', () => { } as unknown as R2Bucket const req = { payload: { - config: {}, collections: { media: { config: { access: {}, upload: true } }, }, diff --git a/test/sizes-to-variants/buildSizesToVariantsConfig.ts b/test/sizes-to-variants/buildSizesToVariantsConfig.ts deleted file mode 100644 index d705c1720e9..00000000000 --- a/test/sizes-to-variants/buildSizesToVariantsConfig.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { sharpTransformer } from '@payloadcms/transformer-sharp' -import path from 'path' -import sharp from 'sharp' -import { fileURLToPath } from 'url' - -import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { mediaSlug, variantName } from './shared.js' - -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) - -export const buildSizesToVariantsConfig = ({ - legacySizes, - staticDirName, - suite, -}: { - legacySizes: boolean - staticDirName: string - suite: string -}) => - buildConfigWithDefaults({ - config: { - collections: [ - { - slug: 'users', - auth: true, - fields: [], - }, - { - slug: mediaSlug, - access: { - read: () => true, - }, - fields: [], - upload: { - staticDir: path.resolve(dirname, staticDirName), - }, - versions: true, - }, - ], - upload: { - legacySizes, - transformers: [ - sharpTransformer({ - collections: { - [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, - }, - sharp, - }), - ], - }, - }, - suite, - }) diff --git a/test/sizes-to-variants/config.ts b/test/sizes-to-variants/config.ts index 5f702b91cf3..cd58f974a4e 100644 --- a/test/sizes-to-variants/config.ts +++ b/test/sizes-to-variants/config.ts @@ -1,7 +1,41 @@ -import { buildSizesToVariantsConfig } from './buildSizesToVariantsConfig.js' +import { sharpTransformer } from '@payloadcms/transformer-sharp' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' -export default buildSizesToVariantsConfig({ - legacySizes: false, - staticDirName: 'media', +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + config: { + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + { + slug: mediaSlug, + fields: [], + upload: { + staticDir: path.resolve(dirname, 'media'), + }, + versions: true, + }, + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, + }, + sharp, + }), + ], + }, + }, suite: 'sizes-to-variants', }) diff --git a/test/sizes-to-variants/legacySizes.config.ts b/test/sizes-to-variants/legacySizes.config.ts deleted file mode 100644 index 99b4e2daedb..00000000000 --- a/test/sizes-to-variants/legacySizes.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildSizesToVariantsConfig } from './buildSizesToVariantsConfig.js' - -export default buildSizesToVariantsConfig({ - legacySizes: true, - staticDirName: 'media-legacy-sizes', - suite: 'sizes-to-variants-legacy-sizes', -}) diff --git a/test/sizes-to-variants/legacySizes.int.spec.ts b/test/sizes-to-variants/legacySizes.int.spec.ts deleted file mode 100644 index e1db9fbff76..00000000000 --- a/test/sizes-to-variants/legacySizes.int.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { Payload } from 'payload' - -import { migrateSizesToVariants as migrateMongoSizesToVariants } from '@payloadcms/db-mongodb/migration-utils' -import { migrateSizesToVariants as migratePostgresSizesToVariants } from '@payloadcms/db-postgres/migration-utils' -import { migrateSizesToVariants as migrateSqliteSizesToVariants } from '@payloadcms/db-sqlite/migration-utils' -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -import { expect } from 'vitest' - -import { test } from '../__helpers/int/vitest.js' -import { mediaSlug, variantName } from './shared.js' - -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) -const staticDir = path.resolve(dirname, 'media-legacy-sizes') - -const createMedia = async ({ payload }: { payload: Payload }) => { - const doc = await payload.create({ - collection: mediaSlug, - data: {}, - filePath: path.resolve(dirname, '../uploads/image.png'), - overrideAccess: true, - }) - - const variantFilename = doc.variants?.[variantName]?.filename - - if (!variantFilename) { - throw new Error(`Expected the "${variantName}" variant to be generated`) - } - - return { doc, variantFilename } -} - -test.suite('legacySizes', { config: './legacySizes.config.ts' }, () => { - test.afterEach(() => { - fs.rmSync(staticDir, { force: true, recursive: true }) - }) - - test('should return the stored sizes and the variants alias with the same data', async ({ - payload, - }) => { - const { doc } = await createMedia({ payload }) - - const found = await payload.findByID({ - id: doc.id, - collection: mediaSlug, - overrideAccess: true, - }) - - expect(found.sizes?.[variantName]?.url).toBeTruthy() - expect(found.variants).toEqual(found.sizes) - }) - - test('should resolve where queries on both sizes and variants paths', async ({ payload }) => { - const { doc, variantFilename } = await createMedia({ payload }) - - for (const fieldName of ['sizes', 'variants']) { - const { docs } = await payload.find({ - collection: mediaSlug, - overrideAccess: true, - where: { [`${fieldName}.${variantName}.filename`]: { equals: variantFilename } }, - }) - - expect(docs.map(({ id }) => id)).toEqual([doc.id]) - } - }) - - test('should resolve a variants select', async ({ payload }) => { - const { doc } = await createMedia({ payload }) - - const found = await payload.findByID({ - id: doc.id, - collection: mediaSlug, - overrideAccess: true, - select: { variants: true }, - }) - - expect(found.variants?.[variantName]?.filename).toBeTruthy() - expect(found).not.toHaveProperty('filename') - }) - - test('should serve a generated variant file by its filename', async ({ payload, restClient }) => { - const { variantFilename } = await createMedia({ payload }) - - const response = await restClient.GET(`/${mediaSlug}/file/${variantFilename}`) - - expect(response.status).toBe(200) - }) - - test('should delete the generated variant files with the document', async ({ payload }) => { - const { doc, variantFilename } = await createMedia({ payload }) - - expect(fs.existsSync(path.resolve(staticDir, variantFilename))).toBe(true) - - await payload.delete({ id: doc.id, collection: mediaSlug, overrideAccess: true }) - - expect(fs.existsSync(path.resolve(staticDir, variantFilename))).toBe(false) - }) - - test('should not store writes to the variants alias', async ({ payload }) => { - const { doc, variantFilename } = await createMedia({ payload }) - - await payload.update({ - id: doc.id, - collection: mediaSlug, - data: { variants: { [variantName]: { filename: 'ignored.png' } } } as never, - overrideAccess: true, - }) - - const found = await payload.findByID({ - id: doc.id, - collection: mediaSlug, - overrideAccess: true, - }) - - expect(found.sizes?.[variantName]?.filename).toBe(variantFilename) - expect(found.variants?.[variantName]?.filename).toBe(variantFilename) - }) - - test.options.describe('MongoDB', { db: 'mongo' }, () => { - test('should store variants under the legacy sizes field', async ({ payload }) => { - const { doc, variantFilename } = await createMedia({ payload }) - - const rawDocument = await (payload.db as any).collections[mediaSlug].collection.findOne({ - filename: doc.filename, - }) - - expect(rawDocument.sizes?.[variantName]?.filename).toBe(variantFilename) - expect(rawDocument.variants).toBeUndefined() - }) - - test('should refuse to run the Mongo migration while legacySizes is on', async ({ - payload, - }) => { - await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow(/legacySizes/) - }) - }) - - test.options.describe('SQL', { db: 'drizzle' }, () => { - test('should keep the legacy sizes columns in the schema', ({ payload }) => { - const columnNames = Object.values<{ name: string }>( - (payload.db as any).rawTables[mediaSlug].columns, - ).map(({ name }) => name) - - expect(columnNames).toContain(`sizes_${variantName}_filename`) - expect(columnNames).not.toContain(`variants_${variantName}_filename`) - }) - - test('should refuse to run the SQL migration while legacySizes is on', async ({ payload }) => { - const migrate = - (payload.db as any).name === 'sqlite' - ? migrateSqliteSizesToVariants - : migratePostgresSizesToVariants - - await expect(migrate({ db: (payload.db as any).drizzle, payload })).rejects.toThrow( - /legacySizes/, - ) - }) - }) -}) From 09ff01d7185f450bbd8be2a53f8a7c0ebebad3ff Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 1 Oct 2026 17:47:41 +0100 Subject: [PATCH 11/13] chore: fix sizes-to-variants migration issues found in end-to-end testing --- .../migrateSizesToVariants.ts | 47 ++++--- .../scripts/renamePredefinedMigrations.ts | 6 +- .../scripts/renamePredefinedMigrations.ts | 6 +- ...PredefinedSizesToVariantsMigration.spec.ts | 119 +++++++++++++++++ ...namicPredefinedSizesToVariantsMigration.ts | 121 +++++++++++++++++- .../utilities/getSizesToVariantsRenames.ts | 11 +- test/sizes-to-variants/config.ts | 9 +- test/sizes-to-variants/int.spec.ts | 73 +++++++---- test/sizes-to-variants/shared.ts | 2 + 9 files changed, 338 insertions(+), 56 deletions(-) create mode 100644 packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts index 3fa246d3204..4e95fd477b4 100644 --- a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -29,36 +29,45 @@ export async function migrateSizesToVariants({ const session = await getSession(adapter, req) const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] + const targets: { collection: Collection; fromPath: string; toPath: string }[] = [] + for (const collection of payload.config.collections) { if (!collection.upload) { continue } - const targets: { collection: Collection; path: string }[] = [ - { collection: adapter.collections[collection.slug]!.collection, path: '' }, - ] + targets.push({ + collection: adapter.collections[collection.slug]!.collection, + fromPath: from, + toPath: to, + }) if (collection.versions && adapter.versions[collection.slug]) { - targets.push({ collection: adapter.versions[collection.slug]!.collection, path: 'version.' }) + targets.push({ + collection: adapter.versions[collection.slug]!.collection, + fromPath: `version.${from}`, + toPath: `version.${to}`, + }) } + } - for (const target of targets) { - const fromPath = `${target.path}${from}` - const toPath = `${target.path}${to}` - - const result = await target.collection.updateMany( - { [fromPath]: { $exists: true }, [toPath]: { $exists: false } }, - { $rename: { [fromPath]: toPath } }, - { session }, - ) + // Index changes can't be part of a multi-document transaction, and one made after the + // migration's transaction has written to a collection aborts it. A transaction's snapshot starts + // at its first operation, so re-keying every index before any transactional write is safe. + for (const target of targets) { + await rekeyIndexes(target) + } - // Index builds can't run inside a multi-document transaction, so they skip the session. - await rekeyIndexes({ collection: target.collection, fromPath, toPath }) + for (const { collection, fromPath, toPath } of targets) { + const result = await collection.updateMany( + { [fromPath]: { $exists: true }, [toPath]: { $exists: false } }, + { $rename: { [fromPath]: toPath } }, + { session }, + ) - payload.logger.info({ - msg: `sizes-to-variants (${direction}): renamed ${fromPath} on ${result.modifiedCount} document(s) in "${target.collection.collectionName}"`, - }) - } + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${fromPath} on ${result.modifiedCount} document(s) in "${collection.collectionName}"`, + }) } } diff --git a/packages/db-postgres/scripts/renamePredefinedMigrations.ts b/packages/db-postgres/scripts/renamePredefinedMigrations.ts index 625f82cf00c..8df36e4becb 100644 --- a/packages/db-postgres/scripts/renamePredefinedMigrations.ts +++ b/packages/db-postgres/scripts/renamePredefinedMigrations.ts @@ -2,7 +2,9 @@ import fs from 'fs' import path from 'path' /** - * Changes built .js files to .mjs to for ESM imports + * Adds an .mjs copy of each built .js file for ESM imports of predefined migrations. The .js file + * is kept, since `exports/migration-utils` imports helpers from this folder (e.g. + * `migrateLocalizeStatus.js`) by their .js name. */ const rename = () => { fs.readdirSync(path.resolve('./dist/predefinedMigrations')) @@ -11,7 +13,7 @@ const rename = () => { }) .forEach((file) => { const newPath = path.join('./dist/predefinedMigrations', file) - fs.renameSync(newPath, newPath.replace('.js', '.mjs')) + fs.copyFileSync(newPath, newPath.replace('.js', '.mjs')) }) console.log('done') } diff --git a/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts b/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts index 625f82cf00c..8df36e4becb 100644 --- a/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts +++ b/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts @@ -2,7 +2,9 @@ import fs from 'fs' import path from 'path' /** - * Changes built .js files to .mjs to for ESM imports + * Adds an .mjs copy of each built .js file for ESM imports of predefined migrations. The .js file + * is kept, since `exports/migration-utils` imports helpers from this folder (e.g. + * `migrateLocalizeStatus.js`) by their .js name. */ const rename = () => { fs.readdirSync(path.resolve('./dist/predefinedMigrations')) @@ -11,7 +13,7 @@ const rename = () => { }) .forEach((file) => { const newPath = path.join('./dist/predefinedMigrations', file) - fs.renameSync(newPath, newPath.replace('.js', '.mjs')) + fs.copyFileSync(newPath, newPath.replace('.js', '.mjs')) }) console.log('done') } diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts new file mode 100644 index 00000000000..02960ae596b --- /dev/null +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts @@ -0,0 +1,119 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it } from 'vitest' + +import { buildDynamicPredefinedSizesToVariantsMigration } from './buildDynamicPredefinedSizesToVariantsMigration.js' + +const tempDirs: string[] = [] + +const makeMigrationDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sizes-to-variants-')) + tempDirs.push(dir) + return dir +} + +/** A 3.x snapshot: a camelCase `heroLarge` size stored as `sizes_*`, plus an unrelated table. */ +const legacySnapshot = { + id: 'previous-snapshot-id', + tables: { + 'public.media': { + name: 'media', + columns: { + id: { name: 'id' }, + sizes_hero_large_filename: { name: 'sizes_hero_large_filename' }, + }, + indexes: { + media_sizes_hero_large_sizes_hero_large_filename_idx: { + name: 'media_sizes_hero_large_sizes_hero_large_filename_idx', + columns: [{ expression: 'sizes_hero_large_filename', isExpression: false }], + }, + }, + }, + 'public.posts': { + name: 'posts', + columns: { title: { name: 'title' } }, + indexes: {}, + }, + }, + version: '7', +} + +const makePayload = ({ migrationDir }: { migrationDir: string }) => + ({ + db: { + migrationDir, + payload: { config: { collections: [{ slug: 'media', upload: {} }] } }, + rawTables: { + media: { + name: 'media', + columns: { + id: { name: 'id' }, + variants_heroLarge_filename: { name: 'variants_hero_large_filename' }, + }, + indexes: { + media_variants_hero_large_variants_hero_large_filename_idx: { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + on: 'variants_heroLarge_filename', + }, + }, + }, + }, + requireDrizzleKit: () => ({ generateDrizzleJson: () => Promise.resolve({ version: '7' }) }), + schema: {}, + tableNameMap: new Map([['media', 'media']]), + versionsSuffix: '_v', + }, + }) as any + +describe('buildDynamicPredefinedSizesToVariantsMigration', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }) + } + }) + + it('should write the previous snapshot with only the sizes-to-variants renames applied', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(legacySnapshot), + ) + + await buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }) + + const written = JSON.parse(fs.readFileSync(`${filePath}.json`, 'utf8')) + const media = written.tables['public.media'] + + expect(Object.keys(media.columns)).toEqual(['id', 'variants_hero_large_filename']) + expect(media.indexes).toEqual({ + media_variants_hero_large_variants_hero_large_filename_idx: { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + columns: [{ expression: 'variants_hero_large_filename', isExpression: false }], + }, + }) + expect(written.tables['public.posts']).toEqual(legacySnapshot.tables['public.posts']) + expect(written.prevId).toBe(legacySnapshot.id) + }) + + it('should not write a snapshot when the project has no previous one', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + + await buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) +}) diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts index 6ee053cf5ad..e3bb399f99a 100644 --- a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -1,14 +1,40 @@ import type { DynamicMigrationTemplate } from 'payload' -import { writeFileSync } from 'fs' +import { randomUUID } from 'crypto' +import { readdirSync, readFileSync, writeFileSync } from 'fs' import type { DrizzleAdapter } from '../types.js' +import type { SizesToVariantsTableRenames } from './getSizesToVariantsRenames.js' + +import { getSizesToVariantsRenames } from './getSizesToVariantsRenames.js' + +type SnapshotIndex = { + columns: ({ expression: string } | string)[] + name: string +} + +type SnapshotTable = { + columns: Record + indexes?: Record + name: string +} + +type Snapshot = { + id?: string + prevId?: string + tables: Record + version: string +} /** * Builds the `sizes-to-variants` predefined migration for a SQL adapter. The migration renames * columns rather than letting drizzle-kit diff the schema (which would drop and re-add them, - * losing every stored image size), so it also writes the current schema snapshot: the next - * generated migration then diffs from the renamed columns instead of the legacy ones. + * losing every stored image size). + * + * It also writes a schema snapshot: the project's previous snapshot with only these renames + * applied. The next generated migration then diffs from the renamed columns instead of dropping + * them, and still picks up every other schema change the upgrade brings. Without a previous + * snapshot (a project that has only ever used `push`) there's nothing to update, so none is written. */ export const buildDynamicPredefinedSizesToVariantsMigration = ({ packageName, @@ -17,10 +43,16 @@ export const buildDynamicPredefinedSizesToVariantsMigration = ({ }): DynamicMigrationTemplate => { return async ({ filePath, payload }) => { const adapter = payload.db as unknown as DrizzleAdapter - const { generateDrizzleJson } = adapter.requireDrizzleKit() - const drizzleJsonAfter = await generateDrizzleJson(adapter.schema) + const previousSnapshot = await readLatestSnapshot({ adapter }) - writeFileSync(`${filePath}.json`, JSON.stringify(drizzleJsonAfter, null, 2)) + if (previousSnapshot) { + const renamedSnapshot = applySizesToVariantsRenames({ + plan: getSizesToVariantsRenames({ adapter, direction: 'up' }), + snapshot: previousSnapshot, + }) + + writeFileSync(`${filePath}.json`, JSON.stringify(renamedSnapshot, null, 2)) + } return { downSQL: ` await migrateSizesToVariants({ db, direction: 'down', payload })`, @@ -29,3 +61,80 @@ export const buildDynamicPredefinedSizesToVariantsMigration = ({ } } } + +/** The latest snapshot in the migrations directory, upgraded to drizzle-kit's current format. */ +async function readLatestSnapshot({ + adapter, +}: { + adapter: DrizzleAdapter +}): Promise { + const dir = adapter.migrationDir + const latestSnapshotFile = readdirSync(dir) + .filter((file) => file.endsWith('.json')) + .sort() + .reverse()[0] + + if (!latestSnapshotFile) { + return undefined + } + + const { generateDrizzleJson, upSnapshot } = adapter.requireDrizzleKit() + const currentVersion = (await generateDrizzleJson(adapter.schema)).version + const snapshot = JSON.parse(readFileSync(`${dir}/${latestSnapshotFile}`, 'utf8')) as Snapshot + + return upSnapshot && snapshot.version < currentVersion ? upSnapshot(snapshot) : snapshot +} + +function applySizesToVariantsRenames({ + plan, + snapshot, +}: { + plan: SizesToVariantsTableRenames[] + snapshot: Snapshot +}): Snapshot { + const renamed: Snapshot = structuredClone(snapshot) + + for (const { columns, indexes, tableName } of plan) { + const table = Object.values(renamed.tables).find(({ name }) => name === tableName) + + if (!table) { + continue + } + + for (const { from, to } of columns) { + const column = table.columns[from] + + if (column) { + delete table.columns[from] + table.columns[to] = { ...column, name: to } + } + } + + for (const [key, index] of Object.entries(table.indexes ?? {})) { + const indexColumns = index.columns.map((column) => { + const name = typeof column === 'string' ? column : column.expression + const renamedName = columns.find(({ from }) => from === name)?.to ?? name + + return typeof column === 'string' ? renamedName : { ...column, expression: renamedName } + }) + const indexColumnNames = indexColumns.map((column) => + typeof column === 'string' ? column : column.expression, + ) + const target = indexes.find( + (planned) => planned.columns.join(',') === indexColumnNames.join(','), + ) + + if (!target) { + continue + } + + delete table.indexes[key] + table.indexes[target.to] = { ...index, name: target.to, columns: indexColumns } + } + } + + renamed.prevId = snapshot.id + renamed.id = randomUUID() + + return renamed +} diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts index 62bac10f1ad..18c88f934fc 100644 --- a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -1,6 +1,6 @@ import toSnakeCase from 'to-snake-case' -import type { DrizzleAdapter, RawIndex } from '../types.js' +import type { DrizzleAdapter, RawIndex, RawTable } from '../types.js' import { maxGeneratedIdentifierLength } from './validateIdentifierLength.js' @@ -91,7 +91,9 @@ export function getSizesToVariantsRenames({ } const indexes = Object.values(rawTable.indexes ?? {}) - .map((index) => planIndexRename({ columns, direction, index, tableName })) + .map((index) => + planIndexRename({ columns, direction, index, rawColumns: rawTable.columns, tableName }), + ) .filter((index): index is SizesToVariantsIndexRename => Boolean(index)) plan.push({ columns, indexes, tableName }) @@ -105,14 +107,17 @@ function planIndexRename({ columns, direction, index, + rawColumns, tableName, }: { columns: SizesToVariantsColumnRename[] direction: SizesToVariantsDirection index: RawIndex + rawColumns: RawTable['columns'] tableName: string }): SizesToVariantsIndexRename | undefined { - const currentColumns = [index.on].flat() + // `on` holds the column's schema key (e.g. `variants_heroLarge_filename`), not its database name. + const currentColumns = [index.on].flat().map((key) => rawColumns[key]?.name ?? key) const renamedColumns = currentColumns.map((column) => columns.find((rename) => (direction === 'up' ? rename.to : rename.from) === column), ) diff --git a/test/sizes-to-variants/config.ts b/test/sizes-to-variants/config.ts index cd58f974a4e..f3abf2aa97f 100644 --- a/test/sizes-to-variants/config.ts +++ b/test/sizes-to-variants/config.ts @@ -4,7 +4,7 @@ import sharp from 'sharp' import { fileURLToPath } from 'url' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { mediaSlug, variantName } from './shared.js' +import { camelCaseVariantName, mediaSlug, variantName } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -30,7 +30,12 @@ export default buildConfigWithDefaults({ transformers: [ sharpTransformer({ collections: { - [mediaSlug]: { variants: [{ name: variantName, height: 40, width: 40 }] }, + [mediaSlug]: { + variants: [ + { name: variantName, height: 40, width: 40 }, + { name: camelCaseVariantName, width: 60 }, + ], + }, }, sharp, }), diff --git a/test/sizes-to-variants/int.spec.ts b/test/sizes-to-variants/int.spec.ts index 09c4665ee97..ec4273035d2 100644 --- a/test/sizes-to-variants/int.spec.ts +++ b/test/sizes-to-variants/int.spec.ts @@ -1,4 +1,4 @@ -import type { Payload } from 'payload' +import type { Payload, PayloadRequest } from 'payload' import { migrateSizesToVariants as migrateMongoSizesToVariants } from '@payloadcms/db-mongodb/migration-utils' import { migrateSizesToVariants as migratePostgresSizesToVariants } from '@payloadcms/db-postgres/migration-utils' @@ -6,12 +6,13 @@ import { migrateSizesToVariants as migrateSqliteSizesToVariants } from '@payload import { sql } from 'drizzle-orm' import fs from 'fs' import path from 'path' +import { commitTransaction, initTransaction } from 'payload' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import { expect } from 'vitest' import { test } from '../__helpers/int/vitest.js' -import { mediaSlug, variantName } from './shared.js' +import { camelCaseVariantName, mediaSlug, variantName } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -36,19 +37,29 @@ const createMedia = async ({ payload }: { payload: Payload }) => { return { id: doc.id, filename: doc.filename!, variantFilename } } -/** The index the current schema expects on a table's variant filename column. */ -const getExpectedIndexName = ({ +/** The index names the current schema expects on a table's variant filename columns. */ +const getExpectedIndexNames = ({ payload, tableName, }: { payload: Payload tableName: string -}): string | undefined => { +}): string[] => { const rawTable = (payload.db as any).rawTables[tableName] - return Object.values<{ name: string; on: string | string[] }>(rawTable.indexes ?? {}).find( - (index) => [index.on].flat().some((column) => column.endsWith(variantFilenameColumn)), - )?.name + return [variantName, camelCaseVariantName].map((name) => { + // Raw index `on` holds the column's schema key, which keeps the camelCase variant name. + const columnKey = `variants_${name}_filename` + const index = Object.values<{ name: string; on: string | string[] }>( + rawTable.indexes ?? {}, + ).find((rawIndex) => [rawIndex.on].flat().includes(columnKey)) + + if (!index) { + throw new Error(`Expected an index on ${columnKey}`) + } + + return index.name + }) } test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { @@ -102,7 +113,7 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => migratePostgresSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) - test('should move legacy sizes columns to variants without losing data', async ({ + test('should move legacy sizes columns to variants in Postgres without losing data', async ({ payload, }) => { const { id, variantFilename } = await createMedia({ payload }) @@ -134,19 +145,19 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { }) => { await createMedia({ payload }) - const expectedIndexName = getExpectedIndexName({ payload, tableName: mediaSlug }) - - expect(expectedIndexName).toBeTruthy() + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) await migrate({ direction: 'down', payload }) - expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toContain( - expectedIndexName, + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toEqual( + expect.arrayContaining(expectedIndexNames), ) await migrate({ direction: 'up', payload }) - expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(expectedIndexName) + expect(await getIndexNames({ payload, tableName: mediaSlug })).toEqual( + expect.arrayContaining(expectedIndexNames), + ) }) test('should be safe to re-run once the columns are renamed', async ({ payload }) => { @@ -187,7 +198,7 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => migrateSqliteSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) - test('should move legacy sizes columns to variants without losing data', async ({ + test('should move legacy sizes columns to variants in SQLite without losing data', async ({ payload, }) => { const { id, variantFilename } = await createMedia({ payload }) @@ -216,19 +227,19 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { }) => { await createMedia({ payload }) - const expectedIndexName = getExpectedIndexName({ payload, tableName: mediaSlug }) - - expect(expectedIndexName).toBeTruthy() + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) await migrate({ direction: 'down', payload }) - expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toContain( - expectedIndexName, + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toEqual( + expect.arrayContaining(expectedIndexNames), ) await migrate({ direction: 'up', payload }) - expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(expectedIndexName) + expect(await getIndexNames({ payload, tableName: mediaSlug })).toEqual( + expect.arrayContaining(expectedIndexNames), + ) }) }) @@ -273,6 +284,24 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) }) + test('should migrate inside the transaction payload migrate runs it in', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const req = { payload } as unknown as PayloadRequest + + await initTransaction(req) + await migrateMongoSizesToVariants({ payload, req }) + await commitTransaction(req) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + test('should re-key indexes on the legacy sizes field', async ({ payload }) => { await createMedia({ payload }) diff --git a/test/sizes-to-variants/shared.ts b/test/sizes-to-variants/shared.ts index f7a673d039f..5fe6008eb61 100644 --- a/test/sizes-to-variants/shared.ts +++ b/test/sizes-to-variants/shared.ts @@ -1,2 +1,4 @@ export const mediaSlug = 'media' export const variantName = 'thumbnail' +/** Covers index names built from a camelCase variant (`variants_heroLarge_filename` → `variants_hero_large_filename`). */ +export const camelCaseVariantName = 'heroLarge' From ef4ed6e70271f38ad525514d9e7403be373b8b12 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 5 Oct 2026 15:41:27 +0100 Subject: [PATCH 12/13] chore: document variants migration conflict --- docs/migration-guide/v4.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index ec712ca7ab6..709dc4f4e72 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1893,6 +1893,16 @@ GraphQL queries select `variants { thumbnail { url } }` instead of `sizes { thum Existing documents must be migrated. Create the predefined migration for your database adapter and run it: + + Payload 4 uses `variants` as the top-level field for generated upload + variants. If an upload collection already defines a custom field named + `variants`, rename that field before you run the predefined migration. Choose + a new name, update your config, and write a custom migration that moves the + existing custom data from `variants` to the new field. Then run the predefined + `sizes-to-variants` migration. The predefined migration cannot choose a new + name or merge this custom data for you. + + ```sh # MongoDB payload migrate:create --file @payloadcms/db-mongodb/sizes-to-variants From 5c0e865e8a723786f1b5fb53a990c4c470f46105 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 5 Oct 2026 17:32:17 +0100 Subject: [PATCH 13/13] chore: harden sizes to variants migration --- docs/migration-guide/v4.mdx | 6 +- .../migrateSizesToVariants.ts | 351 ++++++++++++- .../sizes-to-variants/index.ts | 43 +- .../sizes-to-variants/index.ts | 133 +++-- ...PredefinedSizesToVariantsMigration.spec.ts | 123 ++++- ...namicPredefinedSizesToVariantsMigration.ts | 35 +- .../utilities/getSizesToVariantsRenames.ts | 142 +++++- test/sizes-to-variants/int.spec.ts | 465 ++++++++++++++++++ 8 files changed, 1223 insertions(+), 75 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 709dc4f4e72..425aaca5e5c 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1899,8 +1899,10 @@ Existing documents must be migrated. Create the predefined migration for your da `variants`, rename that field before you run the predefined migration. Choose a new name, update your config, and write a custom migration that moves the existing custom data from `variants` to the new field. Then run the predefined - `sizes-to-variants` migration. The predefined migration cannot choose a new - name or merge this custom data for you. + `sizes-to-variants` migration. If legacy `sizes` data or columns and custom + `variants` data or columns are both present, the predefined migration stops + before it changes data or indexes. It cannot choose a new name or merge this + custom data for you. ```sh diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts index 4e95fd477b4..018b1bbda87 100644 --- a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -1,14 +1,30 @@ -import type { Collection, IndexDescription } from 'mongodb' +import type { + Collection, + CreateIndexesOptions, + Document, + IndexDescriptionInfo, + IndexDirection, +} from 'mongodb' import type { Payload, PayloadRequest } from 'payload' +import { isDeepStrictEqual } from 'node:util' + import type { MongooseAdapter } from '../index.js' import { getSession } from '../utilities/getSession.js' -/** Mongo error codes for an index that already exists with the same key but different options. */ +/** Mongo error codes for an index that conflicts with an existing index. */ const INDEX_CONFLICT_CODES = new Set([85, 86]) /** Mongo error code for a collection that hasn't been created yet. */ const NAMESPACE_NOT_FOUND_CODE = 26 +const GENERATED_VARIANT_METADATA_FIELDS = new Set([ + 'filename', + 'filesize', + 'height', + 'mimeType', + 'url', + 'width', +]) /** * Moves every upload collection's stored image sizes between the legacy `sizes` field and @@ -29,15 +45,24 @@ export async function migrateSizesToVariants({ const session = await getSession(adapter, req) const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] - const targets: { collection: Collection; fromPath: string; toPath: string }[] = [] + const targets: { + collection: Collection + expectedVariantNames: string[] + fromPath: string + toPath: string + }[] = [] for (const collection of payload.config.collections) { if (!collection.upload) { continue } + const upload = typeof collection.upload === 'object' ? collection.upload : undefined + const expectedVariantNames = (upload?.variants ?? []).map(({ name }) => name) + targets.push({ collection: adapter.collections[collection.slug]!.collection, + expectedVariantNames, fromPath: from, toPath: to, }) @@ -45,12 +70,33 @@ export async function migrateSizesToVariants({ if (collection.versions && adapter.versions[collection.slug]) { targets.push({ collection: adapter.versions[collection.slug]!.collection, + expectedVariantNames, fromPath: `version.${from}`, toPath: `version.${to}`, }) } } + for (const { collection, expectedVariantNames, fromPath, toPath } of targets) { + const [sourceData, directCollision] = await Promise.all([ + collection.findOne({ [fromPath]: { $exists: true } }, { projection: { _id: 1 } }), + collection.findOne( + { [fromPath]: { $exists: true }, [toPath]: { $exists: true } }, + { projection: { _id: 1 } }, + ), + ]) + + if ( + sourceData && + (directCollision || + (await findUnexpectedDestinationData({ collection, expectedVariantNames, toPath }))) + ) { + throw new Error( + `Cannot run the sizes-to-variants migration because collection "${collection.collectionName}" contains both "${fromPath}" and "${toPath}" data. Move or rename the existing destination data before running this migration.`, + ) + } + } + // Index changes can't be part of a multi-document transaction, and one made after the // migration's transaction has written to a collection aborts it. A transaction's snapshot starts // at its first operation, so re-keying every index before any transactional write is safe. @@ -71,6 +117,83 @@ export async function migrateSizesToVariants({ } } +async function findUnexpectedDestinationData({ + collection, + expectedVariantNames, + toPath, +}: { + collection: Collection + expectedVariantNames: string[] + toPath: string +}): Promise { + const expectedVariantNameSet = new Set(expectedVariantNames) + const cursor = collection.find( + { [toPath]: { $exists: true } }, + { projection: { _id: 1, [toPath]: 1 } }, + ) + + try { + for await (const document of cursor) { + const destinationData = getDocumentPath({ document, path: toPath }) + + if (hasUnexpectedVariantData({ destinationData, expectedVariantNameSet })) { + return document + } + } + } finally { + await cursor.close() + } + + return null +} + +function getDocumentPath({ document, path }: { document: Document; path: string }): unknown { + return path.split('.').reduce((value, pathSegment) => { + if (!isRecord(value)) { + return undefined + } + + return value[pathSegment] + }, document) +} + +function hasUnexpectedVariantData({ + destinationData, + expectedVariantNameSet, +}: { + destinationData: unknown + expectedVariantNameSet: Set +}): boolean { + if (!isRecord(destinationData)) { + return destinationData !== null && destinationData !== undefined + } + + for (const [variantName, variantData] of Object.entries(destinationData)) { + if (!expectedVariantNameSet.has(variantName)) { + return true + } + + if (variantData === null || variantData === undefined) { + continue + } + + if ( + !isRecord(variantData) || + Object.keys(variantData).some( + (metadataFieldName) => !GENERATED_VARIANT_METADATA_FIELDS.has(metadataFieldName), + ) + ) { + return true + } + } + + return false +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + async function rekeyIndexes({ collection, fromPath, @@ -80,7 +203,7 @@ async function rekeyIndexes({ fromPath: string toPath: string }): Promise { - let indexes: IndexDescription[] + let indexes: IndexDescriptionInfo[] try { indexes = await collection.indexes() @@ -93,34 +216,222 @@ async function rekeyIndexes({ } for (const index of indexes) { - const keys = Object.keys(index.key) + const sourceKeys = getIndexKeysForCreate({ fromPath, index, toPath: fromPath }) - if (!keys.some((key) => key.startsWith(`${fromPath}.`))) { + if (!Object.keys(sourceKeys).some((key) => isPathAtOrBelow({ key, path: fromPath }))) { continue } - const rekeyed = Object.fromEntries( - Object.entries(index.key).map(([key, value]) => [ - key.startsWith(`${fromPath}.`) ? `${toPath}${key.slice(fromPath.length)}` : key, - value, - ]), - ) + const rekeyedKeys = getIndexKeysForCreate({ fromPath, index, toPath }) + const destinationOptions = getDestinationIndexOptions({ fromPath, index, toPath }) - try { - await collection.createIndex(rekeyed, { - ...(index.partialFilterExpression - ? { partialFilterExpression: index.partialFilterExpression } - : {}), - ...(index.sparse ? { sparse: true } : {}), - ...(index.unique ? { unique: true } : {}), + if (index.weights && '_fts' in index.key && '_ftsx' in index.key) { + await rekeyTextIndex({ + collection, + destinationOptions, + index, + rekeyedKeys, + sourceKeys, }) + continue + } + + try { + await collection.createIndex(rekeyedKeys, destinationOptions) } catch (err) { - // The current schema already created this index (e.g. at connect) with its own options. if (!INDEX_CONFLICT_CODES.has((err as { code?: number }).code ?? 0)) { throw err } + + const destinationIndex = (await collection.indexes()).find((candidate) => + isDeepStrictEqual( + getIndexKeysForCreate({ fromPath: toPath, index: candidate, toPath }), + rekeyedKeys, + ), + ) + const existingDestinationOptions = destinationIndex + ? getDestinationIndexOptions({ + fromPath: toPath, + index: destinationIndex, + toPath, + }) + : undefined + + if (!destinationIndex || !isDeepStrictEqual(existingDestinationOptions, destinationOptions)) { + throw err + } } await collection.dropIndex(index.name!) } } + +async function rekeyTextIndex({ + collection, + destinationOptions, + index, + rekeyedKeys, + sourceKeys, +}: { + collection: Collection + destinationOptions: CreateIndexesOptions + index: IndexDescriptionInfo + rekeyedKeys: Record + sourceKeys: Record +}): Promise { + if (!index.name) { + throw new Error('Cannot re-key an unnamed MongoDB text index.') + } + + const sourceOptions: CreateIndexesOptions = { + ...getDestinationIndexOptions({ fromPath: '', index, toPath: '' }), + name: index.name, + } + + await collection.dropIndex(index.name) + + try { + await collection.createIndex(rekeyedKeys, destinationOptions) + } catch (err) { + try { + await collection.createIndex(sourceKeys, sourceOptions) + } catch (restoreErr) { + throw new AggregateError( + [err, restoreErr], + `Failed to create the replacement for MongoDB text index "${index.name}" and failed to restore the source index.`, + ) + } + + throw err + } +} + +function getIndexKeysForCreate({ + fromPath, + index, + toPath, +}: { + fromPath: string + index: IndexDescriptionInfo + toPath: string +}): Record { + const keys: [string, IndexDirection][] = [] + const hasInternalTextKeys = '_fts' in index.key && '_ftsx' in index.key + + for (const [key, value] of Object.entries(index.key)) { + if (hasInternalTextKeys && key === '_fts') { + for (const textPath of Object.keys(index.weights ?? {})) { + keys.push([rekeyPath({ fromPath, path: textPath, toPath }), 'text']) + } + continue + } + + if (hasInternalTextKeys && key === '_ftsx') { + continue + } + + keys.push([rekeyPath({ fromPath, path: key, toPath }), value]) + } + + return Object.fromEntries(keys) +} + +function getDestinationIndexOptions({ + fromPath, + index, + toPath, +}: { + fromPath: string + index: IndexDescriptionInfo + toPath: string +}): CreateIndexesOptions { + const options: CreateIndexesOptions = { + '2dsphereIndexVersion': index['2dsphereIndexVersion'], + background: index.background, + bits: index.bits, + bucketSize: index.bucketSize, + collation: index.collation, + default_language: index.default_language, + expireAfterSeconds: index.expireAfterSeconds, + hidden: index.hidden, + language_override: index.language_override, + max: index.max, + min: index.min, + partialFilterExpression: index.partialFilterExpression + ? rekeyDocumentPaths({ document: index.partialFilterExpression, fromPath, toPath }) + : undefined, + sparse: index.sparse, + storageEngine: index.storageEngine, + textIndexVersion: index.textIndexVersion, + unique: index.unique, + weights: index.weights + ? rekeyDocumentPaths({ document: index.weights, fromPath, toPath }) + : undefined, + wildcardProjection: index.wildcardProjection + ? rekeyDocumentPaths({ document: index.wildcardProjection, fromPath, toPath }) + : undefined, + } + + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ) as CreateIndexesOptions +} + +function rekeyDocumentPaths({ + document, + fromPath, + toPath, +}: { + document: Document + fromPath: string + toPath: string +}): Document { + return Object.fromEntries( + Object.entries(document).map(([key, value]) => [ + rekeyPath({ fromPath, path: key, toPath }), + rekeyDocumentValue({ fromPath, toPath, value }), + ]), + ) +} + +function isPathAtOrBelow({ key, path }: { key: string; path: string }): boolean { + return key === path || key.startsWith(`${path}.`) +} + +function rekeyPath({ + fromPath, + path, + toPath, +}: { + fromPath: string + path: string + toPath: string +}): string { + return isPathAtOrBelow({ key: path, path: fromPath }) + ? `${toPath}${path.slice(fromPath.length)}` + : path +} + +function rekeyDocumentValue({ + fromPath, + toPath, + value, +}: { + fromPath: string + toPath: string + value: unknown +}): unknown { + if (Array.isArray(value)) { + return value.map((item) => rekeyDocumentValue({ fromPath, toPath, value: item })) + } + + if ( + value !== null && + typeof value === 'object' && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ) { + return rekeyDocumentPaths({ document: value as Document, fromPath, toPath }) + } + + return value +} diff --git a/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts index c084ef4bea0..a01c2f892b1 100644 --- a/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts +++ b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts @@ -5,7 +5,10 @@ import { sql } from 'drizzle-orm' import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' import type { BasePostgresAdapter } from '../../types.js' -import { getSizesToVariantsRenames } from '../../../utilities/getSizesToVariantsRenames.js' +import { + assertNoSizesToVariantsColumnCollisions, + getSizesToVariantsRenames, +} from '../../../utilities/getSizesToVariantsRenames.js' /** * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and @@ -25,9 +28,29 @@ export async function migratePostgresSizesToVariants({ const adapter = payload.db as unknown as BasePostgresAdapter const schemaName = adapter.schemaName || 'public' const plan = getSizesToVariantsRenames({ adapter, direction }) + const plannedTables: Array< + (typeof plan)[number] & { + existingColumns: Set + } + > = [] + + for (const tablePlan of plan) { + const existingColumns = await getColumnNames({ + db, + schemaName, + tableName: tablePlan.tableName, + }) + + assertNoSizesToVariantsColumnCollisions({ + columns: tablePlan.columns, + existingColumns, + tableName: tablePlan.tableName, + }) + + plannedTables.push({ ...tablePlan, existingColumns }) + } - for (const { columns, indexes, tableName } of plan) { - const existingColumns = await getColumnNames({ db, schemaName, tableName }) + for (const { columns, existingColumns, indexes, tableName } of plannedTables) { let renamedCount = 0 for (const { from, to } of columns) { @@ -35,9 +58,10 @@ export async function migratePostgresSizesToVariants({ continue } - await db.execute( - sql.raw(`ALTER TABLE "${schemaName}"."${tableName}" RENAME COLUMN "${from}" TO "${to}"`), - ) + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(tableName)} + RENAME COLUMN ${sql.identifier(from)} TO ${sql.identifier(to)} + `) renamedCount++ } @@ -57,9 +81,10 @@ export async function migratePostgresSizesToVariants({ continue } - await db.execute( - sql.raw(`ALTER INDEX "${schemaName}"."${existingName}" RENAME TO "${index.to}"`), - ) + await db.execute(sql` + ALTER INDEX ${sql.identifier(schemaName)}.${sql.identifier(existingName)} + RENAME TO ${sql.identifier(index.to)} + `) } payload.logger.info({ diff --git a/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts index 80d80cffe47..9f6e3b65293 100644 --- a/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts +++ b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts @@ -5,7 +5,11 @@ import { sql } from 'drizzle-orm' import type { DrizzleAdapter } from '../../../types.js' import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' -import { getSizesToVariantsRenames } from '../../../utilities/getSizesToVariantsRenames.js' +import { + assertNoSizesToVariantsColumnCollisions, + getSizesToVariantsRenames, + isGeneratedLegacyIndexName, +} from '../../../utilities/getSizesToVariantsRenames.js' /** * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and @@ -25,9 +29,26 @@ export async function migrateSqliteSizesToVariants({ }): Promise { const adapter = payload.db as unknown as DrizzleAdapter const plan = getSizesToVariantsRenames({ adapter, direction }) + const plannedTables: Array< + (typeof plan)[number] & { + existingColumns: Set + } + > = [] + + for (const tablePlan of plan) { + const existingColumns = await getColumnNames({ db, tableName: tablePlan.tableName }) + + assertNoSizesToVariantsColumnCollisions({ + columns: tablePlan.columns, + existingColumns, + tableName: tablePlan.tableName, + }) + + plannedTables.push({ ...tablePlan, existingColumns }) + } - for (const { columns, indexes, tableName } of plan) { - const existingColumns = await getColumnNames({ db, tableName }) + for (const { columns, existingColumns, indexes, tableName } of plannedTables) { + const columnsAfterRename = new Set(existingColumns) let renamedCount = 0 for (const { from, to } of columns) { @@ -35,34 +56,64 @@ export async function migrateSqliteSizesToVariants({ continue } - await db.run(sql.raw(`ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\``)) + columnsAfterRename.delete(from) + columnsAfterRename.add(to) + + await db.run(sql` + ALTER TABLE ${sql.identifier(tableName)} + RENAME COLUMN ${sql.identifier(from)} TO ${sql.identifier(to)} + `) renamedCount++ } const existingIndexes = await getIndexes({ db, tableName }) for (const index of indexes) { - const existingName = - index.from ?? - existingIndexes.find(({ columns }) => columns.join(',') === index.columns.join(','))?.name - - if ( - !existingName || - existingName === index.to || - !existingIndexes.some(({ name }) => name === existingName) || - existingIndexes.some(({ name }) => name === index.to) - ) { + if (!index.columns.every((column) => columnsAfterRename.has(column))) { continue } - const columnList = index.columns.map((column) => `\`${column}\``).join(', ') + const destinationIndex = existingIndexes.find(({ name }) => name === index.to) + const sourceIndex = destinationIndex + ? undefined + : index.from + ? existingIndexes.find(({ name }) => name === index.from) + : index.legacyNameBase + ? existingIndexes.find( + ({ name, columns }) => + columns.join(',') === index.columns.join(',') && + isGeneratedLegacyIndexName({ + indexName: name, + legacyNameBase: index.legacyNameBase, + }), + ) + : undefined + + if (destinationIndex) { + if ( + destinationIndex.columns.join(',') !== index.columns.join(',') || + destinationIndex.unique !== index.unique + ) { + throw new Error( + `Cannot run the sizes-to-variants migration because index "${index.to}" on table "${tableName}" does not match the expected columns or uniqueness.`, + ) + } + } else { + const unique = index.unique ? sql.raw('UNIQUE ') : sql.empty() + const columnList = sql.join( + index.columns.map((column) => sql.identifier(column)), + sql.raw(', '), + ) + + await db.run(sql` + CREATE ${unique}INDEX ${sql.identifier(index.to)} + ON ${sql.identifier(tableName)} (${columnList}) + `) + } - await db.run(sql.raw(`DROP INDEX \`${existingName}\``)) - await db.run( - sql.raw( - `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX \`${index.to}\` ON \`${tableName}\` (${columnList})`, - ), - ) + if (sourceIndex && sourceIndex.name !== index.to) { + await db.run(sql`DROP INDEX ${sql.identifier(sourceIndex.name)}`) + } } payload.logger.info({ @@ -79,7 +130,7 @@ async function getColumnNames({ tableName: string }): Promise> { const rows: { name: string }[] = await db.all( - sql.raw(`SELECT name FROM pragma_table_info('${tableName}')`), + sql`SELECT name FROM pragma_table_info(${tableName})`, ) return new Set(rows.map(({ name }) => name)) @@ -91,23 +142,29 @@ async function getIndexes({ }: { db: any tableName: string -}): Promise<{ columns: string[]; name: string }[]> { +}): Promise<{ columns: string[]; name: string; unique: boolean }[]> { // `origin = 'c'` keeps only `CREATE INDEX` indexes, skipping SQLite's internal autoindexes. - const rows: { column_name: string; index_name: string; seqno: number }[] = await db.all( - sql.raw( - `SELECT il.name AS index_name, ii.name AS column_name, ii.seqno AS seqno - FROM pragma_index_list('${tableName}') AS il - JOIN pragma_index_info(il.name) AS ii - WHERE il.origin = 'c' - ORDER BY il.name, ii.seqno`, - ), - ) - - const indexes = new Map() - - for (const { column_name, index_name } of rows) { - indexes.set(index_name, [...(indexes.get(index_name) ?? []), column_name]) + const rows: { column_name: string; index_name: string; is_unique: number; seqno: number }[] = + await db.all(sql` + SELECT + il.name AS index_name, + il."unique" AS is_unique, + ii.name AS column_name, + ii.seqno AS seqno + FROM pragma_index_list(${tableName}) AS il + JOIN pragma_index_info(il.name) AS ii + WHERE il.origin = 'c' + ORDER BY il.name, ii.seqno + `) + + const indexes = new Map() + + for (const { column_name, index_name, is_unique } of rows) { + const index = indexes.get(index_name) ?? { columns: [], unique: Boolean(is_unique) } + + index.columns.push(column_name) + indexes.set(index_name, index) } - return [...indexes].map(([name, columns]) => ({ name, columns })) + return [...indexes].map(([name, index]) => ({ name, ...index })) } diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts index 02960ae596b..768d8b0eac1 100644 --- a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts @@ -4,6 +4,7 @@ import path from 'path' import { afterEach, describe, expect, it } from 'vitest' import { buildDynamicPredefinedSizesToVariantsMigration } from './buildDynamicPredefinedSizesToVariantsMigration.js' +import { getSizesToVariantsRenames } from './getSizesToVariantsRenames.js' const tempDirs: string[] = [] @@ -43,7 +44,16 @@ const makePayload = ({ migrationDir }: { migrationDir: string }) => ({ db: { migrationDir, - payload: { config: { collections: [{ slug: 'media', upload: {} }] } }, + payload: { + config: { + collections: [ + { + slug: 'media', + upload: { variants: [{ name: 'heroLarge' }] }, + }, + ], + }, + }, rawTables: { media: { name: 'media', @@ -116,4 +126,115 @@ describe('buildDynamicPredefinedSizesToVariantsMigration', () => { expect(fs.existsSync(`${filePath}.json`)).toBe(false) }) + + it('should only plan columns for configured generated variants', () => { + const migrationDir = makeMigrationDir() + const payload = makePayload({ migrationDir }) + const collection = payload.db.payload.config.collections[0] + + collection.fields = [ + { + fields: [{ name: 'customValue', type: 'text' }], + name: 'variants', + type: 'group', + }, + ] + payload.db.rawTables.media.columns.variants_heroLarge_credit = { + name: 'variants_hero_large_credit', + } + + expect(getSizesToVariantsRenames({ adapter: payload.db, direction: 'up' })[0]?.columns).toEqual( + [ + { + from: 'sizes_hero_large_filename', + to: 'variants_hero_large_filename', + }, + ], + ) + }) + + it('should reject a snapshot that contains both source and destination columns', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCollision = structuredClone(legacySnapshot) + + snapshotWithCollision.tables['public.media'].columns.variants_hero_large_filename = { + name: 'variants_hero_large_filename', + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCollision), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow( + 'contains both "sizes_hero_large_filename" and "variants_hero_large_filename"', + ) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) + + it('should reject a snapshot that contains legacy sizes and a disjoint custom variants field', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCustomVariants = structuredClone(legacySnapshot) + + snapshotWithCustomVariants.tables['public.media'].columns.variants_custom_value = { + name: 'variants_custom_value', + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCustomVariants), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow('contains both the "sizes" and "variants" fields') + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) + + it('should reject a snapshot that contains both source and destination indexes', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCollision = structuredClone(legacySnapshot) + + snapshotWithCollision.tables[ + 'public.media' + ].indexes.media_variants_hero_large_variants_hero_large_filename_idx = { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + columns: [{ expression: 'variants_hero_large_filename', isExpression: false }], + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCollision), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow( + 'contains both indexes "media_sizes_hero_large_sizes_hero_large_filename_idx" and "media_variants_hero_large_variants_hero_large_filename_idx"', + ) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) }) diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts index e3bb399f99a..e57b22d119a 100644 --- a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -6,7 +6,10 @@ import { readdirSync, readFileSync, writeFileSync } from 'fs' import type { DrizzleAdapter } from '../types.js' import type { SizesToVariantsTableRenames } from './getSizesToVariantsRenames.js' -import { getSizesToVariantsRenames } from './getSizesToVariantsRenames.js' +import { + findSizesToVariantsFieldCollision, + getSizesToVariantsRenames, +} from './getSizesToVariantsRenames.js' type SnapshotIndex = { columns: ({ expression: string } | string)[] @@ -101,10 +104,34 @@ function applySizesToVariantsRenames({ continue } + const existingColumns = new Set(Object.values(table.columns).map(({ name }) => name)) + + for (const { from, to } of columns) { + if (existingColumns.has(from) && existingColumns.has(to)) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before creating this migration.`, + ) + } + } + + const fieldCollision = findSizesToVariantsFieldCollision({ columns, existingColumns }) + + if (fieldCollision) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both the "${fieldCollision.from}" and "${fieldCollision.to}" fields. Move or rename the existing destination data before creating this migration.`, + ) + } + for (const { from, to } of columns) { const column = table.columns[from] if (column) { + if (table.columns[to]) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before creating this migration.`, + ) + } + delete table.columns[from] table.columns[to] = { ...column, name: to } } @@ -128,6 +155,12 @@ function applySizesToVariantsRenames({ continue } + if (key !== target.to && table.indexes?.[target.to]) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both indexes "${key}" and "${target.to}". Move or rename the existing destination index before creating this migration.`, + ) + } + delete table.indexes[key] table.indexes[target.to] = { ...index, name: target.to, columns: indexColumns } } diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts index 18c88f934fc..784ebc0a0e0 100644 --- a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -16,6 +16,8 @@ export type SizesToVariantsIndexRename = { columns: string[] /** The index name to rename from on `up` (unknown up front — looked up by column at runtime). */ from?: string + /** Base used to verify a generated legacy index name, including a possible numeric suffix. */ + legacyNameBase?: string to: string unique: boolean } @@ -26,10 +28,77 @@ export type SizesToVariantsTableRenames = { tableName: string } +export function findSizesToVariantsFieldCollision({ + columns, + existingColumns, +}: { + columns: SizesToVariantsColumnRename[] + existingColumns: Set +}): { from: string; to: string } | undefined { + const firstRename = columns[0] + + if (!firstRename) { + return undefined + } + + const fromField = getSizesToVariantsField({ columnName: firstRename.from }) + const toField = getSizesToVariantsField({ columnName: firstRename.to }) + + if (!fromField || !toField) { + return undefined + } + + const hasSourceField = [...existingColumns].some((column) => + column.startsWith(fromField.columnPrefix), + ) + const plannedDestinationColumns = new Set(columns.map(({ to }) => to)) + const hasUnexpectedDestinationField = [...existingColumns].some( + (column) => column.startsWith(toField.columnPrefix) && !plannedDestinationColumns.has(column), + ) + + return hasSourceField && hasUnexpectedDestinationField + ? { from: fromField.fieldPath, to: toField.fieldPath } + : undefined +} + +export function assertNoSizesToVariantsColumnCollisions({ + columns, + existingColumns, + tableName, +}: { + columns: SizesToVariantsColumnRename[] + existingColumns: Set + tableName: string +}): void { + for (const { from, to } of columns) { + if (existingColumns.has(from) && existingColumns.has(to)) { + throw new Error( + `Cannot run the sizes-to-variants migration because table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before running this migration.`, + ) + } + } + + const fieldCollision = findSizesToVariantsFieldCollision({ columns, existingColumns }) + + if (fieldCollision) { + throw new Error( + `Cannot run the sizes-to-variants migration because table "${tableName}" contains both the "${fieldCollision.from}" and "${fieldCollision.to}" fields. Move or rename the existing destination data before running this migration.`, + ) + } +} + const columnPrefixes = [ { current: 'variants_', legacy: 'sizes_' }, { current: 'version_variants_', legacy: 'version_sizes_' }, ] +const generatedVariantMetadataFieldNames = [ + 'filename', + 'filesize', + 'height', + 'mimeType', + 'url', + 'width', +] /** * Plans the column and index renames that move every upload collection's stored image sizes @@ -55,6 +124,20 @@ export function getSizesToVariantsRenames({ continue } + const upload = typeof collection.upload === 'object' ? collection.upload : undefined + const generatedVariantColumnKeys = new Set( + (upload?.variants ?? []).flatMap(({ name }) => + generatedVariantMetadataFieldNames.flatMap((metadataFieldName) => [ + `variants_${name}_${metadataFieldName}`, + `version_variants_${name}_${metadataFieldName}`, + ]), + ), + ) + + if (generatedVariantColumnKeys.size === 0) { + continue + } + const tableNames = [adapter.tableNameMap.get(toSnakeCase(collection.slug))] if (collection.versions) { @@ -72,7 +155,11 @@ export function getSizesToVariantsRenames({ const columns: SizesToVariantsColumnRename[] = [] - for (const { name } of Object.values(rawTable.columns)) { + for (const [columnKey, { name }] of Object.entries(rawTable.columns)) { + if (!generatedVariantColumnKeys.has(columnKey)) { + continue + } + const prefix = columnPrefixes.find(({ current }) => name.startsWith(current)) if (!prefix) { @@ -103,6 +190,30 @@ export function getSizesToVariantsRenames({ return plan } +function getSizesToVariantsField({ + columnName, +}: { + columnName: string +}): { columnPrefix: string; fieldPath: string } | undefined { + if (columnName.startsWith('version_sizes_')) { + return { columnPrefix: 'version_sizes_', fieldPath: 'version.sizes' } + } + + if (columnName.startsWith('version_variants_')) { + return { columnPrefix: 'version_variants_', fieldPath: 'version.variants' } + } + + if (columnName.startsWith('sizes_')) { + return { columnPrefix: 'sizes_', fieldPath: 'sizes' } + } + + if (columnName.startsWith('variants_')) { + return { columnPrefix: 'variants_', fieldPath: 'variants' } + } + + return undefined +} + function planIndexRename({ columns, direction, @@ -129,7 +240,14 @@ function planIndexRename({ const unique = Boolean(index.unique) if (direction === 'up') { - return { columns: currentColumns, to: index.name, unique } + const legacyColumns = currentColumns.map((column, i) => renamedColumns[i]?.from ?? column) + + return { + columns: currentColumns, + legacyNameBase: `${tableName}_${legacyColumns.join('_')}`, + to: index.name, + unique, + } } const legacyColumns = currentColumns.map((column, i) => renamedColumns[i]?.to ?? column) @@ -143,8 +261,24 @@ function planIndexRename({ } /** Mirrors `buildIndexName` for an index that didn't collide, without registering the name. */ -function buildLegacyIndexName(name: string): string { - const suffix = '_idx' +export function isGeneratedLegacyIndexName({ + indexName, + legacyNameBase, +}: { + indexName: string + legacyNameBase: string +}): boolean { + const suffixMatch = indexName.match(/(?:_(\d+))?_idx$/) + + if (!suffixMatch) { + return false + } + + return indexName === buildLegacyIndexName(legacyNameBase, Number(suffixMatch[1] ?? 0)) +} + +function buildLegacyIndexName(name: string, number = 0): string { + const suffix = `${number ? `_${number}` : ''}_idx` const indexName = `${name}${suffix}` return indexName.length > maxGeneratedIdentifierLength diff --git a/test/sizes-to-variants/int.spec.ts b/test/sizes-to-variants/int.spec.ts index ec4273035d2..c56b55b7c22 100644 --- a/test/sizes-to-variants/int.spec.ts +++ b/test/sizes-to-variants/int.spec.ts @@ -19,6 +19,47 @@ const dirname = path.dirname(filename) const variantFilenameColumn = `variants_${variantName}_filename` const legacyFilenameColumn = `sizes_${variantName}_filename` +const quotedIdentifierCollectionSlug = 'quoted-identifiers' +const quotedIdentifierColumn = `variants_v_"'\`` +const quotedIdentifierIndex = `i_"'\`` +const quotedIdentifierTable = `m_"'\`` + +const getQuotedIdentifierMigrationPayload = ({ payload }: { payload: Payload }): Payload => { + const adapter = payload.db as any + + return { + db: { + payload: { + config: { + collections: [ + { + slug: quotedIdentifierCollectionSlug, + upload: { variants: [{ name: 'v' }] }, + }, + ], + }, + }, + rawTables: { + [quotedIdentifierTable]: { + name: quotedIdentifierTable, + columns: { + variants_v_value: { name: quotedIdentifierColumn }, + }, + indexes: { + variant: { + name: quotedIdentifierIndex, + on: 'variants_v_value', + }, + }, + }, + }, + schemaName: adapter.schemaName, + tableNameMap: new Map([['quoted_identifiers', quotedIdentifierTable]]), + versionsSuffix: adapter.versionsSuffix, + }, + logger: payload.logger, + } as Payload +} const createMedia = async ({ payload }: { payload: Payload }) => { const doc = await payload.create({ @@ -169,6 +210,100 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) }) + + test('should reject a PostgreSQL source and destination column collision before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(variantFilenameColumn)} varchar + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + `contains both "${legacyFilenameColumn}" and "${variantFilenameColumn}"`, + ) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(legacyFilenameColumn) + expect(columns).toContain(variantFilenameColumn) + } finally { + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(variantFilenameColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should reject disjoint custom variants columns in PostgreSQL before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const customVariantsColumn = 'variants_custom_value' + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(customVariantsColumn)} varchar + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + 'contains both the "sizes" and "variants" fields', + ) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain( + legacyFilenameColumn, + ) + } finally { + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(customVariantsColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should migrate quoted PostgreSQL identifiers', async ({ payload }) => { + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await db.execute(sql` + CREATE TABLE ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} ( + ${sql.identifier(quotedIdentifierColumn)} varchar + ) + `) + await db.execute(sql` + CREATE INDEX ${sql.identifier(quotedIdentifierIndex)} + ON ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} + (${sql.identifier(quotedIdentifierColumn)}) + `) + + try { + await migratePostgresSizesToVariants({ db, direction: 'down', payload: migrationPayload }) + await migratePostgresSizesToVariants({ db, payload: migrationPayload }) + + expect(await getColumns({ payload, tableName: quotedIdentifierTable })).toContain( + quotedIdentifierColumn, + ) + } finally { + await db.execute(sql` + DROP TABLE IF EXISTS ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} + `) + } + }) }, ) @@ -241,6 +376,188 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { expect.arrayContaining(expectedIndexNames), ) }) + + test('should reject a SQLite source and destination column collision before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(variantFilenameColumn)} text + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + `contains both "${legacyFilenameColumn}" and "${variantFilenameColumn}"`, + ) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(legacyFilenameColumn) + expect(columns).toContain(variantFilenameColumn) + } finally { + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(variantFilenameColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should restore a missing destination index when retrying after a partial failure', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) + + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + const legacyIndexRows: { index_name: string }[] = await db.all(sql` + SELECT il.name AS index_name + FROM pragma_index_list(${mediaSlug}) AS il + JOIN pragma_index_info(il.name) AS ii + WHERE il.origin = 'c' AND ii.name = ${legacyFilenameColumn} + `) + const legacyIndexName = legacyIndexRows[0]?.index_name + + expect(legacyIndexName).toBeDefined() + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + RENAME COLUMN ${sql.identifier(legacyFilenameColumn)} + TO ${sql.identifier(variantFilenameColumn)} + `) + await db.run(sql`DROP INDEX ${sql.identifier(legacyIndexName)}`) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain( + expectedIndexNames[0], + ) + }) + + test('should preserve a custom index on a destination column when re-running', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + await migrate({ direction: 'up', payload }) + + const customIndexName = 'custom_variant_filename_idx' + const db = (payload.db as any).drizzle + + await db.run(sql` + CREATE INDEX ${sql.identifier(customIndexName)} + ON ${sql.identifier(mediaSlug)} (${sql.identifier(variantFilenameColumn)}) + `) + + try { + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(customIndexName) + } finally { + await db.run(sql`DROP INDEX IF EXISTS ${sql.identifier(customIndexName)}`) + } + }) + + test('should preserve a custom index when repairing a missing destination index', async ({ + payload, + }) => { + await createMedia({ payload }) + + const [destinationIndexName] = getExpectedIndexNames({ payload, tableName: mediaSlug }) + const customIndexName = 'custom_variant_filename_repair_idx' + const db = (payload.db as any).drizzle + + await db.run(sql`DROP INDEX ${sql.identifier(destinationIndexName)}`) + await db.run(sql` + CREATE INDEX ${sql.identifier(customIndexName)} + ON ${sql.identifier(mediaSlug)} (${sql.identifier(variantFilenameColumn)}) + `) + + try { + await migrate({ direction: 'up', payload }) + + const indexNames = await getIndexNames({ payload, tableName: mediaSlug }) + + expect(indexNames).toContain(destinationIndexName) + expect(indexNames).toContain(customIndexName) + } finally { + await db.run(sql`DROP INDEX IF EXISTS ${sql.identifier(customIndexName)}`) + } + }) + + test('should reject disjoint custom variants columns in SQLite before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const customVariantsColumn = 'variants_custom_value' + const db = (payload.db as any).drizzle + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(customVariantsColumn)} text + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + 'contains both the "sizes" and "variants" fields', + ) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + } finally { + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(customVariantsColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should migrate quoted SQLite identifiers', async ({ payload }) => { + const db = (payload.db as any).drizzle + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await db.run(sql` + CREATE TABLE ${sql.identifier(quotedIdentifierTable)} ( + ${sql.identifier(quotedIdentifierColumn)} text + ) + `) + await db.run(sql` + CREATE INDEX ${sql.identifier(quotedIdentifierIndex)} + ON ${sql.identifier(quotedIdentifierTable)} (${sql.identifier(quotedIdentifierColumn)}) + `) + + try { + await migrateSqliteSizesToVariants({ db, direction: 'down', payload: migrationPayload }) + await migrateSqliteSizesToVariants({ db, payload: migrationPayload }) + + const rows: { name: string }[] = await db.all( + sql`SELECT name FROM pragma_table_info(${quotedIdentifierTable})`, + ) + + expect(rows.map(({ name }) => name)).toContain(quotedIdentifierColumn) + } finally { + await db.run(sql`DROP TABLE IF EXISTS ${sql.identifier(quotedIdentifierTable)}`) + } + }) + + test('should skip indexes when the configured table does not exist', async ({ payload }) => { + const db = (payload.db as any).drizzle + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await expect( + migrateSqliteSizesToVariants({ db, payload: migrationPayload }), + ).resolves.toBeUndefined() + }) }) test.options.describe('MongoDB', { db: 'mongo' }, () => { @@ -260,6 +577,19 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { return indexes.flatMap(({ key }) => Object.keys(key)) } + const getIndexes = ({ payload }: { payload: Payload }) => + (payload.db as any).collections[mediaSlug].collection.indexes() as Promise< + { + collation?: { locale?: string; strength?: number } + hidden?: boolean + key: Record + name: string + partialFilterExpression?: Record + unique?: boolean + weights?: Record + }[] + > + test('should move a legacy sizes field to variants without losing data', async ({ payload, }) => { @@ -316,5 +646,140 @@ test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { expect(keys).toContain(`variants.${variantName}.filename`) expect(keys).not.toContain(`sizes.${variantName}.filename`) }) + + test('should reject documents with both source and destination fields before changing indexes', async ({ + payload, + }) => { + await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const legacyIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + await collection.insertOne({ + filename: 'custom-variants-only.jpg', + variants: { [variantName]: { credit: 'Custom credit' } }, + }) + + try { + await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow( + 'contains both "sizes" and "variants" data', + ) + + expect((await getIndexes({ payload })).map(({ name }) => name)).toContain(legacyIndex?.name) + } finally { + await collection.deleteOne({ filename: 'custom-variants-only.jpg' }) + await migrateMongoSizesToVariants({ payload }) + } + }) + + test('should re-key text indexes on the legacy sizes field', async ({ payload }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const sourceIndexName = 'legacy_variant_filename_text' + + await collection.createIndex( + { [legacyPath]: 'text' }, + { name: sourceIndexName, weights: { [legacyPath]: 7 } }, + ) + + try { + await migrateMongoSizesToVariants({ payload }) + + const indexes = await getIndexes({ payload }) + const destinationIndex = indexes.find(({ weights }) => destinationPath in (weights ?? {})) + + expect(destinationIndex?.weights).toEqual({ [destinationPath]: 7 }) + expect(indexes.map(({ name }) => name)).not.toContain(sourceIndexName) + } finally { + const indexes = await getIndexes({ payload }) + + for (const index of indexes) { + if (legacyPath in (index.weights ?? {}) || destinationPath in (index.weights ?? {})) { + await collection.dropIndex(index.name) + } + } + } + }) + + test('should rewrite partial filters and preserve index options', async ({ payload }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const legacyIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + expect(legacyIndex).toBeDefined() + + await collection.dropIndex(legacyIndex!.name) + await collection.createIndex( + { [legacyPath]: 1 }, + { + name: legacyIndex!.name, + collation: { locale: 'en', strength: 2 }, + hidden: true, + partialFilterExpression: { [legacyPath]: { $exists: true } }, + unique: true, + }, + ) + + try { + await migrateMongoSizesToVariants({ payload }) + + const destinationIndex = (await getIndexes({ payload })).find( + ({ key }) => destinationPath in key, + ) + + expect(destinationIndex).toMatchObject({ + collation: { locale: 'en', strength: 2 }, + hidden: true, + partialFilterExpression: { [destinationPath]: { $exists: true } }, + unique: true, + }) + } finally { + const indexes = await getIndexes({ payload }) + + for (const index of indexes) { + if (legacyPath in index.key || destinationPath in index.key) { + await collection.dropIndex(index.name) + } + } + + await collection.createIndex({ [destinationPath]: 1 }) + } + }) + + test('should reject an incompatible destination index before removing the source index', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const sourceIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + expect(sourceIndex).toBeDefined() + + await collection.createIndex({ incompatibleDestination: 1 }, { name: `${destinationPath}_1` }) + + try { + await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow() + + expect((await getIndexes({ payload })).map(({ name }) => name)).toContain(sourceIndex!.name) + } finally { + await collection.dropIndex(`${destinationPath}_1`) + await migrateMongoSizesToVariants({ payload }) + } + }) }) })