plugin.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import { MiddlewareConsumer, NestModule, OnApplicationBootstrap } from '@nestjs/common';
  2. import { Type } from '@vendure/common/lib/shared-types';
  3. import {
  4. AssetStorageStrategy,
  5. Logger,
  6. PluginCommonModule,
  7. ProcessContext,
  8. registerPluginStartupMessage,
  9. RuntimeVendureConfig,
  10. VendurePlugin,
  11. } from '@vendure/core';
  12. import { createHash } from 'crypto';
  13. import express, { NextFunction, Request, Response } from 'express';
  14. import fs from 'fs-extra';
  15. import path from 'path';
  16. import { getValidFormat } from './common';
  17. import { DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
  18. import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
  19. import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
  20. import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
  21. import { transformImage } from './transform-image';
  22. import { AssetServerOptions, ImageTransformPreset } from './types';
  23. async function getFileType(buffer: Buffer) {
  24. const { fileTypeFromBuffer } = await import('file-type');
  25. return fileTypeFromBuffer(buffer);
  26. }
  27. /**
  28. * @description
  29. * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
  30. * other storage strategies (e.g. {@link S3AssetStorageStrategy}. It can also perform on-the-fly image transformations
  31. * and caches the results for subsequent calls.
  32. *
  33. * ## Installation
  34. *
  35. * `yarn add \@vendure/asset-server-plugin`
  36. *
  37. * or
  38. *
  39. * `npm install \@vendure/asset-server-plugin`
  40. *
  41. * @example
  42. * ```ts
  43. * import { AssetServerPlugin } from '\@vendure/asset-server-plugin';
  44. *
  45. * const config: VendureConfig = {
  46. * // Add an instance of the plugin to the plugins array
  47. * plugins: [
  48. * AssetServerPlugin.init({
  49. * route: 'assets',
  50. * assetUploadDir: path.join(__dirname, 'assets'),
  51. * }),
  52. * ],
  53. * };
  54. * ```
  55. *
  56. * The full configuration is documented at [AssetServerOptions](/reference/core-plugins/asset-server-plugin/asset-server-options)
  57. *
  58. * ## Image transformation
  59. *
  60. * Asset preview images can be transformed (resized & cropped) on the fly by appending query parameters to the url:
  61. *
  62. * `http://localhost:3000/assets/some-asset.jpg?w=500&h=300&mode=resize`
  63. *
  64. * The above URL will return `some-asset.jpg`, resized to fit in the bounds of a 500px x 300px rectangle.
  65. *
  66. * ### Preview mode
  67. *
  68. * The `mode` parameter can be either `crop` or `resize`. See the [ImageTransformMode](/reference/core-plugins/asset-server-plugin/image-transform-mode) docs for details.
  69. *
  70. * ### Focal point
  71. *
  72. * When cropping an image (`mode=crop`), Vendure will attempt to keep the most "interesting" area of the image in the cropped frame. It does this
  73. * by finding the area of the image with highest entropy (the busiest area of the image). However, sometimes this does not yield a satisfactory
  74. * result - part or all of the main subject may still be cropped out.
  75. *
  76. * This is where specifying the focal point can help. The focal point of the image may be specified by passing the `fpx` and `fpy` query parameters.
  77. * These are normalized coordinates (i.e. a number between 0 and 1), so the `fpx=0&fpy=0` corresponds to the top left of the image.
  78. *
  79. * For example, let's say there is a very wide landscape image which we want to crop to be square. The main subject is a house to the far left of the
  80. * image. The following query would crop it to a square with the house centered:
  81. *
  82. * `http://localhost:3000/assets/landscape.jpg?w=150&h=150&mode=crop&fpx=0.2&fpy=0.7`
  83. *
  84. * ### Format
  85. *
  86. * Since v1.7.0, the image format can be specified by adding the `format` query parameter:
  87. *
  88. * `http://localhost:3000/assets/some-asset.jpg?format=webp`
  89. *
  90. * This means that, no matter the format of your original asset files, you can use more modern formats in your storefront if the browser
  91. * supports them. Supported values for `format` are:
  92. *
  93. * * `jpeg` or `jpg`
  94. * * `png`
  95. * * `webp`
  96. * * `avif`
  97. *
  98. * The `format` parameter can also be combined with presets (see below).
  99. *
  100. * ### Quality
  101. *
  102. * Since v2.2.0, the image quality can be specified by adding the `q` query parameter:
  103. *
  104. * `http://localhost:3000/assets/some-asset.jpg?q=75`
  105. *
  106. * This applies to the `jpg`, `webp` and `avif` formats. The default quality value for `jpg` and `webp` is 80, and for `avif` is 50.
  107. *
  108. * The `q` parameter can also be combined with presets (see below).
  109. *
  110. * ### Transform presets
  111. *
  112. * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are
  113. * configured via the AssetServerOptions [presets property](/reference/core-plugins/asset-server-plugin/asset-server-options/#presets).
  114. *
  115. * For example, defining the following preset:
  116. *
  117. * ```ts
  118. * AssetServerPlugin.init({
  119. * // ...
  120. * presets: [
  121. * { name: 'my-preset', width: 85, height: 85, mode: 'crop' },
  122. * ],
  123. * }),
  124. * ```
  125. *
  126. * means that a request to:
  127. *
  128. * `http://localhost:3000/assets/some-asset.jpg?preset=my-preset`
  129. *
  130. * is equivalent to:
  131. *
  132. * `http://localhost:3000/assets/some-asset.jpg?w=85&h=85&mode=crop`
  133. *
  134. * The AssetServerPlugin comes pre-configured with the following presets:
  135. *
  136. * name | width | height | mode
  137. * -----|-------|--------|-----
  138. * tiny | 50px | 50px | crop
  139. * thumb | 150px | 150px | crop
  140. * small | 300px | 300px | resize
  141. * medium | 500px | 500px | resize
  142. * large | 800px | 800px | resize
  143. *
  144. * ### Caching
  145. * By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
  146. * a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
  147. *
  148. * @docsCategory core plugins/AssetServerPlugin
  149. */
  150. @VendurePlugin({
  151. imports: [PluginCommonModule],
  152. configuration: config => AssetServerPlugin.configure(config),
  153. compatibility: '^2.0.0',
  154. })
  155. export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
  156. private static assetStorage: AssetStorageStrategy;
  157. private readonly cacheDir = 'cache';
  158. private presets: ImageTransformPreset[] = [
  159. { name: 'tiny', width: 50, height: 50, mode: 'crop' },
  160. { name: 'thumb', width: 150, height: 150, mode: 'crop' },
  161. { name: 'small', width: 300, height: 300, mode: 'resize' },
  162. { name: 'medium', width: 500, height: 500, mode: 'resize' },
  163. { name: 'large', width: 800, height: 800, mode: 'resize' },
  164. ];
  165. private static options: AssetServerOptions;
  166. private cacheHeader: string;
  167. /**
  168. * @description
  169. * Set the plugin options.
  170. */
  171. static init(options: AssetServerOptions): Type<AssetServerPlugin> {
  172. AssetServerPlugin.options = options;
  173. return this;
  174. }
  175. /** @internal */
  176. static async configure(config: RuntimeVendureConfig) {
  177. const storageStrategyFactory =
  178. this.options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
  179. this.assetStorage = await storageStrategyFactory(this.options);
  180. config.assetOptions.assetPreviewStrategy =
  181. this.options.previewStrategy ??
  182. new SharpAssetPreviewStrategy({
  183. maxWidth: this.options.previewMaxWidth,
  184. maxHeight: this.options.previewMaxHeight,
  185. });
  186. config.assetOptions.assetStorageStrategy = this.assetStorage;
  187. config.assetOptions.assetNamingStrategy =
  188. this.options.namingStrategy || new HashedAssetNamingStrategy();
  189. return config;
  190. }
  191. constructor(private processContext: ProcessContext) {}
  192. /** @internal */
  193. onApplicationBootstrap(): void {
  194. if (this.processContext.isWorker) {
  195. return;
  196. }
  197. if (AssetServerPlugin.options.presets) {
  198. for (const preset of AssetServerPlugin.options.presets) {
  199. const existingIndex = this.presets.findIndex(p => p.name === preset.name);
  200. if (-1 < existingIndex) {
  201. this.presets.splice(existingIndex, 1, preset);
  202. } else {
  203. this.presets.push(preset);
  204. }
  205. }
  206. }
  207. // Configure Cache-Control header
  208. const { cacheHeader } = AssetServerPlugin.options;
  209. if (!cacheHeader) {
  210. this.cacheHeader = DEFAULT_CACHE_HEADER;
  211. } else {
  212. if (typeof cacheHeader === 'string') {
  213. this.cacheHeader = cacheHeader;
  214. } else {
  215. this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
  216. .filter(value => !!value)
  217. .join(', ');
  218. }
  219. }
  220. const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
  221. fs.ensureDirSync(cachePath);
  222. }
  223. configure(consumer: MiddlewareConsumer) {
  224. if (this.processContext.isWorker) {
  225. return;
  226. }
  227. Logger.info('Creating asset server middleware', loggerCtx);
  228. consumer.apply(this.createAssetServer()).forRoutes(AssetServerPlugin.options.route);
  229. registerPluginStartupMessage('Asset server', AssetServerPlugin.options.route);
  230. }
  231. /**
  232. * Creates the image server instance
  233. */
  234. private createAssetServer() {
  235. const assetServer = express.Router();
  236. assetServer.use(this.sendAsset(), this.generateTransformedImage());
  237. return assetServer;
  238. }
  239. /**
  240. * Reads the file requested and send the response to the browser.
  241. */
  242. private sendAsset() {
  243. return async (req: Request, res: Response, next: NextFunction) => {
  244. const key = this.getFileNameFromRequest(req);
  245. try {
  246. const file = await AssetServerPlugin.assetStorage.readFileToBuffer(key);
  247. let mimeType = this.getMimeType(key);
  248. if (!mimeType) {
  249. mimeType = (await getFileType(file))?.mime || 'application/octet-stream';
  250. }
  251. res.contentType(mimeType);
  252. res.setHeader('content-security-policy', "default-src 'self'");
  253. res.setHeader('Cache-Control', this.cacheHeader);
  254. res.send(file);
  255. } catch (e: any) {
  256. const err = new Error('File not found');
  257. (err as any).status = 404;
  258. return next(err);
  259. }
  260. };
  261. }
  262. /**
  263. * If an exception was thrown by the first handler, then it may be because a transformed image
  264. * is being requested which does not yet exist. In this case, this handler will generate the
  265. * transformed image, save it to cache, and serve the result as a response.
  266. */
  267. private generateTransformedImage() {
  268. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  269. if (err && (err.status === 404 || err.statusCode === 404)) {
  270. if (req.query) {
  271. const decodedReqPath = decodeURIComponent(req.path);
  272. Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx);
  273. let file: Buffer;
  274. try {
  275. file = await AssetServerPlugin.assetStorage.readFileToBuffer(decodedReqPath);
  276. } catch (_err: any) {
  277. res.status(404).send('Resource not found');
  278. return;
  279. }
  280. const image = await transformImage(file, req.query as any, this.presets || []);
  281. try {
  282. const imageBuffer = await image.toBuffer();
  283. const cachedFileName = this.getFileNameFromRequest(req);
  284. if (!req.query.cache || req.query.cache === 'true') {
  285. await AssetServerPlugin.assetStorage.writeFileFromBuffer(
  286. cachedFileName,
  287. imageBuffer,
  288. );
  289. Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
  290. }
  291. let mimeType = this.getMimeType(cachedFileName);
  292. if (!mimeType) {
  293. mimeType = (await getFileType(imageBuffer))?.mime || 'image/jpeg';
  294. }
  295. res.set('Content-Type', mimeType);
  296. res.setHeader('content-security-policy', "default-src 'self'");
  297. res.send(imageBuffer);
  298. return;
  299. } catch (e: any) {
  300. Logger.error(e, loggerCtx, e.stack);
  301. res.status(500).send(e.message);
  302. return;
  303. }
  304. }
  305. }
  306. next();
  307. };
  308. }
  309. private getFileNameFromRequest(req: Request): string {
  310. const { w, h, mode, preset, fpx, fpy, format, q } = req.query;
  311. /* eslint-disable @typescript-eslint/restrict-template-expressions */
  312. const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
  313. const quality = q ? `_q${q}` : '';
  314. const imageFormat = getValidFormat(format);
  315. let imageParamsString = '';
  316. if (w || h) {
  317. const width = w || '';
  318. const height = h || '';
  319. imageParamsString = `_transform_w${width}_h${height}_m${mode}`;
  320. } else if (preset) {
  321. if (this.presets && !!this.presets.find(p => p.name === preset)) {
  322. imageParamsString = `_transform_pre_${preset}`;
  323. }
  324. }
  325. if (focalPoint) {
  326. imageParamsString += focalPoint;
  327. }
  328. if (imageFormat) {
  329. imageParamsString += imageFormat;
  330. }
  331. if (quality) {
  332. imageParamsString += quality;
  333. }
  334. /* eslint-enable @typescript-eslint/restrict-template-expressions */
  335. const decodedReqPath = decodeURIComponent(req.path);
  336. if (imageParamsString !== '') {
  337. const imageParamHash = this.md5(imageParamsString);
  338. return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat));
  339. } else {
  340. return decodedReqPath;
  341. }
  342. }
  343. private md5(input: string): string {
  344. return createHash('md5').update(input).digest('hex');
  345. }
  346. private addSuffix(fileName: string, suffix: string, ext?: string): string {
  347. const originalExt = path.extname(fileName);
  348. const effectiveExt = ext ? `.${ext}` : originalExt;
  349. const baseName = path.basename(fileName, originalExt);
  350. const dirName = path.dirname(fileName);
  351. return path.join(dirName, `${baseName}${suffix}${effectiveExt}`);
  352. }
  353. /**
  354. * Attempt to get the mime type from the file name.
  355. */
  356. private getMimeType(fileName: string): string | undefined {
  357. const ext = path.extname(fileName);
  358. switch (ext) {
  359. case '.jpg':
  360. case '.jpeg':
  361. return 'image/jpeg';
  362. case '.png':
  363. return 'image/png';
  364. case '.gif':
  365. return 'image/gif';
  366. case '.svg':
  367. return 'image/svg+xml';
  368. case '.tiff':
  369. return 'image/tiff';
  370. case '.webp':
  371. return 'image/webp';
  372. }
  373. }
  374. }