-
Notifications
You must be signed in to change notification settings - Fork 538
/
image-request.ts
518 lines (463 loc) · 19.5 KB
/
image-request.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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import S3 from "aws-sdk/clients/s3";
import { createHmac } from "crypto";
import {
ContentTypes,
DefaultImageRequest,
Headers,
ImageEdits,
ImageFormatTypes,
ImageHandlerError,
ImageHandlerEvent,
ImageRequestInfo,
RequestTypes,
StatusCodes,
} from "./lib";
import { SecretProvider } from "./secret-provider";
import { ThumborMapper } from "./thumbor-mapper";
type OriginalImageInfo = Partial<{
contentType: string;
expires: string;
lastModified: string;
cacheControl: string;
originalImage: Buffer;
}>;
export class ImageRequest {
private static readonly DEFAULT_EFFORT = 4;
constructor(private readonly s3Client: S3, private readonly secretProvider: SecretProvider) {}
/**
* Determines the output format of an image
* @param imageRequestInfo Initialized image request information
* @param event Lambda requrest body
*/
private determineOutputFormat(imageRequestInfo: ImageRequestInfo, event: ImageHandlerEvent): void {
const outputFormat = this.getOutputFormat(event, imageRequestInfo.requestType);
// if webp check reduction effort, if invalid value, use 4 (default in sharp)
if (outputFormat === ImageFormatTypes.WEBP && imageRequestInfo.requestType === RequestTypes.DEFAULT) {
const decoded = this.decodeRequest(event);
if (typeof decoded.effort !== "undefined") {
const effort = Math.trunc(decoded.effort);
const isValid = !isNaN(effort) && effort >= 0 && effort <= 6;
imageRequestInfo.effort = isValid ? effort : ImageRequest.DEFAULT_EFFORT;
}
}
if (imageRequestInfo.edits && imageRequestInfo.edits.toFormat) {
imageRequestInfo.outputFormat = imageRequestInfo.edits.toFormat;
} else if (outputFormat) {
imageRequestInfo.outputFormat = outputFormat;
}
}
/**
* Fix quality for Thumbor and Custom request type if outputFormat is different from quality type.
* @param imageRequestInfo Initialized image request information
*/
private fixQuality(imageRequestInfo: ImageRequestInfo): void {
if (imageRequestInfo.outputFormat) {
const requestType = [RequestTypes.CUSTOM, RequestTypes.THUMBOR];
const acceptedValues = [
ImageFormatTypes.JPEG,
ImageFormatTypes.PNG,
ImageFormatTypes.WEBP,
ImageFormatTypes.TIFF,
ImageFormatTypes.HEIF,
ImageFormatTypes.GIF,
];
imageRequestInfo.contentType = `image/${imageRequestInfo.outputFormat}`;
if (
requestType.includes(imageRequestInfo.requestType) &&
acceptedValues.includes(imageRequestInfo.outputFormat)
) {
const qualityKey = Object.keys(imageRequestInfo.edits).filter((key) =>
acceptedValues.includes(key as ImageFormatTypes)
)[0];
if (qualityKey && qualityKey !== imageRequestInfo.outputFormat) {
imageRequestInfo.edits[imageRequestInfo.outputFormat] = imageRequestInfo.edits[qualityKey];
delete imageRequestInfo.edits[qualityKey];
}
}
}
}
/**
* Initializer function for creating a new image request, used by the image handler to perform image modifications.
* @param event Lambda request body.
* @returns Initialized image request information.
*/
public async setup(event: ImageHandlerEvent): Promise<ImageRequestInfo> {
try {
await this.validateRequestSignature(event);
let imageRequestInfo: ImageRequestInfo = <ImageRequestInfo>{};
imageRequestInfo.requestType = this.parseRequestType(event);
imageRequestInfo.bucket = this.parseImageBucket(event, imageRequestInfo.requestType);
imageRequestInfo.key = this.parseImageKey(event, imageRequestInfo.requestType);
imageRequestInfo.edits = this.parseImageEdits(event, imageRequestInfo.requestType);
const originalImage = await this.getOriginalImage(imageRequestInfo.bucket, imageRequestInfo.key);
imageRequestInfo = { ...imageRequestInfo, ...originalImage };
imageRequestInfo.headers = this.parseImageHeaders(event, imageRequestInfo.requestType);
// If the original image is SVG file and it has any edits but no output format, change the format to PNG.
if (
imageRequestInfo.contentType === ContentTypes.SVG &&
imageRequestInfo.edits &&
Object.keys(imageRequestInfo.edits).length > 0 &&
!imageRequestInfo.edits.toFormat
) {
imageRequestInfo.outputFormat = ImageFormatTypes.PNG;
}
/* Decide the output format of the image.
* 1) If the format is provided, the output format is the provided format.
* 2) If headers contain "Accept: image/webp", the output format is webp.
* 3) Use the default image format for the rest of cases.
*/
if (
imageRequestInfo.contentType !== ContentTypes.SVG ||
imageRequestInfo.edits.toFormat ||
imageRequestInfo.outputFormat
) {
this.determineOutputFormat(imageRequestInfo, event);
}
// Fix quality for Thumbor and Custom request type if outputFormat is different from quality type.
this.fixQuality(imageRequestInfo);
return imageRequestInfo;
} catch (error) {
console.error(error);
throw error;
}
}
/**
* Gets the original image from an Amazon S3 bucket.
* @param bucket The name of the bucket containing the image.
* @param key The key name corresponding to the image.
* @returns The original image or an error.
*/
public async getOriginalImage(bucket: string, key: string): Promise<OriginalImageInfo> {
try {
const result: OriginalImageInfo = {};
const imageLocation = { Bucket: bucket, Key: key };
const originalImage = await this.s3Client.getObject(imageLocation).promise();
const imageBuffer = Buffer.from(originalImage.Body as Uint8Array);
if (originalImage.ContentType) {
// If using default S3 ContentType infer from hex headers
if (["binary/octet-stream", "application/octet-stream"].includes(originalImage.ContentType)) {
result.contentType = this.inferImageType(imageBuffer);
} else {
result.contentType = originalImage.ContentType;
}
} else {
result.contentType = "image";
}
if (originalImage.Expires) {
result.expires = new Date(originalImage.Expires).toUTCString();
}
if (originalImage.LastModified) {
result.lastModified = new Date(originalImage.LastModified).toUTCString();
}
result.cacheControl = originalImage.CacheControl ?? "max-age=31536000,public";
result.originalImage = imageBuffer;
return result;
} catch (error) {
let status = StatusCodes.INTERNAL_SERVER_ERROR;
let message = error.message;
if (error.code === "NoSuchKey") {
status = StatusCodes.NOT_FOUND;
message = `The image ${key} does not exist or the request may not be base64 encoded properly.`;
}
throw new ImageHandlerError(status, error.code, message);
}
}
/**
* Parses the name of the appropriate Amazon S3 bucket to source the original image from.
* @param event Lambda request body.
* @param requestType Image handler request type.
* @returns The name of the appropriate Amazon S3 bucket.
*/
public parseImageBucket(event: ImageHandlerEvent, requestType: RequestTypes): string {
if (requestType === RequestTypes.DEFAULT) {
// Decode the image request
const request = this.decodeRequest(event);
if (request.bucket !== undefined) {
// Check the provided bucket against the allowed list
const sourceBuckets = this.getAllowedSourceBuckets();
if (sourceBuckets.includes(request.bucket) || request.bucket.match(new RegExp("^" + sourceBuckets[0] + "$"))) {
return request.bucket;
} else {
throw new ImageHandlerError(
StatusCodes.FORBIDDEN,
"ImageBucket::CannotAccessBucket",
"The bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS."
);
}
} else {
// Try to use the default image source bucket env var
const sourceBuckets = this.getAllowedSourceBuckets();
return sourceBuckets[0];
}
} else if (requestType === RequestTypes.THUMBOR || requestType === RequestTypes.CUSTOM) {
// Use the default image source bucket env var
const sourceBuckets = this.getAllowedSourceBuckets();
return sourceBuckets[0];
} else {
throw new ImageHandlerError(
StatusCodes.NOT_FOUND,
"ImageBucket::CannotFindBucket",
"The bucket you specified could not be found. Please check the spelling of the bucket name in your request."
);
}
}
/**
* Parses the edits to be made to the original image.
* @param event Lambda request body.
* @param requestType Image handler request type.
* @returns The edits to be made to the original image.
*/
public parseImageEdits(event: ImageHandlerEvent, requestType: RequestTypes): ImageEdits {
if (requestType === RequestTypes.DEFAULT) {
const decoded = this.decodeRequest(event);
return decoded.edits;
} else if (requestType === RequestTypes.THUMBOR) {
const thumborMapping = new ThumborMapper();
return thumborMapping.mapPathToEdits(event.path);
} else if (requestType === RequestTypes.CUSTOM) {
const thumborMapping = new ThumborMapper();
const parsedPath = thumborMapping.parseCustomPath(event.path);
return thumborMapping.mapPathToEdits(parsedPath);
} else {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"ImageEdits::CannotParseEdits",
"The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance."
);
}
}
/**
* Parses the name of the appropriate Amazon S3 key corresponding to the original image.
* @param event Lambda request body.
* @param requestType Type of the request.
* @returns The name of the appropriate Amazon S3 key.
*/
public parseImageKey(event: ImageHandlerEvent, requestType: RequestTypes): string {
if (requestType === RequestTypes.DEFAULT) {
// Decode the image request and return the image key
const { key } = this.decodeRequest(event);
return key;
}
if (requestType === RequestTypes.THUMBOR || requestType === RequestTypes.CUSTOM) {
let { path } = event;
if (requestType === RequestTypes.CUSTOM) {
const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env;
if (typeof REWRITE_MATCH_PATTERN === "string") {
const patternStrings = REWRITE_MATCH_PATTERN.split("/");
const flags = patternStrings.pop();
const parsedPatternString = REWRITE_MATCH_PATTERN.slice(1, REWRITE_MATCH_PATTERN.length - 1 - flags.length);
const regExp = new RegExp(parsedPatternString, flags);
path = path.replace(regExp, REWRITE_SUBSTITUTION);
} else {
path = path.replace(REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION);
}
}
return decodeURIComponent(
path
.replace(
/\/\d+x\d+:\d+x\d+\/|(?<=\/)\d+x\d+\/|filters:watermark\(.*\)|filters:[^/]+|\/fit-in(?=\/)|^\/+/g,
""
)
.replace(/^\/+/, "")
);
}
// Return an error for all other conditions
throw new ImageHandlerError(
StatusCodes.NOT_FOUND,
"ImageEdits::CannotFindImage",
"The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists."
);
}
/**
* Determines how to handle the request being made based on the URL path prefix to the image request.
* Categorizes a request as either "image" (uses the Sharp library), "thumbor" (uses Thumbor mapping), or "custom" (uses the rewrite function).
* @param event Lambda request body.
* @returns The request type.
*/
public parseRequestType(event: ImageHandlerEvent): RequestTypes {
const { path } = event;
const matchDefault = /^(\/?)([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
const matchThumbor =
/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)(((.(?!(\.[^.\\/]+$)))*$)|.*(\.jpg$|\.jpeg$|.\.png$|\.webp$|\.tiff$|\.tif$|\.svg$|\.gif$))/i;
const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env;
const definedEnvironmentVariables =
REWRITE_MATCH_PATTERN !== "" &&
REWRITE_SUBSTITUTION !== "" &&
REWRITE_MATCH_PATTERN !== undefined &&
REWRITE_SUBSTITUTION !== undefined;
// Check if path is base 64 encoded
let isBase64Encoded = true;
try {
this.decodeRequest(event);
} catch (error) {
console.info("Path is not base64 encoded.");
isBase64Encoded = false;
}
if (matchDefault.test(path) && isBase64Encoded) {
// use sharp
return RequestTypes.DEFAULT;
} else if (definedEnvironmentVariables) {
// use rewrite function then thumbor mappings
return RequestTypes.CUSTOM;
} else if (matchThumbor.test(path)) {
// use thumbor mappings
return RequestTypes.THUMBOR;
} else {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"RequestTypeError",
"The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg, gif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests."
);
}
}
// eslint-disable-next-line jsdoc/require-returns-check
/**
* Parses the headers to be sent with the response.
* @param event Lambda request body.
* @param requestType Image handler request type.
* @returns (optional) The headers to be sent with the response.
*/
public parseImageHeaders(event: ImageHandlerEvent, requestType: RequestTypes): Headers {
if (requestType === RequestTypes.DEFAULT) {
const { headers } = this.decodeRequest(event);
if (headers) {
return headers;
}
}
}
/**
* Decodes the base64-encoded image request path associated with default image requests.
* Provides error handling for invalid or undefined path values.
* @param event Lambda request body.
* @returns The decoded from base-64 image request.
*/
public decodeRequest(event: ImageHandlerEvent): DefaultImageRequest {
const { path } = event;
if (path) {
const encoded = path.charAt(0) === "/" ? path.slice(1) : path;
const toBuffer = Buffer.from(encoded, "base64");
try {
// To support European characters, 'ascii' was removed.
return JSON.parse(toBuffer.toString());
} catch (error) {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"DecodeRequest::CannotDecodeRequest",
"The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance."
);
}
} else {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"DecodeRequest::CannotReadPath",
"The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation."
);
}
}
/**
* Returns a formatted image source bucket allowed list as specified in the SOURCE_BUCKETS environment variable of the image handler Lambda function.
* Provides error handling for missing/invalid values.
* @returns A formatted image source bucket.
*/
public getAllowedSourceBuckets(): string[] {
const { SOURCE_BUCKETS } = process.env;
if (SOURCE_BUCKETS === undefined) {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"GetAllowedSourceBuckets::NoSourceBuckets",
"The SOURCE_BUCKETS variable could not be read. Please check that it is not empty and contains at least one source bucket, or multiple buckets separated by commas. Spaces can be provided between commas and bucket names, these will be automatically parsed out when decoding."
);
} else {
return SOURCE_BUCKETS.replace(/\s+/g, "").split(",");
}
}
/**
* Return the output format depending on the accepts headers and request type.
* @param event Lambda request body.
* @param requestType The request type.
* @returns The output format.
*/
public getOutputFormat(event: ImageHandlerEvent, requestType: RequestTypes = undefined): ImageFormatTypes {
const { AUTO_WEBP } = process.env;
if (AUTO_WEBP === "Yes" && event.headers.Accept && event.headers.Accept.includes(ContentTypes.WEBP)) {
return ImageFormatTypes.WEBP;
} else if (requestType === RequestTypes.DEFAULT) {
const decoded = this.decodeRequest(event);
return decoded.outputFormat;
}
return null;
}
/**
* Return the output format depending on first four hex values of an image file.
* @param imageBuffer Image buffer.
* @returns The output format.
*/
public inferImageType(imageBuffer: Buffer): string {
const imageSignature = imageBuffer.slice(0, 4).toString("hex").toUpperCase();
switch (imageSignature) {
case "89504E47":
return ContentTypes.PNG;
case "FFD8FFDB":
case "FFD8FFE0":
case "FFD8FFED":
case "FFD8FFEE":
case "FFD8FFE1":
return ContentTypes.JPEG;
case "52494646":
return ContentTypes.WEBP;
case "49492A00":
case "4D4D002A":
return ContentTypes.TIFF;
case "47494638":
return ContentTypes.GIF;
default:
throw new ImageHandlerError(
StatusCodes.INTERNAL_SERVER_ERROR,
"RequestTypeError",
"The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests."
);
}
}
/**
* Validates the request's signature.
* @param event Lambda request body.
* @returns A promise.
* @throws Throws the error if validation is enabled and the provided signature is invalid.
*/
private async validateRequestSignature(event: ImageHandlerEvent): Promise<void> {
const { ENABLE_SIGNATURE, SECRETS_MANAGER, SECRET_KEY } = process.env;
// Checks signature enabled
if (ENABLE_SIGNATURE === "Yes") {
const { path, queryStringParameters } = event;
if (!queryStringParameters?.signature) {
throw new ImageHandlerError(
StatusCodes.BAD_REQUEST,
"AuthorizationQueryParametersError",
"Query-string requires the signature parameter."
);
}
try {
const { signature } = queryStringParameters;
const secret = JSON.parse(await this.secretProvider.getSecret(SECRETS_MANAGER));
const key = secret[SECRET_KEY];
const hash = createHmac("sha256", key).update(path).digest("hex");
// Signature should be made with the full path.
if (signature !== hash) {
throw new ImageHandlerError(StatusCodes.FORBIDDEN, "SignatureDoesNotMatch", "Signature does not match.");
}
} catch (error) {
if (error.code === "SignatureDoesNotMatch") {
throw error;
}
console.error("Error occurred while checking signature.", error);
throw new ImageHandlerError(
StatusCodes.INTERNAL_SERVER_ERROR,
"SignatureValidationFailure",
"Signature validation failed."
);
}
}
}
}