ソースを参照

feat(asset-server-plugin): Implement ImageTransformStrategy for improved control over image transformations (#3240)

Closes #3040
Michael Bromley 1 年間 前
コミット
dde738d79e
30 ファイル変更1137 行追加367 行削除
  1. 4 0
      docs/docs/guides/deployment/production-configuration/index.md
  2. 29 0
      docs/docs/guides/developer-guide/security/index.md
  3. 25 13
      docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md
  4. 1 1
      docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md
  5. 1 1
      docs/docs/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy.md
  6. 1 1
      docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md
  7. 1 1
      docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md
  8. 148 0
      docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md
  9. 32 5
      docs/docs/reference/core-plugins/asset-server-plugin/index.md
  10. 1 1
      docs/docs/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy.md
  11. 122 0
      docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md
  12. 4 5
      docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md
  13. 2 2
      docs/docs/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy.md
  14. 60 60
      docs/docs/reference/typescript-api/services/order-service.md
  15. 46 0
      docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md
  16. 11 11
      docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md
  17. 25 1
      packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts
  18. 6 2
      packages/asset-server-plugin/index.ts
  19. 296 0
      packages/asset-server-plugin/src/asset-server.ts
  20. 3 2
      packages/asset-server-plugin/src/config/default-asset-storage-strategy-factory.ts
  21. 0 0
      packages/asset-server-plugin/src/config/hashed-asset-naming-strategy.ts
  22. 64 0
      packages/asset-server-plugin/src/config/image-transform-strategy.ts
  23. 0 0
      packages/asset-server-plugin/src/config/local-asset-storage-strategy.ts
  24. 112 0
      packages/asset-server-plugin/src/config/preset-only-strategy.ts
  25. 3 3
      packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts
  26. 2 2
      packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts
  27. 1 0
      packages/asset-server-plugin/src/constants.ts
  28. 105 228
      packages/asset-server-plugin/src/plugin.ts
  29. 16 28
      packages/asset-server-plugin/src/transform-image.ts
  30. 16 0
      packages/asset-server-plugin/src/types.ts

+ 4 - 0
docs/docs/guides/deployment/production-configuration/index.md

@@ -116,3 +116,7 @@ In **Postgres**, you can execute:
 show timezone;
 show timezone;
 ```
 ```
 and you should expect to see `UTC` or `Etc/UTC`.
 and you should expect to see `UTC` or `Etc/UTC`.
+
+## Security Considerations
+
+Please read over the [Security](/guides/developer-guide/security) section of the Developer Guide for more information on how to secure your Vendure application.

+ 29 - 0
docs/docs/guides/developer-guide/security/index.md

@@ -72,6 +72,35 @@ export const config: VendureConfig = {
 For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs](/reference/core-plugins/harden-plugin/).
 For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs](/reference/core-plugins/harden-plugin/).
 :::
 :::
 
 
+### Harden the AssetServerPlugin
+
+If you are using the [AssetServerPlugin](/reference/core-plugins/asset-server-plugin/), it is possible by default to use the dynamic
+image transform feature to overload the server with requests for new image sizes & formats. To prevent this, you can
+configure the plugin to only allow transformations for the preset sizes, and limited quality levels and formats.
+Since v3.1 we ship the [PresetOnlyStrategy](/reference/core-plugins/asset-server-plugin/preset-only-strategy/) for this purpose, and
+you can also create your own strategies.
+
+```ts
+import { VendureConfig } from '@vendure/core';
+import { AssetServerPlugin, PresetOnlyStrategy } from '@vendure/asset-server-plugin';
+
+export const config: VendureConfig = {
+  // ...
+  plugins: [
+    AssetServerPlugin.init({
+      // ...
+      // highlight-start  
+      imageTransformStrategy: new PresetOnlyStrategy({
+        defaultPreset: 'large',
+        permittedQuality: [0, 50, 75, 85, 95],
+        permittedFormats: ['jpg', 'webp', 'avif'],
+        allowFocalPoint: false,
+      }),
+      // highlight-end
+    }),
+  ]
+};
+```
 
 
 ## OWASP Top Ten Security Assessment
 ## OWASP Top Ten Security Assessment
 
 

+ 25 - 13
docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## AssetServerOptions
 ## AssetServerOptions
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="72" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="74" packageName="@vendure/asset-server-plugin" />
 
 
 The configuration options for the AssetServerPlugin.
 The configuration options for the AssetServerPlugin.
 
 
@@ -23,10 +23,11 @@ interface AssetServerOptions {
     previewMaxWidth?: number;
     previewMaxWidth?: number;
     previewMaxHeight?: number;
     previewMaxHeight?: number;
     presets?: ImageTransformPreset[];
     presets?: ImageTransformPreset[];
+    imageTransformStrategy?: ImageTransformStrategy | ImageTransformStrategy[];
     namingStrategy?: AssetNamingStrategy;
     namingStrategy?: AssetNamingStrategy;
     previewStrategy?: AssetPreviewStrategy;
     previewStrategy?: AssetPreviewStrategy;
-    storageStrategyFactory?: (
-        options: AssetServerOptions,
+    storageStrategyFactory?: (
+        options: AssetServerOptions,
     ) => AssetStorageStrategy | Promise<AssetStorageStrategy>;
     ) => AssetStorageStrategy | Promise<AssetStorageStrategy>;
     cacheHeader?: CacheConfig | string;
     cacheHeader?: CacheConfig | string;
 }
 }
@@ -48,12 +49,12 @@ The local directory to which assets will be uploaded when using the <a href='/re
 
 
 <MemberInfo kind="property" type={`string | ((ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, identifier: string) =&#62; string)`}   />
 <MemberInfo kind="property" type={`string | ((ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, identifier: string) =&#62; string)`}   />
 
 
-The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/". A
-function can also be provided to handle more complex cases, such as serving multiple domains
-from a single server. In this case, the function should return a string url prefix.
-
-If not provided, the plugin will attempt to guess based off the incoming
-request and the configured route. However, in all but the simplest cases,
+The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/". A
+function can also be provided to handle more complex cases, such as serving multiple domains
+from a single server. In this case, the function should return a string url prefix.
+
+If not provided, the plugin will attempt to guess based off the incoming
+request and the configured route. However, in all but the simplest cases,
 this guess may not yield correct results.
 this guess may not yield correct results.
 ### previewMaxWidth
 ### previewMaxWidth
 
 
@@ -70,6 +71,17 @@ The max height in pixels of a generated preview image.
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-preset#imagetransformpreset'>ImageTransformPreset</a>[]`}   />
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-preset#imagetransformpreset'>ImageTransformPreset</a>[]`}   />
 
 
 An array of additional <a href='/reference/core-plugins/asset-server-plugin/image-transform-preset#imagetransformpreset'>ImageTransformPreset</a> objects.
 An array of additional <a href='/reference/core-plugins/asset-server-plugin/image-transform-preset#imagetransformpreset'>ImageTransformPreset</a> objects.
+### imageTransformStrategy
+
+<MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformstrategy'>ImageTransformStrategy</a> | <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformstrategy'>ImageTransformStrategy</a>[]`} default={`[]`}  since="3.1.0"  />
+
+The strategy or strategies to use to determine the parameters for transforming an image.
+This can be used to implement custom image transformation logic, for example to
+limit transform parameters to a known set of presets.
+
+If multiple strategies are provided, they will be executed in the order in which they are defined.
+If a strategy throws an error, the image transformation will be aborted and the error
+will be logged, with an HTTP 400 response sent to the client.
 ### namingStrategy
 ### namingStrategy
 
 
 <MemberInfo kind="property" type={`<a href='/reference/typescript-api/assets/asset-naming-strategy#assetnamingstrategy'>AssetNamingStrategy</a>`} default={`<a href='/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy#hashedassetnamingstrategy'>HashedAssetNamingStrategy</a>`}   />
 <MemberInfo kind="property" type={`<a href='/reference/typescript-api/assets/asset-naming-strategy#assetnamingstrategy'>AssetNamingStrategy</a>`} default={`<a href='/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy#hashedassetnamingstrategy'>HashedAssetNamingStrategy</a>`}   />
@@ -79,19 +91,19 @@ Defines how asset files and preview images are named before being saved.
 
 
 <MemberInfo kind="property" type={`<a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a>`}  since="1.7.0"  />
 <MemberInfo kind="property" type={`<a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a>`}  since="1.7.0"  />
 
 
-Defines how previews are generated for a given Asset binary. By default, this uses
+Defines how previews are generated for a given Asset binary. By default, this uses
 the <a href='/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy#sharpassetpreviewstrategy'>SharpAssetPreviewStrategy</a>
 the <a href='/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy#sharpassetpreviewstrategy'>SharpAssetPreviewStrategy</a>
 ### storageStrategyFactory
 ### storageStrategyFactory
 
 
-<MemberInfo kind="property" type={`(
         options: <a href='/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions'>AssetServerOptions</a>,
     ) =&#62; <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a> | Promise&#60;<a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a>&#62;`} default={`() =&#62; <a href='/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy#localassetstoragestrategy'>LocalAssetStorageStrategy</a>`}   />
+<MemberInfo kind="property" type={`(         options: <a href='/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions'>AssetServerOptions</a>,     ) =&#62; <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a> | Promise&#60;<a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a>&#62;`} default={`() =&#62; <a href='/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy#localassetstoragestrategy'>LocalAssetStorageStrategy</a>`}   />
 
 
-A function which can be used to configure an <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a>. This is useful e.g. if you wish to store your assets
+A function which can be used to configure an <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a>. This is useful e.g. if you wish to store your assets
 using a cloud storage provider. By default, the <a href='/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy#localassetstoragestrategy'>LocalAssetStorageStrategy</a> is used.
 using a cloud storage provider. By default, the <a href='/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy#localassetstoragestrategy'>LocalAssetStorageStrategy</a> is used.
 ### cacheHeader
 ### cacheHeader
 
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/cache-config#cacheconfig'>CacheConfig</a> | string`} default={`'public, max-age=15552000'`}  since="1.9.3"  />
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/cache-config#cacheconfig'>CacheConfig</a> | string`} default={`'public, max-age=15552000'`}  since="1.9.3"  />
 
 
-Configures the `Cache-Control` directive for response to control caching in browsers and shared caches (e.g. Proxies, CDNs).
+Configures the `Cache-Control` directive for response to control caching in browsers and shared caches (e.g. Proxies, CDNs).
 Defaults to publicly cached for 6 months.
 Defaults to publicly cached for 6 months.
 
 
 
 

+ 1 - 1
docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## CacheConfig
 ## CacheConfig
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="52" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="54" packageName="@vendure/asset-server-plugin" />
 
 
 A configuration option for the Cache-Control header in the AssetServerPlugin asset response.
 A configuration option for the Cache-Control header in the AssetServerPlugin asset response.
 
 

+ 1 - 1
docs/docs/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## HashedAssetNamingStrategy
 ## HashedAssetNamingStrategy
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/hashed-asset-naming-strategy.ts" sourceLine="20" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/hashed-asset-naming-strategy.ts" sourceLine="20" packageName="@vendure/asset-server-plugin" />
 
 
 An extension of the <a href='/reference/typescript-api/assets/default-asset-naming-strategy#defaultassetnamingstrategy'>DefaultAssetNamingStrategy</a> which prefixes file names with
 An extension of the <a href='/reference/typescript-api/assets/default-asset-naming-strategy#defaultassetnamingstrategy'>DefaultAssetNamingStrategy</a> which prefixes file names with
 the type (`'source'` or `'preview'`) as well as a 2-character sub-directory based on
 the type (`'source'` or `'preview'`) as well as a 2-character sub-directory based on

+ 1 - 1
docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## ImageTransformMode
 ## ImageTransformMode
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="21" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="23" packageName="@vendure/asset-server-plugin" />
 
 
 Specifies the way in which an asset preview image will be resized to fit in the
 Specifies the way in which an asset preview image will be resized to fit in the
 proscribed dimensions:
 proscribed dimensions:

+ 1 - 1
docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## ImageTransformPreset
 ## ImageTransformPreset
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="39" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/types.ts" sourceLine="41" packageName="@vendure/asset-server-plugin" />
 
 
 A configuration option for an image size preset for the AssetServerPlugin.
 A configuration option for an image size preset for the AssetServerPlugin.
 
 

+ 148 - 0
docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md

@@ -0,0 +1,148 @@
+---
+title: "ImageTransformStrategy"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## ImageTransformStrategy
+
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/image-transform-strategy.ts" sourceLine="56" packageName="@vendure/asset-server-plugin" since="3.1.0" />
+
+An injectable strategy which is used to determine the parameters for transforming an image.
+This can be used to implement custom image transformation logic, for example to
+limit transform parameters to a known set of presets.
+
+This is set via the `imageTransformStrategy` option in the AssetServerOptions. Multiple
+strategies can be defined and will be executed in the order in which they are defined.
+
+If a strategy throws an error, the image transformation will be aborted and the error
+will be logged, with an HTTP 400 response sent to the client.
+
+```ts title="Signature"
+interface ImageTransformStrategy extends InjectableStrategy {
+    getImageTransformParameters(
+        args: GetImageTransformParametersArgs,
+    ): Promise<ImageTransformParameters> | ImageTransformParameters;
+}
+```
+* Extends: <code><a href='/reference/typescript-api/common/injectable-strategy#injectablestrategy'>InjectableStrategy</a></code>
+
+
+
+<div className="members-wrapper">
+
+### getImageTransformParameters
+
+<MemberInfo kind="method" type={`(args: <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#getimagetransformparametersargs'>GetImageTransformParametersArgs</a>) => Promise&#60;<a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformparameters'>ImageTransformParameters</a>&#62; | <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformparameters'>ImageTransformParameters</a>`}   />
+
+Given the input parameters, return the parameters which should be used to transform the image.
+
+
+</div>
+
+
+## ImageTransformParameters
+
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/image-transform-strategy.ts" sourceLine="14" packageName="@vendure/asset-server-plugin" since="3.1.0" />
+
+Parameters which are used to transform the image.
+
+```ts title="Signature"
+interface ImageTransformParameters {
+    width: number | undefined;
+    height: number | undefined;
+    mode: ImageTransformMode | undefined;
+    quality: number | undefined;
+    format: ImageTransformFormat | undefined;
+    fpx: number | undefined;
+    fpy: number | undefined;
+    preset: string | undefined;
+}
+```
+
+<div className="members-wrapper">
+
+### width
+
+<MemberInfo kind="property" type={`number | undefined`}   />
+
+
+### height
+
+<MemberInfo kind="property" type={`number | undefined`}   />
+
+
+### mode
+
+<MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-mode#imagetransformmode'>ImageTransformMode</a> | undefined`}   />
+
+
+### quality
+
+<MemberInfo kind="property" type={`number | undefined`}   />
+
+
+### format
+
+<MemberInfo kind="property" type={`ImageTransformFormat | undefined`}   />
+
+
+### fpx
+
+<MemberInfo kind="property" type={`number | undefined`}   />
+
+
+### fpy
+
+<MemberInfo kind="property" type={`number | undefined`}   />
+
+
+### preset
+
+<MemberInfo kind="property" type={`string | undefined`}   />
+
+
+
+
+</div>
+
+
+## GetImageTransformParametersArgs
+
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/image-transform-strategy.ts" sourceLine="33" packageName="@vendure/asset-server-plugin" since="3.1.0" />
+
+The arguments passed to the `getImageTransformParameters` method of an ImageTransformStrategy.
+
+```ts title="Signature"
+interface GetImageTransformParametersArgs {
+    req: Request;
+    availablePresets: ImageTransformPreset[];
+    input: ImageTransformParameters;
+}
+```
+
+<div className="members-wrapper">
+
+### req
+
+<MemberInfo kind="property" type={`Request`}   />
+
+
+### availablePresets
+
+<MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-preset#imagetransformpreset'>ImageTransformPreset</a>[]`}   />
+
+
+### input
+
+<MemberInfo kind="property" type={`<a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformparameters'>ImageTransformParameters</a>`}   />
+
+
+
+
+</div>

+ 32 - 5
docs/docs/reference/core-plugins/asset-server-plugin/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## AssetServerPlugin
 ## AssetServerPlugin
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/plugin.ts" sourceLine="153" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/plugin.ts" sourceLine="176" packageName="@vendure/asset-server-plugin" />
 
 
 The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
 The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
 other storage strategies (e.g. <a href='/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#s3assetstoragestrategy'>S3AssetStorageStrategy</a>. It can also perform on-the-fly image transformations
 other storage strategies (e.g. <a href='/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#s3assetstoragestrategy'>S3AssetStorageStrategy</a>. It can also perform on-the-fly image transformations
@@ -133,14 +133,41 @@ large | 800px | 800px | resize
 By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
 By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
 a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
 a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
 
 
+### Limiting transformations
+
+By default, the AssetServerPlugin will allow any transformation to be performed on an image. However, it is possible to restrict the transformations
+which can be performed by using an <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformstrategy'>ImageTransformStrategy</a>. This can be used to limit the transformations to a known set of presets, for example.
+
+This is advisable in order to prevent abuse of the image transformation feature, as it can be computationally expensive.
+
+Since v3.1.0 we ship with a <a href='/reference/core-plugins/asset-server-plugin/preset-only-strategy#presetonlystrategy'>PresetOnlyStrategy</a> which allows only transformations using a known set of presets.
+
+*Example*
+
+```ts
+import { AssetServerPlugin, PresetOnlyStrategy } from '@vendure/core';
+
+// ...
+
+AssetServerPlugin.init({
+  //...
+  imageTransformStrategy: new PresetOnlyStrategy({
+    defaultPreset: 'thumbnail',
+    permittedQuality: [0, 50, 75, 85, 95],
+    permittedFormats: ['jpg', 'webp', 'avif'],
+    allowFocalPoint: false,
+  }),
+});
+```
+
 ```ts title="Signature"
 ```ts title="Signature"
-class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
+class AssetServerPlugin implements NestModule, OnApplicationBootstrap, OnApplicationShutdown {
     init(options: AssetServerOptions) => Type<AssetServerPlugin>;
     init(options: AssetServerOptions) => Type<AssetServerPlugin>;
-    constructor(processContext: ProcessContext)
+    constructor(options: AssetServerOptions, processContext: ProcessContext, moduleRef: ModuleRef, assetServer: AssetServer)
     configure(consumer: MiddlewareConsumer) => ;
     configure(consumer: MiddlewareConsumer) => ;
 }
 }
 ```
 ```
-* Implements: <code>NestModule</code>, <code>OnApplicationBootstrap</code>
+* Implements: <code>NestModule</code>, <code>OnApplicationBootstrap</code>, <code>OnApplicationShutdown</code>
 
 
 
 
 
 
@@ -153,7 +180,7 @@ class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
 Set the plugin options.
 Set the plugin options.
 ### constructor
 ### constructor
 
 
-<MemberInfo kind="method" type={`(processContext: <a href='/reference/typescript-api/common/process-context#processcontext'>ProcessContext</a>) => AssetServerPlugin`}   />
+<MemberInfo kind="method" type={`(options: <a href='/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions'>AssetServerOptions</a>, processContext: <a href='/reference/typescript-api/common/process-context#processcontext'>ProcessContext</a>, moduleRef: ModuleRef, assetServer: AssetServer) => AssetServerPlugin`}   />
 
 
 
 
 ### configure
 ### configure

+ 1 - 1
docs/docs/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## LocalAssetStorageStrategy
 ## LocalAssetStorageStrategy
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/local-asset-storage-strategy.ts" sourceLine="15" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/local-asset-storage-strategy.ts" sourceLine="15" packageName="@vendure/asset-server-plugin" />
 
 
 A persistence strategy which saves files to the local file system.
 A persistence strategy which saves files to the local file system.
 
 

+ 122 - 0
docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md

@@ -0,0 +1,122 @@
+---
+title: "PresetOnlyStrategy"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## PresetOnlyStrategy
+
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/preset-only-strategy.ts" sourceLine="85" packageName="@vendure/asset-server-plugin" since="3.1.0" />
+
+An <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformstrategy'>ImageTransformStrategy</a> which only allows transformations to be made using
+presets which are defined in the available presets.
+
+With this strategy enabled, requests to the asset server must include a `preset` parameter (or use the default preset)
+
+This is valid: `http://localhost:3000/assets/some-asset.jpg?preset=medium`
+
+This is invalid: `http://localhost:3000/assets/some-asset.jpg?w=200&h=200`, and the dimensions will be ignored.
+
+The strategy can be configured to allow only certain quality values and formats, and to
+optionally allow the focal point to be specified in the URL.
+
+If a preset is not found in the available presets, an error will be thrown.
+
+*Example*
+
+```ts
+import { AssetServerPlugin, PresetOnlyStrategy } from '@vendure/core';
+
+// ...
+
+AssetServerPlugin.init({
+  //...
+  imageTransformStrategy: new PresetOnlyStrategy({
+    defaultPreset: 'thumbnail',
+    permittedQuality: [0, 50, 75, 85, 95],
+    permittedFormats: ['jpg', 'webp', 'avif'],
+    allowFocalPoint: true,
+  }),
+});
+```
+
+```ts title="Signature"
+class PresetOnlyStrategy implements ImageTransformStrategy {
+    constructor(options: PresetOnlyStrategyOptions)
+    getImageTransformParameters({
+        input,
+        availablePresets,
+    }: GetImageTransformParametersArgs) => Promise<ImageTransformParameters> | ImageTransformParameters;
+}
+```
+* Implements: <code><a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformstrategy'>ImageTransformStrategy</a></code>
+
+
+
+<div className="members-wrapper">
+
+### constructor
+
+<MemberInfo kind="method" type={`(options: <a href='/reference/core-plugins/asset-server-plugin/preset-only-strategy#presetonlystrategyoptions'>PresetOnlyStrategyOptions</a>) => PresetOnlyStrategy`}   />
+
+
+### getImageTransformParameters
+
+<MemberInfo kind="method" type={`({
+        input,
+        availablePresets,
+    }: <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#getimagetransformparametersargs'>GetImageTransformParametersArgs</a>) => Promise&#60;<a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformparameters'>ImageTransformParameters</a>&#62; | <a href='/reference/core-plugins/asset-server-plugin/image-transform-strategy#imagetransformparameters'>ImageTransformParameters</a>`}   />
+
+
+
+
+</div>
+
+
+## PresetOnlyStrategyOptions
+
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/preset-only-strategy.ts" sourceLine="16" packageName="@vendure/asset-server-plugin" />
+
+Configuration options for the <a href='/reference/core-plugins/asset-server-plugin/preset-only-strategy#presetonlystrategy'>PresetOnlyStrategy</a>.
+
+```ts title="Signature"
+interface PresetOnlyStrategyOptions {
+    defaultPreset: string;
+    permittedQuality?: number[];
+    permittedFormats?: ImageTransformFormat[];
+    allowFocalPoint?: boolean;
+}
+```
+
+<div className="members-wrapper">
+
+### defaultPreset
+
+<MemberInfo kind="property" type={`string`}   />
+
+The name of the default preset to use if no preset is specified in the URL.
+### permittedQuality
+
+<MemberInfo kind="property" type={`number[]`} default={`[0, 50, 75, 85, 95]`}   />
+
+The permitted quality of the transformed images. If set to 'any', then any quality is permitted.
+If set to an array of numbers (0-100), then only those quality values are permitted.
+### permittedFormats
+
+<MemberInfo kind="property" type={`ImageTransformFormat[]`} default={`['jpg', 'webp', 'avif']`}   />
+
+The permitted formats of the transformed images. If set to 'any', then any format is permitted.
+If set to an array of strings e.g. `['jpg', 'webp']`, then only those formats are permitted.
+### allowFocalPoint
+
+<MemberInfo kind="property" type={`boolean`} default={`false`}   />
+
+Whether to allow the focal point to be specified in the URL.
+
+
+</div>

+ 4 - 5
docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## S3AssetStorageStrategy
 ## S3AssetStorageStrategy
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/s3-asset-storage-strategy.ts" sourceLine="155" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="155" packageName="@vendure/asset-server-plugin" />
 
 
 An <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a> which uses [Amazon S3](https://aws.amazon.com/s3/) object storage service.
 An <a href='/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy'>AssetStorageStrategy</a> which uses [Amazon S3](https://aws.amazon.com/s3/) object storage service.
 To us this strategy you must first have access to an AWS account.
 To us this strategy you must first have access to an AWS account.
@@ -100,7 +100,7 @@ class S3AssetStorageStrategy implements AssetStorageStrategy {
 
 
 ## S3Config
 ## S3Config
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/s3-asset-storage-strategy.ts" sourceLine="19" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="19" packageName="@vendure/asset-server-plugin" />
 
 
 Configuration for connecting to AWS S3.
 Configuration for connecting to AWS S3.
 
 
@@ -149,10 +149,9 @@ Using type `any` in order to avoid the need to include `aws-sdk` dependency in g
 
 
 ## configureS3AssetStorage
 ## configureS3AssetStorage
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/s3-asset-storage-strategy.ts" sourceLine="119" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="119" packageName="@vendure/asset-server-plugin" />
 
 
-Returns a configured instance of the <a href='/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#s3assetstoragestrategy'>S3AssetStorageStrategy</a> which can then be passed to the <a href='/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions'>AssetServerOptions</a>
-`storageStrategyFactory` property.
+Returns a configured instance of the <a href='/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#s3assetstoragestrategy'>S3AssetStorageStrategy</a> which can then be passed to the <a href='/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions'>AssetServerOptions</a>`storageStrategyFactory` property.
 
 
 Before using this strategy, make sure you have the `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` package installed:
 Before using this strategy, make sure you have the `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` package installed:
 
 

+ 2 - 2
docs/docs/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## SharpAssetPreviewStrategy
 ## SharpAssetPreviewStrategy
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts" sourceLine="95" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts" sourceLine="95" packageName="@vendure/asset-server-plugin" />
 
 
 This <a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a> uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
 This <a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a> uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
 preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type
 preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type
@@ -62,7 +62,7 @@ class SharpAssetPreviewStrategy implements AssetPreviewStrategy {
 
 
 ## SharpAssetPreviewConfig
 ## SharpAssetPreviewConfig
 
 
-<GenerationInfo sourceFile="packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts" sourceLine="17" packageName="@vendure/asset-server-plugin" />
+<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts" sourceLine="17" packageName="@vendure/asset-server-plugin" />
 
 
 This <a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a> uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
 This <a href='/reference/typescript-api/assets/asset-preview-strategy#assetpreviewstrategy'>AssetPreviewStrategy</a> uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
 preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type
 preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type

+ 60 - 60
docs/docs/reference/typescript-api/services/order-service.md

@@ -36,10 +36,10 @@ class OrderService {
     updateCustomFields(ctx: RequestContext, orderId: ID, customFields: any) => ;
     updateCustomFields(ctx: RequestContext, orderId: ID, customFields: any) => ;
     updateOrderCustomer(ctx: RequestContext, { customerId, orderId, note }: SetOrderCustomerInput) => ;
     updateOrderCustomer(ctx: RequestContext, { customerId, orderId, note }: SetOrderCustomerInput) => ;
     addItemToOrder(ctx: RequestContext, orderId: ID, productVariantId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>;
     addItemToOrder(ctx: RequestContext, orderId: ID, productVariantId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>;
-    addItemsToOrder(ctx: RequestContext, orderId: ID, items: Array<{
-            productVariantId: ID;
-            quantity: number;
-            customFields?: { [key: string]: any };
+    addItemsToOrder(ctx: RequestContext, orderId: ID, items: Array<{
+            productVariantId: ID;
+            quantity: number;
+            customFields?: { [key: string]: any };
         }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>;
         }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>;
     adjustOrderLine(ctx: RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>;
     adjustOrderLine(ctx: RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>;
     adjustOrderLines(ctx: RequestContext, orderId: ID, lines: Array<{ orderLineId: ID; quantity: number; customFields?: { [key: string]: any } }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>;
     adjustOrderLines(ctx: RequestContext, orderId: ID, lines: Array<{ orderLineId: ID; quantity: number; customFields?: { [key: string]: any } }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>;
@@ -95,8 +95,8 @@ class OrderService {
 
 
 <MemberInfo kind="method" type={`() => OrderProcessState[]`}   />
 <MemberInfo kind="method" type={`() => OrderProcessState[]`}   />
 
 
-Returns an array of all the configured states and transitions of the order process. This is
-based on the default order process plus all configured <a href='/reference/typescript-api/orders/order-process#orderprocess'>OrderProcess</a> objects
+Returns an array of all the configured states and transitions of the order process. This is
+based on the default order process plus all configured <a href='/reference/typescript-api/orders/order-process#orderprocess'>OrderProcess</a> objects
 defined in the <a href='/reference/typescript-api/orders/order-options#orderoptions'>OrderOptions</a> `process` array.
 defined in the <a href='/reference/typescript-api/orders/order-options#orderoptions'>OrderOptions</a> `process` array.
 ### findAll
 ### findAll
 
 
@@ -157,13 +157,13 @@ Returns any <a href='/reference/typescript-api/entities/refund#refund'>Refund</a
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a> | undefined&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a> | undefined&#62;`}   />
 
 
-Returns any Order associated with the specified User's Customer account
+Returns any Order associated with the specified User's Customer account
 that is still in the `active` state.
 that is still in the `active` state.
 ### create
 ### create
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId?: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId?: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;`}   />
 
 
-Creates a new, empty Order. If a `userId` is passed, the Order will get associated with that
+Creates a new, empty Order. If a `userId` is passed, the Order will get associated with that
 User's Customer account.
 User's Customer account.
 ### createDraft
 ### createDraft
 
 
@@ -179,55 +179,55 @@ Updates the custom fields of an Order.
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { customerId, orderId, note }: SetOrderCustomerInput) => `}  since="2.2.0"  />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { customerId, orderId, note }: SetOrderCustomerInput) => `}  since="2.2.0"  />
 
 
-Updates the Customer which is assigned to a given Order. The target Customer must be assigned to the same
+Updates the Customer which is assigned to a given Order. The target Customer must be assigned to the same
 Channels as the Order, otherwise an error will be thrown.
 Channels as the Order, otherwise an error will be thrown.
 ### addItemToOrder
 ### addItemToOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, productVariantId: <a href='/reference/typescript-api/common/id#id'>ID</a>, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;UpdateOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, productVariantId: <a href='/reference/typescript-api/common/id#id'>ID</a>, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;UpdateOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Adds an item to the Order, either creating a new OrderLine or
-incrementing an existing one.
-
+Adds an item to the Order, either creating a new OrderLine or
+incrementing an existing one.
+
 If you need to add multiple items to an Order, use `addItemsToOrder()` instead.
 If you need to add multiple items to an Order, use `addItemsToOrder()` instead.
 ### addItemsToOrder
 ### addItemsToOrder
 
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, items: Array&#60;{             productVariantId: <a href='/reference/typescript-api/common/id#id'>ID</a>;             quantity: number;             customFields?: { [key: string]: any };         }&#62;, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;{ order: <a href='/reference/typescript-api/entities/order#order'>Order</a>; errorResults: Array&#60;JustErrorResults&#60;UpdateOrderItemsResult&#62;&#62; }&#62;`}  since="3.1.0"  />
-
-Adds multiple items to an Order. This method is more efficient than calling `addItemToOrder`
-multiple times, as it only needs to fetch the entire Order once, and only performs
-price adjustments once at the end.
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, items: Array&#60;{
             productVariantId: <a href='/reference/typescript-api/common/id#id'>ID</a>;
             quantity: number;
             customFields?: { [key: string]: any };
         }&#62;, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;{ order: <a href='/reference/typescript-api/entities/order#order'>Order</a>; errorResults: Array&#60;JustErrorResults&#60;UpdateOrderItemsResult&#62;&#62; }&#62;`}  since="3.1.0"  />
 
 
-Since this method can return multiple error results, it is recommended to check the `errorResults`
+Adds multiple items to an Order. This method is more efficient than calling `addItemToOrder`
+multiple times, as it only needs to fetch the entire Order once, and only performs
+price adjustments once at the end.
+
+Since this method can return multiple error results, it is recommended to check the `errorResults`
 array to determine if any errors occurred.
 array to determine if any errors occurred.
 ### adjustOrderLine
 ### adjustOrderLine
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;UpdateOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;UpdateOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Adjusts the quantity and/or custom field values of an existing OrderLine.
-
+Adjusts the quantity and/or custom field values of an existing OrderLine.
+
 If you need to adjust multiple OrderLines, use `adjustOrderLines()` instead.
 If you need to adjust multiple OrderLines, use `adjustOrderLines()` instead.
 ### adjustOrderLines
 ### adjustOrderLines
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, lines: Array&#60;{ orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>; quantity: number; customFields?: { [key: string]: any } }&#62;, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;{ order: <a href='/reference/typescript-api/entities/order#order'>Order</a>; errorResults: Array&#60;JustErrorResults&#60;UpdateOrderItemsResult&#62;&#62; }&#62;`}  since="3.1.0"  />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, lines: Array&#60;{ orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>; quantity: number; customFields?: { [key: string]: any } }&#62;, relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;{ order: <a href='/reference/typescript-api/entities/order#order'>Order</a>; errorResults: Array&#60;JustErrorResults&#60;UpdateOrderItemsResult&#62;&#62; }&#62;`}  since="3.1.0"  />
 
 
-Adjusts the quantity and/or custom field values of existing OrderLines.
-This method is more efficient than calling `adjustOrderLine` multiple times, as it only needs to fetch
-the entire Order once, and only performs price adjustments once at the end.
-Since this method can return multiple error results, it is recommended to check the `errorResults`
+Adjusts the quantity and/or custom field values of existing OrderLines.
+This method is more efficient than calling `adjustOrderLine` multiple times, as it only needs to fetch
+the entire Order once, and only performs price adjustments once at the end.
+Since this method can return multiple error results, it is recommended to check the `errorResults`
 array to determine if any errors occurred.
 array to determine if any errors occurred.
 ### removeItemFromOrder
 ### removeItemFromOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RemoveOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RemoveOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Removes the specified OrderLine from the Order.
-
+Removes the specified OrderLine from the Order.
+
 If you need to remove multiple OrderLines, use `removeItemsFromOrder()` instead.
 If you need to remove multiple OrderLines, use `removeItemsFromOrder()` instead.
 ### removeItemsFromOrder
 ### removeItemsFromOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineIds: <a href='/reference/typescript-api/common/id#id'>ID</a>[]) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RemoveOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}  since="3.1.0"  />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, orderLineIds: <a href='/reference/typescript-api/common/id#id'>ID</a>[]) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RemoveOrderItemsResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}  since="3.1.0"  />
 
 
-Removes the specified OrderLines from the Order.
-This method is more efficient than calling `removeItemFromOrder` multiple times, as it only needs to fetch
+Removes the specified OrderLines from the Order.
+This method is more efficient than calling `removeItemFromOrder` multiple times, as it only needs to fetch
 the entire Order once, and only performs price adjustments once at the end.
 the entire Order once, and only performs price adjustments once at the end.
 ### removeAllItemsFromOrder
 ### removeAllItemsFromOrder
 
 
@@ -248,7 +248,7 @@ Removes a <a href='/reference/typescript-api/entities/surcharge#surcharge'>Surch
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, couponCode: string) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;ApplyCouponCodeResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, couponCode: string) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;ApplyCouponCodeResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Applies a coupon code to the Order, which should be a valid coupon code as specified in the configuration
+Applies a coupon code to the Order, which should be a valid coupon code as specified in the configuration
 of an active <a href='/reference/typescript-api/entities/promotion#promotion'>Promotion</a>.
 of an active <a href='/reference/typescript-api/entities/promotion#promotion'>Promotion</a>.
 ### removeCouponCode
 ### removeCouponCode
 
 
@@ -289,10 +289,10 @@ Unsets the billing address for the Order.
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;ShippingMethodQuote[]&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;ShippingMethodQuote[]&#62;`}   />
 
 
-Returns an array of quotes stating which <a href='/reference/typescript-api/entities/shipping-method#shippingmethod'>ShippingMethod</a>s may be applied to this Order.
-This is determined by the configured <a href='/reference/typescript-api/shipping/shipping-eligibility-checker#shippingeligibilitychecker'>ShippingEligibilityChecker</a> of each ShippingMethod.
-
-The quote also includes a price for each method, as determined by the configured
+Returns an array of quotes stating which <a href='/reference/typescript-api/entities/shipping-method#shippingmethod'>ShippingMethod</a>s may be applied to this Order.
+This is determined by the configured <a href='/reference/typescript-api/shipping/shipping-eligibility-checker#shippingeligibilitychecker'>ShippingEligibilityChecker</a> of each ShippingMethod.
+
+The quote also includes a price for each method, as determined by the configured
 <a href='/reference/typescript-api/shipping/shipping-calculator#shippingcalculator'>ShippingCalculator</a> of each eligible ShippingMethod.
 <a href='/reference/typescript-api/shipping/shipping-calculator#shippingcalculator'>ShippingCalculator</a> of each eligible ShippingMethod.
 ### getEligiblePaymentMethods
 ### getEligiblePaymentMethods
 
 
@@ -313,7 +313,7 @@ Transitions the Order to the given state.
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, fulfillmentId: <a href='/reference/typescript-api/common/id#id'>ID</a>, state: <a href='/reference/typescript-api/fulfillment/fulfillment-state#fulfillmentstate'>FulfillmentState</a>) => Promise&#60;<a href='/reference/typescript-api/entities/fulfillment#fulfillment'>Fulfillment</a> | FulfillmentStateTransitionError&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, fulfillmentId: <a href='/reference/typescript-api/common/id#id'>ID</a>, state: <a href='/reference/typescript-api/fulfillment/fulfillment-state#fulfillmentstate'>FulfillmentState</a>) => Promise&#60;<a href='/reference/typescript-api/entities/fulfillment#fulfillment'>Fulfillment</a> | FulfillmentStateTransitionError&#62;`}   />
 
 
-Transitions a Fulfillment to the given state and then transitions the Order state based on
+Transitions a Fulfillment to the given state and then transitions the Order state based on
 whether all Fulfillments of the Order are shipped or delivered.
 whether all Fulfillments of the Order are shipped or delivered.
 ### transitionRefundToState
 ### transitionRefundToState
 
 
@@ -324,51 +324,51 @@ Transitions a Refund to the given state
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: ModifyOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;ModifyOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: ModifyOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;ModifyOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Allows the Order to be modified, which allows several aspects of the Order to be changed:
-
-* Changes to OrderLine quantities
-* New OrderLines being added
-* Arbitrary <a href='/reference/typescript-api/entities/surcharge#surcharge'>Surcharge</a>s being added
-* Shipping or billing address changes
-
-Setting the `dryRun` input property to `true` will apply all changes, including updating the price of the
-Order, except history entry and additional payment actions.
-
+Allows the Order to be modified, which allows several aspects of the Order to be changed:
+
+* Changes to OrderLine quantities
+* New OrderLines being added
+* Arbitrary <a href='/reference/typescript-api/entities/surcharge#surcharge'>Surcharge</a>s being added
+* Shipping or billing address changes
+
+Setting the `dryRun` input property to `true` will apply all changes, including updating the price of the
+Order, except history entry and additional payment actions.
+
 __Using dryRun option, you must wrap function call in transaction manually.__
 __Using dryRun option, you must wrap function call in transaction manually.__
 ### transitionPaymentToState
 ### transitionPaymentToState
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>, state: <a href='/reference/typescript-api/payment/payment-state#paymentstate'>PaymentState</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;TransitionPaymentToStateResult, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>, state: <a href='/reference/typescript-api/payment/payment-state#paymentstate'>PaymentState</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;TransitionPaymentToStateResult, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 
 
-Transitions the given <a href='/reference/typescript-api/entities/payment#payment'>Payment</a> to a new state. If the order totalWithTax price is then
-covered by Payments, the Order state will be automatically transitioned to `PaymentSettled`
+Transitions the given <a href='/reference/typescript-api/entities/payment#payment'>Payment</a> to a new state. If the order totalWithTax price is then
+covered by Payments, the Order state will be automatically transitioned to `PaymentSettled`
 or `PaymentAuthorized`.
 or `PaymentAuthorized`.
 ### addPaymentToOrder
 ### addPaymentToOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, input: PaymentInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;AddPaymentToOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, orderId: <a href='/reference/typescript-api/common/id#id'>ID</a>, input: PaymentInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;AddPaymentToOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Adds a new Payment to the Order. If the Order totalWithTax is covered by Payments, then the Order
+Adds a new Payment to the Order. If the Order totalWithTax is covered by Payments, then the Order
 state will get automatically transitioned to the `PaymentSettled` or `PaymentAuthorized` state.
 state will get automatically transitioned to the `PaymentSettled` or `PaymentAuthorized` state.
 ### addManualPaymentToOrder
 ### addManualPaymentToOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: ManualPaymentInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;AddManualPaymentToOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: ManualPaymentInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;AddManualPaymentToOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-This method is used after modifying an existing completed order using the `modifyOrder()` method. If the modifications
-cause the order total to increase (such as when adding a new OrderLine), then there will be an outstanding charge to
-pay.
-
-This method allows you to add a new Payment and assumes the actual processing has been done manually, e.g. in the
+This method is used after modifying an existing completed order using the `modifyOrder()` method. If the modifications
+cause the order total to increase (such as when adding a new OrderLine), then there will be an outstanding charge to
+pay.
+
+This method allows you to add a new Payment and assumes the actual processing has been done manually, e.g. in the
 dashboard of your payment provider.
 dashboard of your payment provider.
 ### settlePayment
 ### settlePayment
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;<a href='/reference/typescript-api/payment/payment-method-types#settlepaymentresult'>SettlePaymentResult</a>, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;<a href='/reference/typescript-api/payment/payment-method-types#settlepaymentresult'>SettlePaymentResult</a>, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 
 
-Settles a payment by invoking the <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>'s `settlePayment()` method. Automatically
+Settles a payment by invoking the <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>'s `settlePayment()` method. Automatically
 transitions the Order state if all Payments are settled.
 transitions the Order state if all Payments are settled.
 ### cancelPayment
 ### cancelPayment
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;<a href='/reference/typescript-api/payment/payment-method-types#cancelpaymentresult'>CancelPaymentResult</a>, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, paymentId: <a href='/reference/typescript-api/common/id#id'>ID</a>) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;<a href='/reference/typescript-api/payment/payment-method-types#cancelpaymentresult'>CancelPaymentResult</a>, <a href='/reference/typescript-api/entities/payment#payment'>Payment</a>&#62;&#62;`}   />
 
 
-Cancels a payment by invoking the <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>'s `cancelPayment()` method (if defined), and transitions the Payment to
+Cancels a payment by invoking the <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>'s `cancelPayment()` method (if defined), and transitions the Payment to
 the `Cancelled` state.
 the `Cancelled` state.
 ### createFulfillment
 ### createFulfillment
 
 
@@ -389,13 +389,13 @@ Returns an array of all Surcharges associated with the Order.
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: CancelOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;CancelOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: CancelOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;CancelOrderResult, <a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;&#62;`}   />
 
 
-Cancels an Order by transitioning it to the `Cancelled` state. If stock is being tracked for the ProductVariants
+Cancels an Order by transitioning it to the `Cancelled` state. If stock is being tracked for the ProductVariants
 in the Order, then new <a href='/reference/typescript-api/entities/stock-movement#stockmovement'>StockMovement</a>s will be created to correct the stock levels.
 in the Order, then new <a href='/reference/typescript-api/entities/stock-movement#stockmovement'>StockMovement</a>s will be created to correct the stock levels.
 ### refundOrder
 ### refundOrder
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: RefundOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RefundOrderResult, <a href='/reference/typescript-api/entities/refund#refund'>Refund</a>&#62;&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: RefundOrderInput) => Promise&#60;<a href='/reference/typescript-api/errors/error-result-union#errorresultunion'>ErrorResultUnion</a>&#60;RefundOrderResult, <a href='/reference/typescript-api/entities/refund#refund'>Refund</a>&#62;&#62;`}   />
 
 
-Creates a <a href='/reference/typescript-api/entities/refund#refund'>Refund</a> against the order and in doing so invokes the `createRefund()` method of the
+Creates a <a href='/reference/typescript-api/entities/refund#refund'>Refund</a> against the order and in doing so invokes the `createRefund()` method of the
 <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>.
 <a href='/reference/typescript-api/payment/payment-method-handler#paymentmethodhandler'>PaymentMethodHandler</a>.
 ### settleRefund
 ### settleRefund
 
 
@@ -431,15 +431,15 @@ Deletes an Order, ensuring that any Sessions that reference this Order are deref
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, user: <a href='/reference/typescript-api/entities/user#user'>User</a>, guestOrder?: <a href='/reference/typescript-api/entities/order#order'>Order</a>, existingOrder?: <a href='/reference/typescript-api/entities/order#order'>Order</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a> | undefined&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, user: <a href='/reference/typescript-api/entities/user#user'>User</a>, guestOrder?: <a href='/reference/typescript-api/entities/order#order'>Order</a>, existingOrder?: <a href='/reference/typescript-api/entities/order#order'>Order</a>) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a> | undefined&#62;`}   />
 
 
-When a guest user with an anonymous Order signs in and has an existing Order associated with that Customer,
-we need to reconcile the contents of the two orders.
-
+When a guest user with an anonymous Order signs in and has an existing Order associated with that Customer,
+we need to reconcile the contents of the two orders.
+
 The logic used to do the merging is specified in the <a href='/reference/typescript-api/orders/order-options#orderoptions'>OrderOptions</a> `mergeStrategy` config setting.
 The logic used to do the merging is specified in the <a href='/reference/typescript-api/orders/order-options#orderoptions'>OrderOptions</a> `mergeStrategy` config setting.
 ### applyPriceAdjustments
 ### applyPriceAdjustments
 
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, order: <a href='/reference/typescript-api/entities/order#order'>Order</a>, updatedOrderLines?: <a href='/reference/typescript-api/entities/order-line#orderline'>OrderLine</a>[], relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;`}   />
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, order: <a href='/reference/typescript-api/entities/order#order'>Order</a>, updatedOrderLines?: <a href='/reference/typescript-api/entities/order-line#orderline'>OrderLine</a>[], relations?: RelationPaths&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;) => Promise&#60;<a href='/reference/typescript-api/entities/order#order'>Order</a>&#62;`}   />
 
 
-Applies promotions, taxes and shipping to the Order. If the `updatedOrderLines` argument is passed in,
+Applies promotions, taxes and shipping to the Order. If the `updatedOrderLines` argument is passed in,
 then all of those OrderLines will have their prices re-calculated using the configured <a href='/reference/typescript-api/orders/order-item-price-calculation-strategy#orderitempricecalculationstrategy'>OrderItemPriceCalculationStrategy</a>.
 then all of those OrderLines will have their prices re-calculated using the configured <a href='/reference/typescript-api/orders/order-item-price-calculation-strategy#orderitempricecalculationstrategy'>OrderItemPriceCalculationStrategy</a>.
 
 
 
 

+ 46 - 0
docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md

@@ -0,0 +1,46 @@
+---
+title: "AddressBasedTaxZoneStrategy"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## AddressBasedTaxZoneStrategy
+
+<GenerationInfo sourceFile="packages/core/src/config/tax/address-based-tax-zone-strategy.ts" sourceLine="27" packageName="@vendure/core" since="3.1.0
+
+:::info
+
+This is configured via `taxOptions.taxZoneStrategy = new AddressBasedTaxZoneStrategy()` in
+your VendureConfig.
+
+:::" />
+
+Address based <a href='/reference/typescript-api/tax/tax-zone-strategy#taxzonestrategy'>TaxZoneStrategy</a> which tries to find the applicable <a href='/reference/typescript-api/entities/zone#zone'>Zone</a> based on the
+country of the billing address, or else the country of the shipping address of the Order.
+
+Returns the default <a href='/reference/typescript-api/entities/channel#channel'>Channel</a>'s default tax zone if no applicable zone is found.
+
+```ts title="Signature"
+class AddressBasedTaxZoneStrategy implements TaxZoneStrategy {
+    determineTaxZone(ctx: RequestContext, zones: Zone[], channel: Channel, order?: Order) => Zone;
+}
+```
+* Implements: <code><a href='/reference/typescript-api/tax/tax-zone-strategy#taxzonestrategy'>TaxZoneStrategy</a></code>
+
+
+
+<div className="members-wrapper">
+
+### determineTaxZone
+
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, zones: <a href='/reference/typescript-api/entities/zone#zone'>Zone</a>[], channel: <a href='/reference/typescript-api/entities/channel#channel'>Channel</a>, order?: <a href='/reference/typescript-api/entities/order#order'>Order</a>) => <a href='/reference/typescript-api/entities/zone#zone'>Zone</a>`}   />
+
+
+
+
+</div>

+ 11 - 11
docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md

@@ -27,10 +27,10 @@ class SimpleGraphQLClient {
     asUserWithCredentials(username: string, password: string) => ;
     asUserWithCredentials(username: string, password: string) => ;
     asSuperAdmin() => ;
     asSuperAdmin() => ;
     asAnonymousUser() => ;
     asAnonymousUser() => ;
-    fileUploadMutation(options: {
-        mutation: DocumentNode;
-        filePaths: string[];
-        mapVariables: (filePaths: string[]) => any;
+    fileUploadMutation(options: {
+        mutation: DocumentNode;
+        filePaths: string[];
+        mapVariables: (filePaths: string[]) => any;
     }) => Promise<any>;
     }) => Promise<any>;
 }
 }
 ```
 ```
@@ -66,8 +66,8 @@ Performs both query and mutation operations.
 
 
 <MemberInfo kind="method" type={`(url: string, options: RequestInit = {}) => Promise&#60;Response&#62;`}   />
 <MemberInfo kind="method" type={`(url: string, options: RequestInit = {}) => Promise&#60;Response&#62;`}   />
 
 
-Performs a raw HTTP request to the given URL, but also includes the authToken & channelToken
-headers if they have been set. Useful for testing non-GraphQL endpoints, e.g. for plugins
+Performs a raw HTTP request to the given URL, but also includes the authToken & channelToken
+headers if they have been set. Useful for testing non-GraphQL endpoints, e.g. for plugins
 which make use of REST controllers.
 which make use of REST controllers.
 ### queryStatus
 ### queryStatus
 
 
@@ -91,12 +91,12 @@ Logs in as the SuperAdmin user.
 Logs out so that the client is then treated as an anonymous user.
 Logs out so that the client is then treated as an anonymous user.
 ### fileUploadMutation
 ### fileUploadMutation
 
 
-<MemberInfo kind="method" type={`(options: {         mutation: DocumentNode;         filePaths: string[];         mapVariables: (filePaths: string[]) =&#62; any;     }) => Promise&#60;any&#62;`}   />
-
-Perform a file upload mutation.
-
-Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+<MemberInfo kind="method" type={`(options: {
         mutation: DocumentNode;
         filePaths: string[];
         mapVariables: (filePaths: string[]) =&#62; any;
     }) => Promise&#60;any&#62;`}   />
 
 
+Perform a file upload mutation.
+
+Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+
 Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
 Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
 
 
 *Example*
 *Example*

+ 25 - 1
packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts

@@ -11,6 +11,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import {
+    GetImageTransformParametersArgs,
+    ImageTransformParameters,
+    ImageTransformStrategy,
+} from '../src/config/image-transform-strategy';
 import { AssetServerPlugin } from '../src/plugin';
 import { AssetServerPlugin } from '../src/plugin';
 
 
 import {
 import {
@@ -23,18 +28,28 @@ import {
 const TEST_ASSET_DIR = 'test-assets';
 const TEST_ASSET_DIR = 'test-assets';
 const IMAGE_BASENAME = 'derick-david-409858-unsplash';
 const IMAGE_BASENAME = 'derick-david-409858-unsplash';
 
 
+class TestImageTransformStrategy implements ImageTransformStrategy {
+    getImageTransformParameters(args: GetImageTransformParametersArgs): ImageTransformParameters {
+        if (args.input.preset === 'test') {
+            throw new Error('Test error');
+        }
+        return args.input;
+    }
+}
+
 describe('AssetServerPlugin', () => {
 describe('AssetServerPlugin', () => {
     let asset: AssetFragment;
     let asset: AssetFragment;
     const sourceFilePath = path.join(__dirname, TEST_ASSET_DIR, `source/b6/${IMAGE_BASENAME}.jpg`);
     const sourceFilePath = path.join(__dirname, TEST_ASSET_DIR, `source/b6/${IMAGE_BASENAME}.jpg`);
     const previewFilePath = path.join(__dirname, TEST_ASSET_DIR, `preview/71/${IMAGE_BASENAME}__preview.jpg`);
     const previewFilePath = path.join(__dirname, TEST_ASSET_DIR, `preview/71/${IMAGE_BASENAME}__preview.jpg`);
 
 
-    const { server, adminClient, shopClient } = createTestEnvironment(
+    const { server, adminClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
         mergeConfig(testConfig(), {
             // logger: new DefaultLogger({ level: LogLevel.Info }),
             // logger: new DefaultLogger({ level: LogLevel.Info }),
             plugins: [
             plugins: [
                 AssetServerPlugin.init({
                 AssetServerPlugin.init({
                     assetUploadDir: path.join(__dirname, TEST_ASSET_DIR),
                     assetUploadDir: path.join(__dirname, TEST_ASSET_DIR),
                     route: 'assets',
                     route: 'assets',
+                    imageTransformStrategy: new TestImageTransformStrategy(),
                 }),
                 }),
             ],
             ],
         }),
         }),
@@ -228,6 +243,8 @@ describe('AssetServerPlugin', () => {
             it('blocks path traversal 7', testPathTraversalOnUrl(`/.%2F.%2F.%2Fpackage.json`));
             it('blocks path traversal 7', testPathTraversalOnUrl(`/.%2F.%2F.%2Fpackage.json`));
             it('blocks path traversal 8', testPathTraversalOnUrl(`/..\\\\..\\\\package.json`));
             it('blocks path traversal 8', testPathTraversalOnUrl(`/..\\\\..\\\\package.json`));
             it('blocks path traversal 9', testPathTraversalOnUrl(`/\\\\\\..\\\\\\..\\\\\\package.json`));
             it('blocks path traversal 9', testPathTraversalOnUrl(`/\\\\\\..\\\\\\..\\\\\\package.json`));
+            it('blocks path traversal 10', testPathTraversalOnUrl(`/./../././.././package.json`));
+            it('blocks path traversal 11', testPathTraversalOnUrl(`/\\.\\..\\.\\.\\..\\.\\package.json`));
         });
         });
     });
     });
 
 
@@ -315,6 +332,13 @@ describe('AssetServerPlugin', () => {
         expect(createAssets.length).toBe(1);
         expect(createAssets.length).toBe(1);
         expect(createAssets[0].name).toBe('bad-image.jpg');
         expect(createAssets[0].name).toBe('bad-image.jpg');
     });
     });
+
+    it('ImageTransformStrategy can throw to prevent transform', async () => {
+        const res = await fetch(`${asset.preview}?preset=test`);
+        expect(res.status).toBe(400);
+        const text = await res.text();
+        expect(text).toContain('Invalid parameters');
+    });
 });
 });
 
 
 export const CREATE_ASSETS = gql`
 export const CREATE_ASSETS = gql`

+ 6 - 2
packages/asset-server-plugin/index.ts

@@ -1,4 +1,8 @@
 export * from './src/plugin';
 export * from './src/plugin';
-export * from './src/s3-asset-storage-strategy';
-export * from './src/sharp-asset-preview-strategy';
+export * from './src/asset-server';
+export * from './src/config/s3-asset-storage-strategy';
+export * from './src/config/sharp-asset-preview-strategy';
+export * from './src/config/image-transform-strategy';
+export * from './src/config/preset-only-strategy';
+export * from './src/config/hashed-asset-naming-strategy';
 export * from './src/types';
 export * from './src/types';

+ 296 - 0
packages/asset-server-plugin/src/asset-server.ts

@@ -0,0 +1,296 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { AssetStorageStrategy, ConfigService, Logger, ProcessContext } from '@vendure/core';
+import { createHash } from 'crypto';
+import express, { NextFunction, Request, Response } from 'express';
+import fs from 'fs-extra';
+import path from 'path';
+
+import { getValidFormat } from './common';
+import { ImageTransformParameters, ImageTransformStrategy } from './config/image-transform-strategy';
+import { ASSET_SERVER_PLUGIN_INIT_OPTIONS, DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
+import { transformImage } from './transform-image';
+import { AssetServerOptions, ImageTransformMode, ImageTransformPreset } from './types';
+
+async function getFileType(buffer: Buffer) {
+    const { fileTypeFromBuffer } = await import('file-type');
+    return fileTypeFromBuffer(buffer);
+}
+
+/**
+ * This houses the actual Express server that handles incoming requests, performs image transformations,
+ * caches the results, and serves the transformed images.
+ */
+@Injectable()
+export class AssetServer {
+    private readonly assetStorageStrategy: AssetStorageStrategy;
+    private readonly cacheDir = 'cache';
+    private cacheHeader: string;
+    private presets: ImageTransformPreset[];
+    private imageTransformStrategies: ImageTransformStrategy[];
+
+    constructor(
+        @Inject(ASSET_SERVER_PLUGIN_INIT_OPTIONS) private options: AssetServerOptions,
+        private configService: ConfigService,
+        private processContext: ProcessContext,
+    ) {
+        this.assetStorageStrategy = this.configService.assetOptions.assetStorageStrategy;
+    }
+
+    /** @internal */
+    onApplicationBootstrap() {
+        if (this.processContext.isWorker) {
+            return;
+        }
+        // Configure Cache-Control header
+        const { cacheHeader } = this.options;
+        if (!cacheHeader) {
+            this.cacheHeader = DEFAULT_CACHE_HEADER;
+        } else {
+            if (typeof cacheHeader === 'string') {
+                this.cacheHeader = cacheHeader;
+            } else {
+                this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
+                    .filter(value => !!value)
+                    .join(', ');
+            }
+        }
+
+        const cachePath = path.join(this.options.assetUploadDir, this.cacheDir);
+        fs.ensureDirSync(cachePath);
+    }
+
+    /**
+     * Creates the image server instance
+     */
+    createAssetServer(serverConfig: {
+        presets: ImageTransformPreset[];
+        imageTransformStrategies: ImageTransformStrategy[];
+    }) {
+        this.presets = serverConfig.presets;
+        this.imageTransformStrategies = serverConfig.imageTransformStrategies;
+        const assetServer = express.Router();
+        assetServer.use(this.sendAsset(), this.generateTransformedImage());
+        return assetServer;
+    }
+
+    /**
+     * Reads the file requested and send the response to the browser.
+     */
+    private sendAsset() {
+        return async (req: Request, res: Response, next: NextFunction) => {
+            let params: ImageTransformParameters;
+            try {
+                params = await this.getImageTransformParameters(req);
+            } catch (e: any) {
+                Logger.error(e.message, loggerCtx);
+                res.status(400).send('Invalid parameters');
+                return;
+            }
+            const key = this.getFileNameFromParameters(req.path, params);
+            try {
+                const file = await this.assetStorageStrategy.readFileToBuffer(key);
+                let mimeType = this.getMimeType(key);
+                if (!mimeType) {
+                    mimeType = (await getFileType(file))?.mime || 'application/octet-stream';
+                }
+                res.contentType(mimeType);
+                res.setHeader('content-security-policy', "default-src 'self'");
+                res.setHeader('Cache-Control', this.cacheHeader);
+                res.send(file);
+            } catch (e: any) {
+                const err = new Error('File not found');
+                (err as any).status = 404;
+                return next(err);
+            }
+        };
+    }
+
+    /**
+     * If an exception was thrown by the first handler, then it may be because a transformed image
+     * is being requested which does not yet exist. In this case, this handler will generate the
+     * transformed image, save it to cache, and serve the result as a response.
+     */
+    private generateTransformedImage() {
+        return async (err: any, req: Request, res: Response, next: NextFunction) => {
+            if (err && (err.status === 404 || err.statusCode === 404)) {
+                if (req.query) {
+                    const decodedReqPath = this.sanitizeFilePath(req.path);
+                    Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx);
+                    let file: Buffer;
+                    try {
+                        file = await this.assetStorageStrategy.readFileToBuffer(decodedReqPath);
+                    } catch (_err: any) {
+                        res.status(404).send('Resource not found');
+                        return;
+                    }
+                    try {
+                        const parameters = await this.getImageTransformParameters(req);
+                        const image = await transformImage(file, parameters);
+                        const imageBuffer = await image.toBuffer();
+                        const cachedFileName = this.getFileNameFromParameters(req.path, parameters);
+                        if (!req.query.cache || req.query.cache === 'true') {
+                            await this.assetStorageStrategy.writeFileFromBuffer(cachedFileName, imageBuffer);
+                            Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
+                        }
+                        let mimeType = this.getMimeType(cachedFileName);
+                        if (!mimeType) {
+                            mimeType = (await getFileType(imageBuffer))?.mime || 'image/jpeg';
+                        }
+                        res.set('Content-Type', mimeType);
+                        res.setHeader('content-security-policy', "default-src 'self'");
+                        res.send(imageBuffer);
+                        return;
+                    } catch (e: any) {
+                        Logger.error(e.message, loggerCtx, e.stack);
+                        res.status(500).send('An error occurred when generating the image');
+                        return;
+                    }
+                }
+            }
+            next();
+        };
+    }
+
+    private async getImageTransformParameters(req: Request): Promise<ImageTransformParameters> {
+        let parameters = this.getInitialImageTransformParameters(req.query as any);
+        for (const strategy of this.imageTransformStrategies) {
+            try {
+                parameters = await strategy.getImageTransformParameters({
+                    req,
+                    input: { ...parameters },
+                    availablePresets: this.presets,
+                });
+            } catch (e: any) {
+                Logger.error(`Error applying ImageTransformStrategy: ` + (e.message as string), loggerCtx);
+                throw e;
+            }
+        }
+
+        let targetWidth: number | undefined = parameters.width;
+        let targetHeight: number | undefined = parameters.height;
+        let targetMode: ImageTransformMode | undefined = parameters.mode;
+
+        if (parameters.preset) {
+            const matchingPreset = this.presets.find(p => p.name === parameters.preset);
+            if (matchingPreset) {
+                targetWidth = matchingPreset.width;
+                targetHeight = matchingPreset.height;
+                targetMode = matchingPreset.mode;
+            }
+        }
+        return {
+            ...parameters,
+            width: targetWidth,
+            height: targetHeight,
+            mode: targetMode,
+        };
+    }
+
+    private getInitialImageTransformParameters(
+        queryParams: Record<string, string>,
+    ): ImageTransformParameters {
+        const width = Math.round(+queryParams.w) || undefined;
+        const height = Math.round(+queryParams.h) || undefined;
+        const quality =
+            queryParams.q != null ? Math.round(Math.max(Math.min(+queryParams.q, 100), 1)) : undefined;
+        const mode: ImageTransformMode = queryParams.mode === 'resize' ? 'resize' : 'crop';
+        const fpx = +queryParams.fpx || undefined;
+        const fpy = +queryParams.fpy || undefined;
+        const format = getValidFormat(queryParams.format);
+
+        return {
+            width,
+            height,
+            quality,
+            format,
+            mode,
+            fpx,
+            fpy,
+            preset: queryParams.preset,
+        };
+    }
+
+    private getFileNameFromParameters(filePath: string, params: ImageTransformParameters): string {
+        const { width: w, height: h, mode, preset, fpx, fpy, format, quality: q } = params;
+        /* eslint-disable @typescript-eslint/restrict-template-expressions */
+        const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
+        const quality = q ? `_q${q}` : '';
+        const imageFormat = getValidFormat(format);
+        let imageParamsString = '';
+        if (w || h) {
+            const width = w || '';
+            const height = h || '';
+            imageParamsString = `_transform_w${width}_h${height}_m${mode}`;
+        } else if (preset) {
+            if (this.presets && !!this.presets.find(p => p.name === preset)) {
+                imageParamsString = `_transform_pre_${preset}`;
+            }
+        }
+
+        if (focalPoint) {
+            imageParamsString += focalPoint;
+        }
+        if (imageFormat) {
+            imageParamsString += imageFormat;
+        }
+        if (quality) {
+            imageParamsString += quality;
+        }
+
+        const decodedReqPath = this.sanitizeFilePath(filePath);
+        if (imageParamsString !== '') {
+            const imageParamHash = this.md5(imageParamsString);
+            return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat));
+        } else {
+            return decodedReqPath;
+        }
+    }
+
+    /**
+     * Sanitize the file path to prevent directory traversal attacks.
+     */
+    private sanitizeFilePath(filePath: string): string {
+        let decodedPath: string;
+        try {
+            decodedPath = decodeURIComponent(filePath);
+        } catch (e: any) {
+            Logger.error((e.message as string) + ': ' + filePath, loggerCtx);
+            return '';
+        }
+        return path.normalize(decodedPath).replace(/(\.\.[\/\\])+/, '');
+    }
+
+    private md5(input: string): string {
+        return createHash('md5').update(input).digest('hex');
+    }
+
+    private addSuffix(fileName: string, suffix: string, ext?: string): string {
+        const originalExt = path.extname(fileName);
+        const effectiveExt = ext ? `.${ext}` : originalExt;
+        const baseName = path.basename(fileName, originalExt);
+        const dirName = path.dirname(fileName);
+        return path.join(dirName, `${baseName}${suffix}${effectiveExt}`);
+    }
+
+    /**
+     * Attempt to get the mime type from the file name.
+     */
+    private getMimeType(fileName: string): string | undefined {
+        const ext = path.extname(fileName);
+        switch (ext) {
+            case '.jpg':
+            case '.jpeg':
+                return 'image/jpeg';
+            case '.png':
+                return 'image/png';
+            case '.gif':
+                return 'image/gif';
+            case '.svg':
+                return 'image/svg+xml';
+            case '.tiff':
+                return 'image/tiff';
+            case '.webp':
+                return 'image/webp';
+        }
+    }
+}

+ 3 - 2
packages/asset-server-plugin/src/default-asset-storage-strategy-factory.ts → packages/asset-server-plugin/src/config/default-asset-storage-strategy-factory.ts

@@ -1,8 +1,9 @@
 import { Request } from 'express';
 import { Request } from 'express';
 
 
-import { getAssetUrlPrefixFn } from './common';
+import { getAssetUrlPrefixFn } from '../common';
+import { AssetServerOptions } from '../types';
+
 import { LocalAssetStorageStrategy } from './local-asset-storage-strategy';
 import { LocalAssetStorageStrategy } from './local-asset-storage-strategy';
-import { AssetServerOptions } from './types';
 
 
 /**
 /**
  * By default the AssetServerPlugin will configure and use the LocalStorageStrategy to persist Assets.
  * By default the AssetServerPlugin will configure and use the LocalStorageStrategy to persist Assets.

+ 0 - 0
packages/asset-server-plugin/src/hashed-asset-naming-strategy.ts → packages/asset-server-plugin/src/config/hashed-asset-naming-strategy.ts


+ 64 - 0
packages/asset-server-plugin/src/config/image-transform-strategy.ts

@@ -0,0 +1,64 @@
+import { InjectableStrategy } from '@vendure/core';
+import { Request } from 'express';
+
+import { ImageTransformFormat, ImageTransformMode, ImageTransformPreset } from '../types';
+
+/**
+ * @description
+ * Parameters which are used to transform the image.
+ *
+ * @docsCategory core plugins/AssetServerPlugin
+ * @since 3.1.0
+ * @docsPage ImageTransformStrategy
+ */
+export interface ImageTransformParameters {
+    width: number | undefined;
+    height: number | undefined;
+    mode: ImageTransformMode | undefined;
+    quality: number | undefined;
+    format: ImageTransformFormat | undefined;
+    fpx: number | undefined;
+    fpy: number | undefined;
+    preset: string | undefined;
+}
+
+/**
+ * @description
+ * The arguments passed to the `getImageTransformParameters` method of an ImageTransformStrategy.
+ *
+ * @docsCategory core plugins/AssetServerPlugin
+ * @since 3.1.0
+ * @docsPage ImageTransformStrategy
+ */
+export interface GetImageTransformParametersArgs {
+    req: Request;
+    availablePresets: ImageTransformPreset[];
+    input: ImageTransformParameters;
+}
+
+/**
+ * @description
+ * An injectable strategy which is used to determine the parameters for transforming an image.
+ * This can be used to implement custom image transformation logic, for example to
+ * limit transform parameters to a known set of presets.
+ *
+ * This is set via the `imageTransformStrategy` option in the AssetServerOptions. Multiple
+ * strategies can be defined and will be executed in the order in which they are defined.
+ *
+ * If a strategy throws an error, the image transformation will be aborted and the error
+ * will be logged, with an HTTP 400 response sent to the client.
+ *
+ * @docsCategory core plugins/AssetServerPlugin
+ * @docsPage ImageTransformStrategy
+ * @docsWeight 0
+ * @since 3.1.0
+ */
+export interface ImageTransformStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Given the input parameters, return the parameters which should be used to transform the image.
+     */
+    getImageTransformParameters(
+        args: GetImageTransformParametersArgs,
+    ): Promise<ImageTransformParameters> | ImageTransformParameters;
+}

+ 0 - 0
packages/asset-server-plugin/src/local-asset-storage-strategy.ts → packages/asset-server-plugin/src/config/local-asset-storage-strategy.ts


+ 112 - 0
packages/asset-server-plugin/src/config/preset-only-strategy.ts

@@ -0,0 +1,112 @@
+import { ImageTransformFormat } from '../types';
+
+import {
+    GetImageTransformParametersArgs,
+    ImageTransformParameters,
+    ImageTransformStrategy,
+} from './image-transform-strategy';
+
+/**
+ * @description
+ * Configuration options for the {@link PresetOnlyStrategy}.
+ *
+ * @docsCategory core plugins/AssetServerPlugin
+ * @docsPage PresetOnlyStrategy
+ */
+export interface PresetOnlyStrategyOptions {
+    /**
+     * @description
+     * The name of the default preset to use if no preset is specified in the URL.
+     */
+    defaultPreset: string;
+    /**
+     * @description
+     * The permitted quality of the transformed images. If set to 'any', then any quality is permitted.
+     * If set to an array of numbers (0-100), then only those quality values are permitted.
+     *
+     * @default [0, 50, 75, 85, 95]
+     */
+    permittedQuality?: number[];
+    /**
+     * @description
+     * The permitted formats of the transformed images. If set to 'any', then any format is permitted.
+     * If set to an array of strings e.g. `['jpg', 'webp']`, then only those formats are permitted.
+     *
+     * @default ['jpg', 'webp', 'avif']
+     */
+    permittedFormats?: ImageTransformFormat[];
+    /**
+     * @description
+     * Whether to allow the focal point to be specified in the URL.
+     *
+     * @default false
+     */
+    allowFocalPoint?: boolean;
+}
+
+/**
+ * @description
+ * An {@link ImageTransformStrategy} which only allows transformations to be made using
+ * presets which are defined in the available presets.
+ *
+ * With this strategy enabled, requests to the asset server must include a `preset` parameter (or use the default preset)
+ *
+ * This is valid: `http://localhost:3000/assets/some-asset.jpg?preset=medium`
+ *
+ * This is invalid: `http://localhost:3000/assets/some-asset.jpg?w=200&h=200`, and the dimensions will be ignored.
+ *
+ * The strategy can be configured to allow only certain quality values and formats, and to
+ * optionally allow the focal point to be specified in the URL.
+ *
+ * If a preset is not found in the available presets, an error will be thrown.
+ *
+ * @example
+ * ```ts
+ * import { AssetServerPlugin, PresetOnlyStrategy } from '\@vendure/core';
+ *
+ * // ...
+ *
+ * AssetServerPlugin.init({
+ *   //...
+ *   imageTransformStrategy: new PresetOnlyStrategy({
+ *     defaultPreset: 'thumbnail',
+ *     permittedQuality: [0, 50, 75, 85, 95],
+ *     permittedFormats: ['jpg', 'webp', 'avif'],
+ *     allowFocalPoint: true,
+ *   }),
+ * });
+ * ```
+ *
+ * @docsCategory core plugins/AssetServerPlugin
+ * @docsPage PresetOnlyStrategy
+ * @docsWeight 0
+ * @since 3.1.0
+ */
+export class PresetOnlyStrategy implements ImageTransformStrategy {
+    constructor(private options: PresetOnlyStrategyOptions) {}
+
+    getImageTransformParameters({
+        input,
+        availablePresets,
+    }: GetImageTransformParametersArgs): Promise<ImageTransformParameters> | ImageTransformParameters {
+        const presetName = input.preset ?? this.options.defaultPreset;
+        const matchingPreset = availablePresets.find(p => p.name === presetName);
+        if (!matchingPreset) {
+            throw new Error(`Preset "${presetName}" not found`);
+        }
+        const permittedQuality = this.options.permittedQuality ?? [0, 50, 75, 85, 95];
+        const permittedFormats = this.options.permittedFormats ?? ['jpg', 'webp', 'avif'];
+        const quality = input.quality && permittedQuality.includes(input.quality) ? input.quality : undefined;
+        const format = input.format && permittedFormats.includes(input.format) ? input.format : undefined;
+        return {
+            width: matchingPreset.width,
+            height: matchingPreset.height,
+            mode: matchingPreset.mode,
+            quality,
+            format,
+            fpx: this.options.allowFocalPoint ? input.fpx : undefined,
+            fpy: this.options.allowFocalPoint ? input.fpy : undefined,
+            preset: input.preset,
+        };
+    }
+}

+ 3 - 3
packages/asset-server-plugin/src/s3-asset-storage-strategy.ts → packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts

@@ -5,9 +5,9 @@ import { Request } from 'express';
 import * as path from 'node:path';
 import * as path from 'node:path';
 import { Readable } from 'node:stream';
 import { Readable } from 'node:stream';
 
 
-import { getAssetUrlPrefixFn } from './common';
-import { loggerCtx } from './constants';
-import { AssetServerOptions } from './types';
+import { getAssetUrlPrefixFn } from '../common';
+import { loggerCtx } from '../constants';
+import { AssetServerOptions } from '../types';
 
 
 /**
 /**
  * @description
  * @description

+ 2 - 2
packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts → packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts

@@ -3,7 +3,7 @@ import { AssetPreviewStrategy, getAssetType, Logger, RequestContext } from '@ven
 import path from 'path';
 import path from 'path';
 import sharp from 'sharp';
 import sharp from 'sharp';
 
 
-import { loggerCtx } from './constants';
+import { loggerCtx } from '../constants';
 
 
 /**
 /**
  * @description
  * @description
@@ -175,7 +175,7 @@ export class SharpAssetPreviewStrategy implements AssetPreviewStrategy {
     }
     }
 
 
     private generateBinaryFilePreview(mimeType: string): Promise<Buffer> {
     private generateBinaryFilePreview(mimeType: string): Promise<Buffer> {
-        return sharp(path.join(__dirname, 'file-icon.png'))
+        return sharp(path.join(__dirname, '..', 'file-icon.png'))
             .resize(800, 800, { fit: 'outside' })
             .resize(800, 800, { fit: 'outside' })
             .composite([
             .composite([
                 {
                 {

+ 1 - 0
packages/asset-server-plugin/src/constants.ts

@@ -1,2 +1,3 @@
 export const loggerCtx = 'AssetServerPlugin';
 export const loggerCtx = 'AssetServerPlugin';
 export const DEFAULT_CACHE_HEADER = 'public, max-age=15552000';
 export const DEFAULT_CACHE_HEADER = 'public, max-age=15552000';
+export const ASSET_SERVER_PLUGIN_INIT_OPTIONS = Symbol('ASSET_SERVER_PLUGIN_INIT_OPTIONS');

+ 105 - 228
packages/asset-server-plugin/src/plugin.ts

@@ -1,32 +1,29 @@
-import { MiddlewareConsumer, NestModule, OnApplicationBootstrap } from '@nestjs/common';
+import {
+    Inject,
+    MiddlewareConsumer,
+    NestModule,
+    OnApplicationBootstrap,
+    OnApplicationShutdown,
+} from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Type } from '@vendure/common/lib/shared-types';
 import {
 import {
-    AssetStorageStrategy,
+    Injector,
     Logger,
     Logger,
     PluginCommonModule,
     PluginCommonModule,
     ProcessContext,
     ProcessContext,
     registerPluginStartupMessage,
     registerPluginStartupMessage,
-    RuntimeVendureConfig,
     VendurePlugin,
     VendurePlugin,
 } from '@vendure/core';
 } from '@vendure/core';
-import { createHash } from 'crypto';
-import express, { NextFunction, Request, Response } from 'express';
-import fs from 'fs-extra';
-import path from 'path';
 
 
-import { getValidFormat } from './common';
-import { DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
-import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
-import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
-import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
-import { transformImage } from './transform-image';
+import { AssetServer } from './asset-server';
+import { defaultAssetStorageStrategyFactory } from './config/default-asset-storage-strategy-factory';
+import { HashedAssetNamingStrategy } from './config/hashed-asset-naming-strategy';
+import { ImageTransformStrategy } from './config/image-transform-strategy';
+import { SharpAssetPreviewStrategy } from './config/sharp-asset-preview-strategy';
+import { ASSET_SERVER_PLUGIN_INIT_OPTIONS, loggerCtx } from './constants';
 import { AssetServerOptions, ImageTransformPreset } from './types';
 import { AssetServerOptions, ImageTransformPreset } from './types';
 
 
-async function getFileType(buffer: Buffer) {
-    const { fileTypeFromBuffer } = await import('file-type');
-    return fileTypeFromBuffer(buffer);
-}
-
 /**
 /**
  * @description
  * @description
  * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
  * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
@@ -148,25 +145,64 @@ async function getFileType(buffer: Buffer) {
  * By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
  * By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
  * a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
  * a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
  *
  *
+ * ### Limiting transformations
+ *
+ * By default, the AssetServerPlugin will allow any transformation to be performed on an image. However, it is possible to restrict the transformations
+ * which can be performed by using an {@link ImageTransformStrategy}. This can be used to limit the transformations to a known set of presets, for example.
+ *
+ * This is advisable in order to prevent abuse of the image transformation feature, as it can be computationally expensive.
+ *
+ * Since v3.1.0 we ship with a {@link PresetOnlyStrategy} which allows only transformations using a known set of presets.
+ *
+ * @example
+ * ```ts
+ * import { AssetServerPlugin, PresetOnlyStrategy } from '\@vendure/core';
+ *
+ * // ...
+ *
+ * AssetServerPlugin.init({
+ *   //...
+ *   imageTransformStrategy: new PresetOnlyStrategy({
+ *     defaultPreset: 'thumbnail',
+ *     permittedQuality: [0, 50, 75, 85, 95],
+ *     permittedFormats: ['jpg', 'webp', 'avif'],
+ *     allowFocalPoint: false,
+ *   }),
+ * });
+ * ```
+ *
  * @docsCategory core plugins/AssetServerPlugin
  * @docsCategory core plugins/AssetServerPlugin
  */
  */
 @VendurePlugin({
 @VendurePlugin({
     imports: [PluginCommonModule],
     imports: [PluginCommonModule],
-    configuration: config => AssetServerPlugin.configure(config),
+    configuration: async config => {
+        const options = AssetServerPlugin.options;
+        const storageStrategyFactory = options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
+        config.assetOptions.assetPreviewStrategy =
+            options.previewStrategy ??
+            new SharpAssetPreviewStrategy({
+                maxWidth: options.previewMaxWidth,
+                maxHeight: options.previewMaxHeight,
+            });
+        config.assetOptions.assetStorageStrategy = await storageStrategyFactory(options);
+        config.assetOptions.assetNamingStrategy = options.namingStrategy || new HashedAssetNamingStrategy();
+        return config;
+    },
+    providers: [
+        { provide: ASSET_SERVER_PLUGIN_INIT_OPTIONS, useFactory: () => AssetServerPlugin.options },
+        AssetServer,
+    ],
     compatibility: '^3.0.0',
     compatibility: '^3.0.0',
 })
 })
-export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
-    private static assetStorage: AssetStorageStrategy;
-    private readonly cacheDir = 'cache';
-    private presets: ImageTransformPreset[] = [
+export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, OnApplicationShutdown {
+    private static options: AssetServerOptions;
+    private readonly defaultPresets: ImageTransformPreset[] = [
         { name: 'tiny', width: 50, height: 50, mode: 'crop' },
         { name: 'tiny', width: 50, height: 50, mode: 'crop' },
         { name: 'thumb', width: 150, height: 150, mode: 'crop' },
         { name: 'thumb', width: 150, height: 150, mode: 'crop' },
         { name: 'small', width: 300, height: 300, mode: 'resize' },
         { name: 'small', width: 300, height: 300, mode: 'resize' },
         { name: 'medium', width: 500, height: 500, mode: 'resize' },
         { name: 'medium', width: 500, height: 500, mode: 'resize' },
         { name: 'large', width: 800, height: 800, mode: 'resize' },
         { name: 'large', width: 800, height: 800, mode: 'resize' },
     ];
     ];
-    private static options: AssetServerOptions;
-    private cacheHeader: string;
 
 
     /**
     /**
      * @description
      * @description
@@ -177,230 +213,71 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
         return this;
         return this;
     }
     }
 
 
-    /** @internal */
-    static async configure(config: RuntimeVendureConfig) {
-        const storageStrategyFactory =
-            this.options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
-        this.assetStorage = await storageStrategyFactory(this.options);
-        config.assetOptions.assetPreviewStrategy =
-            this.options.previewStrategy ??
-            new SharpAssetPreviewStrategy({
-                maxWidth: this.options.previewMaxWidth,
-                maxHeight: this.options.previewMaxHeight,
-            });
-        config.assetOptions.assetStorageStrategy = this.assetStorage;
-        config.assetOptions.assetNamingStrategy =
-            this.options.namingStrategy || new HashedAssetNamingStrategy();
-        return config;
-    }
-
-    constructor(private processContext: ProcessContext) {}
+    constructor(
+        @Inject(ASSET_SERVER_PLUGIN_INIT_OPTIONS) private options: AssetServerOptions,
+        private processContext: ProcessContext,
+        private moduleRef: ModuleRef,
+        private assetServer: AssetServer,
+    ) {}
 
 
     /** @internal */
     /** @internal */
-    onApplicationBootstrap(): void {
+    async onApplicationBootstrap() {
         if (this.processContext.isWorker) {
         if (this.processContext.isWorker) {
             return;
             return;
         }
         }
-        if (AssetServerPlugin.options.presets) {
-            for (const preset of AssetServerPlugin.options.presets) {
-                const existingIndex = this.presets.findIndex(p => p.name === preset.name);
-                if (-1 < existingIndex) {
-                    this.presets.splice(existingIndex, 1, preset);
-                } else {
-                    this.presets.push(preset);
+        if (this.options.imageTransformStrategy != null) {
+            const injector = new Injector(this.moduleRef);
+            for (const strategy of this.getImageTransformStrategyArray()) {
+                if (typeof strategy.init === 'function') {
+                    await strategy.init(injector);
                 }
                 }
             }
             }
         }
         }
-
-        // Configure Cache-Control header
-        const { cacheHeader } = AssetServerPlugin.options;
-        if (!cacheHeader) {
-            this.cacheHeader = DEFAULT_CACHE_HEADER;
-        } else {
-            if (typeof cacheHeader === 'string') {
-                this.cacheHeader = cacheHeader;
-            } else {
-                this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
-                    .filter(value => !!value)
-                    .join(', ');
-            }
-        }
-
-        const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
-        fs.ensureDirSync(cachePath);
     }
     }
 
 
-    configure(consumer: MiddlewareConsumer) {
+    /** @internal */
+    async onApplicationShutdown() {
         if (this.processContext.isWorker) {
         if (this.processContext.isWorker) {
             return;
             return;
         }
         }
-        Logger.info('Creating asset server middleware', loggerCtx);
-        consumer.apply(this.createAssetServer()).forRoutes(AssetServerPlugin.options.route);
-        registerPluginStartupMessage('Asset server', AssetServerPlugin.options.route);
-    }
-
-    /**
-     * Creates the image server instance
-     */
-    private createAssetServer() {
-        const assetServer = express.Router();
-        assetServer.use(this.sendAsset(), this.generateTransformedImage());
-        return assetServer;
-    }
-
-    /**
-     * Reads the file requested and send the response to the browser.
-     */
-    private sendAsset() {
-        return async (req: Request, res: Response, next: NextFunction) => {
-            const key = this.getFileNameFromRequest(req);
-            try {
-                const file = await AssetServerPlugin.assetStorage.readFileToBuffer(key);
-                let mimeType = this.getMimeType(key);
-                if (!mimeType) {
-                    mimeType = (await getFileType(file))?.mime || 'application/octet-stream';
+        if (this.options.imageTransformStrategy != null) {
+            for (const strategy of this.getImageTransformStrategyArray()) {
+                if (typeof strategy.destroy === 'function') {
+                    await strategy.destroy();
                 }
                 }
-                res.contentType(mimeType);
-                res.setHeader('content-security-policy', "default-src 'self'");
-                res.setHeader('Cache-Control', this.cacheHeader);
-                res.send(file);
-            } catch (e: any) {
-                const err = new Error('File not found');
-                (err as any).status = 404;
-                return next(err);
             }
             }
-        };
+        }
     }
     }
 
 
-    /**
-     * If an exception was thrown by the first handler, then it may be because a transformed image
-     * is being requested which does not yet exist. In this case, this handler will generate the
-     * transformed image, save it to cache, and serve the result as a response.
-     */
-    private generateTransformedImage() {
-        return async (err: any, req: Request, res: Response, next: NextFunction) => {
-            if (err && (err.status === 404 || err.statusCode === 404)) {
-                if (req.query) {
-                    const decodedReqPath = this.sanitizeFilePath(req.path);
-                    Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx);
-                    let file: Buffer;
-                    try {
-                        file = await AssetServerPlugin.assetStorage.readFileToBuffer(decodedReqPath);
-                    } catch (_err: any) {
-                        res.status(404).send('Resource not found');
-                        return;
-                    }
-                    const image = await transformImage(file, req.query as any, this.presets || []);
-                    try {
-                        const imageBuffer = await image.toBuffer();
-                        const cachedFileName = this.getFileNameFromRequest(req);
-                        if (!req.query.cache || req.query.cache === 'true') {
-                            await AssetServerPlugin.assetStorage.writeFileFromBuffer(
-                                cachedFileName,
-                                imageBuffer,
-                            );
-                            Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
-                        }
-                        let mimeType = this.getMimeType(cachedFileName);
-                        if (!mimeType) {
-                            mimeType = (await getFileType(imageBuffer))?.mime || 'image/jpeg';
-                        }
-                        res.set('Content-Type', mimeType);
-                        res.setHeader('content-security-policy', "default-src 'self'");
-                        res.send(imageBuffer);
-                        return;
-                    } catch (e: any) {
-                        Logger.error(e.message, loggerCtx, e.stack);
-                        res.status(500).send('An error occurred when generating the image');
-                        return;
-                    }
+    configure(consumer: MiddlewareConsumer) {
+        if (this.processContext.isWorker) {
+            return;
+        }
+        const presets = [...this.defaultPresets];
+        if (this.options.presets) {
+            for (const preset of this.options.presets) {
+                const existingIndex = presets.findIndex(p => p.name === preset.name);
+                if (-1 < existingIndex) {
+                    presets.splice(existingIndex, 1, preset);
+                } else {
+                    presets.push(preset);
                 }
                 }
             }
             }
-            next();
-        };
-    }
-
-    private getFileNameFromRequest(req: Request): string {
-        const { w, h, mode, preset, fpx, fpy, format, q } = req.query;
-        /* eslint-disable @typescript-eslint/restrict-template-expressions */
-        const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
-        const quality = q ? `_q${q}` : '';
-        const imageFormat = getValidFormat(format);
-        let imageParamsString = '';
-        if (w || h) {
-            const width = w || '';
-            const height = h || '';
-            imageParamsString = `_transform_w${width}_h${height}_m${mode}`;
-        } else if (preset) {
-            if (this.presets && !!this.presets.find(p => p.name === preset)) {
-                imageParamsString = `_transform_pre_${preset}`;
-            }
         }
         }
-
-        if (focalPoint) {
-            imageParamsString += focalPoint;
-        }
-        if (imageFormat) {
-            imageParamsString += imageFormat;
-        }
-        if (quality) {
-            imageParamsString += quality;
-        }
-
-        const decodedReqPath = this.sanitizeFilePath(req.path);
-        if (imageParamsString !== '') {
-            const imageParamHash = this.md5(imageParamsString);
-            return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat));
-        } else {
-            return decodedReqPath;
-        }
-    }
-
-    /**
-     * Sanitize the file path to prevent directory traversal attacks.
-     */
-    private sanitizeFilePath(filePath: string): string {
-        let decodedPath: string;
-        try {
-            decodedPath = decodeURIComponent(filePath);
-        } catch (e: any) {
-            Logger.error((e.message as string) + ': ' + filePath, loggerCtx);
-            return '';
-        }
-        return path.normalize(decodedPath).replace(/(\.\.[\/\\])+/, '');
-    }
-
-    private md5(input: string): string {
-        return createHash('md5').update(input).digest('hex');
-    }
-
-    private addSuffix(fileName: string, suffix: string, ext?: string): string {
-        const originalExt = path.extname(fileName);
-        const effectiveExt = ext ? `.${ext}` : originalExt;
-        const baseName = path.basename(fileName, originalExt);
-        const dirName = path.dirname(fileName);
-        return path.join(dirName, `${baseName}${suffix}${effectiveExt}`);
+        Logger.info('Creating asset server middleware', loggerCtx);
+        const assetServerRouter = this.assetServer.createAssetServer({
+            presets,
+            imageTransformStrategies: this.getImageTransformStrategyArray(),
+        });
+        consumer.apply(assetServerRouter).forRoutes(this.options.route);
+        registerPluginStartupMessage('Asset server', this.options.route);
     }
     }
 
 
-    /**
-     * Attempt to get the mime type from the file name.
-     */
-    private getMimeType(fileName: string): string | undefined {
-        const ext = path.extname(fileName);
-        switch (ext) {
-            case '.jpg':
-            case '.jpeg':
-                return 'image/jpeg';
-            case '.png':
-                return 'image/png';
-            case '.gif':
-                return 'image/gif';
-            case '.svg':
-                return 'image/svg+xml';
-            case '.tiff':
-                return 'image/tiff';
-            case '.webp':
-                return 'image/webp';
-        }
+    private getImageTransformStrategyArray(): ImageTransformStrategy[] {
+        return this.options.imageTransformStrategy
+            ? Array.isArray(this.options.imageTransformStrategy)
+                ? this.options.imageTransformStrategy
+                : [this.options.imageTransformStrategy]
+            : [];
     }
     }
 }
 }

+ 16 - 28
packages/asset-server-plugin/src/transform-image.ts

@@ -1,9 +1,9 @@
 import { Logger } from '@vendure/core';
 import { Logger } from '@vendure/core';
 import sharp, { FormatEnum, Region, ResizeOptions } from 'sharp';
 import sharp, { FormatEnum, Region, ResizeOptions } from 'sharp';
 
 
-import { getValidFormat } from './common';
+import { ImageTransformParameters } from './config/image-transform-strategy';
 import { loggerCtx } from './constants';
 import { loggerCtx } from './constants';
-import { ImageTransformFormat, ImageTransformPreset } from './types';
+import { ImageTransformFormat } from './types';
 
 
 export type Dimensions = { w: number; h: number };
 export type Dimensions = { w: number; h: number };
 export type Point = { x: number; y: number };
 export type Point = { x: number; y: number };
@@ -13,25 +13,9 @@ export type Point = { x: number; y: number };
  */
  */
 export async function transformImage(
 export async function transformImage(
     originalImage: Buffer,
     originalImage: Buffer,
-    queryParams: Record<string, string>,
-    presets: ImageTransformPreset[],
+    parameters: ImageTransformParameters,
 ): Promise<sharp.Sharp> {
 ): Promise<sharp.Sharp> {
-    let targetWidth = Math.round(+queryParams.w) || undefined;
-    let targetHeight = Math.round(+queryParams.h) || undefined;
-    const quality =
-        queryParams.q != null ? Math.round(Math.max(Math.min(+queryParams.q, 100), 1)) : undefined;
-    let mode = queryParams.mode || 'crop';
-    const fpx = +queryParams.fpx || undefined;
-    const fpy = +queryParams.fpy || undefined;
-    const imageFormat = getValidFormat(queryParams.format);
-    if (queryParams.preset) {
-        const matchingPreset = presets.find(p => p.name === queryParams.preset);
-        if (matchingPreset) {
-            targetWidth = matchingPreset.width;
-            targetHeight = matchingPreset.height;
-            mode = matchingPreset.mode;
-        }
-    }
+    const { width, height, mode, format } = parameters;
     const options: ResizeOptions = {};
     const options: ResizeOptions = {};
     if (mode === 'crop') {
     if (mode === 'crop') {
         options.position = sharp.strategy.entropy;
         options.position = sharp.strategy.entropy;
@@ -41,25 +25,29 @@ export async function transformImage(
 
 
     const image = sharp(originalImage);
     const image = sharp(originalImage);
     try {
     try {
-        await applyFormat(image, imageFormat, quality);
+        await applyFormat(image, parameters.format, parameters.quality);
     } catch (e: any) {
     } catch (e: any) {
         Logger.error(e.message, loggerCtx, e.stack);
         Logger.error(e.message, loggerCtx, e.stack);
     }
     }
-    if (fpx && fpy && targetWidth && targetHeight && mode === 'crop') {
+    if (parameters.fpx && parameters.fpy && width && height && mode === 'crop') {
         const metadata = await image.metadata();
         const metadata = await image.metadata();
         if (metadata.width && metadata.height) {
         if (metadata.width && metadata.height) {
-            const xCenter = fpx * metadata.width;
-            const yCenter = fpy * metadata.height;
-            const { width, height, region } = resizeToFocalPoint(
+            const xCenter = parameters.fpx * metadata.width;
+            const yCenter = parameters.fpy * metadata.height;
+            const {
+                width: resizedWidth,
+                height: resizedHeight,
+                region,
+            } = resizeToFocalPoint(
                 { w: metadata.width, h: metadata.height },
                 { w: metadata.width, h: metadata.height },
-                { w: targetWidth, h: targetHeight },
+                { w: width, h: height },
                 { x: xCenter, y: yCenter },
                 { x: xCenter, y: yCenter },
             );
             );
-            return image.resize(width, height).extract(region);
+            return image.resize(resizedWidth, resizedHeight).extract(region);
         }
         }
     }
     }
 
 
-    return image.resize(targetWidth, targetHeight, options);
+    return image.resize(width, height, options);
 }
 }
 
 
 async function applyFormat(
 async function applyFormat(

+ 16 - 0
packages/asset-server-plugin/src/types.ts

@@ -5,6 +5,8 @@ import {
     RequestContext,
     RequestContext,
 } from '@vendure/core';
 } from '@vendure/core';
 
 
+import { ImageTransformStrategy } from './config/image-transform-strategy';
+
 export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif';
 export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif';
 
 
 /**
 /**
@@ -112,6 +114,20 @@ export interface AssetServerOptions {
      * An array of additional {@link ImageTransformPreset} objects.
      * An array of additional {@link ImageTransformPreset} objects.
      */
      */
     presets?: ImageTransformPreset[];
     presets?: ImageTransformPreset[];
+    /**
+     * @description
+     * The strategy or strategies to use to determine the parameters for transforming an image.
+     * This can be used to implement custom image transformation logic, for example to
+     * limit transform parameters to a known set of presets.
+     *
+     * If multiple strategies are provided, they will be executed in the order in which they are defined.
+     * If a strategy throws an error, the image transformation will be aborted and the error
+     * will be logged, with an HTTP 400 response sent to the client.
+     *
+     * @since 3.1.0
+     * @default []
+     */
+    imageTransformStrategy?: ImageTransformStrategy | ImageTransformStrategy[];
     /**
     /**
      * @description
      * @description
      * Defines how asset files and preview images are named before being saved.
      * Defines how asset files and preview images are named before being saved.