From 19cbc3ce759d7c8d8ddc35081972d7ac1daf0c71 Mon Sep 17 00:00:00 2001 From: georgebearden Date: Thu, 6 Feb 2020 15:36:23 -0500 Subject: [PATCH] Updated to Version 4.2 --- CHANGELOG.md | 31 ++- LICENSE.txt | 2 +- NOTICE.txt | 2 + deployment/serverless-image-handler.template | 14 +- source/custom-resource/index.js | 2 +- source/custom-resource/lib/s3-helper.js | 2 - source/image-handler/image-handler.js | 123 ++++++++++-- source/image-handler/image-request.js | 113 ++++++++--- source/image-handler/index.js | 23 ++- source/image-handler/package.json | 18 +- .../image-handler/test/test-image-handler.js | 34 ++-- .../image-handler/test/test-image-request.js | 138 +++++++++++-- .../test/test-thumbor-mapping.js | 189 +++++++++++++++--- source/image-handler/thumbor-mapping.js | 91 +++++++-- 14 files changed, 633 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34042aac1..89117c02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2] - 2020-02-06 +### Added +- Honor outputFormat Parameter from the pull request [#117](https://github.com/awslabs/serverless-image-handler/pull/117) +- Support serving images under s3 subdirectories, Fix to make /fit-in/ work; Fix for VipsJpeg: Invalid SOS error plus several other critical fixes from the pull request [#130](https://github.com/awslabs/serverless-image-handler/pull/130) +- Allow regex in SOURCE_BUCKETS for environment variable from the pull request [#138](https://github.com/awslabs/serverless-image-handler/pull/138) +- Fix build script on other platforms from the pull request [#139](https://github.com/awslabs/serverless-image-handler/pull/139) +- Add Cache-Control response header from the pull request [#151](https://github.com/awslabs/serverless-image-handler/pull/151) +- Add AUTO_WEBP option to automatically serve WebP if the client supports it from the pull request [#152](https://github.com/awslabs/serverless-image-handler/pull/152) +- Use HTTP 404 & forward Cache-Control, Content-Type, Expires, and Last-Modified headers from S3 from the pull request [#158](https://github.com/awslabs/serverless-image-handler/pull/158) +- fix: DeprecationWarning: Buffer() is deprecated from the pull request [#174](https://github.com/awslabs/serverless-image-handler/pull/174) +- Add hex color support for Thumbor ```filters:background_color``` and ```filters:fill``` [#154](https://github.com/awslabs/serverless-image-handler/issues/154) +- Add format and watermark support for Thumbor [#109](https://github.com/awslabs/serverless-image-handler/issues/109), [#131](https://github.com/awslabs/serverless-image-handler/issues/131), [#109](https://github.com/awslabs/serverless-image-handler/issues/142) +* __Note that__ duplicated features has been merged gracefully. + +### Changed +- sharp base version (from 0.23.3 to 0.23.4) +- Image handler Amazon CloudFront distribution ```DefaultCacheBehavior.ForwaredValues.Header``` to ```["Origin", "Accept"]``` for webp +- Image resize process change for ```filters:no_upscale()``` handling by ```withoutEnlargement``` edit key [#144](https://github.com/awslabs/serverless-image-handler/issues/144) + +### Fixed +- Add and fix Cache-control, Content-Type, Expires, and Last-Modified headers to response: [#103](https://github.com/awslabs/serverless-image-handler/issues/103), [#107](https://github.com/awslabs/serverless-image-handler/issues/107), [#120](https://github.com/awslabs/serverless-image-handler/issues/120) +- Fix Amazon S3 bucket subfolder issue: [#106](https://github.com/awslabs/serverless-image-handler/issues/106), [#112](https://github.com/awslabs/serverless-image-handler/issues/112), [#119](https://github.com/awslabs/serverless-image-handler/issues/119), [#123](https://github.com/awslabs/serverless-image-handler/issues/123), [#167](https://github.com/awslabs/serverless-image-handler/issues/167), [#175](https://github.com/awslabs/serverless-image-handler/issues/175) +- Fix HTTP status code for missing images from 500 to 404: [#159](https://github.com/awslabs/serverless-image-handler/issues/159) +- Fix European character in filename issue: [#149](https://github.com/awslabs/serverless-image-handler/issues/149) +- Fix image scaling issue for filename containing 'x' character: [#163](https://github.com/awslabs/serverless-image-handler/issues/163), [#176](https://github.com/awslabs/serverless-image-handler/issues/176) +- Fix regular expression issue: [#114](https://github.com/awslabs/serverless-image-handler/issues/114), [#121](https://github.com/awslabs/serverless-image-handler/issues/121), [#125](https://github.com/awslabs/serverless-image-handler/issues/125) +- Fix not working quality parameter: [#129](https://github.com/awslabs/serverless-image-handler/issues/129) + ## [4.1] - 2019-12-31 ### Added - CHANGELOG file @@ -15,5 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Image handler function to use Composite API (https://sharp.pixelplumbing.com/en/stable/api-composite/) - License to Apache-2.0 -# Removed +### Removed - Reference to deprecated sharp function (overlayWith) +- Capability to resize images proportionally if width or height is set to 0 (sharp v0.23.1 and later check that the width and height - if present - are positive integers) diff --git a/LICENSE.txt b/LICENSE.txt index f30fb6fb0..96ad5c3c1 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2019 - 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE.txt b/NOTICE.txt index 1e69eb65b..c2836a8d1 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -20,3 +20,5 @@ sharp under the Apache License Version 2.0 sinon under the BSD-3-Clause license sinon-chai under the BSD-2-Clause license uuid under the Massachusetts Institute of Technology (MIT) license +color under the Massachusetts Institute of Technology (MIT) license +color-name under the Massachusetts Institute of Technology (MIT) license \ No newline at end of file diff --git a/deployment/serverless-image-handler.template b/deployment/serverless-image-handler.template index 2c58d6c81..25277b42e 100644 --- a/deployment/serverless-image-handler.template +++ b/deployment/serverless-image-handler.template @@ -30,6 +30,12 @@ "Default" : 1, "Type" : "Number", "AllowedValues" : [ 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653 ] + }, + "AutoWebP" : { + "Description" : "Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.", + "Default" : "No", + "Type" : "String", + "AllowedValues" : [ "Yes", "No" ] } }, "Metadata": { @@ -74,6 +80,7 @@ "Resources": { "Logs": { "DeletionPolicy": "Retain", + "UpdateReplacePolicy": "Retain", "Type": "AWS::S3::Bucket", "Properties": { "AccessControl": "LogDeliveryWrite", @@ -130,7 +137,7 @@ "TargetOriginId": { "Fn::Sub": "${ImageHandlerApi}" }, "ForwardedValues": { "QueryString": false, - "Headers": [ "Origin" ], + "Headers": [ "Origin", "Accept" ], "Cookies": { "Forward": "none" } }, "ViewerProtocolPolicy": "https-only" @@ -310,6 +317,7 @@ }, "ImageHandlerApiDeployment": { "Type": "AWS::ApiGateway::Deployment", + "DependsOn": "ApiAccountConfig", "Properties": { "RestApiId": { "Ref": "ImageHandlerApi" }, "StageName": "image", @@ -371,6 +379,9 @@ "Timeout": 30, "Environment" : { "Variables" : { + "AUTO_WEBP" : { + "Ref" : "AutoWebP" + }, "CORS_ENABLED" : { "Ref" : "CorsEnabled" }, @@ -514,6 +525,7 @@ "Type": "AWS::S3::Bucket", "Condition": "DeployDemoUICondition", "DeletionPolicy": "Retain", + "UpdateReplacePolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js index 50ceea1ed..f90c1e3ca 100644 --- a/source/custom-resource/index.js +++ b/source/custom-resource/index.js @@ -218,7 +218,7 @@ let sendResponse = function(event, callback, logStreamName, responseStatus, resp const responseBody = JSON.stringify({ Status: responseStatus, Reason: reason, - PhysicalResourceId: logStreamName, + PhysicalResourceId: event.LogicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, diff --git a/source/custom-resource/lib/s3-helper.js b/source/custom-resource/lib/s3-helper.js index 822b4e464..5d403a7bb 100644 --- a/source/custom-resource/lib/s3-helper.js +++ b/source/custom-resource/lib/s3-helper.js @@ -17,7 +17,6 @@ 'use strict'; -let moment = require('moment'); let AWS = require('aws-sdk'); const fs = require('fs'); @@ -75,7 +74,6 @@ class s3Helper { console.log(`Attempting to save content blob destination location: ${destS3Bucket}/${destS3key}`); console.log(JSON.stringify(content)); - let _self = this; return new Promise((resolve, reject) => { let _content = `'use strict';\n\nconst appVariables = {\n`; diff --git a/source/image-handler/image-handler.js b/source/image-handler/image-handler.js index c85b6864f..49bf4b615 100644 --- a/source/image-handler/image-handler.js +++ b/source/image-handler/image-handler.js @@ -26,7 +26,7 @@ class ImageHandler { if (edits !== undefined) { const modifiedImage = await this.applyEdits(originalImage, edits); if (request.outputFormat !== undefined) { - await modifiedImage.toFormat(request.outputFormat); + modifiedImage.toFormat(request.outputFormat); } const bufferImage = await modifiedImage.toBuffer(); return bufferImage.toString('base64'); @@ -42,26 +42,79 @@ class ImageHandler { * @param {Object} edits - The edits to be made to the original image. */ async applyEdits(originalImage, edits) { - const image = sharp(originalImage); + if (edits.resize === undefined) { + edits.resize = {}; + edits.resize.fit = 'inside'; + } + + const image = sharp(originalImage, { failOnError: false }); + const metadata = await image.metadata(); const keys = Object.keys(edits); const values = Object.values(edits); + // Apply the image edits for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = values[i]; if (key === 'overlayWith') { - const overlay = await this.getOverlayImage(value.bucket, value.key); - const params = [{ ...value.options, input: overlay }]; + let imageMetadata = metadata; + if (edits.resize) { + let imageBuffer = await image.toBuffer(); + imageMetadata = await sharp(imageBuffer).resize({ edits: { resize: edits.resize }}).metadata(); + } + + 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; + if (options) { + if (options.left) { + let left = options.left; + if (left.endsWith('p')) { + left = parseInt(left.replace('p', '')); + if (left < 0) { + left = imageMetadata.width + (imageMetadata.width * left / 100) - overlayMetadata.width; + } else { + left = imageMetadata.width * left / 100; + } + } else { + left = parseInt(left); + if (left < 0) { + left = imageMetadata.width + left - overlayMetadata.width; + } + } + options.left = parseInt(left); + } + if (options.top) { + let top = options.top; + if (top.endsWith('p')) { + top = parseInt(top.replace('p', '')); + if (top < 0) { + top = imageMetadata.height + (imageMetadata.height * top / 100) - overlayMetadata.height; + } else { + top = imageMetadata.height * top / 100; + } + } else { + top = parseInt(top); + if (top < 0) { + top = imageMetadata.height + top - overlayMetadata.height; + } + } + options.top = parseInt(top); + } + } + + const params = [{ ...options, input: overlay }]; image.composite(params); } else if (key === 'smartCrop') { const options = value; const imageBuffer = await image.toBuffer(); - const metadata = await image.metadata(); - // ---- const boundingBox = await this.getBoundingBox(imageBuffer, options.faceIndex); - const cropArea = await this.getCropArea(boundingBox, options, metadata); - try { image.extract(cropArea) } - catch (err) { + const cropArea = this.getCropArea(boundingBox, options, metadata); + try { + image.extract(cropArea) + } catch (err) { throw ({ status: 400, code: 'SmartCrop::PaddingOutOfBounds', @@ -82,18 +135,48 @@ class ImageHandler { * @param {string} bucket - The name of the bucket containing the overlay. * @param {string} key - The keyname corresponding to the overlay. */ - async getOverlayImage(bucket, key) { + async getOverlayImage(bucket, key, wRatio, hRatio, alpha, sourceImageMetadata) { const s3 = new AWS.S3(); const params = { Bucket: bucket, Key: key }; - // Request - const request = s3.getObject(params).promise(); - // Response handling try { - const overlayImage = await request; - return Promise.resolve(overlayImage.Body); + const { width, height } = sourceImageMetadata; + const overlayImage = await s3.getObject(params).promise(); + let resize = { + 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'] = parseInt(width * wRatio / 100); + } + if (zeroToHundred.test(hRatio)) { + resize['height'] = parseInt(height * hRatio / 100); + } + + // If alpha is not within 0-100, the default alpha is 0 (fully opaque). + if (zeroToHundred.test(alpha)) { + alpha = parseInt(alpha); + } else { + alpha = 0; + } + + const convertedImage = await sharp(overlayImage.Body) + .resize(resize) + .composite([{ + input: Buffer.from([255, 255, 255, 255 * (1 - alpha / 100)]), + raw: { + width: 1, + height: 1, + channels: 4 + }, + tile: true, + blend: 'dest-in' + }]).toBuffer(); + return Promise.resolve(convertedImage); } catch (err) { return Promise.reject({ - status: 500, + status: err.statusCode ? err.statusCode : 500, code: err.code, message: err.message }) @@ -131,12 +214,9 @@ class ImageHandler { const rekognition = new AWS.Rekognition(); const params = { Image: { Bytes: imageBuffer }}; const faceIdx = (faceIndex !== undefined) ? faceIndex : 0; - // Request - const request = rekognition.detectFaces(params).promise(); - // Response handling try { - const response = (await request).FaceDetails[faceIdx].BoundingBox; - return Promise.resolve(await response); + const response = await rekognition.detectFaces(params).promise(); + return Promise.resolve(response.FaceDetails[faceIdx].BoundingBox); } catch (err) { console.log(err); if (err.message === "Cannot read property 'BoundingBox' of undefined") { @@ -158,4 +238,3 @@ class ImageHandler { // Exports module.exports = ImageHandler; - diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js index 405ba6313..2a47530da 100644 --- a/source/image-handler/image-request.js +++ b/source/image-handler/image-request.js @@ -26,7 +26,36 @@ class ImageRequest { this.bucket = this.parseImageBucket(event, this.requestType); this.key = this.parseImageKey(event, this.requestType); this.edits = this.parseImageEdits(event, this.requestType); - this.originalImage = await this.getOriginalImage(this.bucket, this.key) + this.originalImage = await this.getOriginalImage(this.bucket, this.key); + + /* 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. + */ + let outputFormat = this.getOutputFormat(event); + if (this.edits && this.edits.toFormat) { + this.outputFormat = this.edits.toFormat; + } else if (outputFormat) { + this.outputFormat = outputFormat; + } + + // 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']; + + 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 (qualityKey && (qualityKey !== this.outputFormat)) { + const qualityValue = this.edits[qualityKey]; + this.edits[this.outputFormat] = qualityValue; + delete this.edits[qualityKey]; + } + } + } + return Promise.resolve(this); } catch (err) { return Promise.reject(err); @@ -43,17 +72,36 @@ class ImageRequest { const S3 = require('aws-sdk/clients/s3'); const s3 = new S3(); const imageLocation = { Bucket: bucket, Key: key }; - const request = s3.getObject(imageLocation).promise(); try { - const originalImage = await request; + const originalImage = await s3.getObject(imageLocation).promise(); + + if (originalImage.ContentType) { + this.ContentType = originalImage.ContentType; + } else { + this.ContentType = "image"; + } + + if (originalImage.Expires) { + this.Expires = new Date(originalImage.Expires).toUTCString(); + } + + if (originalImage.LastModified) { + this.LastModified = new Date(originalImage.LastModified).toUTCString(); + } + + if (originalImage.CacheControl) { + this.CacheControl = originalImage.CacheControl; + } else { + this.CacheControl = "max-age=31536000,public"; + } + return Promise.resolve(originalImage.Body); - } - catch(err) { + } catch(err) { return Promise.reject({ - status: 500, + status: ('NoSuchKey' === err.code) ? 404 : 500, code: err.code, message: err.message - }) + }); } } @@ -70,7 +118,7 @@ class ImageRequest { if (decoded.bucket !== undefined) { // Check the provided bucket against the whitelist const sourceBuckets = this.getAllowedSourceBuckets(); - if (sourceBuckets.includes(decoded.bucket)) { + if (sourceBuckets.includes(decoded.bucket) || decoded.bucket.match(new RegExp('^' + sourceBuckets[0] + '$'))) { return decoded.bucket; } else { throw ({ @@ -90,7 +138,7 @@ class ImageRequest { return sourceBuckets[0]; } else { throw ({ - status: 400, + status: 404, code: 'ImageBucket::CannotFindBucket', message: 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.' }); @@ -135,18 +183,18 @@ class ImageRequest { // Decode the image request and return the image key const decoded = this.decodeRequest(event); return decoded.key; - } else if (requestType === "Thumbor" || requestType === "Custom") { - // Parse the key from the end of the path - const key = (event["path"]).split("/"); - return key[key.length - 1]; - } else { - // Return an error for all other conditions - throw ({ - status: 400, - 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.' - }); } + + if (requestType === "Thumbor" || requestType === "Custom") { + return decodeURIComponent(event["path"].replace(/\d+x\d+\/|filters[:-][^/;]+|\/fit-in\/+|^\/+/g,'').replace(/^\/+/,'')); + } + + // Return an error for all other conditions + throw ({ + status: 404, + 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.' + }); } /** @@ -160,8 +208,8 @@ class ImageRequest { const path = event["path"]; // ---- const matchDefault = new RegExp(/^(\/?)([0-9a-zA-Z+\/]{4})*(([0-9a-zA-Z+\/]{2}==)|([0-9a-zA-Z+\/]{3}=))?$/); - const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(.+jpg|.+png|.+webp|.+tiff|.+jpeg)$/); - const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg)/); + const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(.+jpg|.+png|.+webp|.+tiff|.+jpeg)$/i); + const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg)/i); const definedEnvironmentVariables = ( (process.env.REWRITE_MATCH_PATTERN !== "") && (process.env.REWRITE_SUBSTITUTION !== "") && @@ -194,9 +242,10 @@ class ImageRequest { if (path !== undefined) { const splitPath = path.split("/"); const encoded = splitPath[splitPath.length - 1]; - const toBuffer = new Buffer(encoded, 'base64'); + const toBuffer = Buffer.from(encoded, 'base64'); try { - return JSON.parse(toBuffer.toString('ascii')); + // To support European characters, 'ascii' was removed. + return JSON.parse(toBuffer.toString()); } catch (e) { throw ({ status: 400, @@ -232,6 +281,22 @@ class ImageRequest { return buckets; } } + + /** + * Return the output format depending on the accepts headers and request type + * @param {Object} event - The request body. + */ + getOutputFormat(event) { + const autoWebP = process.env.AUTO_WEBP; + if (autoWebP && event.headers.Accept && event.headers.Accept.includes('image/webp')) { + return 'webp'; + } else if (this.requestType === 'Default') { + const decoded = this.decodeRequest(event); + return decoded.outputFormat; + } + + return null; + } } // Exports diff --git a/source/image-handler/index.js b/source/image-handler/index.js index f6a9cba8a..98b6f8773 100755 --- a/source/image-handler/index.js +++ b/source/image-handler/index.js @@ -22,22 +22,28 @@ exports.handler = async (event) => { const request = await imageRequest.setup(event); console.log(request); const processedRequest = await imageHandler.process(request); - const response = { + + const headers = getResponseHeaders(); + headers["Content-Type"] = request.ContentType; + headers["Expires"] = request.Expires; + headers["Last-Modified"] = request.LastModified; + headers["Cache-Control"] = request.CacheControl; + + return { "statusCode": 200, - "headers" : getResponseHeaders(), + "headers" : headers, "body": processedRequest, "isBase64Encoded": true - } - return response; + }; } catch (err) { console.log(err); - const response = { + + return { "statusCode": err.status, "headers" : getResponseHeaders(true), "body": JSON.stringify(err), "isBase64Encoded": false - } - return response; + }; } } @@ -51,8 +57,7 @@ const getResponseHeaders = (isErr) => { const headers = { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, - "Content-Type": "image" + "Access-Control-Allow-Credentials": true } if (corsEnabled) { headers["Access-Control-Allow-Origin"] = process.env.CORS_ORIGIN; diff --git a/source/image-handler/package.json b/source/image-handler/package.json index e1a5d0028..66201a474 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -8,22 +8,24 @@ "version": "0.0.1", "private": true, "dependencies": { - "mocha": "^6.1.4", - "sharp": "^0.23.3", - "sinon": "^7.3.2", - "nyc": "^14.0.0" + "sharp": "^0.23.4", + "color": "3.1.2", + "color-name": "1.1.4" }, "devDependencies": { "aws-sdk": "^2.437.0", - "aws-sdk-mock": "^4.4.0" + "aws-sdk-mock": "^4.4.0", + "mocha": "^6.1.4", + "sinon": "^7.3.2", + "nyc": "^14.0.0" }, "scripts": { - "pretest": "npm install", + "pretest": "npm run build:init && npm install", "test": "nyc --reporter=html --reporter=text mocha", "build:init": "rm -rf package-lock.json && rm -rf dist && rm -rf node_modules", - "build:zip": "zip -rq image-handler.zip . -x template.yml", + "build:zip": "zip -rq image-handler.zip .", "build:dist": "mkdir dist && mv image-handler.zip dist/", - "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist" + "build": "npm run build:init && npm install --arch=x64 --platform=linux --production && npm run build:zip && npm run build:dist" }, "license": "Apache-2.0" } diff --git a/source/image-handler/test/test-image-handler.js b/source/image-handler/test/test-image-handler.js index 739d2777c..a42c2a918 100644 --- a/source/image-handler/test/test-image-handler.js +++ b/source/image-handler/test/test-image-handler.js @@ -12,6 +12,7 @@ *********************************************************************************************************************/ const ImageHandler = require('../image-handler'); +const sharp = require('sharp'); let assert = require('assert'); // ---------------------------------------------------------------------------- @@ -27,7 +28,7 @@ describe('process()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.returns({ promise: () => { return { - Body: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') }} }) // ---- @@ -39,7 +40,7 @@ describe('process()', function() { grayscale: true, flip: true }, - originalImage: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') } // Act const imageHandler = new ImageHandler(); @@ -57,7 +58,7 @@ describe('process()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.returns({ promise: () => { return { - Body: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') }} }) // ---- @@ -70,7 +71,7 @@ describe('process()', function() { grayscale: true, flip: true }, - originalImage: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') } // Act const imageHandler = new ImageHandler(); @@ -88,7 +89,7 @@ describe('process()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.returns({ promise: () => { return { - Body: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') }} }) // ---- @@ -96,7 +97,7 @@ describe('process()', function() { requestType: "default", bucket: "sample-bucket", key: "sample-image-001.jpg", - originalImage: new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') } // Act const imageHandler = new ImageHandler(); @@ -115,7 +116,7 @@ describe('applyEdits()', function() { it(`Should pass if a series of standard edits are provided to the function`, async function() { // Arrange - const originalImage = Buffer.from('sampleImageContent'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { grayscale: true, flip: true @@ -140,11 +141,11 @@ describe('applyEdits()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.returns({ promise: () => { return { - Body: Buffer.from('sampleImageContent') + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') }} }) // Act - const originalImage = Buffer.from('sampleImageContent'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { overlayWith: { bucket: 'aaa', @@ -179,7 +180,7 @@ describe('applyEdits()', function() { }} }) // Act - const originalImage = new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { smartCrop: { faceIndex: 0, @@ -219,7 +220,7 @@ describe('applyEdits()', function() { }} }) // Act - const originalImage = new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { smartCrop: { faceIndex: 0, @@ -259,7 +260,7 @@ describe('applyEdits()', function() { }} }) // Act - const originalImage = new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { smartCrop: { faceIndex: 10, @@ -299,7 +300,7 @@ describe('applyEdits()', function() { }} }) // Act - const originalImage = new Buffer('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const edits = { smartCrop: true } @@ -330,14 +331,15 @@ describe('getOverlayImage()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ promise: () => { return { - Body: Buffer.from('SampleImageContent\n') + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') }} }) // Act const imageHandler = new ImageHandler(); - const result = await imageHandler.getOverlayImage('validBucket', 'validKey'); + const metadata = await sharp(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')).metadata(); + const result = await imageHandler.getOverlayImage('validBucket', 'validKey', '100', '100', '20', metadata); // Assert - assert.deepEqual(result, Buffer.from('SampleImageContent\n')); + assert.deepEqual(result, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsSAAALEgHS3X78AAAADUlEQVQI12P4z8CQCgAEZgFlTg0nBwAAAABJRU5ErkJggg==', 'base64')); }); }); describe('002/imageDoesNotExist', async function() { diff --git a/source/image-handler/test/test-image-request.js b/source/image-handler/test/test-image-request.js index f748e8df8..9507dacef 100644 --- a/source/image-handler/test/test-image-request.js +++ b/source/image-handler/test/test-image-request.js @@ -23,7 +23,7 @@ describe('setup()', function() { the ImageRequest object with the proper values`, async function() { // Arrange const event = { - path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfX0=' + path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9' } process.env = { SOURCE_BUCKETS : "validBucket, validBucket2" @@ -34,7 +34,7 @@ describe('setup()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ promise: () => { return { - Body: Buffer.from('SampleImageContent\n') + Body: Buffer.from('SampleImageContent\n') }} }) // Act @@ -45,7 +45,10 @@ describe('setup()', function() { bucket: 'validBucket', key: 'validKey', edits: { grayscale: true }, - originalImage: Buffer.from('SampleImageContent\n') + outputFormat: 'jpeg', + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + ContentType: 'image/jpeg' } // Assert assert.deepEqual(imageRequest, expectedResult); @@ -67,7 +70,7 @@ describe('setup()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'allowedBucket001', Key: 'test-image-001.jpg'}).returns({ promise: () => { return { - Body: Buffer.from('SampleImageContent\n') + Body: Buffer.from('SampleImageContent\n') }} }) // Act @@ -78,7 +81,9 @@ describe('setup()', function() { bucket: 'allowedBucket001', key: 'test-image-001.jpg', edits: { grayscale: true }, - originalImage: Buffer.from('SampleImageContent\n') + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + ContentType: 'image' } // Assert assert.deepEqual(imageRequest, expectedResult); @@ -102,6 +107,10 @@ describe('setup()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'allowedBucket001', Key: 'custom-image.jpg'}).returns({ promise: () => { return { + CacheControl: 'max-age=300,public', + ContentType: 'custom-type', + Expires: 'Tue, 24 Dec 2019 13:46:28 GMT', + LastModified: 'Sat, 19 Dec 2009 16:30:47 GMT', Body: Buffer.from('SampleImageContent\n') }} }) @@ -116,7 +125,11 @@ describe('setup()', function() { grayscale: true, rotate: 90 }, - originalImage: Buffer.from('SampleImageContent\n') + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=300,public', + ContentType: 'custom-type', + Expires: 'Tue, 24 Dec 2019 13:46:28 GMT', + LastModified: 'Sat, 19 Dec 2009 16:30:47 GMT', } // Assert assert.deepEqual(imageRequest, expectedResult); @@ -134,7 +147,7 @@ describe('setup()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ promise: () => { return { - Body: Buffer.from('SampleImageContent\n') + Body: Buffer.from('SampleImageContent\n') }} }) // Act @@ -162,7 +175,7 @@ describe('getOriginalImage()', function() { const getObject = S3.prototype.getObject = sinon.stub(); getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ promise: () => { return { - Body: Buffer.from('SampleImageContent\n') + Body: Buffer.from('SampleImageContent\n') }} }) // Act @@ -182,8 +195,8 @@ describe('getOriginalImage()', function() { getObject.withArgs({Bucket: 'invalidBucket', Key: 'invalidKey'}).returns({ promise: () => { return Promise.reject({ - code: 500, - message: 'SimulatedInvalidParameterException' + code: 'NoSuchKey', + message: 'SimulatedException' }) } }); @@ -192,9 +205,31 @@ describe('getOriginalImage()', function() { // Assert imageRequest.getOriginalImage('invalidBucket', 'invalidKey').then((result) => { assert.equal(typeof result, Error); - }).catch((err) => { - console.log(err) - }) + assert.equal(result.status, 404); + }).catch((err) => console.log(err)); + }); + }); + describe('003/unknownError', async function() { + it(`Should throw an error if an unkown problem happens when getting an object`, async function() { + // Arrange + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'invalidBucket', Key: 'invalidKey'}).returns({ + promise: () => { + return Promise.reject({ + code: 'InternalServerError', + message: 'SimulatedException' + }) + } + }); + // Act + const imageRequest = new ImageRequest(); + // Assert + imageRequest.getOriginalImage('invalidBucket', 'invalidKey').then((result) => { + assert.equal(typeof result, Error); + assert.equal(result.status, 500); + }).catch((err) => console.log(err)); }); }); }); @@ -644,4 +679,79 @@ describe('getAllowedSourceBuckets()', function() { }); }); }); -}) \ No newline at end of file +}); + +// ---------------------------------------------------------------------------- +// getOutputFormat() +// ---------------------------------------------------------------------------- +describe('getOutputFormat()', function () { + describe('001/AcceptsHeaderIncludesWebP', function () { + it(`Should pass if it returns "webp" for an accepts header which includes webp`, function () { + // Arrange + process.env = { + AUTO_WEBP: true + }; + const event = { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" + } + }; + // Act + const imageRequest = new ImageRequest(); + var result = imageRequest.getOutputFormat(event); + // Assert + assert.deepEqual(result, 'webp'); + }); + }); + describe('002/AcceptsHeaderDoesNotIncludeWebP', function () { + it(`Should pass if it returns null for an accepts header which does not include webp`, function () { + // Arrange + process.env = { + AUTO_WEBP: true + }; + const event = { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" + } + }; + // Act + const imageRequest = new ImageRequest(); + var result = imageRequest.getOutputFormat(event); + // Assert + assert.deepEqual(result, null); + }); + }); + describe('003/AutoWebPDisabled', function () { + it(`Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp`, function () { + // Arrange + process.env = { + AUTO_WEBP: false + }; + const event = { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" + } + }; + // Act + const imageRequest = new ImageRequest(); + var result = imageRequest.getOutputFormat(event); + // Assert + assert.deepEqual(result, null); + }); + }); + describe('004/AutoWebPUnset', function () { + it(`Should pass if it returns null when AUTO_WEBP is not set with accepts header including webp`, function () { + // Arrange + const event = { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" + } + }; + // Act + const imageRequest = new ImageRequest(); + var result = imageRequest.getOutputFormat(event); + // Assert + assert.deepEqual(result, null); + }); + }); +}); diff --git a/source/image-handler/test/test-thumbor-mapping.js b/source/image-handler/test/test-thumbor-mapping.js index d12adc6a1..1186bc5d3 100644 --- a/source/image-handler/test/test-thumbor-mapping.js +++ b/source/image-handler/test/test-thumbor-mapping.js @@ -33,7 +33,8 @@ describe('process()', function() { edits: { resize: { width: 200, - height: 300 + height: 300, + fit: 'inside' }, grayscale: true } @@ -121,7 +122,7 @@ describe('mapFilter()', function() { thumborMapping.mapFilter(edit, filetype); // Assert const expectedResult = { - edits: { toFormat: 'jpg' } + edits: { toFormat: 'jpeg' } }; assert.deepEqual(thumborMapping, expectedResult); }); @@ -130,14 +131,14 @@ describe('mapFilter()', function() { it(`Should pass if the filter is successfully translated from Thumbor:background_color()`, function() { // Arrange - const edit = 'filters:background_color(#ffff)'; + const edit = 'filters:background_color(ffff)'; const filetype = 'jpg'; // Act const thumborMapping = new ThumborMapping(); thumborMapping.mapFilter(edit, filetype); // Assert const expectedResult = { - edits: { flatten: { background: '#ffff' }} + edits: { flatten: { background: {r: 255, g: 255, b: 255}}} }; assert.deepEqual(thumborMapping, expectedResult); }); @@ -214,14 +215,14 @@ describe('mapFilter()', function() { it(`Should pass if the filter is successfully translated from Thumbor:fill()`, function() { // Arrange - const edit = 'filters:fill(#fff)'; + const edit = 'filters:fill(fff)'; const filetype = 'jpg'; // Act const thumborMapping = new ThumborMapping(); thumborMapping.mapFilter(edit, filetype); // Assert const expectedResult = { - edits: { resize: { background: '#fff' }} + edits: { resize: { background: { r: 255, g: 255, b: 255 } }} }; assert.deepEqual(thumborMapping, expectedResult); }); @@ -231,7 +232,7 @@ describe('mapFilter()', function() { it(`Should pass if the filter is successfully translated from Thumbor:fill()`, function() { // Arrange - const edit = 'filters:fill(#fff)'; + const edit = 'filters:fill(fff)'; const filetype = 'jpg'; // Act const thumborMapping = new ThumborMapping(); @@ -239,7 +240,7 @@ describe('mapFilter()', function() { thumborMapping.mapFilter(edit, filetype); // Assert const expectedResult = { - edits: { resize: { background: '#fff' }} + edits: { resize: { background: { r: 255, g: 255, b: 255 } }} }; assert.deepEqual(thumborMapping, expectedResult); }); @@ -289,9 +290,7 @@ describe('mapFilter()', function() { const expectedResult = { edits: { resize: { - fit: 'inside', - height: undefined, - width: undefined + withoutEnlargement: true } } }; @@ -306,15 +305,18 @@ describe('mapFilter()', function() { const filetype = 'jpg'; // Act const thumborMapping = new ThumborMapping(); - thumborMapping.edits.resize = {}; + thumborMapping.edits.resize = { + height: 400, + width: 300 + }; thumborMapping.mapFilter(edit, filetype); // Assert const expectedResult = { edits: { resize: { - fit: 'inside', - height: undefined, - width: undefined + height: 400, + width: 300, + withoutEnlargement: true } } }; @@ -443,7 +445,27 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('019/quality/other', function() { + describe('019/quality/heif', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:quality()`, function() { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = 'heif'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + heif: { + quality: 50 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('020/quality/other', function() { it(`Should return undefined if an unsupported file type is provided`, function() { // Arrange @@ -459,7 +481,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('020/rgb', function() { + describe('021/rgb', function() { it(`Should pass if the filter is successfully translated from Thumbor:rgb()`, function() { // Arrange @@ -481,7 +503,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('021/rotate', function() { + describe('022/rotate', function() { it(`Should pass if the filter is successfully translated from Thumbor:rotate()`, function() { // Arrange @@ -499,7 +521,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('022/sharpen', function() { + describe('023/sharpen', function() { it(`Should pass if the filter is successfully translated from Thumbor:sharpen()`, function() { // Arrange @@ -517,7 +539,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('023/stretch/default', function() { + describe('024/stretch/default', function() { it(`Should pass if the filter is successfully translated from Thumbor:stretch()`, function() { // Arrange @@ -535,7 +557,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('024/stretch/resizeDefined', function() { + describe('025/stretch/resizeDefined', function() { it(`Should pass if the filter is successfully translated from Thumbor:stretch()`, function() { // Arrange @@ -554,7 +576,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('025/stretch/sizingMethodUndefined', function() { + describe('026/stretch/sizingMethodUndefined', function() { it(`Should pass if the filter is successfully translated from Thumbor:stretch()`, function() { // Arrange @@ -575,7 +597,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('026/stretch/sizingMethodNotFitIn', function() { + describe('027/stretch/sizingMethodNotFitIn', function() { it(`Should pass if the filter is successfully translated from Thumbor:stretch()`, function() { // Arrange @@ -596,7 +618,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('027/stretch/sizingMethodFitIn', function() { + describe('028/stretch/sizingMethodFitIn', function() { it(`Should pass if the filter is successfully translated from Thumbor:stretch()`, function() { // Arrange @@ -617,7 +639,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('028/strip_exif', function() { + describe('029/strip_exif', function() { it(`Should pass if the filter is successfully translated from Thumbor:strip_exif()`, function() { // Arrange @@ -635,7 +657,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('029/strip_icc', function() { + describe('030/strip_icc', function() { it(`Should pass if the filter is successfully translated from Thumbor:strip_icc()`, function() { // Arrange @@ -653,7 +675,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('030/upscale', function() { + describe('031/upscale', function() { it(`Should pass if the filter is successfully translated from Thumbor:upscale()`, function() { // Arrange @@ -673,7 +695,7 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('031/upscale/resizeNotUndefined', function() { + describe('032/upscale/resizeNotUndefined', function() { it(`Should pass if the filter is successfully translated from Thumbor:upscale()`, function() { // Arrange @@ -694,7 +716,116 @@ describe('mapFilter()', function() { assert.deepEqual(thumborMapping, expectedResult); }); }); - describe('032/elseCondition', function() { + describe('032/watermark/positionDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:watermark()`, function() { + // Arrange + const edit = 'filters:watermark(bucket,key,100,100,0)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + overlayWith: { + bucket: 'bucket', + key: 'key', + alpha: '0', + wRatio: undefined, + hRatio: undefined, + options: { + left: '100', + top: '100' + } + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('033/watermark/positionDefinedByPercentile', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:watermark()`, function() { + // Arrange + const edit = 'filters:watermark(bucket,key,50p,30p,0)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + overlayWith: { + bucket: 'bucket', + key: 'key', + alpha: '0', + wRatio: undefined, + hRatio: undefined, + options: { + left: '50p', + top: '30p' + } + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('034/watermark/positionDefinedWrong', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:watermark()`, function() { + // Arrange + const edit = 'filters:watermark(bucket,key,x,x,0)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + overlayWith: { + bucket: 'bucket', + key: 'key', + alpha: '0', + wRatio: undefined, + hRatio: undefined, + options: {} + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('035/watermark/ratioDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:watermark()`, function() { + // Arrange + const edit = 'filters:watermark(bucket,key,100,100,0,10,10)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + overlayWith: { + bucket: 'bucket', + key: 'key', + alpha: '0', + wRatio: '10', + hRatio: '10', + options: { + left: '100', + top: '100' + } + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('036/elseCondition', function() { it(`Should pass if undefined is returned for an unsupported filter`, function() { // Arrange diff --git a/source/image-handler/thumbor-mapping.js b/source/image-handler/thumbor-mapping.js index 17b5bcfd2..4dca04f57 100644 --- a/source/image-handler/thumbor-mapping.js +++ b/source/image-handler/thumbor-mapping.js @@ -11,6 +11,9 @@ * and limitations under the License. * *********************************************************************************************************************/ +const Color = require('color'); +const ColorName = require('color-name'); + class ThumborMapping { // Constructor @@ -29,23 +32,37 @@ class ThumborMapping { this.path = event.path; const edits = this.path.split('/'); const filetype = (this.path.split('.'))[(this.path.split('.')).length - 1]; + + // Process the Dimensions + const dimPath = this.path.match(/[^\/]\d+x\d+/g); + if (dimPath) { + const dims = dimPath[0].split('x'); + // Set only if the dimensions provided are valid + if (!isNaN(dims[0]) && !isNaN(dims[1])) { + this.edits.resize = {}; + this.edits.resize.fit = 'fill'; + + // Assign dimenions from the first match only to avoid parsing dimension from image file names + this.edits.resize.width = Number(dims[0]); + this.edits.resize.height = Number(dims[1]); + } + } + // Parse the image path for (let i = 0; i < edits.length; i++) { const edit = edits[i]; if (edit === ('fit-in')) { - this.edits.resize = {}; + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + + this.edits.resize.fit = 'inside'; this.sizingMethod = edit; - } - else if (edit.includes('x')) { - this.edits.resize = {}; - const dims = edit.split('x'); - this.edits.resize.width = Number(dims[0]); - this.edits.resize.height = Number(dims[1]); - } - if (edit.includes('filters:')) { + } else if (edit.includes('filters:')) { this.mapFilter(edit, filetype); } } + return this; } @@ -61,7 +78,7 @@ class ThumborMapping { // Perform the substitution and return if (path !== undefined && matchPattern !== undefined && substitution !== undefined) { const parsedPath = path.replace(matchPattern, substitution); - const output = { path : parsedPath }; + const output = { path: parsedPath }; return output; } else { throw new Error('ThumborMapping::ParseCustomPath::ParsingError'); @@ -80,10 +97,13 @@ class ThumborMapping { let value = matched[2]; // Find the proper filter if (key === ('autojpg')) { - this.edits.toFormat = 'jpg'; + this.edits.toFormat = 'jpeg'; } else if (key === ('background_color')) { - this.edits.flatten = { background: value }; + if (!ColorName[value]) { + value = `#${value}` + } + this.edits.flatten = { background: Color(value).object() }; } else if (key === ('blur')) { const val = value.split(','); @@ -95,7 +115,7 @@ class ThumborMapping { let matrix = []; strMatrix.forEach(function(str) { matrix.push(Number(str)); - }) + }); const matrixWidth = arr[1]; let matrixHeight = 0; let counter = 0; @@ -120,11 +140,14 @@ class ThumborMapping { if (this.edits.resize === undefined) { this.edits.resize = {}; } - this.edits.resize.background = value; + if (!ColorName[value]) { + value = `#${value}` + } + this.edits.resize.background = Color(value).object(); } else if (key === ('format')) { - const formattedValue = value.replace(/[^0-9a-z]/gi, ''); - const acceptedValues = ['jpeg', 'gif', 'jpg', 'webp', 'png']; + const formattedValue = value.replace(/[^0-9a-z]/gi, '').replace(/jpg/i, 'jpeg'); + const acceptedValues = ['heic', 'heif', 'jpeg', 'png', 'raw', 'tiff', 'webp']; if (acceptedValues.includes(formattedValue)) { this.edits.toFormat = formattedValue; } @@ -136,9 +159,7 @@ class ThumborMapping { if (this.edits.resize === undefined) { this.edits.resize = {}; } - this.edits.resize.fit = "inside" - this.edits.resize.width = undefined; - this.edits.resize.height = undefined; + this.edits.resize.withoutEnlargement = true; } else if (key === ('proportion')) { if (this.edits.resize === undefined) { @@ -149,7 +170,7 @@ class ThumborMapping { this.edits.resize.height = Number(this.edits.resize.height * prop); } else if (key === ('quality')) { - if (filetype === 'jpg') { + if (['jpg', 'jpeg'].includes(filetype)) { this.edits.jpeg = { quality: Number(value) } } else if (filetype === 'png') { this.edits.png = { quality: Number(value) } @@ -157,12 +178,14 @@ class ThumborMapping { this.edits.webp = { quality: Number(value) } } else if (filetype === 'tiff') { this.edits.tiff = { quality: Number(value) } + } else if (filetype === 'heif') { + this.edits.heif = { quality: Number(value) } } } else if (key === ('rgb')) { const percentages = value.split(','); const values = []; - percentages.forEach(function(percentage) { + percentages.forEach(function (percentage) { const parsedPercentage = Number(percentage); const val = 255 * (parsedPercentage / 100); values.push(val); @@ -197,6 +220,32 @@ class ThumborMapping { } this.edits.resize.fit = "inside" } + else if (key === ('watermark')) { + const options = value.replace(/\s+/g, '').split(','); + const bucket = options[0]; + const key = options[1]; + const xPos = options[2]; + const yPos = options[3]; + const alpha = options[4]; + const wRatio = options[5]; + const hRatio = options[6]; + + this.edits.overlayWith = { + bucket, + key, + alpha, + wRatio, + hRatio, + 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; + } + if (allowedPosPattern.test(yPos) || !isNaN(yPos)) { + this.edits.overlayWith.options['top'] = yPos; + } + } else { return undefined; }