@websolutespa/bom-mixer-image 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,538 +1,538 @@
1
- // Importing the 'fs' module to work with the file system
2
- import * as fs from 'fs';
3
- // Importing the 'NextApiRequest' and 'NextApiResponse' from 'next' for handling API requests and responses
4
- import { NextApiRequest, NextApiResponse } from 'next';
5
- import { NextRequest, NextResponse } from 'next/server';
6
- // Importing the 'path' module to work with file and directory paths
7
- import * as path from 'path';
8
- // Importing 'sharp' for image processing
9
- import sharp from 'sharp';
10
- // Importing 'apiHandler' from '../api/api.handler'
11
- import { decryptImageResize, IImageResize, ImageStrategy } from '@websolutespa/bom-core';
12
- import { apiHandler } from '../api/api.handler';
13
-
14
- const IMAGE_STRATEGY: ImageStrategy = process.env.IMAGE_STRATEGY ?
15
- process.env.IMAGE_STRATEGY as ImageStrategy :
16
- ImageStrategy.Mixer;
17
-
18
- const IMAGE_MAX_AGE = 604800;
19
- const IMAGE_FOCAL_X = 50;
20
- const IMAGE_FOCAL_Y = 50;
21
- const IMAGE_QUALITY = 90;
22
-
23
- export type IImageHandlerOptions = {
24
- domains: string[];
25
- sizes: number[];
26
- quality: number;
27
- };
28
-
29
- export type IImageOptions = IImageResize & {
30
- origin: string;
31
- search: string;
32
- url: string;
33
- };
34
-
35
- type IImageHandlerNextContext = {
36
- params: Promise<{ slugs?: string[] }> | { slugs?: string[] };
37
- };
38
-
39
- export function imageHandler({
40
- domains = [],
41
- sizes = [],
42
- quality = IMAGE_QUALITY,
43
- }: IImageHandlerOptions) {
44
- // Returning the 'apiHandler' function with a configuration object
45
- return apiHandler({
46
- // Defining the 'get' method for handling GET requests
47
- get: async (request: NextApiRequest, response: NextApiResponse) => {
48
- try {
49
- let imageOptions: IImageOptions;
50
- try {
51
- imageOptions = (IMAGE_STRATEGY === ImageStrategy.Token ?
52
- getEncryptedImageOptions(request, { domains, sizes, quality }) :
53
- getImageOptions(request, { domains, sizes, quality }));
54
- } catch (error) {
55
- return response.status(400).json({
56
- errors: [{
57
- message: error,
58
- }],
59
- });
60
- }
61
- // Generating a cached name for the image
62
- const cachedName = urlToCachedName(imageOptions);
63
- // Checking if the cached image exists
64
- if (fs.existsSync(cachedName)) {
65
- // console.log('imageHandler.fromCache', cachedName);
66
- // Reading the cached image
67
- const cachedImage = await getCachedImage(cachedName);
68
- // Setting the 'Content-Type' header to 'image/webp'
69
- response.setHeader('Content-Type', 'image/webp');
70
- // Setting the 'Expires' header to the current time plus the maximum age of the image (in seconds)
71
- response.setHeader('Expires', new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString());
72
- // Setting the 'Cache-Control' header to 'public' and setting 'max-age' to the maximum age of the image
73
- response.setHeader('Cache-Control', `public, max-age=${IMAGE_MAX_AGE}`);
74
- // Ending the response and sending the cached image
75
- return response.send(cachedImage);
76
- }
77
- // Check referrer
78
- if (isInvalidReferer(request)) {
79
- return response.status(401).send('you are not authorized to perform this action');
80
- }
81
- // Temporarily fix Vercel local image issue
82
- if (imageOptions.url.indexOf('http') === -1 && isVercel()) {
83
- response.redirect(307, imageOptions.url);
84
- }
85
- // Reading the original image
86
- const originalImageBuffer = await getOriginalImage(imageOptions.url, domains);
87
- // Checking if the original image exists
88
- if (originalImageBuffer) {
89
- // Original image
90
- const originalImage = sharp(originalImageBuffer, {
91
- animated: isGIF(imageOptions.url),
92
- });
93
- // Resizing the original image and converting it to WebP format
94
- const resizedImage = await resizeImage(originalImage, imageOptions);
95
- // Converting resized image to WebP format
96
- const compressedImage = resizedImage
97
- .webp({
98
- quality: imageOptions.q,
99
- // nearLossless: true,
100
- });
101
- // Saving the resized image to the cache
102
- compressedImage.toFile(cachedName)
103
- .then(() => console.log('imageHandler.sharp.cached', cachedName))
104
- .catch((error: any) => console.error('imageHandler.sharp.error', cachedName, error));
105
- // Converting the resized image to a buffer
106
- const data = await compressedImage.toBuffer();
107
- // Setting the 'Content-Type' header to 'image/webp'
108
- response.setHeader('Content-Type', 'image/webp');
109
- // Setting the 'Expires' header to the current time plus the maximum age of the image (in seconds)
110
- response.setHeader('Expires', new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString());
111
- // Setting the 'Cache-Control' header to 'public' and setting 'max-age' to the maximum age of the image
112
- response.setHeader('Cache-Control', `public, max-age=${IMAGE_MAX_AGE}`);
113
- // Sending data in the response
114
- return response.end(data);
115
- }
116
- } catch (error: any) {
117
- // Logging an error message and throwing the error
118
- console.error('imageHandler.error', error);
119
- return response.status(error.status || 500).json({
120
- errors: [{
121
- message: error.message || error.name || error.statusText || 'An error occurred',
122
- }],
123
- });
124
- }
125
- },
126
- });
127
- }
128
-
129
- export function imageGetHandler({
130
- domains = [],
131
- sizes = [],
132
- quality = IMAGE_QUALITY,
133
- }: IImageHandlerOptions) {
134
- return async (request: NextRequest, context: IImageHandlerNextContext): Promise<Response> => {
135
- try {
136
- let imageOptions: IImageOptions;
137
- try {
138
- imageOptions = (IMAGE_STRATEGY === ImageStrategy.Token ?
139
- await getEncryptedImageOptionsNext(request, context, { domains, sizes, quality }) :
140
- await getImageOptionsNext(request, context, { domains, sizes, quality }));
141
- } catch (error) {
142
- return getJsonErrorResponse(400, error);
143
- }
144
- const cachedName = urlToCachedName(imageOptions);
145
- if (fs.existsSync(cachedName)) {
146
- const cachedImage = await getCachedImage(cachedName);
147
- return getImageResponse(cachedImage);
148
- }
149
- if (isInvalidRefererValue(request.headers.get('referer'))) {
150
- return new Response('you are not authorized to perform this action', {
151
- status: 401,
152
- });
153
- }
154
- if (imageOptions.url.indexOf('http') === -1 && isVercel()) {
155
- return NextResponse.redirect(new URL(imageOptions.url, request.url), 307);
156
- }
157
- const originalImageBuffer = await getOriginalImage(imageOptions.url, domains);
158
- if (originalImageBuffer) {
159
- const originalImage = sharp(originalImageBuffer, {
160
- animated: isGIF(imageOptions.url),
161
- });
162
- const resizedImage = await resizeImage(originalImage, imageOptions);
163
- const compressedImage = resizedImage
164
- .webp({
165
- quality: imageOptions.q,
166
- });
167
- compressedImage.toFile(cachedName)
168
- .then(() => console.log('imageHandler.sharp.cached', cachedName))
169
- .catch((error: any) => console.error('imageHandler.sharp.error', cachedName, error));
170
- const data = await compressedImage.toBuffer();
171
- return getImageResponse(data);
172
- }
173
- return getJsonErrorResponse(404, 'image not found');
174
- } catch (error: any) {
175
- console.error('imageHandler.error', error);
176
- return getJsonErrorResponse(
177
- error.status || 500,
178
- error.message || error.name || error.statusText || 'An error occurred'
179
- );
180
- }
181
- };
182
- }
183
-
184
- function isGIF(src: string): boolean {
185
- return src.indexOf('.gif') !== -1;
186
- }
187
-
188
- function isInvalidReferer(request: NextApiRequest): boolean {
189
- const { headers } = request;
190
- const referer = headers.referer;
191
- return isInvalidRefererValue(referer);
192
- }
193
-
194
- function isInvalidRefererValue(referer?: string | null): boolean {
195
- const publicUrl = process.env.NEXT_PUBLIC_URL || '';
196
- // console.log('referef', referer, 'publicUrl', publicUrl, 'IMAGE_STRATEGY', IMAGE_STRATEGY, ImageStrategy.Token);
197
- if (
198
- IMAGE_STRATEGY !== ImageStrategy.Token &&
199
- referer &&
200
- publicUrl !== 'https://' &&
201
- referer.indexOf(publicUrl) !== 0
202
- ) {
203
- return true;
204
- }
205
- return false;
206
- }
207
-
208
- async function getImageOptionsNext(
209
- request: NextRequest,
210
- context: IImageHandlerNextContext,
211
- options: IImageHandlerOptions
212
- ): Promise<IImageOptions> {
213
- const params = await context.params;
214
- const query = Object.fromEntries(request.nextUrl.searchParams.entries());
215
- return getImageOptionsFromQuery({
216
- ...query,
217
- slugs: params.slugs,
218
- }, options);
219
- }
220
-
221
- function getImageOptions(request: NextApiRequest, options: IImageHandlerOptions): IImageOptions {
222
- return getImageOptionsFromQuery(request.query, options);
223
- }
224
-
225
- function getImageOptionsFromQuery(
226
- query: Partial<Record<'origin' | 'search' | 'w' | 'h' | 'x' | 'y' | 'q', string | string[] | undefined>> & {
227
- slugs?: string[];
228
- },
229
- options: IImageHandlerOptions
230
- ): IImageOptions {
231
- // Destructuring 'slugs', 'origin', 'search', 'w', 'h', 'x', 'y', 'q' from the request query
232
- const { slugs, origin = '', search = '', w, h, x, y, q } = query;
233
- // Checking slugs
234
- if (!Array.isArray(slugs) || slugs.length === 0) {
235
- // console.error('imageHandler', 'missing slugs parameter');
236
- throw 'missing slugs parameter';
237
- }
238
- // Parsing 'w' to an integer and assigning it to 'width'
239
- const width = typeof w === 'string' ? parseInt(w) : undefined;
240
- // Checking if 'width' is not defined
241
- if (!width) {
242
- // Logging an error message and returning a 400 status code with a message
243
- // console.error('imageHandler', 'missing w parameter');
244
- throw 'missing w parameter';
245
- }
246
- // if (width > 2048) {
247
- if (!options.sizes.includes(width)) {
248
- throw 'invalid w parameter';
249
- }
250
- // Parsing 'h' to an integer and assigning it to 'height'
251
- const height = typeof h === 'string' ? parseInt(h) : undefined;
252
- // Parsing 'x' to an integer and assigning it to 'focalX'
253
- const focalX = typeof x === 'string' ? parseInt(x) : IMAGE_FOCAL_X;
254
- // Parsing 'y' to an integer and assigning it to 'focalY'
255
- const focalY = typeof y === 'string' ? parseInt(y) : IMAGE_FOCAL_Y;
256
- // Parsing 'q' to an integer, ensuring it's between 30 and 100, and assigning it to 'imageQuality'
257
- const imageQuality = Math.max(30, Math.min(100, (typeof q === 'string' ? parseInt(q) : options.quality)));
258
- // if (width > 2048) {
259
- if (imageQuality !== options.quality) {
260
- throw 'invalid q parameter';
261
- }
262
- // Generating url
263
- const url = origin + '/' + slugs.join('/') + search;
264
- //
265
- return height ? {
266
- w: width,
267
- h: height,
268
- x: focalX,
269
- y: focalY,
270
- q: imageQuality,
271
- src: url,
272
- url: url,
273
- origin: String(origin),
274
- search: String(search),
275
- } : {
276
- w: width,
277
- q: imageQuality,
278
- src: url,
279
- url: url,
280
- origin: String(origin),
281
- search: String(search),
282
- };
283
- }
284
-
285
- async function getEncryptedImageOptionsNext(
286
- request: NextRequest,
287
- context: IImageHandlerNextContext,
288
- options: IImageHandlerOptions
289
- ): Promise<IImageOptions> {
290
- const params = await context.params;
291
- const query = Object.fromEntries(request.nextUrl.searchParams.entries());
292
- return getEncryptedImageOptionsFromQuery({
293
- ...query,
294
- slugs: params.slugs,
295
- }, options);
296
- }
297
-
298
- function getEncryptedImageOptions(request: NextApiRequest, options: IImageHandlerOptions): IImageOptions {
299
- return getEncryptedImageOptionsFromQuery(request.query, options);
300
- }
301
-
302
- function getEncryptedImageOptionsFromQuery(
303
- query: Partial<Record<'origin' | 'search' | 't', string | string[] | undefined>> & {
304
- slugs?: string[];
305
- },
306
- options: IImageHandlerOptions
307
- ): IImageOptions {
308
- // Destructuring 'slugs', 'origin', 'search', 't' from the request query
309
- const { slugs, origin = '', search = '', t } = query;
310
- // Checking slugs
311
- if (!Array.isArray(slugs) || slugs.length === 0) {
312
- // console.error('imageHandler', 'missing slugs parameter');
313
- throw 'missing slugs parameter';
314
- }
315
- if (typeof t !== 'string') {
316
- throw 'missing t parameter';
317
- }
318
- const imageResize = decryptImageResize(t);
319
- /*
320
- // Checking if 'w' is not defined
321
- if (!imageResize.w) {
322
- throw 'missing w parameter';
323
- }
324
- // if (width > 2048) {
325
- if (!options.sizes.includes(imageResize.w)) {
326
- throw 'invalid w parameter';
327
- }
328
- // Ensuring 'q' is between 30 and 100, and assigning it to 'imageQuality'
329
- const imageQuality = Math.max(30, Math.min(100, imageResize.q || options.quality));
330
- if (imageQuality !== options.quality) {
331
- throw 'invalid q parameter';
332
- }
333
- */
334
- imageResize.src = decodeURIComponent(imageResize.src);
335
- // Generating url
336
- const url = decodeURIComponent(origin + '/' + slugs.join('/') + search);
337
- //
338
- if (imageResize.src !== url) {
339
- throw `invalid src parameter ${imageResize.src} ${url}`;
340
- }
341
- //
342
- return {
343
- ...imageResize,
344
- url: url,
345
- origin: String(origin),
346
- search: String(search),
347
- };
348
- }
349
-
350
- async function resizeImage(originalImage: sharp.Sharp, imageOptions: IImageOptions): Promise<sharp.Sharp> {
351
- const { w: resizeWidth, h: resizeHeight, x: focalX = IMAGE_FOCAL_X, y: focalY = IMAGE_FOCAL_Y } = imageOptions;
352
- const defaultResizeOptions = () => {
353
- return originalImage.resize({
354
- width: resizeWidth,
355
- height: resizeHeight ? resizeHeight : undefined,
356
- fit: sharp.fit.cover,
357
- position: sharp.strategy.attention,
358
- withoutEnlargement: true,
359
- });
360
- };
361
- if (resizeHeight && (focalX !== IMAGE_FOCAL_X || focalY !== IMAGE_FOCAL_Y)) {
362
- const { width: originalWidth, height: originalHeight } = await originalImage.metadata();
363
- if (originalWidth && originalHeight) {
364
- const resizeAspectRatio = resizeWidth / resizeHeight;
365
- const originalAspectRatio = originalWidth / originalHeight;
366
- const prioritizeHeight = resizeAspectRatio < originalAspectRatio;
367
- const imageToResize = originalImage.clone();
368
- // Scale the image up or down to fit the resize dimensions
369
- const scaledImage = imageToResize.resize(prioritizeHeight ? {
370
- height: resizeHeight,
371
- fit: sharp.fit.cover,
372
- } : {
373
- width: resizeWidth,
374
- fit: sharp.fit.cover,
375
- });
376
- const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true });
377
- // extractWidth
378
- const extractWidth = resizeWidth ?? scaledImageInfo.width;
379
- const maxOffsetX = scaledImageInfo.width - extractWidth;
380
- const leftFocalEdge = Math.round(
381
- scaledImageInfo.width * (focalX / 100) - extractWidth / 2
382
- );
383
- // extractX
384
- const extractX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX);
385
- // extractHeight
386
- const extractHeight = resizeHeight ?? scaledImageInfo.height;
387
- const maxOffsetY = scaledImageInfo.height - extractHeight;
388
- const topFocalEdge = Math.round(
389
- scaledImageInfo.height * (focalY / 100) - extractHeight / 2
390
- );
391
- // extractY
392
- const extractY = Math.min(Math.max(0, topFocalEdge), maxOffsetY);
393
- // Extract the focal area from the scaled image
394
- return scaledImage.extract({
395
- left: extractX,
396
- top: extractY,
397
- width: extractWidth,
398
- height: extractHeight,
399
- });
400
- } else {
401
- return defaultResizeOptions();
402
- }
403
- } else {
404
- return defaultResizeOptions();
405
- }
406
- }
407
-
408
- // Defining the 'getOriginalImage' function
409
- function getOriginalImage(url: string, domains: string[]): Promise<Buffer> {
410
- // Returning a new promise
411
- return new Promise((resolve, reject) => {
412
- // Checking if 'url' starts with 'http'
413
- if (url.indexOf('http') === 0) {
414
- if (isInvalidDomain(url, domains)) {
415
- return reject({ status: 401, message: 'invalid origin' });
416
- }
417
- // Fetching the image from the URL
418
- fetch(url, {
419
- method: 'GET',
420
- mode: 'cors',
421
- headers: {
422
- 'Access-Control-Allow-Origin': '*',
423
- },
424
- })
425
- .then(response => {
426
- if (!response.ok) {
427
- return reject({ status: response.status, message: response.statusText });
428
- }
429
- response.arrayBuffer().then(buffer => {
430
- return resolve(Buffer.from(buffer));
431
- }).catch(error => {
432
- return reject(error);
433
- });
434
- })
435
- .catch(error => {
436
- // Rejecting the promise with the error
437
- return reject(error);
438
- });
439
- } else {
440
- // Reading the image from the file system
441
- const filePath = path.join(process.cwd(), 'public', url);
442
- // console.log('imageHandler.getOriginalImage.filePath', filePath);
443
- fs.readFile(filePath, (error, data) => {
444
- if (error) {
445
- return reject(error);
446
- }
447
- // Resolving the promise with the image data
448
- return resolve(data);
449
- });
450
- }
451
- });
452
- }
453
-
454
- function getImageResponse(data: Buffer<ArrayBufferLike>): Response {
455
- return new Response(data as BodyInit, {
456
- headers: {
457
- 'Cache-Control': `public, max-age=${IMAGE_MAX_AGE}`,
458
- 'Content-Type': 'image/webp',
459
- Expires: new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString(),
460
- },
461
- });
462
- }
463
-
464
- function getJsonErrorResponse(status: number, message: unknown): Response {
465
- return Response.json({
466
- errors: [{
467
- message,
468
- }],
469
- }, {
470
- status,
471
- });
472
- }
473
-
474
- // Defining the 'getCachedImage' function
475
- function getCachedImage(pathname: string): Promise<Buffer> {
476
- // Returning a new promise
477
- return new Promise((resolve, reject) => {
478
- // Reading the image from the file system
479
- fs.readFile(pathname, (error, data) => {
480
- if (error) {
481
- // Rejecting the promise with the error
482
- return reject(error);
483
- }
484
- // Resolving the promise with the image data
485
- return resolve(data);
486
- });
487
- });
488
- }
489
-
490
- // Defining the 'urlToFileName' function
491
- function urlToFileName(url: string): string {
492
- // Replacing useless characters with UNDERSCORE
493
- const segments = url.split('?');
494
- const path = segments[0]!;
495
- const query = segments[1];
496
- let uniqueName = path.replace('://', '_').replace(/\.|\/|\?|=/g, '_');
497
- // Replacing last UNDERSCORE with a DOT
498
- uniqueName = uniqueName.substring(0, uniqueName.lastIndexOf('_'))
499
- + (query ? '_' + query.replace(/\.|\/|\?|=/g, '_') : '')
500
- + '.' + uniqueName.substring(uniqueName.lastIndexOf('_') + 1, uniqueName.length);
501
- // Returning the unique name
502
- return uniqueName;
503
- }
504
-
505
- function isVercel(): boolean {
506
- return process && process.env && String(process.env.VERCEL) === '1';
507
- }
508
-
509
- // Defining the 'urlToCachedName' function
510
- function urlToCachedName(options: IImageOptions): string {
511
- // Converting the URL to a file name
512
- const fileName = urlToFileName(options.url);
513
- // Parsing the file name
514
- const parsedName = path.parse(fileName);
515
- // Defining the cache directory
516
- const cacheDir = isVercel() ?
517
- path.join('/tmp', '.cache') :
518
- path.join(process.cwd(), '.cache');
519
- // Checking if the cache directory exists
520
- if (!fs.existsSync(cacheDir)) {
521
- // Creating the cache directory
522
- fs.mkdirSync(cacheDir);
523
- }
524
- // Generating the cached name
525
- const cachedName = options.h ?
526
- path.join(cacheDir, `${parsedName.name}_w_${options.w}_h_${options.h || 0}_x_${options.x || IMAGE_FOCAL_X}_y_${options.y || IMAGE_FOCAL_Y}_q_${options.q}${parsedName.ext}`) :
527
- path.join(cacheDir, `${parsedName.name}_w_${options.w}_q_${options.q}${parsedName.ext}`);
528
- // Returning the cached name
529
- return cachedName;
530
- }
531
-
532
- function isInvalidDomain(src: string, domains: string[]): boolean {
533
- if (IMAGE_STRATEGY === ImageStrategy.Token) {
534
- return false;
535
- }
536
- const url = new URL(src);
537
- return !domains.includes(url.hostname);
538
- }
1
+ // Importing the 'fs' module to work with the file system
2
+ import * as fs from 'fs';
3
+ // Importing the 'NextApiRequest' and 'NextApiResponse' from 'next' for handling API requests and responses
4
+ import { NextApiRequest, NextApiResponse } from 'next';
5
+ import { NextRequest, NextResponse } from 'next/server.js';
6
+ // Importing the 'path' module to work with file and directory paths
7
+ import * as path from 'path';
8
+ // Importing 'sharp' for image processing
9
+ import sharp from 'sharp';
10
+ // Importing 'apiHandler' from '../api/api.handler'
11
+ import { decryptImageResize, IImageResize, ImageStrategy } from '@websolutespa/bom-core';
12
+ import { apiHandler } from '../api/api.handler';
13
+
14
+ const IMAGE_STRATEGY: ImageStrategy = process.env.IMAGE_STRATEGY ?
15
+ process.env.IMAGE_STRATEGY as ImageStrategy :
16
+ ImageStrategy.Mixer;
17
+
18
+ const IMAGE_MAX_AGE = 604800;
19
+ const IMAGE_FOCAL_X = 50;
20
+ const IMAGE_FOCAL_Y = 50;
21
+ const IMAGE_QUALITY = 90;
22
+
23
+ export type IImageHandlerOptions = {
24
+ domains: string[];
25
+ sizes: number[];
26
+ quality: number;
27
+ };
28
+
29
+ export type IImageOptions = IImageResize & {
30
+ origin: string;
31
+ search: string;
32
+ url: string;
33
+ };
34
+
35
+ type IImageHandlerNextContext = {
36
+ params: Promise<{ slugs?: string[] }> | { slugs?: string[] };
37
+ };
38
+
39
+ export function imageHandler({
40
+ domains = [],
41
+ sizes = [],
42
+ quality = IMAGE_QUALITY,
43
+ }: IImageHandlerOptions) {
44
+ // Returning the 'apiHandler' function with a configuration object
45
+ return apiHandler({
46
+ // Defining the 'get' method for handling GET requests
47
+ get: async (request: NextApiRequest, response: NextApiResponse) => {
48
+ try {
49
+ let imageOptions: IImageOptions;
50
+ try {
51
+ imageOptions = (IMAGE_STRATEGY === ImageStrategy.Token ?
52
+ getEncryptedImageOptions(request, { domains, sizes, quality }) :
53
+ getImageOptions(request, { domains, sizes, quality }));
54
+ } catch (error) {
55
+ return response.status(400).json({
56
+ errors: [{
57
+ message: error,
58
+ }],
59
+ });
60
+ }
61
+ // Generating a cached name for the image
62
+ const cachedName = urlToCachedName(imageOptions);
63
+ // Checking if the cached image exists
64
+ if (fs.existsSync(cachedName)) {
65
+ // console.log('imageHandler.fromCache', cachedName);
66
+ // Reading the cached image
67
+ const cachedImage = await getCachedImage(cachedName);
68
+ // Setting the 'Content-Type' header to 'image/webp'
69
+ response.setHeader('Content-Type', 'image/webp');
70
+ // Setting the 'Expires' header to the current time plus the maximum age of the image (in seconds)
71
+ response.setHeader('Expires', new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString());
72
+ // Setting the 'Cache-Control' header to 'public' and setting 'max-age' to the maximum age of the image
73
+ response.setHeader('Cache-Control', `public, max-age=${IMAGE_MAX_AGE}`);
74
+ // Ending the response and sending the cached image
75
+ return response.send(cachedImage);
76
+ }
77
+ // Check referrer
78
+ if (isInvalidReferer(request)) {
79
+ return response.status(401).send('you are not authorized to perform this action');
80
+ }
81
+ // Temporarily fix Vercel local image issue
82
+ if (imageOptions.url.indexOf('http') === -1 && isVercel()) {
83
+ response.redirect(307, imageOptions.url);
84
+ }
85
+ // Reading the original image
86
+ const originalImageBuffer = await getOriginalImage(imageOptions.url, domains);
87
+ // Checking if the original image exists
88
+ if (originalImageBuffer) {
89
+ // Original image
90
+ const originalImage = sharp(originalImageBuffer, {
91
+ animated: isGIF(imageOptions.url),
92
+ });
93
+ // Resizing the original image and converting it to WebP format
94
+ const resizedImage = await resizeImage(originalImage, imageOptions);
95
+ // Converting resized image to WebP format
96
+ const compressedImage = resizedImage
97
+ .webp({
98
+ quality: imageOptions.q,
99
+ // nearLossless: true,
100
+ });
101
+ // Saving the resized image to the cache
102
+ compressedImage.toFile(cachedName)
103
+ .then(() => console.log('imageHandler.sharp.cached', cachedName))
104
+ .catch((error: any) => console.error('imageHandler.sharp.error', cachedName, error));
105
+ // Converting the resized image to a buffer
106
+ const data = await compressedImage.toBuffer();
107
+ // Setting the 'Content-Type' header to 'image/webp'
108
+ response.setHeader('Content-Type', 'image/webp');
109
+ // Setting the 'Expires' header to the current time plus the maximum age of the image (in seconds)
110
+ response.setHeader('Expires', new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString());
111
+ // Setting the 'Cache-Control' header to 'public' and setting 'max-age' to the maximum age of the image
112
+ response.setHeader('Cache-Control', `public, max-age=${IMAGE_MAX_AGE}`);
113
+ // Sending data in the response
114
+ return response.end(data);
115
+ }
116
+ } catch (error: any) {
117
+ // Logging an error message and throwing the error
118
+ console.error('imageHandler.error', error);
119
+ return response.status(error.status || 500).json({
120
+ errors: [{
121
+ message: error.message || error.name || error.statusText || 'An error occurred',
122
+ }],
123
+ });
124
+ }
125
+ },
126
+ });
127
+ }
128
+
129
+ export function imageGetHandler({
130
+ domains = [],
131
+ sizes = [],
132
+ quality = IMAGE_QUALITY,
133
+ }: IImageHandlerOptions) {
134
+ return async (request: NextRequest, context: IImageHandlerNextContext): Promise<Response> => {
135
+ try {
136
+ let imageOptions: IImageOptions;
137
+ try {
138
+ imageOptions = (IMAGE_STRATEGY === ImageStrategy.Token ?
139
+ await getEncryptedImageOptionsNext(request, context, { domains, sizes, quality }) :
140
+ await getImageOptionsNext(request, context, { domains, sizes, quality }));
141
+ } catch (error) {
142
+ return getJsonErrorResponse(400, error);
143
+ }
144
+ const cachedName = urlToCachedName(imageOptions);
145
+ if (fs.existsSync(cachedName)) {
146
+ const cachedImage = await getCachedImage(cachedName);
147
+ return getImageResponse(cachedImage);
148
+ }
149
+ if (isInvalidRefererValue(request.headers.get('referer'))) {
150
+ return new Response('you are not authorized to perform this action', {
151
+ status: 401,
152
+ });
153
+ }
154
+ if (imageOptions.url.indexOf('http') === -1 && isVercel()) {
155
+ return NextResponse.redirect(new URL(imageOptions.url, request.url), 307);
156
+ }
157
+ const originalImageBuffer = await getOriginalImage(imageOptions.url, domains);
158
+ if (originalImageBuffer) {
159
+ const originalImage = sharp(originalImageBuffer, {
160
+ animated: isGIF(imageOptions.url),
161
+ });
162
+ const resizedImage = await resizeImage(originalImage, imageOptions);
163
+ const compressedImage = resizedImage
164
+ .webp({
165
+ quality: imageOptions.q,
166
+ });
167
+ compressedImage.toFile(cachedName)
168
+ .then(() => console.log('imageHandler.sharp.cached', cachedName))
169
+ .catch((error: any) => console.error('imageHandler.sharp.error', cachedName, error));
170
+ const data = await compressedImage.toBuffer();
171
+ return getImageResponse(data);
172
+ }
173
+ return getJsonErrorResponse(404, 'image not found');
174
+ } catch (error: any) {
175
+ console.error('imageHandler.error', error);
176
+ return getJsonErrorResponse(
177
+ error.status || 500,
178
+ error.message || error.name || error.statusText || 'An error occurred'
179
+ );
180
+ }
181
+ };
182
+ }
183
+
184
+ function isGIF(src: string): boolean {
185
+ return src.indexOf('.gif') !== -1;
186
+ }
187
+
188
+ function isInvalidReferer(request: NextApiRequest): boolean {
189
+ const { headers } = request;
190
+ const referer = headers.referer;
191
+ return isInvalidRefererValue(referer);
192
+ }
193
+
194
+ function isInvalidRefererValue(referer?: string | null): boolean {
195
+ const publicUrl = process.env.NEXT_PUBLIC_URL || '';
196
+ // console.log('referef', referer, 'publicUrl', publicUrl, 'IMAGE_STRATEGY', IMAGE_STRATEGY, ImageStrategy.Token);
197
+ if (
198
+ IMAGE_STRATEGY !== ImageStrategy.Token &&
199
+ referer &&
200
+ publicUrl !== 'https://' &&
201
+ referer.indexOf(publicUrl) !== 0
202
+ ) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ async function getImageOptionsNext(
209
+ request: NextRequest,
210
+ context: IImageHandlerNextContext,
211
+ options: IImageHandlerOptions
212
+ ): Promise<IImageOptions> {
213
+ const params = await context.params;
214
+ const query = Object.fromEntries(request.nextUrl.searchParams.entries());
215
+ return getImageOptionsFromQuery({
216
+ ...query,
217
+ slugs: params.slugs,
218
+ }, options);
219
+ }
220
+
221
+ function getImageOptions(request: NextApiRequest, options: IImageHandlerOptions): IImageOptions {
222
+ return getImageOptionsFromQuery(request.query, options);
223
+ }
224
+
225
+ function getImageOptionsFromQuery(
226
+ query: Partial<Record<'origin' | 'search' | 'w' | 'h' | 'x' | 'y' | 'q', string | string[] | undefined>> & {
227
+ slugs?: string[];
228
+ },
229
+ options: IImageHandlerOptions
230
+ ): IImageOptions {
231
+ // Destructuring 'slugs', 'origin', 'search', 'w', 'h', 'x', 'y', 'q' from the request query
232
+ const { slugs, origin = '', search = '', w, h, x, y, q } = query;
233
+ // Checking slugs
234
+ if (!Array.isArray(slugs) || slugs.length === 0) {
235
+ // console.error('imageHandler', 'missing slugs parameter');
236
+ throw 'missing slugs parameter';
237
+ }
238
+ // Parsing 'w' to an integer and assigning it to 'width'
239
+ const width = typeof w === 'string' ? parseInt(w) : undefined;
240
+ // Checking if 'width' is not defined
241
+ if (!width) {
242
+ // Logging an error message and returning a 400 status code with a message
243
+ // console.error('imageHandler', 'missing w parameter');
244
+ throw 'missing w parameter';
245
+ }
246
+ // if (width > 2048) {
247
+ if (!options.sizes.includes(width)) {
248
+ throw 'invalid w parameter';
249
+ }
250
+ // Parsing 'h' to an integer and assigning it to 'height'
251
+ const height = typeof h === 'string' ? parseInt(h) : undefined;
252
+ // Parsing 'x' to an integer and assigning it to 'focalX'
253
+ const focalX = typeof x === 'string' ? parseInt(x) : IMAGE_FOCAL_X;
254
+ // Parsing 'y' to an integer and assigning it to 'focalY'
255
+ const focalY = typeof y === 'string' ? parseInt(y) : IMAGE_FOCAL_Y;
256
+ // Parsing 'q' to an integer, ensuring it's between 30 and 100, and assigning it to 'imageQuality'
257
+ const imageQuality = Math.max(30, Math.min(100, (typeof q === 'string' ? parseInt(q) : options.quality)));
258
+ // if (width > 2048) {
259
+ if (imageQuality !== options.quality) {
260
+ throw 'invalid q parameter';
261
+ }
262
+ // Generating url
263
+ const url = origin + '/' + slugs.join('/') + search;
264
+ //
265
+ return height ? {
266
+ w: width,
267
+ h: height,
268
+ x: focalX,
269
+ y: focalY,
270
+ q: imageQuality,
271
+ src: url,
272
+ url: url,
273
+ origin: String(origin),
274
+ search: String(search),
275
+ } : {
276
+ w: width,
277
+ q: imageQuality,
278
+ src: url,
279
+ url: url,
280
+ origin: String(origin),
281
+ search: String(search),
282
+ };
283
+ }
284
+
285
+ async function getEncryptedImageOptionsNext(
286
+ request: NextRequest,
287
+ context: IImageHandlerNextContext,
288
+ options: IImageHandlerOptions
289
+ ): Promise<IImageOptions> {
290
+ const params = await context.params;
291
+ const query = Object.fromEntries(request.nextUrl.searchParams.entries());
292
+ return getEncryptedImageOptionsFromQuery({
293
+ ...query,
294
+ slugs: params.slugs,
295
+ }, options);
296
+ }
297
+
298
+ function getEncryptedImageOptions(request: NextApiRequest, options: IImageHandlerOptions): IImageOptions {
299
+ return getEncryptedImageOptionsFromQuery(request.query, options);
300
+ }
301
+
302
+ function getEncryptedImageOptionsFromQuery(
303
+ query: Partial<Record<'origin' | 'search' | 't', string | string[] | undefined>> & {
304
+ slugs?: string[];
305
+ },
306
+ options: IImageHandlerOptions
307
+ ): IImageOptions {
308
+ // Destructuring 'slugs', 'origin', 'search', 't' from the request query
309
+ const { slugs, origin = '', search = '', t } = query;
310
+ // Checking slugs
311
+ if (!Array.isArray(slugs) || slugs.length === 0) {
312
+ // console.error('imageHandler', 'missing slugs parameter');
313
+ throw 'missing slugs parameter';
314
+ }
315
+ if (typeof t !== 'string') {
316
+ throw 'missing t parameter';
317
+ }
318
+ const imageResize = decryptImageResize(t);
319
+ /*
320
+ // Checking if 'w' is not defined
321
+ if (!imageResize.w) {
322
+ throw 'missing w parameter';
323
+ }
324
+ // if (width > 2048) {
325
+ if (!options.sizes.includes(imageResize.w)) {
326
+ throw 'invalid w parameter';
327
+ }
328
+ // Ensuring 'q' is between 30 and 100, and assigning it to 'imageQuality'
329
+ const imageQuality = Math.max(30, Math.min(100, imageResize.q || options.quality));
330
+ if (imageQuality !== options.quality) {
331
+ throw 'invalid q parameter';
332
+ }
333
+ */
334
+ imageResize.src = decodeURIComponent(imageResize.src);
335
+ // Generating url
336
+ const url = decodeURIComponent(origin + '/' + slugs.join('/') + search);
337
+ //
338
+ if (imageResize.src !== url) {
339
+ throw `invalid src parameter ${imageResize.src} ${url}`;
340
+ }
341
+ //
342
+ return {
343
+ ...imageResize,
344
+ url: url,
345
+ origin: String(origin),
346
+ search: String(search),
347
+ };
348
+ }
349
+
350
+ async function resizeImage(originalImage: sharp.Sharp, imageOptions: IImageOptions): Promise<sharp.Sharp> {
351
+ const { w: resizeWidth, h: resizeHeight, x: focalX = IMAGE_FOCAL_X, y: focalY = IMAGE_FOCAL_Y } = imageOptions;
352
+ const defaultResizeOptions = () => {
353
+ return originalImage.resize({
354
+ width: resizeWidth,
355
+ height: resizeHeight ? resizeHeight : undefined,
356
+ fit: sharp.fit.cover,
357
+ position: sharp.strategy.attention,
358
+ withoutEnlargement: true,
359
+ });
360
+ };
361
+ if (resizeHeight && (focalX !== IMAGE_FOCAL_X || focalY !== IMAGE_FOCAL_Y)) {
362
+ const { width: originalWidth, height: originalHeight } = await originalImage.metadata();
363
+ if (originalWidth && originalHeight) {
364
+ const resizeAspectRatio = resizeWidth / resizeHeight;
365
+ const originalAspectRatio = originalWidth / originalHeight;
366
+ const prioritizeHeight = resizeAspectRatio < originalAspectRatio;
367
+ const imageToResize = originalImage.clone();
368
+ // Scale the image up or down to fit the resize dimensions
369
+ const scaledImage = imageToResize.resize(prioritizeHeight ? {
370
+ height: resizeHeight,
371
+ fit: sharp.fit.cover,
372
+ } : {
373
+ width: resizeWidth,
374
+ fit: sharp.fit.cover,
375
+ });
376
+ const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true });
377
+ // extractWidth
378
+ const extractWidth = resizeWidth ?? scaledImageInfo.width;
379
+ const maxOffsetX = scaledImageInfo.width - extractWidth;
380
+ const leftFocalEdge = Math.round(
381
+ scaledImageInfo.width * (focalX / 100) - extractWidth / 2
382
+ );
383
+ // extractX
384
+ const extractX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX);
385
+ // extractHeight
386
+ const extractHeight = resizeHeight ?? scaledImageInfo.height;
387
+ const maxOffsetY = scaledImageInfo.height - extractHeight;
388
+ const topFocalEdge = Math.round(
389
+ scaledImageInfo.height * (focalY / 100) - extractHeight / 2
390
+ );
391
+ // extractY
392
+ const extractY = Math.min(Math.max(0, topFocalEdge), maxOffsetY);
393
+ // Extract the focal area from the scaled image
394
+ return scaledImage.extract({
395
+ left: extractX,
396
+ top: extractY,
397
+ width: extractWidth,
398
+ height: extractHeight,
399
+ });
400
+ } else {
401
+ return defaultResizeOptions();
402
+ }
403
+ } else {
404
+ return defaultResizeOptions();
405
+ }
406
+ }
407
+
408
+ // Defining the 'getOriginalImage' function
409
+ function getOriginalImage(url: string, domains: string[]): Promise<Buffer> {
410
+ // Returning a new promise
411
+ return new Promise((resolve, reject) => {
412
+ // Checking if 'url' starts with 'http'
413
+ if (url.indexOf('http') === 0) {
414
+ if (isInvalidDomain(url, domains)) {
415
+ return reject({ status: 401, message: 'invalid origin' });
416
+ }
417
+ // Fetching the image from the URL
418
+ fetch(url, {
419
+ method: 'GET',
420
+ mode: 'cors',
421
+ headers: {
422
+ 'Access-Control-Allow-Origin': '*',
423
+ },
424
+ })
425
+ .then(response => {
426
+ if (!response.ok) {
427
+ return reject({ status: response.status, message: response.statusText });
428
+ }
429
+ response.arrayBuffer().then(buffer => {
430
+ return resolve(Buffer.from(buffer));
431
+ }).catch(error => {
432
+ return reject(error);
433
+ });
434
+ })
435
+ .catch(error => {
436
+ // Rejecting the promise with the error
437
+ return reject(error);
438
+ });
439
+ } else {
440
+ // Reading the image from the file system
441
+ const filePath = path.join(process.cwd(), 'public', url);
442
+ // console.log('imageHandler.getOriginalImage.filePath', filePath);
443
+ fs.readFile(filePath, (error, data) => {
444
+ if (error) {
445
+ return reject(error);
446
+ }
447
+ // Resolving the promise with the image data
448
+ return resolve(data);
449
+ });
450
+ }
451
+ });
452
+ }
453
+
454
+ function getImageResponse(data: Buffer<ArrayBufferLike>): Response {
455
+ return new Response(data as BodyInit, {
456
+ headers: {
457
+ 'Cache-Control': `public, max-age=${IMAGE_MAX_AGE}`,
458
+ 'Content-Type': 'image/webp',
459
+ Expires: new Date(Date.now() + IMAGE_MAX_AGE * 1000).toUTCString(),
460
+ },
461
+ });
462
+ }
463
+
464
+ function getJsonErrorResponse(status: number, message: unknown): Response {
465
+ return Response.json({
466
+ errors: [{
467
+ message,
468
+ }],
469
+ }, {
470
+ status,
471
+ });
472
+ }
473
+
474
+ // Defining the 'getCachedImage' function
475
+ function getCachedImage(pathname: string): Promise<Buffer> {
476
+ // Returning a new promise
477
+ return new Promise((resolve, reject) => {
478
+ // Reading the image from the file system
479
+ fs.readFile(pathname, (error, data) => {
480
+ if (error) {
481
+ // Rejecting the promise with the error
482
+ return reject(error);
483
+ }
484
+ // Resolving the promise with the image data
485
+ return resolve(data);
486
+ });
487
+ });
488
+ }
489
+
490
+ // Defining the 'urlToFileName' function
491
+ function urlToFileName(url: string): string {
492
+ // Replacing useless characters with UNDERSCORE
493
+ const segments = url.split('?');
494
+ const path = segments[0]!;
495
+ const query = segments[1];
496
+ let uniqueName = path.replace('://', '_').replace(/\.|\/|\?|=/g, '_');
497
+ // Replacing last UNDERSCORE with a DOT
498
+ uniqueName = uniqueName.substring(0, uniqueName.lastIndexOf('_'))
499
+ + (query ? '_' + query.replace(/\.|\/|\?|=/g, '_') : '')
500
+ + '.' + uniqueName.substring(uniqueName.lastIndexOf('_') + 1, uniqueName.length);
501
+ // Returning the unique name
502
+ return uniqueName;
503
+ }
504
+
505
+ function isVercel(): boolean {
506
+ return process && process.env && String(process.env.VERCEL) === '1';
507
+ }
508
+
509
+ // Defining the 'urlToCachedName' function
510
+ function urlToCachedName(options: IImageOptions): string {
511
+ // Converting the URL to a file name
512
+ const fileName = urlToFileName(options.url);
513
+ // Parsing the file name
514
+ const parsedName = path.parse(fileName);
515
+ // Defining the cache directory
516
+ const cacheDir = isVercel() ?
517
+ path.join('/tmp', '.cache') :
518
+ path.join(process.cwd(), '.cache');
519
+ // Checking if the cache directory exists
520
+ if (!fs.existsSync(cacheDir)) {
521
+ // Creating the cache directory
522
+ fs.mkdirSync(cacheDir);
523
+ }
524
+ // Generating the cached name
525
+ const cachedName = options.h ?
526
+ path.join(cacheDir, `${parsedName.name}_w_${options.w}_h_${options.h || 0}_x_${options.x || IMAGE_FOCAL_X}_y_${options.y || IMAGE_FOCAL_Y}_q_${options.q}${parsedName.ext}`) :
527
+ path.join(cacheDir, `${parsedName.name}_w_${options.w}_q_${options.q}${parsedName.ext}`);
528
+ // Returning the cached name
529
+ return cachedName;
530
+ }
531
+
532
+ function isInvalidDomain(src: string, domains: string[]): boolean {
533
+ if (IMAGE_STRATEGY === ImageStrategy.Token) {
534
+ return false;
535
+ }
536
+ const url = new URL(src);
537
+ return !domains.includes(url.hostname);
538
+ }