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