diff --git a/docs/docs/guides/deployment/production-configuration/index.md b/docs/docs/guides/deployment/production-configuration/index.md index f30acafdde..17cee3bebf 100644 --- a/docs/docs/guides/deployment/production-configuration/index.md +++ b/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. diff --git a/docs/docs/guides/developer-guide/security/index.md b/docs/docs/guides/developer-guide/security/index.md index 29f892cecd..656b0c7e22 100644 --- a/docs/docs/guides/developer-guide/security/index.md +++ b/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 diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md b/docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md index b86ff793b3..cffaf52e34 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/asset-server-options.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AssetServerOptions - + 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; cacheHeader?: CacheConfig | string; } @@ -48,12 +49,12 @@ The local directory to which assets will be uploaded when using the RequestContext, identifier: string) => 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. ImageTransformPreset[]`} /> An array of additional ImageTransformPreset objects. +### imageTransformStrategy + +ImageTransformStrategy | ImageTransformStrategy[]`} 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 AssetNamingStrategy`} default={`HashedAssetNamingStrategy`} /> @@ -79,19 +91,19 @@ Defines how asset files and preview images are named before being saved. AssetPreviewStrategy`} 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 SharpAssetPreviewStrategy ### storageStrategyFactory -AssetServerOptions, ) => AssetStorageStrategy | Promise<AssetStorageStrategy>`} default={`() => LocalAssetStorageStrategy`} /> +AssetServerOptions, ) => AssetStorageStrategy | Promise<AssetStorageStrategy>`} default={`() => LocalAssetStorageStrategy`} /> -A function which can be used to configure an AssetStorageStrategy. This is useful e.g. if you wish to store your assets +A function which can be used to configure an AssetStorageStrategy. This is useful e.g. if you wish to store your assets using a cloud storage provider. By default, the LocalAssetStorageStrategy is used. ### cacheHeader CacheConfig | 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. diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md b/docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md index 87c471538e..b1d2711c90 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/cache-config.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CacheConfig - + A configuration option for the Cache-Control header in the AssetServerPlugin asset response. diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy.md index 356eb20a6d..204969ed20 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/hashed-asset-naming-strategy.md +++ b/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 - + An extension of the DefaultAssetNamingStrategy which prefixes file names with the type (`'source'` or `'preview'`) as well as a 2-character sub-directory based on diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md index 47475707c5..407af0f49f 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-mode.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ImageTransformMode - + Specifies the way in which an asset preview image will be resized to fit in the proscribed dimensions: diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md index c5061020da..fc16c170b1 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-preset.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ImageTransformPreset - + A configuration option for an image size preset for the AssetServerPlugin. diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md new file mode 100644 index 0000000000..fd1f2f194c --- /dev/null +++ b/docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md @@ -0,0 +1,148 @@ +--- +title: "ImageTransformStrategy" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## ImageTransformStrategy + + + +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; +} +``` +* Extends: InjectableStrategy + + + +
+ +### getImageTransformParameters + +GetImageTransformParametersArgs) => Promise<ImageTransformParameters> | ImageTransformParameters`} /> + +Given the input parameters, return the parameters which should be used to transform the image. + + +
+ + +## ImageTransformParameters + + + +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; +} +``` + +
+ +### width + + + + +### height + + + + +### mode + +ImageTransformMode | undefined`} /> + + +### quality + + + + +### format + + + + +### fpx + + + + +### fpy + + + + +### preset + + + + + + +
+ + +## GetImageTransformParametersArgs + + + +The arguments passed to the `getImageTransformParameters` method of an ImageTransformStrategy. + +```ts title="Signature" +interface GetImageTransformParametersArgs { + req: Request; + availablePresets: ImageTransformPreset[]; + input: ImageTransformParameters; +} +``` + +
+ +### req + + + + +### availablePresets + +ImageTransformPreset[]`} /> + + +### input + +ImageTransformParameters`} /> + + + + +
diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/index.md b/docs/docs/reference/core-plugins/asset-server-plugin/index.md index 901e5c0be8..f1a5fd222d 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/index.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/index.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AssetServerPlugin - + 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. S3AssetStorageStrategy. 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 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 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, + }), +}); +``` + ```ts title="Signature" -class AssetServerPlugin implements NestModule, OnApplicationBootstrap { +class AssetServerPlugin implements NestModule, OnApplicationBootstrap, OnApplicationShutdown { init(options: AssetServerOptions) => Type; - constructor(processContext: ProcessContext) + constructor(options: AssetServerOptions, processContext: ProcessContext, moduleRef: ModuleRef, assetServer: AssetServer) configure(consumer: MiddlewareConsumer) => ; } ``` -* Implements: NestModule, OnApplicationBootstrap +* Implements: NestModule, OnApplicationBootstrap, OnApplicationShutdown @@ -153,7 +180,7 @@ class AssetServerPlugin implements NestModule, OnApplicationBootstrap { Set the plugin options. ### constructor -ProcessContext) => AssetServerPlugin`} /> +AssetServerOptions, processContext: ProcessContext, moduleRef: ModuleRef, assetServer: AssetServer) => AssetServerPlugin`} /> ### configure diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy.md index 73ecfe29f2..9e3ef103ef 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/local-asset-storage-strategy.md +++ b/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 - + A persistence strategy which saves files to the local file system. diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md new file mode 100644 index 0000000000..f6eddb7d06 --- /dev/null +++ b/docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md @@ -0,0 +1,122 @@ +--- +title: "PresetOnlyStrategy" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## PresetOnlyStrategy + + + +An 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, + }), +}); +``` + +```ts title="Signature" +class PresetOnlyStrategy implements ImageTransformStrategy { + constructor(options: PresetOnlyStrategyOptions) + getImageTransformParameters({ + input, + availablePresets, + }: GetImageTransformParametersArgs) => Promise | ImageTransformParameters; +} +``` +* Implements: ImageTransformStrategy + + + +
+ +### constructor + +PresetOnlyStrategyOptions) => PresetOnlyStrategy`} /> + + +### getImageTransformParameters + +GetImageTransformParametersArgs) => Promise<ImageTransformParameters> | ImageTransformParameters`} /> + + + + +
+ + +## PresetOnlyStrategyOptions + + + +Configuration options for the PresetOnlyStrategy. + +```ts title="Signature" +interface PresetOnlyStrategyOptions { + defaultPreset: string; + permittedQuality?: number[]; + permittedFormats?: ImageTransformFormat[]; + allowFocalPoint?: boolean; +} +``` + +
+ +### defaultPreset + + + +The name of the default preset to use if no preset is specified in the URL. +### permittedQuality + + + +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 + + + +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 + + + +Whether to allow the focal point to be specified in the URL. + + +
diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md index 9fa2c2dc37..59f28585f4 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md +++ b/docs/docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## S3AssetStorageStrategy - + An AssetStorageStrategy 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 - + 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 - + -Returns a configured instance of the S3AssetStorageStrategy which can then be passed to the AssetServerOptions -`storageStrategyFactory` property. +Returns a configured instance of the S3AssetStorageStrategy which can then be passed to the AssetServerOptions`storageStrategyFactory` property. Before using this strategy, make sure you have the `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` package installed: diff --git a/docs/docs/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy.md b/docs/docs/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy.md index 23479476e3..9aa4d3f54f 100644 --- a/docs/docs/reference/core-plugins/asset-server-plugin/sharp-asset-preview-strategy.md +++ b/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 - + This AssetPreviewStrategy 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 - + This AssetPreviewStrategy 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 diff --git a/docs/docs/reference/typescript-api/services/order-service.md b/docs/docs/reference/typescript-api/services/order-service.md index 999f816932..66e6f5144f 100644 --- a/docs/docs/reference/typescript-api/services/order-service.md +++ b/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) => Promise>; - 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) => Promise<{ order: Order; errorResults: Array> }>; adjustOrderLine(ctx: RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths) => Promise>; adjustOrderLines(ctx: RequestContext, orderId: ID, lines: Array<{ orderLineId: ID; quantity: number; customFields?: { [key: string]: any } }>, relations?: RelationPaths) => Promise<{ order: Order; errorResults: Array> }>; @@ -95,8 +95,8 @@ class OrderService { 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 OrderProcess 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 OrderProcess objects defined in the OrderOptions `process` array. ### findAll @@ -157,13 +157,13 @@ Returns any RefundRequestContext, userId: ID) => Promise<Order | undefined>`} /> -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 RequestContext, userId?: ID) => Promise<Order>`} /> -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. RequestContext, { 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 RequestContext, orderId: ID, productVariantId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> -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 -RequestContext, orderId: ID, items: Array<{ productVariantId: ID; quantity: number; customFields?: { [key: string]: any }; }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>`} 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. +RequestContext, orderId: ID, items: Array<{ productVariantId: ID; quantity: number; customFields?: { [key: string]: any }; }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>`} 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 RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }, relations?: RelationPaths<Order>) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> -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 RequestContext, orderId: ID, lines: Array<{ orderLineId: ID; quantity: number; customFields?: { [key: string]: any } }>, relations?: RelationPaths<Order>) => Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }>`} 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 RequestContext, orderId: ID, orderLineId: ID) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} /> -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 RequestContext, orderId: ID, orderLineIds: ID[]) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} 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 Surch RequestContext, orderId: ID, couponCode: string) => Promise<ErrorResultUnion<ApplyCouponCodeResult, Order>>`} /> -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 Promotion. ### removeCouponCode @@ -289,10 +289,10 @@ Unsets the billing address for the Order. RequestContext, orderId: ID) => Promise<ShippingMethodQuote[]>`} /> -Returns an array of quotes stating which ShippingMethods may be applied to this Order. -This is determined by the configured ShippingEligibilityChecker of each ShippingMethod. - -The quote also includes a price for each method, as determined by the configured +Returns an array of quotes stating which ShippingMethods may be applied to this Order. +This is determined by the configured ShippingEligibilityChecker of each ShippingMethod. + +The quote also includes a price for each method, as determined by the configured ShippingCalculator of each eligible ShippingMethod. ### getEligiblePaymentMethods @@ -313,7 +313,7 @@ Transitions the Order to the given state. RequestContext, fulfillmentId: ID, state: FulfillmentState) => Promise<Fulfillment | FulfillmentStateTransitionError>`} /> -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 RequestContext, input: ModifyOrderInput) => Promise<ErrorResultUnion<ModifyOrderResult, Order>>`} /> -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 Surcharges 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 Surcharges 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 RequestContext, paymentId: ID, state: PaymentState) => Promise<ErrorResultUnion<TransitionPaymentToStateResult, Payment>>`} /> -Transitions the given Payment 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 Payment 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 RequestContext, orderId: ID, input: PaymentInput) => Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>>`} /> -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 RequestContext, input: ManualPaymentInput) => Promise<ErrorResultUnion<AddManualPaymentToOrderResult, Order>>`} /> -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 RequestContext, paymentId: ID) => Promise<ErrorResultUnion<SettlePaymentResult, Payment>>`} /> -Settles a payment by invoking the PaymentMethodHandler's `settlePayment()` method. Automatically +Settles a payment by invoking the PaymentMethodHandler's `settlePayment()` method. Automatically transitions the Order state if all Payments are settled. ### cancelPayment RequestContext, paymentId: ID) => Promise<ErrorResultUnion<CancelPaymentResult, Payment>>`} /> -Cancels a payment by invoking the PaymentMethodHandler's `cancelPayment()` method (if defined), and transitions the Payment to +Cancels a payment by invoking the PaymentMethodHandler'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. RequestContext, input: CancelOrderInput) => Promise<ErrorResultUnion<CancelOrderResult, Order>>`} /> -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 StockMovements will be created to correct the stock levels. ### refundOrder RequestContext, input: RefundOrderInput) => Promise<ErrorResultUnion<RefundOrderResult, Refund>>`} /> -Creates a Refund against the order and in doing so invokes the `createRefund()` method of the +Creates a Refund against the order and in doing so invokes the `createRefund()` method of the PaymentMethodHandler. ### settleRefund @@ -431,15 +431,15 @@ Deletes an Order, ensuring that any Sessions that reference this Order are deref RequestContext, user: User, guestOrder?: Order, existingOrder?: Order) => Promise<Order | undefined>`} /> -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 OrderOptions `mergeStrategy` config setting. ### applyPriceAdjustments RequestContext, order: Order, updatedOrderLines?: OrderLine[], relations?: RelationPaths<Order>) => Promise<Order>`} /> -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 OrderItemPriceCalculationStrategy. diff --git a/docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md b/docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md new file mode 100644 index 0000000000..6eac410786 --- /dev/null +++ b/docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md @@ -0,0 +1,46 @@ +--- +title: "AddressBasedTaxZoneStrategy" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## AddressBasedTaxZoneStrategy + + + +Address based TaxZoneStrategy which tries to find the applicable Zone based on the +country of the billing address, or else the country of the shipping address of the Order. + +Returns the default Channel'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: TaxZoneStrategy + + + +
+ +### determineTaxZone + +RequestContext, zones: Zone[], channel: Channel, order?: Order) => Zone`} /> + + + + +
diff --git a/docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md b/docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md index c4f398b00f..a4d045d57a 100644 --- a/docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md +++ b/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; } ``` @@ -66,8 +66,8 @@ Performs both query and mutation operations. Promise<Response>`} /> -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 - Promise<any>`} /> - -Perform a file upload mutation. - -Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec + Promise<any>`} /> +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* diff --git a/packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts b/packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts index eac6e699d1..9412004124 100644 --- a/packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts +++ b/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` diff --git a/packages/asset-server-plugin/index.ts b/packages/asset-server-plugin/index.ts index b0d98bed17..897267ca4c 100644 --- a/packages/asset-server-plugin/index.ts +++ b/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'; diff --git a/packages/asset-server-plugin/src/asset-server.ts b/packages/asset-server-plugin/src/asset-server.ts new file mode 100644 index 0000000000..9c4cf5aee4 --- /dev/null +++ b/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 { + 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, + ): 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'; + } + } +} diff --git a/packages/asset-server-plugin/src/default-asset-storage-strategy-factory.ts b/packages/asset-server-plugin/src/config/default-asset-storage-strategy-factory.ts similarity index 88% rename from packages/asset-server-plugin/src/default-asset-storage-strategy-factory.ts rename to packages/asset-server-plugin/src/config/default-asset-storage-strategy-factory.ts index 4b79ba60a5..77508285fb 100644 --- a/packages/asset-server-plugin/src/default-asset-storage-strategy-factory.ts +++ b/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. diff --git a/packages/asset-server-plugin/src/hashed-asset-naming-strategy.ts b/packages/asset-server-plugin/src/config/hashed-asset-naming-strategy.ts similarity index 100% rename from packages/asset-server-plugin/src/hashed-asset-naming-strategy.ts rename to packages/asset-server-plugin/src/config/hashed-asset-naming-strategy.ts diff --git a/packages/asset-server-plugin/src/config/image-transform-strategy.ts b/packages/asset-server-plugin/src/config/image-transform-strategy.ts new file mode 100644 index 0000000000..ea75bb97ba --- /dev/null +++ b/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; +} diff --git a/packages/asset-server-plugin/src/local-asset-storage-strategy.ts b/packages/asset-server-plugin/src/config/local-asset-storage-strategy.ts similarity index 100% rename from packages/asset-server-plugin/src/local-asset-storage-strategy.ts rename to packages/asset-server-plugin/src/config/local-asset-storage-strategy.ts diff --git a/packages/asset-server-plugin/src/config/preset-only-strategy.ts b/packages/asset-server-plugin/src/config/preset-only-strategy.ts new file mode 100644 index 0000000000..dc8fc873a5 --- /dev/null +++ b/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 { + 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, + }; + } +} diff --git a/packages/asset-server-plugin/src/s3-asset-storage-strategy.ts b/packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts similarity index 98% rename from packages/asset-server-plugin/src/s3-asset-storage-strategy.ts rename to packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts index e479419cc1..899b8db494 100644 --- a/packages/asset-server-plugin/src/s3-asset-storage-strategy.ts +++ b/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 diff --git a/packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts b/packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts similarity index 98% rename from packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts rename to packages/asset-server-plugin/src/config/sharp-asset-preview-strategy.ts index a2d328a0d9..639bdd29c7 100644 --- a/packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts +++ b/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 { - return sharp(path.join(__dirname, 'file-icon.png')) + return sharp(path.join(__dirname, '..', 'file-icon.png')) .resize(800, 800, { fit: 'outside' }) .composite([ { diff --git a/packages/asset-server-plugin/src/constants.ts b/packages/asset-server-plugin/src/constants.ts index cd99a002ed..d6d22dcc81 100644 --- a/packages/asset-server-plugin/src/constants.ts +++ b/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'); diff --git a/packages/asset-server-plugin/src/plugin.ts b/packages/asset-server-plugin/src/plugin.ts index 4ba1739f81..90bb8c27f3 100644 --- a/packages/asset-server-plugin/src/plugin.ts +++ b/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] + : []; } } diff --git a/packages/asset-server-plugin/src/transform-image.ts b/packages/asset-server-plugin/src/transform-image.ts index 623bcd3878..6144a3a1c3 100644 --- a/packages/asset-server-plugin/src/transform-image.ts +++ b/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, - presets: ImageTransformPreset[], + parameters: ImageTransformParameters, ): Promise { - 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( diff --git a/packages/asset-server-plugin/src/types.ts b/packages/asset-server-plugin/src/types.ts index 53a6d5ff34..37dc2d4e29 100644 --- a/packages/asset-server-plugin/src/types.ts +++ b/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.