From 9ca7c9c489851a4698e6d1bc2e75f2906bc6fb80 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 27 Nov 2024 09:08:52 +0100 Subject: [PATCH 1/5] feat(asset-server-plugin): Implement ImageTransformStrategy Relates to #3040. This new strategy allows you to control the parameters that get used to transform the image. We also expose a new PresetOnlyStrategy that limits transforms to just the presets. --- packages/asset-server-plugin/index.ts | 2 + packages/asset-server-plugin/src/constants.ts | 1 + .../src/image-transform-strategy.ts | 64 +++++++ packages/asset-server-plugin/src/plugin.ts | 156 ++++++++++++++++-- .../src/preset-only-strategy.ts | 104 ++++++++++++ .../src/transform-image.ts | 44 ++--- packages/asset-server-plugin/src/types.ts | 16 ++ 7 files changed, 341 insertions(+), 46 deletions(-) create mode 100644 packages/asset-server-plugin/src/image-transform-strategy.ts create mode 100644 packages/asset-server-plugin/src/preset-only-strategy.ts diff --git a/packages/asset-server-plugin/index.ts b/packages/asset-server-plugin/index.ts index b0d98bed17..78cd3d6a4a 100644 --- a/packages/asset-server-plugin/index.ts +++ b/packages/asset-server-plugin/index.ts @@ -1,4 +1,6 @@ export * from './src/plugin'; export * from './src/s3-asset-storage-strategy'; export * from './src/sharp-asset-preview-strategy'; +export * from './src/image-transform-strategy'; +export * from './src/preset-only-strategy'; export * from './src/types'; 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/image-transform-strategy.ts b/packages/asset-server-plugin/src/image-transform-strategy.ts new file mode 100644 index 0000000000..f8532bcefd --- /dev/null +++ b/packages/asset-server-plugin/src/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/plugin.ts b/packages/asset-server-plugin/src/plugin.ts index 4ba1739f81..39b81ad6fd 100644 --- a/packages/asset-server-plugin/src/plugin.ts +++ b/packages/asset-server-plugin/src/plugin.ts @@ -1,7 +1,15 @@ -import { MiddlewareConsumer, NestModule, OnApplicationBootstrap } from '@nestjs/common'; +import { + MiddlewareConsumer, + NestModule, + OnApplicationBootstrap, + Inject, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { Type } from '@vendure/common/lib/shared-types'; import { AssetStorageStrategy, + Injector, Logger, PluginCommonModule, ProcessContext, @@ -15,12 +23,13 @@ import fs from 'fs-extra'; import path from 'path'; import { getValidFormat } from './common'; -import { DEFAULT_CACHE_HEADER, loggerCtx } from './constants'; +import { ASSET_SERVER_PLUGIN_INIT_OPTIONS, DEFAULT_CACHE_HEADER, loggerCtx } from './constants'; import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory'; import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy'; +import { ImageTransformParameters, ImageTransformStrategy } from './image-transform-strategy'; import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy'; import { transformImage } from './transform-image'; -import { AssetServerOptions, ImageTransformPreset } from './types'; +import { AssetServerOptions, ImageTransformMode, ImageTransformPreset } from './types'; async function getFileType(buffer: Buffer) { const { fileTypeFromBuffer } = await import('file-type'); @@ -153,9 +162,10 @@ async function getFileType(buffer: Buffer) { @VendurePlugin({ imports: [PluginCommonModule], configuration: config => AssetServerPlugin.configure(config), + providers: [{ provide: ASSET_SERVER_PLUGIN_INIT_OPTIONS, useFactory: () => AssetServerPlugin.options }], compatibility: '^3.0.0', }) -export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { +export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, OnApplicationShutdown { private static assetStorage: AssetStorageStrategy; private readonly cacheDir = 'cache'; private presets: ImageTransformPreset[] = [ @@ -194,15 +204,19 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { return config; } - constructor(private processContext: ProcessContext) {} + constructor( + @Inject(ASSET_SERVER_PLUGIN_INIT_OPTIONS) private options: AssetServerOptions, + private processContext: ProcessContext, + private moduleRef: ModuleRef, + ) {} /** @internal */ - onApplicationBootstrap(): void { + async onApplicationBootstrap() { if (this.processContext.isWorker) { return; } - if (AssetServerPlugin.options.presets) { - for (const preset of AssetServerPlugin.options.presets) { + if (this.options.presets) { + for (const preset of this.options.presets) { const existingIndex = this.presets.findIndex(p => p.name === preset.name); if (-1 < existingIndex) { this.presets.splice(existingIndex, 1, preset); @@ -212,8 +226,20 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } } + if (this.options.imageTransformStrategy != null) { + const strategyArray = Array.isArray(this.options.imageTransformStrategy) + ? this.options.imageTransformStrategy + : [this.options.imageTransformStrategy]; + const injector = new Injector(this.moduleRef); + for (const strategy of strategyArray) { + if (typeof strategy.init === 'function') { + await strategy.init(injector); + } + } + } + // Configure Cache-Control header - const { cacheHeader } = AssetServerPlugin.options; + const { cacheHeader } = this.options; if (!cacheHeader) { this.cacheHeader = DEFAULT_CACHE_HEADER; } else { @@ -226,17 +252,34 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } } - const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir); + const cachePath = path.join(this.options.assetUploadDir, this.cacheDir); fs.ensureDirSync(cachePath); } + /** @internal */ + async onApplicationShutdown() { + if (this.processContext.isWorker) { + return; + } + if (this.options.imageTransformStrategy != null) { + const strategyArray = Array.isArray(this.options.imageTransformStrategy) + ? this.options.imageTransformStrategy + : [this.options.imageTransformStrategy]; + for (const strategy of strategyArray) { + if (typeof strategy.destroy === 'function') { + await strategy.destroy(); + } + } + } + } + configure(consumer: MiddlewareConsumer) { 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); + consumer.apply(this.createAssetServer()).forRoutes(this.options.route); + registerPluginStartupMessage('Asset server', this.options.route); } /** @@ -253,7 +296,15 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { */ private sendAsset() { return async (req: Request, res: Response, next: NextFunction) => { - const key = this.getFileNameFromRequest(req); + 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 AssetServerPlugin.assetStorage.readFileToBuffer(key); let mimeType = this.getMimeType(key); @@ -290,10 +341,11 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { res.status(404).send('Resource not found'); return; } - const image = await transformImage(file, req.query as any, this.presets || []); try { + const parameters = await this.getImageTransformParameters(req); + const image = await transformImage(file, parameters); const imageBuffer = await image.toBuffer(); - const cachedFileName = this.getFileNameFromRequest(req); + const cachedFileName = this.getFileNameFromParameters(req.path, parameters); if (!req.query.cache || req.query.cache === 'true') { await AssetServerPlugin.assetStorage.writeFileFromBuffer( cachedFileName, @@ -320,8 +372,76 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { }; } - private getFileNameFromRequest(req: Request): string { - const { w, h, mode, preset, fpx, fpy, format, q } = req.query; + private async getImageTransformParameters(req: Request): Promise { + let parameters = this.getInitialImageTransformParameters(req.query as any); + const transformStrategies = this.getImageTransformStrategyArray(); + for (const strategy of transformStrategies) { + 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 getImageTransformStrategyArray(): ImageTransformStrategy[] { + return this.options.imageTransformStrategy + ? Array.isArray(this.options.imageTransformStrategy) + ? this.options.imageTransformStrategy + : [this.options.imageTransformStrategy] + : []; + } + + 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}` : ''; @@ -347,7 +467,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { imageParamsString += quality; } - const decodedReqPath = this.sanitizeFilePath(req.path); + const decodedReqPath = this.sanitizeFilePath(filePath); if (imageParamsString !== '') { const imageParamHash = this.md5(imageParamsString); return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat)); diff --git a/packages/asset-server-plugin/src/preset-only-strategy.ts b/packages/asset-server-plugin/src/preset-only-strategy.ts new file mode 100644 index 0000000000..c606320b92 --- /dev/null +++ b/packages/asset-server-plugin/src/preset-only-strategy.ts @@ -0,0 +1,104 @@ +import { + GetImageTransformParametersArgs, + ImageTransformParameters, + ImageTransformStrategy, +} from './image-transform-strategy'; +import { ImageTransformFormat } from './types'; + +/** + * @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. + * + * 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 + * @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/transform-image.ts b/packages/asset-server-plugin/src/transform-image.ts index 623bcd3878..406b39bf8e 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 { loggerCtx } from './constants'; -import { ImageTransformFormat, ImageTransformPreset } from './types'; +import { ImageTransformParameters } from './image-transform-strategy'; +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..7bbe28ddbf 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 './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. From 21fa9fdb5528167bfdb414d2c405c6ab47314226 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 27 Nov 2024 09:49:12 +0100 Subject: [PATCH 2/5] refactor(asset-server-plugin): Improve organization of source files --- packages/asset-server-plugin/index.ts | 10 +- .../asset-server-plugin/src/asset-server.ts | 292 ++++++++++++++ .../default-asset-storage-strategy-factory.ts | 5 +- .../hashed-asset-naming-strategy.ts | 0 .../{ => config}/image-transform-strategy.ts | 2 +- .../local-asset-storage-strategy.ts | 0 .../src/{ => config}/preset-only-strategy.ts | 3 +- .../{ => config}/s3-asset-storage-strategy.ts | 6 +- .../sharp-asset-preview-strategy.ts | 4 +- packages/asset-server-plugin/src/plugin.ts | 359 +++--------------- .../src/transform-image.ts | 2 +- packages/asset-server-plugin/src/types.ts | 2 +- 12 files changed, 356 insertions(+), 329 deletions(-) create mode 100644 packages/asset-server-plugin/src/asset-server.ts rename packages/asset-server-plugin/src/{ => config}/default-asset-storage-strategy-factory.ts (88%) rename packages/asset-server-plugin/src/{ => config}/hashed-asset-naming-strategy.ts (100%) rename packages/asset-server-plugin/src/{ => config}/image-transform-strategy.ts (98%) rename packages/asset-server-plugin/src/{ => config}/local-asset-storage-strategy.ts (100%) rename packages/asset-server-plugin/src/{ => config}/preset-only-strategy.ts (98%) rename packages/asset-server-plugin/src/{ => config}/s3-asset-storage-strategy.ts (98%) rename packages/asset-server-plugin/src/{ => config}/sharp-asset-preview-strategy.ts (98%) diff --git a/packages/asset-server-plugin/index.ts b/packages/asset-server-plugin/index.ts index 78cd3d6a4a..897267ca4c 100644 --- a/packages/asset-server-plugin/index.ts +++ b/packages/asset-server-plugin/index.ts @@ -1,6 +1,8 @@ export * from './src/plugin'; -export * from './src/s3-asset-storage-strategy'; -export * from './src/sharp-asset-preview-strategy'; -export * from './src/image-transform-strategy'; -export * from './src/preset-only-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..27765e94f7 --- /dev/null +++ b/packages/asset-server-plugin/src/asset-server.ts @@ -0,0 +1,292 @@ +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); +} + +@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/image-transform-strategy.ts b/packages/asset-server-plugin/src/config/image-transform-strategy.ts similarity index 98% rename from packages/asset-server-plugin/src/image-transform-strategy.ts rename to packages/asset-server-plugin/src/config/image-transform-strategy.ts index f8532bcefd..ea75bb97ba 100644 --- a/packages/asset-server-plugin/src/image-transform-strategy.ts +++ b/packages/asset-server-plugin/src/config/image-transform-strategy.ts @@ -1,7 +1,7 @@ import { InjectableStrategy } from '@vendure/core'; import { Request } from 'express'; -import { ImageTransformFormat, ImageTransformMode, ImageTransformPreset } from './types'; +import { ImageTransformFormat, ImageTransformMode, ImageTransformPreset } from '../types'; /** * @description 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/preset-only-strategy.ts b/packages/asset-server-plugin/src/config/preset-only-strategy.ts similarity index 98% rename from packages/asset-server-plugin/src/preset-only-strategy.ts rename to packages/asset-server-plugin/src/config/preset-only-strategy.ts index c606320b92..2585ee0ca4 100644 --- a/packages/asset-server-plugin/src/preset-only-strategy.ts +++ b/packages/asset-server-plugin/src/config/preset-only-strategy.ts @@ -1,9 +1,10 @@ +import { ImageTransformFormat } from '../types'; + import { GetImageTransformParametersArgs, ImageTransformParameters, ImageTransformStrategy, } from './image-transform-strategy'; -import { ImageTransformFormat } from './types'; /** * @description 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/plugin.ts b/packages/asset-server-plugin/src/plugin.ts index 39b81ad6fd..f5ebeda8f1 100644 --- a/packages/asset-server-plugin/src/plugin.ts +++ b/packages/asset-server-plugin/src/plugin.ts @@ -1,40 +1,28 @@ import { + Inject, MiddlewareConsumer, NestModule, OnApplicationBootstrap, - Inject, 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 { ASSET_SERVER_PLUGIN_INIT_OPTIONS, DEFAULT_CACHE_HEADER, loggerCtx } from './constants'; -import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory'; -import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy'; -import { ImageTransformParameters, ImageTransformStrategy } from './image-transform-strategy'; -import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy'; -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); -} +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'; /** * @description @@ -161,22 +149,34 @@ async function getFileType(buffer: Buffer) { */ @VendurePlugin({ imports: [PluginCommonModule], - configuration: config => AssetServerPlugin.configure(config), - providers: [{ provide: ASSET_SERVER_PLUGIN_INIT_OPTIONS, useFactory: () => AssetServerPlugin.options }], + 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, OnApplicationShutdown { - private static assetStorage: AssetStorageStrategy; - private readonly cacheDir = 'cache'; - private presets: ImageTransformPreset[] = [ + 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 @@ -187,27 +187,11 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, On 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( @Inject(ASSET_SERVER_PLUGIN_INIT_OPTIONS) private options: AssetServerOptions, private processContext: ProcessContext, private moduleRef: ModuleRef, + private assetServer: AssetServer, ) {} /** @internal */ @@ -215,45 +199,14 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, On if (this.processContext.isWorker) { return; } - if (this.options.presets) { - for (const preset of this.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 strategyArray = Array.isArray(this.options.imageTransformStrategy) - ? this.options.imageTransformStrategy - : [this.options.imageTransformStrategy]; const injector = new Injector(this.moduleRef); - for (const strategy of strategyArray) { + for (const strategy of this.getImageTransformStrategyArray()) { if (typeof strategy.init === 'function') { await strategy.init(injector); } } } - - // 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); } /** @internal */ @@ -262,10 +215,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, On return; } if (this.options.imageTransformStrategy != null) { - const strategyArray = Array.isArray(this.options.imageTransformStrategy) - ? this.options.imageTransformStrategy - : [this.options.imageTransformStrategy]; - for (const strategy of strategyArray) { + for (const strategy of this.getImageTransformStrategyArray()) { if (typeof strategy.destroy === 'function') { await strategy.destroy(); } @@ -277,135 +227,24 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, On if (this.processContext.isWorker) { return; } - Logger.info('Creating asset server middleware', loggerCtx); - consumer.apply(this.createAssetServer()).forRoutes(this.options.route); - registerPluginStartupMessage('Asset server', this.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) => { - 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 AssetServerPlugin.assetStorage.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 AssetServerPlugin.assetStorage.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 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; - } + 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 async getImageTransformParameters(req: Request): Promise { - let parameters = this.getInitialImageTransformParameters(req.query as any); - const transformStrategies = this.getImageTransformStrategyArray(); - for (const strategy of transformStrategies) { - 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, - }; + 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); } private getImageTransformStrategyArray(): ImageTransformStrategy[] { @@ -415,112 +254,4 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap, On : [this.options.imageTransformStrategy] : []; } - - 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/transform-image.ts b/packages/asset-server-plugin/src/transform-image.ts index 406b39bf8e..6144a3a1c3 100644 --- a/packages/asset-server-plugin/src/transform-image.ts +++ b/packages/asset-server-plugin/src/transform-image.ts @@ -1,8 +1,8 @@ import { Logger } from '@vendure/core'; import sharp, { FormatEnum, Region, ResizeOptions } from 'sharp'; +import { ImageTransformParameters } from './config/image-transform-strategy'; import { loggerCtx } from './constants'; -import { ImageTransformParameters } from './image-transform-strategy'; import { ImageTransformFormat } from './types'; export type Dimensions = { w: number; h: number }; diff --git a/packages/asset-server-plugin/src/types.ts b/packages/asset-server-plugin/src/types.ts index 7bbe28ddbf..37dc2d4e29 100644 --- a/packages/asset-server-plugin/src/types.ts +++ b/packages/asset-server-plugin/src/types.ts @@ -5,7 +5,7 @@ import { RequestContext, } from '@vendure/core'; -import { ImageTransformStrategy } from './image-transform-strategy'; +import { ImageTransformStrategy } from './config/image-transform-strategy'; export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif'; From 0572ac5eb0cb9dc7655571f7c364706a45469f6c Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 27 Nov 2024 09:56:33 +0100 Subject: [PATCH 3/5] test(asset-server-plugin): Add e2e test for ImageTransformStrategy Relates to #3040 --- .../e2e/asset-server-plugin.e2e-spec.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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..8c161e623c 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(), }), ], }), @@ -315,6 +330,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` From 28e5e50906307320686dc3f257cf93f00b01dd25 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 27 Nov 2024 10:23:46 +0100 Subject: [PATCH 4/5] docs(asset-server-plugin): Add docs for ImageTransformStrategy Relates to #3040 --- .../production-configuration/index.md | 4 + .../guides/developer-guide/security/index.md | 29 ++++ .../asset-server-options.md | 38 +++-- .../asset-server-plugin/cache-config.md | 2 +- .../hashed-asset-naming-strategy.md | 2 +- .../image-transform-mode.md | 2 +- .../image-transform-preset.md | 2 +- .../image-transform-strategy.md | 148 ++++++++++++++++++ .../core-plugins/asset-server-plugin/index.md | 37 ++++- .../local-asset-storage-strategy.md | 2 +- .../preset-only-strategy.md | 122 +++++++++++++++ .../s3asset-storage-strategy.md | 9 +- .../sharp-asset-preview-strategy.md | 4 +- .../typescript-api/services/order-service.md | 120 +++++++------- .../tax/address-based-tax-zone-strategy.md | 46 ++++++ .../testing/simple-graph-qlclient.md | 22 +-- .../asset-server-plugin/src/asset-server.ts | 4 + .../src/config/preset-only-strategy.ts | 9 +- packages/asset-server-plugin/src/plugin.ts | 26 +++ 19 files changed, 526 insertions(+), 102 deletions(-) create mode 100644 docs/docs/reference/core-plugins/asset-server-plugin/image-transform-strategy.md create mode 100644 docs/docs/reference/core-plugins/asset-server-plugin/preset-only-strategy.md create mode 100644 docs/docs/reference/typescript-api/tax/address-based-tax-zone-strategy.md 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/src/asset-server.ts b/packages/asset-server-plugin/src/asset-server.ts index 27765e94f7..9c4cf5aee4 100644 --- a/packages/asset-server-plugin/src/asset-server.ts +++ b/packages/asset-server-plugin/src/asset-server.ts @@ -16,6 +16,10 @@ async function getFileType(buffer: Buffer) { 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; diff --git a/packages/asset-server-plugin/src/config/preset-only-strategy.ts b/packages/asset-server-plugin/src/config/preset-only-strategy.ts index 2585ee0ca4..dc8fc873a5 100644 --- a/packages/asset-server-plugin/src/config/preset-only-strategy.ts +++ b/packages/asset-server-plugin/src/config/preset-only-strategy.ts @@ -49,6 +49,12 @@ export interface PresetOnlyStrategyOptions { * 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. * @@ -56,7 +62,7 @@ export interface PresetOnlyStrategyOptions { * * @example * ```ts - * import { AssetServerPlugin, PresetOnlyStrategy } from '@vendure/core'; + * import { AssetServerPlugin, PresetOnlyStrategy } from '\@vendure/core'; * * // ... * @@ -73,6 +79,7 @@ export interface PresetOnlyStrategyOptions { * * @docsCategory core plugins/AssetServerPlugin * @docsPage PresetOnlyStrategy + * @docsWeight 0 * @since 3.1.0 */ export class PresetOnlyStrategy implements ImageTransformStrategy { diff --git a/packages/asset-server-plugin/src/plugin.ts b/packages/asset-server-plugin/src/plugin.ts index f5ebeda8f1..90bb8c27f3 100644 --- a/packages/asset-server-plugin/src/plugin.ts +++ b/packages/asset-server-plugin/src/plugin.ts @@ -145,6 +145,32 @@ import { AssetServerOptions, ImageTransformPreset } from './types'; * 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({ From 74ceb239e98c6356dd5685a52770a03786fce052 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 27 Nov 2024 10:42:35 +0100 Subject: [PATCH 5/5] test(asset-server-plugin): Add a couple more path traversal tests In response to a code scanning alert. Looks like it is a false positive. --- .../asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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 8c161e623c..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 @@ -243,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`)); }); });