| ImageTransformParameters;
+}
+```
+* Implements: ImageTransformStrategy
+
+
+
+
+
+
+## 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.