From a19a80760bc540c9f022d9381700fd469c2fd828 Mon Sep 17 00:00:00 2001 From: Matthias Naber Date: Tue, 25 Jun 2024 15:09:33 +0200 Subject: [PATCH] feat: added `Vary: Accept` to all `http/200` responses Also: - formatting with prettier - bumped some dependencies --- .prettierrc | 6 + source/image-handler/package.json | 20 +- source/image-handler/src/image-handler.ts | 158 +++++++------- source/image-handler/src/image-request.ts | 179 ++++++++-------- source/image-handler/src/index.ts | 110 +++++----- .../src/lib/logging/LogStashFormatter.ts | 10 +- source/image-handler/src/thumbor-mapping.ts | 104 +++++----- source/image-handler/test/index.test.ts | 192 ++++++++++-------- 8 files changed, 396 insertions(+), 383 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..2f111c8a3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "printWidth": 120 +} \ No newline at end of file diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 68e51078e..5fa792501 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -5,21 +5,21 @@ "version": "5.1.0", "private": true, "dependencies": { - "@aws-sdk/client-s3": "3.433.0", - "sharp": "0.33.0" + "@aws-sdk/client-s3": "3.600.0", + "sharp": "0.33.4" }, "devDependencies": { - "@aws-lambda-powertools/logger": "1.14.0", + "@aws-lambda-powertools/logger": "1.18.1", "@types/color": "^3.0.6", - "@types/color-name": "^1.1.3", + "@types/color-name": "^1.1.4", "@types/sharp": "^0.32.0", - "@types/aws-lambda": "8.10.130", - "aws-sdk-client-mock": "3.0.0", - "aws-sdk-client-mock-jest": "3.0.0", + "@types/aws-lambda": "8.10.140", + "aws-sdk-client-mock": "4.0.1", + "aws-sdk-client-mock-jest": "4.0.1", "@aws-sdk/util-stream-node": "3.374.0", - "prettier": "3.0.3", - "tsup": "7.2.0", - "vitest": "^1.0.4" + "prettier": "3.3.2", + "tsup": "7.3.0", + "vitest": "^1.6.0" }, "scripts": { "pretest": "npm i --quiet", diff --git a/source/image-handler/src/image-handler.ts b/source/image-handler/src/image-handler.ts index d6af77f16..77ceeb786 100644 --- a/source/image-handler/src/image-handler.ts +++ b/source/image-handler/src/image-handler.ts @@ -1,17 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import {Logger} from "@aws-lambda-powertools/logger"; -import {S3} from "@aws-sdk/client-s3"; +import { Logger } from '@aws-lambda-powertools/logger'; +import { S3 } from '@aws-sdk/client-s3'; import sharp from 'sharp'; -import {LogStashFormatter} from "./lib/logging/LogStashFormatter"; -import {ImageRequest} from "./image-request"; +import { LogStashFormatter } from './lib/logging/LogStashFormatter'; +import { ImageRequest } from './image-request'; const logger = new Logger({ serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME ?? 'image-handler', logFormatter: new LogStashFormatter(), -}) +}); const ApiGWResponseSizeLimit = 6 * 1024 * 1024; @@ -39,18 +39,18 @@ export class ImageHandler { if (hasEdits || hasCropping) { const keys = Object.keys(edits); - if (keys.includes("rotate") && edits.rotate === null) { - image = sharp(originalImage, {failOnError: false}).withMetadata(); + if (keys.includes('rotate') && edits.rotate === null) { + image = sharp(originalImage, { failOnError: false }).withMetadata(); } else { const metadata = await sharp(originalImage, { - failOnError: false + failOnError: false, }).metadata(); if (metadata.orientation) { - image = sharp(originalImage, {failOnError: false}).withMetadata({ - orientation: metadata.orientation + image = sharp(originalImage, { failOnError: false }).withMetadata({ + orientation: metadata.orientation, }); } else { - image = sharp(originalImage, {failOnError: false}).withMetadata(); + image = sharp(originalImage, { failOnError: false }).withMetadata(); } } @@ -61,17 +61,16 @@ export class ImageHandler { if (cropping.left + cropping.width > width || cropping.top + cropping.height > height) { throw { status: 400, - code: "CropOutOfBounds", - message: - `The cropping ${cropping.left},${cropping.top}x${cropping.width}:${cropping.height} is outside the image boundary of ${width}x${height}` + code: 'CropOutOfBounds', + message: `The cropping ${cropping.left},${cropping.top}x${cropping.width}:${cropping.height} is outside the image boundary of ${width}x${height}`, }; } if (cropping.width === 0 || cropping.height === 0) { throw { status: 400, - code: "CropHasZeroDimension", - message: `The cropping with dimension ${cropping.width}x${cropping.height} is invalid` - } + code: 'CropHasZeroDimension', + message: `The cropping with dimension ${cropping.width}x${cropping.height} is invalid`, + }; } image = image.extract(cropping); } @@ -79,36 +78,36 @@ export class ImageHandler { image = await this.applyEdits(image, edits); } } else { - image = sharp(originalImage, {failOnError: false}).withMetadata(); + image = sharp(originalImage, { failOnError: false }).withMetadata(); } - if ('image/webp' === request.ContentType && request.outputFormat === "webp") { - image.webp({effort: 6, alphaQuality: 75}); - } else if ("image/png" === request.ContentType) { - image.png({quality: 100, effort: 7, compressionLevel: 6}); - } else if ("image/jpeg" === request.ContentType) { - image.jpeg({mozjpeg: true}); + if ('image/webp' === request.ContentType && request.outputFormat === 'webp') { + image.webp({ effort: 6, alphaQuality: 75 }); + } else if ('image/png' === request.ContentType) { + image.png({ palette: true, quality: 100, effort: 7, compressionLevel: 6 }); + } else if ('image/jpeg' === request.ContentType) { + image.jpeg({ mozjpeg: true }); } else if (request.outputFormat !== undefined) { image.toFormat(request.outputFormat); } try { const bufferImage = await image.toBuffer(); - returnImage = bufferImage.toString("base64"); + returnImage = bufferImage.toString('base64'); } catch (e) { throw { status: 400, code: 'Cropping failed', - message: `Cropping failed with "${e}"` - } + message: `Cropping failed with "${e}"`, + }; } // If the converted image is larger than Lambda's payload hard limit, throw an error. if (returnImage.length > ApiGWResponseSizeLimit) { throw { status: 413, - code: "TooLargeImageException", - message: `The converted image is too large to return. Actual = ${returnImage.length} - max ${ApiGWResponseSizeLimit}` + code: 'TooLargeImageException', + message: `The converted image is too large to return. Actual = ${returnImage.length} - max ${ApiGWResponseSizeLimit}`, }; } @@ -124,7 +123,7 @@ export class ImageHandler { async applyEdits(image: sharp.Sharp, edits: any) { if (edits.resize === undefined) { edits.resize = {}; - edits.resize.fit = "inside"; + edits.resize.fit = 'inside'; } else { if (edits.resize.width) edits.resize.width = Math.round(Number(edits.resize.width)); if (edits.resize.height) edits.resize.height = Math.round(Number(edits.resize.height)); @@ -133,36 +132,27 @@ export class ImageHandler { // Apply the image edits for (const editKey in edits) { const value = edits[editKey]; - if (editKey === "overlayWith") { + if (editKey === 'overlayWith') { let imageMetadata = await image.metadata(); if (edits.resize) { let imageBuffer = await image.toBuffer(); - imageMetadata = await sharp(imageBuffer) - .resize(edits.resize) - .metadata(); + imageMetadata = await sharp(imageBuffer).resize(edits.resize).metadata(); } - const {bucket, key, wRatio, hRatio, alpha} = value; - const overlay = await this.getOverlayImage( - bucket, - key, - wRatio, - hRatio, - alpha, - imageMetadata - ); + const { bucket, key, wRatio, hRatio, alpha } = value; + const overlay = await this.getOverlayImage(bucket, key, wRatio, hRatio, alpha, imageMetadata); const overlayMetadata = await sharp(overlay).metadata(); - let {options} = value; + let { options } = value; if (options) { if (options.left !== undefined) { let left = options.left; - if (isNaN(left) && left.endsWith("p")) { - left = parseInt(left.replace("p", "")); + if (isNaN(left) && left.endsWith('p')) { + left = parseInt(left.replace('p', '')); if (left < 0) { - left = imageMetadata.width + (imageMetadata.width * left / 100) - overlayMetadata.width; + left = imageMetadata.width + (imageMetadata.width * left) / 100 - overlayMetadata.width; } else { - left = imageMetadata.width * left / 100; + left = (imageMetadata.width * left) / 100; } } else { left = parseInt(left); @@ -170,16 +160,16 @@ export class ImageHandler { left = imageMetadata.width + left - overlayMetadata.width; } } - isNaN(left) ? delete options.left : options.left = left; + isNaN(left) ? delete options.left : (options.left = left); } if (options.top !== undefined) { let top = options.top; - if (isNaN(top) && top.endsWith("p")) { - top = parseInt(top.replace("p", "")); + if (isNaN(top) && top.endsWith('p')) { + top = parseInt(top.replace('p', '')); if (top < 0) { - top = imageMetadata.height + (imageMetadata.height * top / 100) - overlayMetadata.height; + top = imageMetadata.height + (imageMetadata.height * top) / 100 - overlayMetadata.height; } else { - top = imageMetadata.height * top / 100; + top = (imageMetadata.height * top) / 100; } } else { top = parseInt(top); @@ -187,15 +177,15 @@ export class ImageHandler { top = imageMetadata.height + top - overlayMetadata.height; } } - isNaN(top) ? delete options.top : options.top = top; + isNaN(top) ? delete options.top : (options.top = top); } } - const params = [{...options, input: overlay}]; + const params = [{ ...options, input: overlay }]; image.composite(params); } else if (editKey === 'roundCrop') { const options = value; - const imageBuffer = await image.toBuffer({resolveWithObject: true}); + const imageBuffer = await image.toBuffer({ resolveWithObject: true }); let width = imageBuffer.info.width; let height = imageBuffer.info.height; @@ -206,14 +196,16 @@ export class ImageHandler { const leftOffset = options.left && options.left >= 0 ? options.left : width / 2; if (options) { - const ellipse = Buffer.from(` `); - const params: any = [{input: ellipse, blend: 'dest-in'}]; - let data = await image.composite(params) + const ellipse = Buffer.from( + ` `, + ); + const params: any = [{ input: ellipse, blend: 'dest-in' }]; + let data = await image + .composite(params) .png() // transparent background instead of black background .toBuffer(); image = sharp(data).withMetadata().trim(); } - } else { image[editKey](value); } @@ -232,22 +224,29 @@ export class ImageHandler { * @param {number} alpha - The transparency alpha to the overlay. * @param {object} sourceImageMetadata - The metadata of the source image. */ - async getOverlayImage(bucket: any, key: any, wRatio: any, hRatio: any, alpha: any, sourceImageMetadata: sharp.Metadata): Promise { - const params = {Bucket: bucket, Key: key}; + async getOverlayImage( + bucket: any, + key: any, + wRatio: any, + hRatio: any, + alpha: any, + sourceImageMetadata: sharp.Metadata, + ): Promise { + const params = { Bucket: bucket, Key: key }; try { - const {width, height}: sharp.Metadata = sourceImageMetadata; + const { width, height }: sharp.Metadata = sourceImageMetadata; const overlayImage = await this.s3.getObject(params); let resize: Record = { - fit: 'inside' + fit: 'inside', }; // Set width and height of the watermark image based on the ratio const zeroToHundred = /^(100|[1-9]?[0-9])$/; if (zeroToHundred.test(wRatio)) { - resize['width'] = Math.floor(width! * wRatio / 100); + resize['width'] = Math.floor((width! * wRatio) / 100); } if (zeroToHundred.test(hRatio)) { - resize['height'] = Math.floor(height! * hRatio / 100); + resize['height'] = Math.floor((height! * hRatio) / 100); } // If alpha is not within 0-100, the default alpha is 0 (fully opaque). @@ -260,22 +259,25 @@ export class ImageHandler { let input = Buffer.from(await overlayImage.Body?.transformToByteArray()!); return await sharp(input) .resize(resize) - .composite([{ - input: Buffer.from([255, 255, 255, 255 * (1 - alpha / 100)]), - raw: { - width: 1, - height: 1, - channels: 4 + .composite([ + { + input: Buffer.from([255, 255, 255, 255 * (1 - alpha / 100)]), + raw: { + width: 1, + height: 1, + channels: 4, + }, + tile: true, + blend: 'dest-in', }, - tile: true, - blend: 'dest-in' - }]).toBuffer(); + ]) + .toBuffer(); } catch (err: any) { throw { status: err.statusCode ? err.statusCode : 500, - code: (err.code).toString(), - message: err.message + code: err.code.toString(), + message: err.message, }; } } -} \ No newline at end of file +} diff --git a/source/image-handler/src/image-request.ts b/source/image-handler/src/image-request.ts index 8d5fa8e56..5388064c0 100644 --- a/source/image-handler/src/image-request.ts +++ b/source/image-handler/src/image-request.ts @@ -1,22 +1,19 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - -import {Logger} from '@aws-lambda-powertools/logger' -import {LogStashFormatter} from "./lib/logging/LogStashFormatter"; -import {ThumborMapping} from "./thumbor-mapping"; -import {GetObjectCommandOutput, S3} from "@aws-sdk/client-s3"; -import {APIGatewayProxyEventV2} from "aws-lambda"; -import sharp from "sharp"; +import { Logger } from '@aws-lambda-powertools/logger'; +import { LogStashFormatter } from './lib/logging/LogStashFormatter'; +import { ThumborMapping } from './thumbor-mapping'; +import { GetObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; +import { APIGatewayProxyEventV2 } from 'aws-lambda'; +import sharp from 'sharp'; const logger = new Logger({ serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME ?? '', logFormatter: new LogStashFormatter(), -}) - +}); export class ImageRequest { - requestType: any; bucket: any; key: any; @@ -29,10 +26,11 @@ export class ImageRequest { LastModified: any; CacheControl: any; ETag: any; + headers: any; private s3: any; constructor(s3: S3) { - this.s3 = s3 + this.s3 = s3; } /** @@ -41,8 +39,6 @@ export class ImageRequest { * @param {object} event - Lambda request body. */ async setup(event: APIGatewayProxyEventV2): Promise { - - this.requestType = this.parseRequestType(event); this.bucket = this.parseImageBucket(event, this.requestType); this.key = this.parseImageKey(event, this.requestType); @@ -51,11 +47,13 @@ export class ImageRequest { this.originalImage = await this.getOriginalImage(this.bucket, this.key); // If the original image is SVG file and it has any edits but no output format, change the format to WebP. - if (this.ContentType === "image/svg+xml" && + if ( + this.ContentType === 'image/svg+xml' && this.edits && Object.keys(this.edits).length > 0 && - !this.edits.toFormat) { - this.outputFormat = "png"; + !this.edits.toFormat + ) { + this.outputFormat = 'png'; } /* Decide the output format of the image. @@ -75,17 +73,12 @@ export class ImageRequest { // Fix quality for Thumbor and Custom request type if outputFormat is different from quality type. if (this.outputFormat) { - const requestType = ["Custom", "Thumbor"]; - const acceptedValues = ["jpeg", "png", "webp", "tiff", "heif", "avif"]; + const requestType = ['Custom', 'Thumbor']; + const acceptedValues = ['jpeg', 'png', 'webp', 'tiff', 'heif', 'avif']; this.ContentType = `image/${this.outputFormat}`; - if ( - requestType.includes(this.requestType) && - acceptedValues.includes(this.outputFormat) - ) { - let qualityKey = Object.keys(this.edits).filter((key) => - acceptedValues.includes(key) - )[0]; + if (requestType.includes(this.requestType) && acceptedValues.includes(this.outputFormat)) { + let qualityKey = Object.keys(this.edits).filter(key => acceptedValues.includes(key))[0]; if (qualityKey && qualityKey !== this.outputFormat) { const qualityValue = this.edits[qualityKey]; this.edits[this.outputFormat] = qualityValue; @@ -106,11 +99,11 @@ export class ImageRequest { * @return {Promise} - The original image or an error. */ async getOriginalImage(bucket: string, key: string): Promise { - const imageLocation = {Bucket: bucket, Key: key}; + const imageLocation = { Bucket: bucket, Key: key }; try { const originalImage: GetObjectCommandOutput = await this.s3.getObject(imageLocation); const metaData = originalImage['Metadata']; - const isGone = metaData && metaData['buzz-status-code'] && metaData['buzz-status-code'] === '410' + const isGone = metaData && metaData['buzz-status-code'] && metaData['buzz-status-code'] === '410'; const imageBuffer = Buffer.from(await originalImage.Body?.transformToByteArray()!); @@ -122,13 +115,13 @@ export class ImageRequest { this.ContentType = originalImage.ContentType; } } else { - this.ContentType = "image"; + this.ContentType = 'image'; } if (originalImage.Expires) { this.Expires = new Date(originalImage.Expires); } else if (isGone) { - logger.warn(`Content ${imageLocation} is gone`) + logger.warn(`Content ${imageLocation} is gone`); this.Expires = new Date(0); } @@ -136,10 +129,10 @@ export class ImageRequest { this.LastModified = new Date(originalImage.LastModified); } - if (originalImage.CacheControl && !originalImage.CacheControl.includes("31536000")) { + if (originalImage.CacheControl && !originalImage.CacheControl.includes('31536000')) { this.CacheControl = originalImage.CacheControl; } else { - this.CacheControl = "max-age=31536000, immutable"; + this.CacheControl = 'max-age=31536000, immutable'; } if (originalImage.ETag) { @@ -149,9 +142,9 @@ export class ImageRequest { return imageBuffer; } catch (err: any) { throw { - status: "NoSuchKey" === (err?.code || err?.Code || err).toString() ? 404 : 500, + status: 'NoSuchKey' === (err?.code || err?.Code || err).toString() ? 404 : 500, code: (err?.code || err?.Code || err).toString(), - message: err.message + message: err.message, }; } } @@ -163,16 +156,16 @@ export class ImageRequest { * @param {string} requestType - Image handler request type. */ parseImageBucket(event: APIGatewayProxyEventV2, requestType: string) { - if (requestType === "Thumbor" || requestType === "Custom") { + if (requestType === 'Thumbor' || requestType === 'Custom') { // Use the default image source bucket env var const sourceBuckets = this.getAllowedSourceBuckets(); return sourceBuckets[0]; } else { throw { status: 404, - code: "ImageBucket::CannotFindBucket", + code: 'ImageBucket::CannotFindBucket', message: - "The bucket you specified could not be found. Please check the spelling of the bucket name in your request." + 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.', }; } } @@ -183,31 +176,31 @@ export class ImageRequest { * @param {string} requestType - Image handler request type. */ parseImageEdits(event: APIGatewayProxyEventV2, requestType: string) { - if (requestType === "Thumbor") { + if (requestType === 'Thumbor') { const thumborMapping = new ThumborMapping(); thumborMapping.process(event); return thumborMapping.edits; } else { throw { status: 400, - code: "ImageEdits::CannotParseEdits", + code: 'ImageEdits::CannotParseEdits', message: - "The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance." + 'The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance.', }; } } parseCropping(event: APIGatewayProxyEventV2, requestType: string) { - if (requestType === "Thumbor") { + if (requestType === 'Thumbor') { const thumborMapping = new ThumborMapping(); thumborMapping.process(event); return thumborMapping.cropping; } else { throw { status: 400, - code: "Cropping::CannotParseCropping", + code: 'Cropping::CannotParseCropping', message: - "The cropping you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance." + 'The cropping you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance.', }; } } @@ -219,37 +212,37 @@ export class ImageRequest { * @param {String} requestType - Type, either "Default", "Thumbor", or "Custom". */ parseImageKey(event: APIGatewayProxyEventV2, requestType: string) { - if (requestType === "Thumbor" || requestType === "Custom") { + if (requestType === 'Thumbor' || requestType === 'Custom') { let path = event.rawPath; - if (requestType === "Custom") { + if (requestType === 'Custom') { const matchPattern = process.env.REWRITE_MATCH_PATTERN; const substitution = process.env.REWRITE_SUBSTITUTION; if (matchPattern) { - const patternStrings = matchPattern.split("/"); + const patternStrings = matchPattern.split('/'); const flags = patternStrings.pop(); const parsedPatternString = matchPattern.slice(1, matchPattern.length - 1 - flags!.length); const regExp = new RegExp(parsedPatternString, flags); - path = path.replace(regExp, substitution ?? ""); + path = path.replace(regExp, substitution ?? ''); } else { - path = path.replace(matchPattern || "", substitution || ""); + path = path.replace(matchPattern || '', substitution || ''); } } path = path .replace(/^(\/)?authors\//, '$1') - .replace(/\/\d+x\d+:\d+x\d+\//g, "/") - .replace(/\/(\d+|__WIDTH__)x\d+\//g, "/") - .replace(/\/filters:[^\/]+/g, "/") - .replace(/\/fit-in\//g, "/") - .replace(/^\/+/, "") - .replace(/\/+/g, "/"); + .replace(/\/\d+x\d+:\d+x\d+\//g, '/') + .replace(/\/(\d+|__WIDTH__)x\d+\//g, '/') + .replace(/\/filters:[^\/]+/g, '/') + .replace(/\/fit-in\//g, '/') + .replace(/^\/+/, '') + .replace(/\/+/g, '/'); if (path.match(/^\d{4}\/\d{2}\/.*\/[\w-]+\.\w+$/)) { - path = path.replace(/(.*)\/[\w-]+(\.\w+)$/, "$1/image$2"); + path = path.replace(/(.*)\/[\w-]+(\.\w+)$/, '$1/image$2'); } - if (path.endsWith("/")) { - path = path + "image.jpg" + if (path.endsWith('/')) { + path = path + 'image.jpg'; } return decodeURIComponent(path); } @@ -257,9 +250,9 @@ export class ImageRequest { // Return an error for all other conditions throw { status: 404, - code: "ImageEdits::CannotFindImage", + code: 'ImageEdits::CannotFindImage', message: - "The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists." + 'The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists.', }; } @@ -272,17 +265,19 @@ export class ImageRequest { */ parseRequestType(event: APIGatewayProxyEventV2) { const path = event.rawPath; - const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(\.+jpg|\.+png|\.+webp|\.tiff|\.jpeg|\.svg|\.gif|\.avif)$/i); + const matchThumbor = new RegExp( + /^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(\.+jpg|\.+png|\.+webp|\.tiff|\.jpeg|\.svg|\.gif|\.avif)$/i, + ); - if (matchThumbor.test(path) || path.endsWith("/")) { + if (matchThumbor.test(path) || path.endsWith('/')) { // use thumbor mappings - return "Thumbor"; + return 'Thumbor'; } else { throw { status: 400, - code: "RequestTypeError", + code: 'RequestTypeError', message: - "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, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests." + '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, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.', }; } } @@ -297,13 +292,13 @@ export class ImageRequest { if (sourceBuckets === undefined) { throw { status: 400, - code: "GetAllowedSourceBuckets::NoSourceBuckets", + code: 'GetAllowedSourceBuckets::NoSourceBuckets', message: - "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." + '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 { - const formatted = sourceBuckets.replace(/\s+/g, ""); - return formatted.split(","); + const formatted = sourceBuckets.replace(/\s+/g, ''); + return formatted.split(','); } } @@ -315,11 +310,11 @@ export class ImageRequest { getOutputFormat(event: APIGatewayProxyEventV2): keyof sharp.FormatEnum | null { const autoWebP = process.env.AUTO_WEBP; const autoAvif = process.env.AUTO_AVIF; - let accept = (event.headers?.Accept || event.headers?.accept) ?? ""; - if (autoAvif === "Yes" && accept && accept.includes("image/avif")) { - return "avif"; - } else if (autoWebP === "Yes" && accept && accept.includes("image/webp")) { - return "webp"; + let accept = (event.headers?.Accept || event.headers?.accept) ?? ''; + if (autoAvif === 'Yes' && accept && accept.includes('image/avif')) { + return 'avif'; + } else if (autoWebP === 'Yes' && accept && accept.includes('image/webp')) { + return 'webp'; } return null; } @@ -329,30 +324,30 @@ export class ImageRequest { * @param {Buffer} imageBuffer - Image buffer. */ inferImageType(imageBuffer: Buffer) { - switch (imageBuffer.toString("hex").substring(0, 8).toUpperCase()) { - case "89504E47": - return "image/png"; - case "FFD8FFDB": - return "image/jpeg"; - case "FFD8FFE0": - return "image/jpeg"; - case "FFD8FFEE": - return "image/jpeg"; - case "FFD8FFE1": - return "image/jpeg"; - case "52494646": - return "image/webp"; - case "49492A00": - return "image/tiff"; - case "4D4D002A": - return "image/tiff"; + switch (imageBuffer.toString('hex').substring(0, 8).toUpperCase()) { + case '89504E47': + return 'image/png'; + case 'FFD8FFDB': + return 'image/jpeg'; + case 'FFD8FFE0': + return 'image/jpeg'; + case 'FFD8FFEE': + return 'image/jpeg'; + case 'FFD8FFE1': + return 'image/jpeg'; + case '52494646': + return 'image/webp'; + case '49492A00': + return 'image/tiff'; + case '4D4D002A': + return 'image/tiff'; default: throw { status: 500, - code: "RequestTypeError", + code: 'RequestTypeError', message: - "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, avif). Refer to the documentation for additional guidance on forming image requests.", + '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, avif). Refer to the documentation for additional guidance on forming image requests.', }; } } -} \ No newline at end of file +} diff --git a/source/image-handler/src/index.ts b/source/image-handler/src/index.ts index 3e3569d54..0f94cd8cf 100755 --- a/source/image-handler/src/index.ts +++ b/source/image-handler/src/index.ts @@ -1,63 +1,58 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import {Logger} from '@aws-lambda-powertools/logger' -import {LogStashFormatter} from "./lib/logging/LogStashFormatter"; -import {ImageRequest} from "./image-request"; -import {ImageHandler} from "./image-handler"; -import {GetObjectCommandOutput, S3} from "@aws-sdk/client-s3"; -import {APIGatewayProxyEventV2} from "aws-lambda"; -import {APIGatewayProxyStructuredResultV2} from "aws-lambda/trigger/api-gateway-proxy"; +import { Logger } from '@aws-lambda-powertools/logger'; +import { LogStashFormatter } from './lib/logging/LogStashFormatter'; +import { ImageRequest } from './image-request'; +import { ImageHandler } from './image-handler'; +import { GetObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; +import { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda/trigger/api-gateway-proxy'; const s3 = new S3({ region: process.env.AWS_REGION, -}) +}); const logger = new Logger({ serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME ?? '', logFormatter: new LogStashFormatter(), -}) +}); export async function handler(event: APIGatewayProxyEventV2): Promise { - const imageRequest = new ImageRequest(s3); const imageHandler = new ImageHandler(s3); - const isAlb = event.requestContext && event.requestContext.hasOwnProperty("elb"); + const isAlb = event.requestContext && event.requestContext.hasOwnProperty('elb'); try { const request: ImageRequest = await imageRequest.setup(event); - logger.info("Image manipulation request", {...request, originalImage: undefined}); + logger.info('Image manipulation request', { ...request, originalImage: undefined }); let now = Date.now(); if (request.Expires && request.Expires.getTime() < now) { - logger.warn("Expired content was requested: " + request.key); + logger.warn('Expired content was requested: ' + request.key); let headers = getResponseHeaders(410, isAlb); return { statusCode: 410, isBase64Encoded: false, headers: headers, body: JSON.stringify({ - message: "HTTP/410. Content " + request.key + " has expired.", - code: "Gone", - status: 410 - }) + message: 'HTTP/410. Content ' + request.key + ' has expired.', + code: 'Gone', + status: 410, + }), }; } else { const processedRequest = await imageHandler.process(request); const headers = getResponseHeaders(200, isAlb); - headers["Content-Type"] = request.ContentType; - headers["ETag"] = request.ETag; - if (request.LastModified) - headers["Last-Modified"] = request.LastModified.toUTCString(); + headers['Content-Type'] = request.ContentType; + headers['ETag'] = request.ETag; + if (request.LastModified) headers['Last-Modified'] = request.LastModified.toUTCString(); if (request.Expires) { - headers["Expires"] = request.Expires.toUTCString(); - let seconds_until_expiry = Math.min( - 31536000, - Math.floor((request.Expires.getTime() - now) / 1000) - ); - headers["Cache-Control"] = "max-age=" + seconds_until_expiry + ", immutable"; + headers['Expires'] = request.Expires.toUTCString(); + let seconds_until_expiry = Math.min(31536000, Math.floor((request.Expires.getTime() - now) / 1000)); + headers['Cache-Control'] = 'max-age=' + seconds_until_expiry + ', immutable'; } else { - headers["Cache-Control"] = request.CacheControl; + headers['Cache-Control'] = request.CacheControl; } if (request.headers) { @@ -67,17 +62,17 @@ export async function handler(event: APIGatewayProxyEventV2): Promise { - const corsEnabled = process.env.CORS_ENABLED === "Yes"; + const corsEnabled = process.env.CORS_ENABLED === 'Yes'; const headers: Headers = { - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": "Content-Type, Authorization" + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; + if (200 === status_code) { + headers['Vary'] = 'Accept'; + } if (!isAlb) { - headers["Access-Control-Allow-Credentials"] = true; + headers['Access-Control-Allow-Credentials'] = true; } if (corsEnabled) { - headers["Access-Control-Allow-Origin"] = process.env.CORS_ORIGIN ?? "*"; + headers['Access-Control-Allow-Origin'] = process.env.CORS_ORIGIN ?? '*'; } if (200 !== status_code) { - headers["Content-Type"] = "application/json"; + headers['Content-Type'] = 'application/json'; } if (status_code === 404) { - headers["Cache-Control"] = "max-age=60"; + headers['Cache-Control'] = 'max-age=60'; } else if (status_code >= 400 && status_code < 500) { - headers["Cache-Control"] = "max-age=600"; + headers['Cache-Control'] = 'max-age=600'; } else if (status_code >= 500 && status_code < 600) { - headers["Cache-Control"] = "max-age=0, must-revalidate"; + headers['Cache-Control'] = 'max-age=0, must-revalidate'; } return headers; }; diff --git a/source/image-handler/src/lib/logging/LogStashFormatter.ts b/source/image-handler/src/lib/logging/LogStashFormatter.ts index 4367f6461..eeb7d7a7c 100644 --- a/source/image-handler/src/lib/logging/LogStashFormatter.ts +++ b/source/image-handler/src/lib/logging/LogStashFormatter.ts @@ -1,7 +1,7 @@ -import {LogFormatter} from '@aws-lambda-powertools/logger' -import {LogAttributes, UnformattedAttributes,} from '@aws-lambda-powertools/logger/lib/types' +import { LogFormatter } from '@aws-lambda-powertools/logger'; +import { LogAttributes, UnformattedAttributes } from '@aws-lambda-powertools/logger/lib/types'; -type LogStashLog = LogAttributes +type LogStashLog = LogAttributes; class LogStashFormatter extends LogFormatter { public formatAttributes(attributes: UnformattedAttributes): LogStashLog { @@ -24,8 +24,8 @@ class LogStashFormatter extends LogFormatter { version: attributes.lambdaContext?.functionVersion, coldStart: attributes.lambdaContext?.coldStart, }, - } + }; } } -export {LogStashFormatter} +export { LogStashFormatter }; diff --git a/source/image-handler/src/thumbor-mapping.ts b/source/image-handler/src/thumbor-mapping.ts index aab65b4ec..ff20d4acb 100644 --- a/source/image-handler/src/thumbor-mapping.ts +++ b/source/image-handler/src/thumbor-mapping.ts @@ -1,11 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import Color from "color"; -import ColorName from "color-name"; +import Color from 'color'; +import ColorName from 'color-name'; export class ThumborMapping { - // Constructor private path: any; cropping: any; @@ -13,7 +12,7 @@ export class ThumborMapping { constructor() { this.edits = {}; - this.cropping = {} + this.cropping = {}; } /** @@ -24,9 +23,7 @@ export class ThumborMapping { process(event: any) { // Setup this.path = event['path'] || event['rawPath']; - this.path = this.path.replace('__WIDTH__', '1800') - .replace('%28', '(') - .replace('%29', ')'); + this.path = this.path.replace('__WIDTH__', '1800').replace('%28', '(').replace('%29', ')'); // Process the Dimensions const dimPath = this.path.match(/\/(\d+)x(\d+)\//); @@ -59,17 +56,17 @@ export class ThumborMapping { // Parse cropping const cropping = this.path.match(/\/(\d+)x(\d+):(\d+)x(\d+)\//); if (cropping) { - const left = Number(cropping[1]) - const top = Number(cropping[2]) - const width = Number(cropping[3]) - const height = Number(cropping[4]) + const left = Number(cropping[1]); + const top = Number(cropping[2]); + const width = Number(cropping[3]); + const height = Number(cropping[4]); if (!isNaN(left) && !isNaN(top) && !isNaN(width) && !isNaN(height)) { this.cropping = { left: left, top: top, width: width, - height: height + height: height, }; } } @@ -84,7 +81,7 @@ export class ThumborMapping { if (!edits) { edits = []; } - const filetype = (this.path.split('.'))[(this.path.split('.')).length - 1]; + const filetype = this.path.split('.')[this.path.split('.').length - 1]; for (let i = 0; i < edits.length; i++) { const edit = `${edits[i]})`; this.mapFilter(edit, filetype); @@ -93,7 +90,6 @@ export class ThumborMapping { return this; } - /** * Scanner function for matching supported Thumbor filters and converting their * capabilities into Sharp.js supported operations. @@ -106,20 +102,20 @@ export class ThumborMapping { const editKey = matched[1]; let value = matched[2]; // Find the proper filter - if (editKey === ('autojpg')) { + if (editKey === 'autojpg') { this.edits.toFormat = 'jpeg'; - } else if (editKey === ('background_color')) { + } else if (editKey === 'background_color') { // @ts-ignore if (!ColorName[value]) { - value = `#${value}` + value = `#${value}`; } - this.edits.flatten = {background: Color(value).object()}; - } else if (editKey === ('blur')) { + this.edits.flatten = { background: Color(value).object() }; + } else if (editKey === 'blur') { const val = value.split(','); - this.edits.blur = (val.length > 1) ? Number(val[1]) : Number(val[0]) / 2; - } else if (editKey === ('convolution')) { + this.edits.blur = val.length > 1 ? Number(val[1]) : Number(val[0]) / 2; + } else if (editKey === 'convolution') { const arr = value.split(','); - const strMatrix = (arr[0]).split(';'); + const strMatrix = arr[0].split(';'); let matrix: any[] = []; strMatrix.forEach(function (str) { matrix.push(Number(str)); @@ -128,7 +124,7 @@ export class ThumborMapping { let matrixHeight = 0; let counter = 0; for (let i = 0; i < matrix.length; i++) { - if (counter === (matrixWidth - 1)) { + if (counter === matrixWidth - 1) { matrixHeight++; counter = 0; } else { @@ -138,67 +134,67 @@ export class ThumborMapping { this.edits.convolve = { width: Number(matrixWidth), height: Number(matrixHeight), - kernel: matrix - } - } else if (editKey === ('equalize')) { - this.edits.normalize = "true"; - } else if (editKey === ('fill')) { + kernel: matrix, + }; + } else if (editKey === 'equalize') { + this.edits.normalize = 'true'; + } else if (editKey === 'fill') { if (this.edits.resize === undefined) { this.edits.resize = {}; } // @ts-ignore if (!ColorName[value]) { - value = `#${value}` + value = `#${value}`; } this.edits.resize.fit = 'contain'; this.edits.resize.background = Color(value).object(); - } else if (editKey === ('format')) { + } else if (editKey === 'format') { const formattedValue = value.replace(/[^0-9a-z]/gi, '').replace(/jpg/i, 'jpeg'); const acceptedValues = ['heic', 'heif', 'jpeg', 'png', 'raw', 'tiff', 'webp', 'avif']; if (acceptedValues.includes(formattedValue)) { this.edits.toFormat = formattedValue; } - } else if (editKey === ('grayscale')) { + } else if (editKey === 'grayscale') { this.edits.grayscale = true; - } else if (editKey === ('no_upscale')) { + } else if (editKey === 'no_upscale') { if (this.edits.resize === undefined) { this.edits.resize = {}; } this.edits.resize.withoutEnlargement = true; - } else if (editKey === ('proportion')) { + } else if (editKey === 'proportion') { if (this.edits.resize === undefined) { this.edits.resize = {}; } const prop = Number(value); this.edits.resize.width = Number(this.edits.resize.width * prop); this.edits.resize.height = Number(this.edits.resize.height * prop); - } else if (editKey === ('quality')) { + } else if (editKey === 'quality') { if (['jpg', 'jpeg'].includes(filetype)) { - this.edits.jpeg = {quality: Number(value)} + this.edits.jpeg = { quality: Number(value) }; } else if (filetype === 'png') { - this.edits.png = {quality: Number(value)} + this.edits.png = { quality: Number(value) }; } else if (filetype === 'webp') { - this.edits.webp = {quality: Number(value)} + this.edits.webp = { quality: Number(value) }; } else if (filetype === 'tiff') { - this.edits.tiff = {quality: Number(value)} + this.edits.tiff = { quality: Number(value) }; } else if (filetype === 'heif') { - this.edits.heif = {quality: Number(value)} + this.edits.heif = { quality: Number(value) }; } - } else if (editKey === ('rgb')) { + } else if (editKey === 'rgb') { const percentages = value.split(','); const values: any[] = []; percentages.forEach(function (percentage) { const parsedPercentage = Number(percentage); const val = 255 * (parsedPercentage / 100); values.push(val); - }) - this.edits.tint = {r: values[0], g: values[1], b: values[2]}; - } else if (editKey === ('rotate')) { + }); + this.edits.tint = { r: values[0], g: values[1], b: values[2] }; + } else if (editKey === 'rotate') { this.edits.rotate = Number(value); - } else if (editKey === ('sharpen')) { + } else if (editKey === 'sharpen') { const sh = value.split(','); this.edits.sharpen = 1 + Number(sh[1]) / 2; - } else if (editKey === ('stretch')) { + } else if (editKey === 'stretch') { if (this.edits.resize === undefined) { this.edits.resize = {}; } @@ -207,13 +203,13 @@ export class ThumborMapping { if (this.edits.resize.fit !== 'inside') { this.edits.resize.fit = 'fill'; } - } else if (editKey === ('strip_exif') || editKey === ('strip_icc')) { + } else if (editKey === 'strip_exif' || editKey === 'strip_icc') { this.edits.rotate = null; } else if (editKey === 'upscale') { if (this.edits.resize === undefined) { this.edits.resize = {}; } - this.edits.resize.fit = "inside" + this.edits.resize.fit = 'inside'; } else if (editKey === 'watermark') { const options = value.replace(/\s+/g, '').split(','); const bucket = options[0]; @@ -230,8 +226,8 @@ export class ThumborMapping { alpha, wRatio, hRatio, - options: {} - } + options: {}, + }; const allowedPosPattern = /^(100|[1-9]?[0-9]|-(100|[1-9][0-9]?))p$/; if (allowedPosPattern.test(xPos) || !isNaN(xPos)) { this.edits.overlayWith.options['left'] = xPos; @@ -243,10 +239,10 @@ export class ThumborMapping { // Rounded crops, with optional coordinates const roundedImages = value.match(/(\d+)x(\d+)(:(\d+)x(\d+))?/); if (roundedImages) { - const left = Number(roundedImages[1]) - const top = Number(roundedImages[2]) - const r_x = Number(roundedImages[4]) - const r_y = Number(roundedImages[5]) + const left = Number(roundedImages[1]); + const top = Number(roundedImages[2]); + const r_x = Number(roundedImages[4]); + const r_y = Number(roundedImages[5]); this.edits.roundCrop = {}; if (!isNaN(left)) this.edits.roundCrop.left = left; @@ -260,4 +256,4 @@ export class ThumborMapping { return undefined; } } -} \ No newline at end of file +} diff --git a/source/image-handler/test/index.test.ts b/source/image-handler/test/index.test.ts index 5cda8eb4f..d86ce3bcc 100644 --- a/source/image-handler/test/index.test.ts +++ b/source/image-handler/test/index.test.ts @@ -2,49 +2,57 @@ // SPDX-License-Identifier: Apache-2.0 // Import index.js -import {handler}from "../src"; -import {describe, it, beforeEach} from "vitest"; -import {mockClient} from "aws-sdk-client-mock"; -import {GetObjectCommand, S3Client} from "@aws-sdk/client-s3"; -import {Readable} from "stream"; -import {sdkStreamMixin} from "@aws-sdk/util-stream-node"; -import {APIGatewayEventRequestContextV2, APIGatewayProxyEventV2} from "aws-lambda"; +import { handler } from '../src'; +import { beforeEach, describe, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Readable } from 'stream'; +import { sdkStreamMixin } from '@aws-sdk/util-stream-node'; +import { APIGatewayEventRequestContextV2, APIGatewayProxyEventV2 } from 'aws-lambda'; -const {expect} = require("expect"); +const { expect } = require('expect'); (globalThis as any).expect = expect; -require("aws-sdk-client-mock-jest"); +require('aws-sdk-client-mock-jest'); const s3_mock = mockClient(S3Client); let event: APIGatewayProxyEventV2; function generateStream(data: Buffer) { - const stream = Readable.from(data) - return sdkStreamMixin(stream) + const stream = Readable.from(data); + return sdkStreamMixin(stream); } beforeEach(() => { const context: APIGatewayEventRequestContextV2 = { - accountId: "", - apiId: "", - domainName: "", - domainPrefix: "", - http: {method: "", path: "", protocol: "", sourceIp: "", userAgent: ""}, - requestId: "", - routeKey: "", - stage: "", - time: "", - timeEpoch: 0 - } + accountId: '', + apiId: '', + domainName: '', + domainPrefix: '', + http: { method: '', path: '', protocol: '', sourceIp: '', userAgent: '' }, + requestId: '', + routeKey: '', + stage: '', + time: '', + timeEpoch: 0, + }; event = { - headers: {}, isBase64Encoded: false, rawQueryString: "", requestContext: context, routeKey: "", version: "", - rawPath : "/fit-in/200x300/filters:grayscale()/test-image-001.jpg" - } + headers: {}, + isBase64Encoded: false, + rawQueryString: '', + requestContext: context, + routeKey: '', + version: '', + rawPath: '/fit-in/200x300/filters:grayscale()/test-image-001.jpg', + }; }); describe('index', function () { // Arrange process.env.SOURCE_BUCKETS = 'source-bucket'; - const mockImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const mockImage = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ); const mockFallbackImage = Buffer.from('SampleFallbackImageContent\n'); describe('TC: Success', function () { @@ -52,10 +60,10 @@ describe('index', function () { s3_mock.reset(); // Mock s3_mock.on(GetObjectCommand).resolves({ - Body: generateStream(mockImage), - ContentType: 'image/png' - }); + Body: generateStream(mockImage), + ContentType: 'image/png', }); + }); it('001/should return the image when there is no error', async function () { // Arrange @@ -70,13 +78,14 @@ describe('index', function () { 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': true, 'Content-Type': 'image/png', - 'ETag': undefined, + ETag: undefined, 'Cache-Control': 'max-age=31536000, immutable', + Vary: 'Accept', }, - body: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAABP2lDQ1BpY2MAAHicfZC/SwJxGMY/11WWWA05NBQcJU0FUUtTgYZOEfgj1Kbz/FGgdt33QprLoaloiEZrCaLZxhz6A4KgIQqirdWghpKLrw5aUM/yfnh4Xt6XB5TnvFEQ3RoUirYVDvm1eCKpuV5Q8dLPIGO6IczlSDAKIPSSMGwrzw+936PIeTe9rhfTO6/Xq8kFpbo7UY4FP1Yu+F/udEYYwBfgM0zLBkUDxku2KXkJ8BrrehqUODBlxRNJUPakn2vxieRUiy8lW9FwAJQaoOU6ONXBhfy2vCslv/dkirEI0AeMIggTwv9HpreZCRBgBmRfv3sQ2bnZ1pZnEXqeHOdtElyH0DhynM9Tx2mcgfoIta32/mYF5uugHrS91DFc7cPIQ9vzVWCoDNUbU7f0pqUCXdkNqJ/DQAKGb8G99g3j4l+xfPB+eQAAAANQTFRF/wAAGeIJNwAAAAF0Uk5Tf4BctMsAAAAJcEhZcwAACxMAAAsTAQCanBgAAAC0ZVhJZklJKgAIAAAABgASAQMAAQAAAAEAAAAaAQUAAQAAAFYAAAAbAQUAAQAAAF4AAAAoAQMAAQAAAAIAAAATAgMAAQAAAAEAAABphwQAAQAAAGYAAAAAAAAASAAAAAEAAABIAAAAAQAAAAYAAJAHAAQAAAAwMjEwAZEHAAQAAAABAgMAAKAHAAQAAAAwMTAwAaADAAEAAAD//wAAAqAEAAEAAAABAAAAA6AEAAEAAAABAAAAAAAAANu53doAAAAKSURBVHicY2AAAAACAAFIr6RxAAAAAElFTkSuQmCC" + body: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAABP2lDQ1BpY2MAAHicfZC/SwJxGMY/11WWWA05NBQcJU0FUUtTgYZOEfgj1Kbz/FGgdt33QprLoaloiEZrCaLZxhz6A4KgIQqirdWghpKLrw5aUM/yfnh4Xt6XB5TnvFEQ3RoUirYVDvm1eCKpuV5Q8dLPIGO6IczlSDAKIPSSMGwrzw+936PIeTe9rhfTO6/Xq8kFpbo7UY4FP1Yu+F/udEYYwBfgM0zLBkUDxku2KXkJ8BrrehqUODBlxRNJUPakn2vxieRUiy8lW9FwAJQaoOU6ONXBhfy2vCslv/dkirEI0AeMIggTwv9HpreZCRBgBmRfv3sQ2bnZ1pZnEXqeHOdtElyH0DhynM9Tx2mcgfoIta32/mYF5uugHrS91DFc7cPIQ9vzVWCoDNUbU7f0pqUCXdkNqJ/DQAKGb8G99g3j4l+xfPB+eQAAAANQTFRF/wAAGeIJNwAAAAF0Uk5Tf4BctMsAAAAJcEhZcwAACxMAAAsTAQCanBgAAAC0ZVhJZklJKgAIAAAABgASAQMAAQAAAAEAAAAaAQUAAQAAAFYAAAAbAQUAAQAAAF4AAAAoAQMAAQAAAAIAAAATAgMAAQAAAAEAAABphwQAAQAAAGYAAAAAAAAASAAAAAEAAABIAAAAAQAAAAYAAJAHAAQAAAAwMjEwAZEHAAQAAAABAgMAAKAHAAQAAAAwMTAwAaADAAEAAAD//wAAAqAEAAEAAAABAAAAA6AEAAEAAAABAAAAAAAAANu53doAAAAKSURBVHicY2AAAAACAAFIr6RxAAAAAElFTkSuQmCC', }; // Assert - expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); }); }); @@ -90,11 +99,13 @@ describe('index', function () { // Arrange event.rawPath = '/test.jpg'; // Mock - s3_mock.on(GetObjectCommand).resolves(Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: 'NoSuchKey error happened.' - })) + s3_mock.on(GetObjectCommand).resolves( + Promise.reject({ + code: 'NoSuchKey', + status: 404, + message: 'NoSuchKey error happened.', + }), + ); // Act const result = await handler(event); const expectedResult = { @@ -104,17 +115,17 @@ describe('index', function () { 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': true, - "Cache-Control": "max-age=60", - 'Content-Type': 'application/json' + 'Cache-Control': 'max-age=60', + 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 404, code: 'NoSuchKey', - message: 'NoSuchKey error happened.' - }) + message: 'NoSuchKey error happened.', + }), }; // Assert - expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); }); it('003/should return the default fallback image when an error occurs if the default fallback image is enabled', async function () { @@ -126,11 +137,14 @@ describe('index', function () { process.env.CORS_ORIGIN = '*'; event.rawPath = '/test.jpg'; // Mock - let error: any = {code: 500, message: 'UnknownError'}; - s3_mock.on(GetObjectCommand).rejectsOnce(error).resolvesOnce({ - Body: generateStream(mockFallbackImage), - ContentType: 'image/png' - }); + let error: any = { code: 500, message: 'UnknownError' }; + s3_mock + .on(GetObjectCommand) + .rejectsOnce(error) + .resolvesOnce({ + Body: generateStream(mockFallbackImage), + ContentType: 'image/png', + }); // Act const result = await handler(event); const expectedResult = { @@ -142,16 +156,17 @@ describe('index', function () { 'Access-Control-Allow-Credentials': true, 'Access-Control-Allow-Origin': '*', 'Content-Type': 'image/png', - "Cache-Control": "max-age=31536000, immutable", - 'Last-Modified': undefined + 'Cache-Control': 'max-age=31536000, immutable', + 'Last-Modified': undefined, + Vary: 'Accept', }, - body: mockFallbackImage.toString('base64') + body: mockFallbackImage.toString('base64'), }; // Assert - expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); - expect(s3_mock).toHaveReceivedNthCommandWith(2, GetObjectCommand,{ + expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); + expect(s3_mock).toHaveReceivedNthCommandWith(2, GetObjectCommand, { Bucket: 'fallback-image-bucket', - Key: 'fallback-image.png' + Key: 'fallback-image.png', }); expect(result).toEqual(expectedResult); }); @@ -162,7 +177,7 @@ describe('index', function () { let error: any = { code: 'NoSuchKey', status: 404, - message: 'NoSuchKey error happened.' + message: 'NoSuchKey error happened.', }; s3_mock.on(GetObjectCommand).rejects(error); // Act @@ -175,20 +190,20 @@ describe('index', function () { 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': true, 'Access-Control-Allow-Origin': '*', - "Cache-Control": "max-age=60", - 'Content-Type': 'application/json' + 'Cache-Control': 'max-age=60', + 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 404, code: 'NoSuchKey', - message: 'NoSuchKey error happened.' - }) + message: 'NoSuchKey error happened.', + }), }; // Assert - expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(s3_mock).toHaveReceivedNthCommandWith(2, GetObjectCommand, { Bucket: 'fallback-image-bucket', - Key: 'fallback-image.png' + Key: 'fallback-image.png', }); expect(result).toEqual(expectedResult); }); @@ -200,7 +215,7 @@ describe('index', function () { let error: any = { code: 'NoSuchKey', status: 404, - message: 'NoSuchKey error happened.' + message: 'NoSuchKey error happened.', }; s3_mock.on(GetObjectCommand).rejects(error); // Act @@ -213,17 +228,17 @@ describe('index', function () { 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': true, 'Access-Control-Allow-Origin': '*', - "Cache-Control": "max-age=60", - 'Content-Type': 'application/json' + 'Cache-Control': 'max-age=60', + 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 404, code: 'NoSuchKey', - message: 'NoSuchKey error happened.' - }) + message: 'NoSuchKey error happened.', + }), }; // Assert - expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); }); it('006/should return an error JSON when the default fallback image bucket is not provided if the default fallback image is enabled', async function () { @@ -234,7 +249,7 @@ describe('index', function () { let error: any = { code: 'NoSuchKey', status: 404, - message: 'NoSuchKey error happened.' + message: 'NoSuchKey error happened.', }; s3_mock.on(GetObjectCommand).rejects(error); // Act @@ -247,17 +262,17 @@ describe('index', function () { 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': true, 'Access-Control-Allow-Origin': '*', - "Cache-Control": "max-age=60", - 'Content-Type': 'application/json' + 'Cache-Control': 'max-age=60', + 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 404, code: 'NoSuchKey', - message: 'NoSuchKey error happened.' - }) + message: 'NoSuchKey error happened.', + }), }; // Assert - expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); }); }); @@ -272,7 +287,7 @@ describe('index', function () { s3_mock.on(GetObjectCommand).resolves({ Body: generateStream(mockImage), ContentType: 'image/png', - Expires: new Date('Fri, 15 Jan 2021 14:00:00 GMT') + Expires: new Date('Fri, 15 Jan 2021 14:00:00 GMT'), }); // Act const result = await handler(event); @@ -287,12 +302,12 @@ describe('index', function () { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=600', }, - body: '{"message":"HTTP/410. Content test.jpg has expired.","code":"Gone","status":410}' + body: '{"message":"HTTP/410. Content test.jpg has expired.","code":"Gone","status":410}', }; // Assert - expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); - }) + }); it('009/should set expired header and reduce max-age if content is about to expire', async function () { process.env.CORS_ENABLED = 'Yes'; process.env.CORS_ORIGIN = '*'; @@ -304,13 +319,13 @@ describe('index', function () { // Arrange event.rawPath = '/test.jpg'; // Mock - s3_mock.reset() + s3_mock.reset(); s3_mock.on(GetObjectCommand).resolves({ Body: generateStream(mockImage), ContentType: 'image/png', Expires: new Date(date_now_fixture + 30999), - ETag: '"foo"' - }) + ETag: '"foo"', + }); // Act const result = await handler(event); const expectedResult = { @@ -324,15 +339,16 @@ describe('index', function () { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=30, immutable', Expires: new Date(date_now_fixture + 30999).toUTCString(), - ETag: '"foo"' + ETag: '"foo"', + Vary: 'Accept', }, - body: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAABP2lDQ1BpY2MAAHicfZC/SwJxGMY/11WWWA05NBQcJU0FUUtTgYZOEfgj1Kbz/FGgdt33QprLoaloiEZrCaLZxhz6A4KgIQqirdWghpKLrw5aUM/yfnh4Xt6XB5TnvFEQ3RoUirYVDvm1eCKpuV5Q8dLPIGO6IczlSDAKIPSSMGwrzw+936PIeTe9rhfTO6/Xq8kFpbo7UY4FP1Yu+F/udEYYwBfgM0zLBkUDxku2KXkJ8BrrehqUODBlxRNJUPakn2vxieRUiy8lW9FwAJQaoOU6ONXBhfy2vCslv/dkirEI0AeMIggTwv9HpreZCRBgBmRfv3sQ2bnZ1pZnEXqeHOdtElyH0DhynM9Tx2mcgfoIta32/mYF5uugHrS91DFc7cPIQ9vzVWCoDNUbU7f0pqUCXdkNqJ/DQAKGb8G99g3j4l+xfPB+eQAAAANQTFRF/wAAGeIJNwAAAAF0Uk5Tf4BctMsAAAAJcEhZcwAACxMAAAsTAQCanBgAAAC0ZVhJZklJKgAIAAAABgASAQMAAQAAAAEAAAAaAQUAAQAAAFYAAAAbAQUAAQAAAF4AAAAoAQMAAQAAAAIAAAATAgMAAQAAAAEAAABphwQAAQAAAGYAAAAAAAAASAAAAAEAAABIAAAAAQAAAAYAAJAHAAQAAAAwMjEwAZEHAAQAAAABAgMAAKAHAAQAAAAwMTAwAaADAAEAAAD//wAAAqAEAAEAAAABAAAAA6AEAAEAAAABAAAAAAAAANu53doAAAAKSURBVHicY2AAAAACAAFIr6RxAAAAAElFTkSuQmCC" + body: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAABP2lDQ1BpY2MAAHicfZC/SwJxGMY/11WWWA05NBQcJU0FUUtTgYZOEfgj1Kbz/FGgdt33QprLoaloiEZrCaLZxhz6A4KgIQqirdWghpKLrw5aUM/yfnh4Xt6XB5TnvFEQ3RoUirYVDvm1eCKpuV5Q8dLPIGO6IczlSDAKIPSSMGwrzw+936PIeTe9rhfTO6/Xq8kFpbo7UY4FP1Yu+F/udEYYwBfgM0zLBkUDxku2KXkJ8BrrehqUODBlxRNJUPakn2vxieRUiy8lW9FwAJQaoOU6ONXBhfy2vCslv/dkirEI0AeMIggTwv9HpreZCRBgBmRfv3sQ2bnZ1pZnEXqeHOdtElyH0DhynM9Tx2mcgfoIta32/mYF5uugHrS91DFc7cPIQ9vzVWCoDNUbU7f0pqUCXdkNqJ/DQAKGb8G99g3j4l+xfPB+eQAAAANQTFRF/wAAGeIJNwAAAAF0Uk5Tf4BctMsAAAAJcEhZcwAACxMAAAsTAQCanBgAAAC0ZVhJZklJKgAIAAAABgASAQMAAQAAAAEAAAAaAQUAAQAAAFYAAAAbAQUAAQAAAF4AAAAoAQMAAQAAAAIAAAATAgMAAQAAAAEAAABphwQAAQAAAGYAAAAAAAAASAAAAAEAAABIAAAAAQAAAAYAAJAHAAQAAAAwMjEwAZEHAAQAAAABAgMAAKAHAAQAAAAwMTAwAaADAAEAAAD//wAAAqAEAAEAAAABAAAAA6AEAAEAAAABAAAAAAAAANu53doAAAAKSURBVHicY2AAAAACAAFIr6RxAAAAAElFTkSuQmCC', }; // Assert - expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); global.Date.now = realDateNow; - }) + }); it('010/should return gone if status code is in metadata', async function () { process.env.CORS_ENABLED = 'Yes'; process.env.CORS_ORIGIN = '*'; @@ -350,14 +366,14 @@ describe('index', function () { ContentType: 'image/png', ETag: '"foo"', Metadata: { - 'buzz-status-code': '410' - } + 'buzz-status-code': '410', + }, }); // Act const result = await handler(event); const expectedResult = { statusCode: 410, - body: JSON.stringify({"message": "HTTP/410. Content test.jpg has expired.", "code": "Gone", "status": 410}), + body: JSON.stringify({ message: 'HTTP/410. Content test.jpg has expired.', code: 'Gone', status: 410 }), headers: { 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', @@ -369,9 +385,9 @@ describe('index', function () { isBase64Encoded: false, }; // Assert - expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, {Bucket: 'source-bucket', Key: 'test.jpg'}); + expect(s3_mock).toHaveReceivedNthCommandWith(1, GetObjectCommand, { Bucket: 'source-bucket', Key: 'test.jpg' }); expect(result).toEqual(expectedResult); global.Date.now = realDateNow; - }) + }); });