plugin.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { DNSHealthIndicator, TerminusModule } from '@nestjs/terminus';
  2. import { Type } from '@vendure/common/lib/shared-types';
  3. import {
  4. AssetStorageStrategy,
  5. createProxyHandler,
  6. HealthCheckRegistryService,
  7. Logger,
  8. OnVendureBootstrap,
  9. OnVendureClose,
  10. PluginCommonModule,
  11. RuntimeVendureConfig,
  12. VendurePlugin,
  13. } from '@vendure/core';
  14. import { createHash } from 'crypto';
  15. import express, { NextFunction, Request, Response } from 'express';
  16. import { fromBuffer } from 'file-type';
  17. import fs from 'fs-extra';
  18. import { Server } from 'http';
  19. import path from 'path';
  20. import { loggerCtx } from './constants';
  21. import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
  22. import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
  23. import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
  24. import { transformImage } from './transform-image';
  25. import { AssetServerOptions, ImageTransformPreset } from './types';
  26. /**
  27. * @description
  28. * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
  29. * other storage strategies (e.g. {@link S3AssetStorageStrategy}. It can also perform on-the-fly image transformations
  30. * and caches the results for subsequent calls.
  31. *
  32. * ## Installation
  33. *
  34. * `yarn add \@vendure/asset-server-plugin`
  35. *
  36. * or
  37. *
  38. * `npm install \@vendure/asset-server-plugin`
  39. *
  40. * @example
  41. * ```ts
  42. * import { AssetServerPlugin } from '\@vendure/asset-server-plugin';
  43. *
  44. * const config: VendureConfig = {
  45. * // Add an instance of the plugin to the plugins array
  46. * plugins: [
  47. * AssetServerPlugin.init({
  48. * route: 'assets',
  49. * assetUploadDir: path.join(__dirname, 'assets'),
  50. * port: 4000,
  51. * }),
  52. * ],
  53. * };
  54. * ```
  55. *
  56. * The full configuration is documented at [AssetServerOptions]({{< relref "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]({{< relref "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. * ### Transform presets
  85. *
  86. * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are
  87. * configured via the AssetServerOptions [presets property]({{< relref "asset-server-options" >}}#presets).
  88. *
  89. * For example, defining the following preset:
  90. *
  91. * ```ts
  92. * new AssetServerPlugin({
  93. * // ...
  94. * presets: [
  95. * { name: 'my-preset', width: 85, height: 85, mode: 'crop' },
  96. * ],
  97. * }),
  98. * ```
  99. *
  100. * means that a request to:
  101. *
  102. * `http://localhost:3000/assets/some-asset.jpg?preset=my-preset`
  103. *
  104. * is equivalent to:
  105. *
  106. * `http://localhost:3000/assets/some-asset.jpg?w=85&h=85&mode=crop`
  107. *
  108. * The AssetServerPlugin comes pre-configured with the following presets:
  109. *
  110. * name | width | height | mode
  111. * -----|-------|--------|-----
  112. * tiny | 50px | 50px | crop
  113. * thumb | 150px | 150px | crop
  114. * small | 300px | 300px | resize
  115. * medium | 500px | 500px | resize
  116. * large | 800px | 800px | resize
  117. *
  118. * ### Caching
  119. * By default, the AssetServerPlugin will cache every transformed image, so that the transformation only needs to be performed a single time for
  120. * a given configuration. Caching can be disabled per-request by setting the `?cache=false` query parameter.
  121. *
  122. * @docsCategory AssetServerPlugin
  123. */
  124. @VendurePlugin({
  125. imports: [PluginCommonModule, TerminusModule],
  126. configuration: config => AssetServerPlugin.configure(config),
  127. })
  128. export class AssetServerPlugin implements OnVendureBootstrap, OnVendureClose {
  129. private server: Server;
  130. private static assetStorage: AssetStorageStrategy;
  131. private readonly cacheDir = 'cache';
  132. private presets: ImageTransformPreset[] = [
  133. { name: 'tiny', width: 50, height: 50, mode: 'crop' },
  134. { name: 'thumb', width: 150, height: 150, mode: 'crop' },
  135. { name: 'small', width: 300, height: 300, mode: 'resize' },
  136. { name: 'medium', width: 500, height: 500, mode: 'resize' },
  137. { name: 'large', width: 800, height: 800, mode: 'resize' },
  138. ];
  139. private static options: AssetServerOptions;
  140. constructor(
  141. private healthCheckRegistryService: HealthCheckRegistryService,
  142. private dns: DNSHealthIndicator,
  143. ) {}
  144. /**
  145. * @description
  146. * Set the plugin options.
  147. */
  148. static init(options: AssetServerOptions): Type<AssetServerPlugin> {
  149. AssetServerPlugin.options = options;
  150. return this;
  151. }
  152. /** @internal */
  153. static async configure(config: RuntimeVendureConfig) {
  154. const storageStrategyFactory =
  155. this.options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
  156. this.assetStorage = await storageStrategyFactory(this.options);
  157. config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
  158. maxWidth: this.options.previewMaxWidth || 1600,
  159. maxHeight: this.options.previewMaxHeight || 1600,
  160. });
  161. config.assetOptions.assetStorageStrategy = this.assetStorage;
  162. config.assetOptions.assetNamingStrategy =
  163. this.options.namingStrategy || new HashedAssetNamingStrategy();
  164. config.apiOptions.middleware.push({
  165. handler: createProxyHandler({ ...this.options, label: 'Asset Server' }),
  166. route: this.options.route,
  167. });
  168. return config;
  169. }
  170. /** @internal */
  171. onVendureBootstrap(): void | Promise<void> {
  172. if (AssetServerPlugin.options.presets) {
  173. for (const preset of AssetServerPlugin.options.presets) {
  174. const existingIndex = this.presets.findIndex(p => p.name === preset.name);
  175. if (-1 < existingIndex) {
  176. this.presets.splice(existingIndex, 1, preset);
  177. } else {
  178. this.presets.push(preset);
  179. }
  180. }
  181. }
  182. const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
  183. fs.ensureDirSync(cachePath);
  184. this.createAssetServer();
  185. const { hostname, port } = AssetServerPlugin.options;
  186. }
  187. /** @internal */
  188. onVendureClose(): Promise<void> {
  189. return new Promise(resolve => {
  190. this.server.close(() => resolve());
  191. });
  192. }
  193. /**
  194. * Creates the image server instance
  195. */
  196. private createAssetServer() {
  197. const assetServer = express();
  198. assetServer.get('/health', (req, res) => {
  199. res.send('ok');
  200. });
  201. assetServer.use(this.sendAsset(), this.generateTransformedImage());
  202. this.server = assetServer.listen(AssetServerPlugin.options.port, () => {
  203. const addressInfo = this.server.address();
  204. if (addressInfo && typeof addressInfo !== 'string') {
  205. const { address, port } = addressInfo;
  206. Logger.info(`Asset server listening on "http://localhost:${port}"`, loggerCtx);
  207. this.healthCheckRegistryService.registerIndicatorFunction(() =>
  208. this.dns.pingCheck('asset-server', `http://localhost:${port}/health`),
  209. );
  210. }
  211. });
  212. }
  213. /**
  214. * Reads the file requested and send the response to the browser.
  215. */
  216. private sendAsset() {
  217. return async (req: Request, res: Response, next: NextFunction) => {
  218. const key = this.getFileNameFromRequest(req);
  219. try {
  220. const file = await AssetServerPlugin.assetStorage.readFileToBuffer(key);
  221. let mimeType = this.getMimeType(key);
  222. if (!mimeType) {
  223. mimeType = (await fromBuffer(file))?.mime || 'application/octet-stream';
  224. }
  225. res.contentType(mimeType);
  226. res.send(file);
  227. } catch (e) {
  228. const err = new Error('File not found');
  229. (err as any).status = 404;
  230. return next(err);
  231. }
  232. };
  233. }
  234. /**
  235. * If an exception was thrown by the first handler, then it may be because a transformed image
  236. * is being requested which does not yet exist. In this case, this handler will generate the
  237. * transformed image, save it to cache, and serve the result as a response.
  238. */
  239. private generateTransformedImage() {
  240. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  241. if (err && (err.status === 404 || err.statusCode === 404)) {
  242. if (req.query) {
  243. Logger.debug(`Pre-cached Asset not found: ${req.path}`, loggerCtx);
  244. let file: Buffer;
  245. try {
  246. file = await AssetServerPlugin.assetStorage.readFileToBuffer(req.path);
  247. } catch (err) {
  248. res.status(404).send('Resource not found');
  249. return;
  250. }
  251. const image = await transformImage(file, req.query as any, this.presets || []);
  252. try {
  253. const imageBuffer = await image.toBuffer();
  254. if (!req.query.cache || req.query.cache === 'true') {
  255. const cachedFileName = this.getFileNameFromRequest(req);
  256. await AssetServerPlugin.assetStorage.writeFileFromBuffer(
  257. cachedFileName,
  258. imageBuffer,
  259. );
  260. Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
  261. }
  262. res.set('Content-Type', `image/${(await image.metadata()).format}`);
  263. res.send(imageBuffer);
  264. } catch (e) {
  265. Logger.error(e, 'AssetServerPlugin', e.stack);
  266. res.status(500).send(e.message);
  267. }
  268. }
  269. }
  270. next();
  271. };
  272. }
  273. private getFileNameFromRequest(req: Request): string {
  274. const { w, h, mode, preset, fpx, fpy } = req.query;
  275. const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
  276. let imageParamHash: string | null = null;
  277. if (w || h) {
  278. const width = w || '';
  279. const height = h || '';
  280. imageParamHash = this.md5(`_transform_w${width}_h${height}_m${mode}${focalPoint}`);
  281. } else if (preset) {
  282. if (this.presets && !!this.presets.find(p => p.name === preset)) {
  283. imageParamHash = this.md5(`_transform_pre_${preset}${focalPoint}`);
  284. }
  285. }
  286. if (imageParamHash) {
  287. return path.join(this.cacheDir, this.addSuffix(req.path, imageParamHash));
  288. } else {
  289. return req.path;
  290. }
  291. }
  292. private md5(input: string): string {
  293. return createHash('md5').update(input).digest('hex');
  294. }
  295. private addSuffix(fileName: string, suffix: string): string {
  296. const ext = path.extname(fileName);
  297. const baseName = path.basename(fileName, ext);
  298. const dirName = path.dirname(fileName);
  299. return path.join(dirName, `${baseName}${suffix}${ext}`);
  300. }
  301. /**
  302. * Attempt to get the mime type from the file name.
  303. */
  304. private getMimeType(fileName: string): string | undefined {
  305. const ext = path.extname(fileName);
  306. switch (ext) {
  307. case '.jpg':
  308. case '.jpeg':
  309. return 'image/jpeg';
  310. case '.png':
  311. return 'image/png';
  312. case '.gif':
  313. return 'image/gif';
  314. case '.svg':
  315. return 'image/svg+xml';
  316. case '.tiff':
  317. return 'image/tiff';
  318. case '.webp':
  319. return 'image/webp';
  320. }
  321. }
  322. }