asset-server.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { Inject, Injectable } from '@nestjs/common';
  2. import { AssetStorageStrategy, ConfigService, Logger, ProcessContext } from '@vendure/core';
  3. import { createHash } from 'crypto';
  4. import express, { NextFunction, Request, Response } from 'express';
  5. import fs from 'fs-extra';
  6. import path from 'path';
  7. import { getValidFormat } from './common';
  8. import { ImageTransformParameters, ImageTransformStrategy } from './config/image-transform-strategy';
  9. import { S3AssetStorageStrategy } from './config/s3-asset-storage-strategy';
  10. import { ASSET_SERVER_PLUGIN_INIT_OPTIONS, DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
  11. import { transformImage } from './transform-image';
  12. import { AssetServerOptions, ImageTransformMode, ImageTransformPreset } from './types';
  13. async function getFileType(buffer: Buffer) {
  14. const { fileTypeFromBuffer } = await import('file-type');
  15. return fileTypeFromBuffer(buffer);
  16. }
  17. /**
  18. * This houses the actual Express server that handles incoming requests, performs image transformations,
  19. * caches the results, and serves the transformed images.
  20. */
  21. @Injectable()
  22. export class AssetServer {
  23. private readonly assetStorageStrategy: AssetStorageStrategy;
  24. private readonly cacheDir = 'cache';
  25. private cacheHeader: string;
  26. private presets: ImageTransformPreset[];
  27. private imageTransformStrategies: ImageTransformStrategy[];
  28. constructor(
  29. @Inject(ASSET_SERVER_PLUGIN_INIT_OPTIONS) private options: AssetServerOptions,
  30. private configService: ConfigService,
  31. private processContext: ProcessContext,
  32. ) {
  33. this.assetStorageStrategy = this.configService.assetOptions.assetStorageStrategy;
  34. }
  35. /** @internal */
  36. onApplicationBootstrap() {
  37. if (this.processContext.isWorker) {
  38. return;
  39. }
  40. // Configure Cache-Control header
  41. const { cacheHeader } = this.options;
  42. if (!cacheHeader) {
  43. this.cacheHeader = DEFAULT_CACHE_HEADER;
  44. } else {
  45. if (typeof cacheHeader === 'string') {
  46. this.cacheHeader = cacheHeader;
  47. } else {
  48. this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
  49. .filter(value => !!value)
  50. .join(', ');
  51. }
  52. }
  53. const cachePath = path.join(this.options.assetUploadDir, this.cacheDir);
  54. fs.ensureDirSync(cachePath);
  55. }
  56. /**
  57. * Creates the image server instance
  58. */
  59. createAssetServer(serverConfig: {
  60. presets: ImageTransformPreset[];
  61. imageTransformStrategies: ImageTransformStrategy[];
  62. }): express.Router {
  63. this.presets = serverConfig.presets;
  64. this.imageTransformStrategies = serverConfig.imageTransformStrategies;
  65. const assetServer = express.Router();
  66. assetServer.use(this.sendAsset(), this.generateTransformedImage());
  67. return assetServer;
  68. }
  69. /**
  70. * Reads the file requested and send the response to the browser.
  71. */
  72. private sendAsset() {
  73. return async (req: Request, res: Response, next: NextFunction) => {
  74. let params: ImageTransformParameters;
  75. try {
  76. params = await this.getImageTransformParameters(req);
  77. } catch (e: any) {
  78. Logger.error(e.message, loggerCtx);
  79. res.status(400).send('Invalid parameters');
  80. return;
  81. }
  82. const key = this.getFileNameFromParameters(req.path, params);
  83. try {
  84. const file = await this.assetStorageStrategy.readFileToBuffer(key);
  85. let mimeType = this.getMimeType(key);
  86. if (!mimeType) {
  87. mimeType = (await getFileType(file))?.mime || 'application/octet-stream';
  88. }
  89. res.contentType(mimeType);
  90. res.setHeader('content-security-policy', "default-src 'self'");
  91. res.setHeader('Cache-Control', this.cacheHeader);
  92. res.send(file);
  93. } catch (e: any) {
  94. const err = new Error('File not found');
  95. (err as any).status = 404;
  96. return next(err);
  97. }
  98. };
  99. }
  100. /**
  101. * If an exception was thrown by the first handler, then it may be because a transformed image
  102. * is being requested which does not yet exist. In this case, this handler will generate the
  103. * transformed image, save it to cache, and serve the result as a response.
  104. */
  105. private generateTransformedImage() {
  106. return async (err: any, req: Request, res: Response, next: NextFunction) => {
  107. if (err && (err.status === 404 || err.statusCode === 404)) {
  108. if (req.query) {
  109. const decodedReqPath = this.sanitizeFilePath(req.path);
  110. Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx);
  111. let file: Buffer;
  112. try {
  113. file = await this.assetStorageStrategy.readFileToBuffer(decodedReqPath);
  114. } catch (_err: any) {
  115. res.status(404).send('Resource not found');
  116. return;
  117. }
  118. try {
  119. const parameters = await this.getImageTransformParameters(req);
  120. const image = await transformImage(file, parameters);
  121. const imageBuffer = await image.toBuffer();
  122. const cachedFileName = this.getFileNameFromParameters(req.path, parameters);
  123. if (!req.query.cache || req.query.cache === 'true') {
  124. await this.assetStorageStrategy.writeFileFromBuffer(cachedFileName, imageBuffer);
  125. Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
  126. }
  127. let mimeType = this.getMimeType(cachedFileName);
  128. if (!mimeType) {
  129. mimeType = (await getFileType(imageBuffer))?.mime || 'image/jpeg';
  130. }
  131. res.set('Content-Type', mimeType);
  132. res.setHeader('content-security-policy', "default-src 'self'");
  133. res.send(imageBuffer);
  134. return;
  135. } catch (e: any) {
  136. Logger.error(e.message, loggerCtx, e.stack);
  137. res.status(500).send('An error occurred when generating the image');
  138. return;
  139. }
  140. }
  141. }
  142. next();
  143. };
  144. }
  145. private async getImageTransformParameters(req: Request): Promise<ImageTransformParameters> {
  146. let parameters = this.getInitialImageTransformParameters(req.query as any);
  147. for (const strategy of this.imageTransformStrategies) {
  148. try {
  149. parameters = await strategy.getImageTransformParameters({
  150. req,
  151. input: { ...parameters },
  152. availablePresets: this.presets,
  153. });
  154. } catch (e: any) {
  155. Logger.error(`Error applying ImageTransformStrategy: ` + (e.message as string), loggerCtx);
  156. throw e;
  157. }
  158. }
  159. let targetWidth: number | undefined = parameters.width;
  160. let targetHeight: number | undefined = parameters.height;
  161. let targetMode: ImageTransformMode | undefined = parameters.mode;
  162. if (parameters.preset) {
  163. const matchingPreset = this.presets.find(p => p.name === parameters.preset);
  164. if (matchingPreset) {
  165. targetWidth = matchingPreset.width;
  166. targetHeight = matchingPreset.height;
  167. targetMode = matchingPreset.mode;
  168. }
  169. }
  170. return {
  171. ...parameters,
  172. width: targetWidth,
  173. height: targetHeight,
  174. mode: targetMode,
  175. };
  176. }
  177. private getInitialImageTransformParameters(
  178. queryParams: Record<string, string>,
  179. ): ImageTransformParameters {
  180. const width = Math.round(+queryParams.w) || undefined;
  181. const height = Math.round(+queryParams.h) || undefined;
  182. const quality =
  183. queryParams.q != null ? Math.round(Math.max(Math.min(+queryParams.q, 100), 1)) : undefined;
  184. const mode: ImageTransformMode = queryParams.mode === 'resize' ? 'resize' : 'crop';
  185. const fpx = +queryParams.fpx || undefined;
  186. const fpy = +queryParams.fpy || undefined;
  187. const format = getValidFormat(queryParams.format);
  188. return {
  189. width,
  190. height,
  191. quality,
  192. format,
  193. mode,
  194. fpx,
  195. fpy,
  196. preset: queryParams.preset,
  197. };
  198. }
  199. private getFileNameFromParameters(filePath: string, params: ImageTransformParameters): string {
  200. const { width: w, height: h, mode, preset, fpx, fpy, format, quality: q } = params;
  201. /* eslint-disable @typescript-eslint/restrict-template-expressions */
  202. const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
  203. const quality = q ? `_q${q}` : '';
  204. const imageFormat = getValidFormat(format);
  205. let imageParamsString = '';
  206. if (w || h) {
  207. const width = w || '';
  208. const height = h || '';
  209. imageParamsString = `_transform_w${width}_h${height}_m${mode}`;
  210. } else if (preset) {
  211. if (this.presets && !!this.presets.find(p => p.name === preset)) {
  212. imageParamsString = `_transform_pre_${preset}`;
  213. }
  214. }
  215. if (focalPoint) {
  216. imageParamsString += focalPoint;
  217. }
  218. if (imageFormat) {
  219. imageParamsString += imageFormat;
  220. }
  221. if (quality) {
  222. imageParamsString += quality;
  223. }
  224. const decodedReqPath = this.sanitizeFilePath(filePath);
  225. if (imageParamsString !== '') {
  226. const imageParamHash = this.md5(imageParamsString);
  227. return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat));
  228. } else {
  229. return decodedReqPath;
  230. }
  231. }
  232. /**
  233. * Sanitize the file path to prevent directory traversal attacks.
  234. */
  235. private sanitizeFilePath(filePath: string): string {
  236. let decodedPath: string;
  237. try {
  238. decodedPath = decodeURIComponent(filePath);
  239. } catch (e: any) {
  240. Logger.error((e.message as string) + ': ' + filePath, loggerCtx);
  241. return '';
  242. }
  243. if (this.assetStorageStrategy instanceof S3AssetStorageStrategy) {
  244. // For S3 storage, we don't need to sanitize the path because
  245. // directory traversal attacks are not possible, and modifying the
  246. // path in this way can cause S3 files to be not found.
  247. return decodedPath;
  248. } else {
  249. // For local storage, we make sure to sanitize the path to prevent directory traversal attacks.
  250. const normalizedPath = path.normalize(decodedPath);
  251. let sanitizedPath = normalizedPath;
  252. let previousPath;
  253. do {
  254. previousPath = sanitizedPath;
  255. sanitizedPath = previousPath.replace(/(\.\.[\\/\\])+/g, '');
  256. } while (sanitizedPath !== previousPath);
  257. return sanitizedPath;
  258. }
  259. }
  260. private md5(input: string): string {
  261. return createHash('md5').update(input).digest('hex');
  262. }
  263. private addSuffix(fileName: string, suffix: string, ext?: string): string {
  264. const originalExt = path.extname(fileName);
  265. const effectiveExt = ext ? `.${ext}` : originalExt;
  266. const baseName = path.basename(fileName, originalExt);
  267. const dirName = path.dirname(fileName);
  268. return path.join(dirName, `${baseName}${suffix}${effectiveExt}`);
  269. }
  270. /**
  271. * Attempt to get the mime type from the file name.
  272. */
  273. private getMimeType(fileName: string): string | undefined {
  274. const ext = path.extname(fileName);
  275. switch (ext) {
  276. case '.jpg':
  277. case '.jpeg':
  278. return 'image/jpeg';
  279. case '.png':
  280. return 'image/png';
  281. case '.gif':
  282. return 'image/gif';
  283. case '.svg':
  284. return 'image/svg+xml';
  285. case '.tiff':
  286. return 'image/tiff';
  287. case '.webp':
  288. return 'image/webp';
  289. }
  290. }
  291. }