Explorar el Código

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

Closes #3040
Michael Bromley hace 1 año
padre
commit
dde738d79e
Se han modificado 30 ficheros con 1137 adiciones y 367 borrados
  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;
 ```
 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/).
 :::
 
+### 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
 

+ 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
 
-<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.
 
@@ -23,10 +23,11 @@ interface AssetServerOptions {
     previewMaxWidth?: number;
     previewMaxHeight?: number;
     presets?: ImageTransformPreset[];
+    imageTransformStrategy?: ImageTransformStrategy | ImageTransformStrategy[];
     namingStrategy?: AssetNamingStrategy;
     previewStrategy?: AssetPreviewStrategy;
-    storageStrategyFactory?: (
-        options: AssetServerOptions,
+    storageStrategyFactory?: (
+        options: AssetServerOptions,
     ) => AssetStorageStrategy | Promise<AssetStorageStrategy>;
     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)`}   />
 
-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.
 ### 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>[]`}   />
 
 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
 
 <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"  />
 
-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>
 ### 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.
 ### 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"  />
 
-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.
 
 

+ 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
 
-<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.
 

+ 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
 
-<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
 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
 
-<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
 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
 
-<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.
 

+ 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
 
-<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
 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
 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"
-class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
+class AssetServerPlugin implements NestModule, OnApplicationBootstrap, OnApplicationShutdown {
     init(options: AssetServerOptions) => Type<AssetServerPlugin>;
-    constructor(processContext: ProcessContext)
+    constructor(options: AssetServerOptions, processContext: ProcessContext, moduleRef: ModuleRef, assetServer: AssetServer)
     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.
 ### 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

+ 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
 
-<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.
 

+ 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
 
-<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.
 To us this strategy you must first have access to an AWS account.
@@ -100,7 +100,7 @@ class S3AssetStorageStrategy implements AssetStorageStrategy {
 
 ## 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.
 
@@ -149,10 +149,9 @@ Using type `any` in order to avoid the need to include `aws-sdk` dependency in g
 
 ## 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:
 

+ 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
 
-<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
 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
 
-<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
 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) => ;
     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>>;
-    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>> }>;
     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>> }>;
@@ -95,8 +95,8 @@ class OrderService {
 
 <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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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"  />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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.
 ### 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;`}   />
 
-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.
 ### 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"  />
 
-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.
 ### 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;`}   />
 
-Removes the specified OrderLine from the Order.
-
+Removes the specified OrderLine from the Order.
+
 If you need to remove multiple OrderLines, use `removeItemsFromOrder()` instead.
 ### 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"  />
 
-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.
 ### 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;`}   />
 
-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>.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.__
 ### 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;`}   />
 
-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`.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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>.
 ### 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;`}   />
 
-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.
 ### 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;`}   />
 
-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>.
 
 

+ 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) => ;
     asSuperAdmin() => ;
     asAnonymousUser() => ;
-    fileUploadMutation(options: {
-        mutation: DocumentNode;
-        filePaths: string[];
-        mapVariables: (filePaths: string[]) => any;
+    fileUploadMutation(options: {
+        mutation: DocumentNode;
+        filePaths: string[];
+        mapVariables: (filePaths: string[]) => 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;`}   />
 
-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.
 ### queryStatus
 
@@ -91,12 +91,12 @@ Logs in as the SuperAdmin user.
 Logs out so that the client is then treated as an anonymous user.
 ### 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
 
 *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 { 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 {
@@ -23,18 +28,28 @@ import {
 const TEST_ASSET_DIR = 'test-assets';
 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', () => {
     let asset: AssetFragment;
     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 { server, adminClient, shopClient } = createTestEnvironment(
+    const { server, adminClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
             // logger: new DefaultLogger({ level: LogLevel.Info }),
             plugins: [
                 AssetServerPlugin.init({
                     assetUploadDir: path.join(__dirname, TEST_ASSET_DIR),
                     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 8', 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[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`

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

@@ -1,4 +1,8 @@
 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';

+ 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 { getAssetUrlPrefixFn } from './common';
+import { getAssetUrlPrefixFn } from '../common';
+import { AssetServerOptions } from '../types';
+
 import { LocalAssetStorageStrategy } from './local-asset-storage-strategy';
-import { AssetServerOptions } from './types';
 
 /**
  * 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 { 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

+ 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 sharp from 'sharp';
 
-import { loggerCtx } from './constants';
+import { loggerCtx } from '../constants';
 
 /**
  * @description
@@ -175,7 +175,7 @@ export class SharpAssetPreviewStrategy implements AssetPreviewStrategy {
     }
 
     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' })
             .composite([
                 {

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

@@ -1,2 +1,3 @@
 export const loggerCtx = 'AssetServerPlugin';
 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 {
-    AssetStorageStrategy,
+    Injector,
     Logger,
     PluginCommonModule,
     ProcessContext,
     registerPluginStartupMessage,
-    RuntimeVendureConfig,
     VendurePlugin,
 } 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';
 
-async function getFileType(buffer: Buffer) {
-    const { fileTypeFromBuffer } = await import('file-type');
-    return fileTypeFromBuffer(buffer);
-}
-
 /**
  * @description
  * 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
  * 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
  */
 @VendurePlugin({
     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',
 })
-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: 'thumb', width: 150, height: 150, mode: 'crop' },
         { name: 'small', width: 300, height: 300, mode: 'resize' },
         { name: 'medium', width: 500, height: 500, mode: 'resize' },
         { name: 'large', width: 800, height: 800, mode: 'resize' },
     ];
-    private static options: AssetServerOptions;
-    private cacheHeader: string;
 
     /**
      * @description
@@ -177,230 +213,71 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
         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 */
-    onApplicationBootstrap(): void {
+    async onApplicationBootstrap() {
         if (this.processContext.isWorker) {
             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) {
             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 sharp, { FormatEnum, Region, ResizeOptions } from 'sharp';
 
-import { getValidFormat } from './common';
+import { ImageTransformParameters } from './config/image-transform-strategy';
 import { loggerCtx } from './constants';
-import { ImageTransformFormat, ImageTransformPreset } from './types';
+import { ImageTransformFormat } from './types';
 
 export type Dimensions = { w: number; h: number };
 export type Point = { x: number; y: number };
@@ -13,25 +13,9 @@ export type Point = { x: number; y: number };
  */
 export async function transformImage(
     originalImage: Buffer,
-    queryParams: Record<string, string>,
-    presets: ImageTransformPreset[],
+    parameters: ImageTransformParameters,
 ): 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 = {};
     if (mode === 'crop') {
         options.position = sharp.strategy.entropy;
@@ -41,25 +25,29 @@ export async function transformImage(
 
     const image = sharp(originalImage);
     try {
-        await applyFormat(image, imageFormat, quality);
+        await applyFormat(image, parameters.format, parameters.quality);
     } catch (e: any) {
         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();
         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: targetWidth, h: targetHeight },
+                { w: width, h: height },
                 { 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(

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

@@ -5,6 +5,8 @@ import {
     RequestContext,
 } from '@vendure/core';
 
+import { ImageTransformStrategy } from './config/image-transform-strategy';
+
 export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif';
 
 /**
@@ -112,6 +114,20 @@ export interface AssetServerOptions {
      * An array of additional {@link ImageTransformPreset} objects.
      */
     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
      * Defines how asset files and preview images are named before being saved.