From 698e4eab4da2fbd4bb08b9232c881a8feb8b0b66 Mon Sep 17 00:00:00 2001 From: Alireza Assadzadeh Date: Mon, 31 Aug 2020 13:47:09 -0400 Subject: [PATCH] Update to re-add files accidentally deleted --- deployment/manifest-generator/app.js | 63 ++ source/custom-resource/lib/s3-helper.js | 281 +++++ .../lib/usage-metrics/metrics.common.js | 74 ++ .../lib/usage-metrics/test-setup.spec.js | 28 + source/demo-ui/scripts.js | 116 +++ source/image-handler/image-handler.js | 241 +++++ source/image-handler/image-request.js | 303 ++++++ .../image-handler/test/test-image-handler.js | 591 +++++++++++ .../image-handler/test/test-image-request.js | 864 ++++++++++++++++ .../test/test-thumbor-mapping.js | 967 ++++++++++++++++++ source/image-handler/thumbor-mapping.js | 263 +++++ 11 files changed, 3791 insertions(+) create mode 100644 deployment/manifest-generator/app.js create mode 100644 source/custom-resource/lib/s3-helper.js create mode 100644 source/custom-resource/lib/usage-metrics/metrics.common.js create mode 100644 source/custom-resource/lib/usage-metrics/test-setup.spec.js create mode 100644 source/demo-ui/scripts.js create mode 100644 source/image-handler/image-handler.js create mode 100644 source/image-handler/image-request.js create mode 100644 source/image-handler/test/test-image-handler.js create mode 100644 source/image-handler/test/test-image-request.js create mode 100644 source/image-handler/test/test-thumbor-mapping.js create mode 100644 source/image-handler/thumbor-mapping.js diff --git a/deployment/manifest-generator/app.js b/deployment/manifest-generator/app.js new file mode 100644 index 000000000..8baa8c416 --- /dev/null +++ b/deployment/manifest-generator/app.js @@ -0,0 +1,63 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +/** + * @author Solution Builders + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const args = require('minimist')(process.argv.slice(2)); + +// List all files in a directory in Node.js recursively in a synchronous fashion +let walkSync = function(dir, filelist) { + let files = fs.readdirSync(dir); + filelist = filelist || []; + files.forEach(function(file) { + if (fs.statSync(path.join(dir, file)).isDirectory()) { + filelist = walkSync(path.join(dir, file), filelist); + } else { + filelist.push(path.join(dir, file)); + } + }); + + return filelist; +}; + +let _filelist = []; +let _manifest = { + files: [] +}; + +if (!args.hasOwnProperty('target')) { + console.log('--target parameter missing. This should be the target directory containing content for the manifest.'); + process.exit(1); +} + +if (!args.hasOwnProperty('output')) { + console.log('--ouput parameter missing. This should be the out directory where the manifest file will be generated.'); + process.exit(1); +} + +console.log(`Generating a manifest file ${args.output} for directory ${args.target}`); + +walkSync(args.target, _filelist); + +for (let i = 0; i < _filelist.length; i++) { + _manifest.files.push(_filelist[i].replace(`${args.target}/`, '')); +} + +fs.writeFileSync(args.output, JSON.stringify(_manifest, null, 4)); +console.log(`Manifest file ${args.output} generated.`); diff --git a/source/custom-resource/lib/s3-helper.js b/source/custom-resource/lib/s3-helper.js new file mode 100644 index 000000000..5d403a7bb --- /dev/null +++ b/source/custom-resource/lib/s3-helper.js @@ -0,0 +1,281 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +/** + * @author Solution Builders + */ + +'use strict'; + +let AWS = require('aws-sdk'); +const fs = require('fs'); + +/** + * Helper function to interact with AWS S3 for cfn custom resource. + * + * @class s3Helper + */ +class s3Helper { + + /** + * @class s3Helper + * @constructor + */ + constructor() { + this.creds = new AWS.EnvironmentCredentials('AWS'); // Lambda provided credentials + this.downloadLocation = '/tmp/manifest.json'; + } + + /** + * validateBuckets + * Cross-checks provided bucket names against existing bucket names in the account for + * validation. + * @param {String} strBuckets - String of bucket names from the template params. + */ + async validateBuckets(strBuckets) { + const formatted = strBuckets.replace(/\s/g,''); + console.log(`Attempting to check if the following buckets exist: ${formatted}`); + const buckets = formatted.split(','); + const errorBuckets = []; + for (let i = 0; i < buckets.length; i++) { + const s3 = new AWS.S3({ signatureVersion: 'v4' }); + const params = { Bucket: buckets[i] }; + try { + await s3.headBucket(params).promise(); + console.log(`Found bucket: ${buckets[i]}`); + } catch (err) { + console.log(`Could not find bucket: ${buckets[i]}`); + console.log(err); + errorBuckets.push(buckets[i]); + } + } + if (errorBuckets.length === 0) return Promise.resolve(); + else return Promise.reject(errorBuckets); + } + + /** + * putConfigFile + * Saves a JSON config file to S3 location. + * @param {JSON} content - JSON object. + * @param {JSON} destS3Bucket - S3 destination bucket. + * @param {JSON} destS3key - S3 destination key. + */ + putConfigFile(content, destS3Bucket, destS3key) { + console.log(`Attempting to save content blob destination location: ${destS3Bucket}/${destS3key}`); + console.log(JSON.stringify(content)); + + return new Promise((resolve, reject) => { + let _content = `'use strict';\n\nconst appVariables = {\n`; + + let i = 0; + for (let key in content) { + if (i > 0) { + _content += ', \n'; + } + _content += `${key}: '${content[key]}'`; + i++; + } + _content += '\n};'; + + let params = { + Bucket: destS3Bucket, + Key: destS3key, + Body: _content + }; + + let s3 = new AWS.S3({ + signatureVersion: 'v4' + }); + s3.putObject(params, function(err, data) { + if (err) { + console.log(err); + reject(`Error creating ${destS3Bucket}/${destS3key} content \n${err}`); + } else { + console.log(data); + resolve(data); + } + }); + }); + } + + copyAssets(manifestKey, sourceS3Bucket, sourceS3prefix, destS3Bucket) { + console.log(`source bucket: ${sourceS3Bucket}`); + console.log(`source prefix: ${sourceS3prefix}`); + console.log(`destination bucket: ${destS3Bucket}`); + + let _self = this; + return new Promise((resolve, reject) => { + + this._downloadManifest(sourceS3Bucket, manifestKey).then((data) => { + + fs.readFile(_self.downloadLocation, 'utf8', function(err, data) { + if (err) { + console.log(err); + reject(err); + } + + let _manifest = _self._validateJSON(data); + + if (!_manifest) { + reject('Unable to validate downloaded manifest file JSON'); + } else { + _self._uploadFile(_manifest.files, 0, destS3Bucket, `${sourceS3Bucket}/${sourceS3prefix}`).then((resp) => { + console.log(resp); + resolve(resp) + }).catch((err) => { + console.log(err); + reject(err); + }); + } + + }); + }).catch((err) => { + console.log(err); + reject(err); + }); + + }); + }; + + /** + * Helper function to validate the JSON structure of contents of an import manifest file. + * @param {string} body - JSON object stringify-ed. + * @returns {JSON} - The JSON parsed string or null if string parsing failed + */ + _validateJSON(body) { + try { + let data = JSON.parse(body); + console.log(data); + return data; + } catch (e) { + // failed to parse + console.log('Manifest file contains invalid JSON.'); + return null; + } + }; + + _uploadFile(filelist, index, destS3Bucket, sourceS3prefix) { + let _self = this; + return new Promise((resolve, reject) => { + + if (filelist.length > index) { + let params = { + Bucket: destS3Bucket, + Key: filelist[index], + CopySource: [sourceS3prefix, filelist[index]].join('/'), + MetadataDirective: 'REPLACE' + }; + + params.ContentType = this._setContentType(filelist[index]); + params.Metadata = { + 'Content-Type': params.ContentType + }; + console.log(params); + let s3 = new AWS.S3({ + signatureVersion: 'v4' + }); + s3.copyObject(params, function(err, data) { + if (err) { + console.log(err); + reject(`error copying ${sourceS3prefix}/${filelist[index]}\n${err}`); + } else { + console.log(`${sourceS3prefix}/${filelist[index]} uploaded successfully`); + let _next = index + 1; + _self._uploadFile(filelist, _next, destS3Bucket, sourceS3prefix).then((resp) => { + resolve(resp); + }).catch((err2) => { + reject(err2); + }); + } + }); + } else { + resolve(`${index} files copied`); + } + + }); + + } + + /** + * Helper function to download a manifest to local storage for processing. + * @param {string} s3Bucket - Amazon S3 bucket of the manifest to download. + * @param {string} s3Key - Amazon S3 key of the manifest to download. + * @param {string} downloadLocation - Local storage location to download the Amazon S3 object. + */ + _downloadManifest(s3Bucket, s3Key) { + let _self = this; + return new Promise((resolve, reject) => { + + let params = { + Bucket: s3Bucket, + Key: s3Key + }; + + console.log(`Attempting to download manifest: ${JSON.stringify(params)}`); + + // check to see if the manifest file exists + let s3 = new AWS.S3({ + signatureVersion: 'v4' + }); + s3.headObject(params, function(err, metadata) { + if (err) { + console.log(err); + } + + if (err && err.code === 'NotFound') { + // Handle no object on cloud here + console.log('manifest file doesn\'t exist'); + reject('Manifest file was not found.'); + } else { + console.log('manifest file exists'); + console.log(metadata); + let file = require('fs').createWriteStream(_self.downloadLocation); + + s3.getObject(params). + on('httpData', function(chunk) { + file.write(chunk); + }). + on('httpDone', function() { + file.end(); + console.log('manifest downloaded for processing...'); + resolve('success'); + }). + send(); + } + }); + }); + } + + _setContentType(file) { + let _contentType = 'binary/octet-stream'; + if (file.endsWith('.html')) { + _contentType = 'text/html'; + } else if (file.endsWith('.css')) { + _contentType = 'text/css'; + } else if (file.endsWith('.png')) { + _contentType = 'image/png'; + } else if (file.endsWith('.svg')) { + _contentType = 'image/svg+xml'; + } else if (file.endsWith('.jpg')) { + _contentType = 'image/jpeg'; + } else if (file.endsWith('.js')) { + _contentType = 'application/javascript'; + } + + return _contentType; + } + + +} + +module.exports = s3Helper; \ No newline at end of file diff --git a/source/custom-resource/lib/usage-metrics/metrics.common.js b/source/custom-resource/lib/usage-metrics/metrics.common.js new file mode 100644 index 000000000..c0705b5dd --- /dev/null +++ b/source/custom-resource/lib/usage-metrics/metrics.common.js @@ -0,0 +1,74 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +/** + * @author Solution Builders + */ + +'use strict'; +let https = require('https'); + +// Metrics class for sending usage metrics to sb endpoints +class Metrics { + + constructor() { + this.endpoint = 'metrics.awssolutionsbuilder.com'; + } + + sendAnonymousMetric(metric) { + + return new Promise((resolve, reject) => { + + let _options = { + hostname: this.endpoint, + port: 443, + path: '/generic', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }; + + let request = https.request(_options, function(response) { + // data is streamed in chunks from the server + // so we have to handle the "data" event + let buffer; + let data; + let route; + + response.on('data', function(chunk) { + buffer += chunk; + }); + + response.on('end', function(err) { + resolve('metric sent'); + }); + }); + + if (metric) { + request.write(JSON.stringify(metric)); + } + + request.end(); + + request.on('error', (e) => { + console.error(e); + reject(['Error occurred when sending metric request.', JSON.stringify(_payload)].join(' ')); + }); + }); + + } + +} + +module.exports = Metrics; \ No newline at end of file diff --git a/source/custom-resource/lib/usage-metrics/test-setup.spec.js b/source/custom-resource/lib/usage-metrics/test-setup.spec.js new file mode 100644 index 000000000..6c7bb160f --- /dev/null +++ b/source/custom-resource/lib/usage-metrics/test-setup.spec.js @@ -0,0 +1,28 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); + +before(function() { + chai.use(sinonChai); +}); + +beforeEach(function() { + this.sandbox = sinon.sandbox.create(); +}); + +afterEach(function() { + this.sandbox.restore(); +}); diff --git a/source/demo-ui/scripts.js b/source/demo-ui/scripts.js new file mode 100644 index 000000000..00437a669 --- /dev/null +++ b/source/demo-ui/scripts.js @@ -0,0 +1,116 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +function importOriginalImage() { + // Gather the bucket name and image key + const bucketName = $(`#txt-bucket-name`).first().val(); + const keyName = $(`#txt-key-name`).first().val(); + // Assemble the image request + const request = { + bucket: bucketName, + key: keyName + } + const strRequest = JSON.stringify(request); + const encRequest = btoa(strRequest); + // Import the image data into the element + $(`#img-original`) + .attr(`src`, `${appVariables.apiEndpoint}/${encRequest}`) + .attr(`data-bucket`, bucketName) + .attr(`data-key`, keyName); +} + +function getPreviewImage() { + // Gather the editor inputs + const _width = $(`#editor-width`).first().val(); + const _height = $(`#editor-height`).first().val(); + const _resize = $(`#editor-resize-mode`).first().val(); + const _fillColor = $(`#editor-fill-color`).first().val(); + const _backgroundColor = $(`#editor-background-color`).first().val(); + const _grayscale = $(`#editor-grayscale`).first().prop("checked"); + const _flip = $(`#editor-flip`).first().prop("checked"); + const _flop = $(`#editor-flop`).first().prop("checked"); + const _negative = $(`#editor-negative`).first().prop("checked"); + const _flatten = $(`#editor-flatten`).first().prop("checked"); + const _normalize = $(`#editor-normalize`).first().prop("checked"); + const _rgb = $(`#editor-rgb`).first().val(); + const _smartCrop = $(`#editor-smart-crop`).first().prop("checked"); + const _smartCropIndex = $(`#editor-smart-crop-index`).first().val(); + const _smartCropPadding = $(`#editor-smart-crop-padding`).first().val(); + // Setup the edits object + const _edits = {} + _edits.resize = {}; + if (_resize !== "Disabled") { + if (_width !== "") { _edits.resize.width = Number(_width) } + if (_height !== "") { _edits.resize.height = Number(_height) } + _edits.resize.fit = _resize; + } + if (_fillColor !== "") { _edits.resize.background = hexToRgbA(_fillColor, 1) } + if (_backgroundColor !== "") { _edits.flatten = { background: hexToRgbA(_backgroundColor, undefined) }} + if (_grayscale) { _edits.grayscale = _grayscale } + if (_flip) { _edits.flip = _flip } + if (_flop) { _edits.flop = _flop } + if (_negative) { _edits.negate = _negative } + if (_flatten) { _edits.flatten = _flatten } + if (_normalize) { _edits.normalise = _normalize } + if (_rgb !== "") { + const input = _rgb.replace(/\s+/g, ''); + const arr = input.split(','); + const rgb = { r: Number(arr[0]), g: Number(arr[1]), b: Number(arr[2]) }; + _edits.tint = rgb + } + if (_smartCrop) { + _edits.smartCrop = {}; + if (_smartCropIndex !== "") { _edits.smartCrop.faceIndex = Number(_smartCropIndex) } + if (_smartCropPadding !== "") { _edits.smartCrop.padding = Number(_smartCropPadding) } + } + if (Object.keys(_edits.resize).length === 0) { delete _edits.resize }; + // Gather the bucket and key names + const bucketName = $(`#img-original`).first().attr(`data-bucket`); + const keyName = $(`#img-original`).first().attr(`data-key`); + // Set up the request body + const request = { + bucket: bucketName, + key: keyName, + edits: _edits + } + if (Object.keys(request.edits).length === 0) { delete request.edits }; + console.log(request); + // Setup encoded request + const str = JSON.stringify(request); + const enc = btoa(str); + // Fill the preview image + $(`#img-preview`).attr(`src`, `${appVariables.apiEndpoint}/${enc}`); + // Fill the request body field + $(`#preview-request-body`).html(JSON.stringify(request, undefined, 2)); + // Fill the encoded URL field + $(`#preview-encoded-url`).val(`${appVariables.apiEndpoint}/${enc}`); +} + +function hexToRgbA(hex, _alpha) { + var c; + if(/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)){ + c= hex.substring(1).split(''); + if(c.length== 3){ + c= [c[0], c[0], c[1], c[1], c[2], c[2]]; + } + c= '0x'+c.join(''); + return { r: ((c>>16)&255), g: ((c>>8)&255), b: (c&255), alpha: Number(_alpha)}; + } + throw new Error('Bad Hex'); +} + +function resetEdits() { + $('.form-control').val(''); + document.getElementById('editor-resize-mode').selectedIndex = 0; + $(".form-check-input").prop('checked', false); +} \ No newline at end of file diff --git a/source/image-handler/image-handler.js b/source/image-handler/image-handler.js new file mode 100644 index 000000000..df90c70b8 --- /dev/null +++ b/source/image-handler/image-handler.js @@ -0,0 +1,241 @@ +/********************************************************************************************************************* + * Copyright 2020 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const AWS = require('aws-sdk'); +const sharp = require('sharp'); + +class ImageHandler { + + /** + * Main method for processing image requests and outputting modified images. + * @param {ImageRequest} request - An ImageRequest object. + */ + async process(request) { + const originalImage = request.originalImage; + const edits = request.edits; + if (edits !== undefined) { + const modifiedImage = await this.applyEdits(originalImage, edits); + if (request.outputFormat !== undefined) { + modifiedImage.toFormat(request.outputFormat); + } + const bufferImage = await modifiedImage.toBuffer(); + return bufferImage.toString('base64'); + } else { + return originalImage.toString('base64'); + } + } + + /** + * Applies image modifications to the original image based on edits + * specified in the ImageRequest. + * @param {Buffer} originalImage - The original image. + * @param {object} edits - The edits to be made to the original image. + */ + async applyEdits(originalImage, edits) { + if (edits.resize === undefined) { + edits.resize = {}; + edits.resize.fit = 'inside'; + } + + const image = sharp(originalImage, { failOnError: false }); + const metadata = await image.metadata(); + + // Apply the image edits + for (const editKey in edits) { + const value = edits[editKey]; + if (editKey === 'overlayWith') { + 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 !== undefined) { + let left = options.left; + if (isNaN(left) && 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; + } + } + 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 (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; + } + } + isNaN(top) ? delete options.top : options.top = top; + } + } + + const params = [{ ...options, input: overlay }]; + image.composite(params); + } else if (editKey === 'smartCrop') { + const options = value; + const imageBuffer = await image.toBuffer(); + const boundingBox = await this.getBoundingBox(imageBuffer, options.faceIndex); + const cropArea = this.getCropArea(boundingBox, options, metadata); + try { + image.extract(cropArea) + } catch (err) { + throw ({ + status: 400, + code: 'SmartCrop::PaddingOutOfBounds', + message: 'The padding value you provided exceeds the boundaries of the original image. Please try choosing a smaller value or applying padding via Sharp for greater specificity.' + }); + } + } else { + image[editKey](value); + } + } + // Return the modified image + return image; + } + + /** + * Gets an image to be used as an overlay to the primary image from an + * Amazon S3 bucket. + * @param {string} bucket - The name of the bucket containing the overlay. + * @param {string} key - The object keyname corresponding to the overlay. + * @param {number} wRatio - The width rate of the overlay image. + * @param {number} hRatio - The height rate of the overlay image. + * @param {number} alpha - The transparency alpha to the overlay. + * @param {object} sourceImageMetadata - The metadata of the source image. + */ + async getOverlayImage(bucket, key, wRatio, hRatio, alpha, sourceImageMetadata) { + const s3 = new AWS.S3(); + const params = { Bucket: bucket, Key: key }; + try { + 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 convertedImage; + } catch (err) { + throw { + status: err.statusCode ? err.statusCode : 500, + code: err.code, + message: err.message + }; + } + } + + /** + * Calculates the crop area for a smart-cropped image based on the bounding + * box data returned by Amazon Rekognition, as well as padding options and + * the image metadata. + * @param {Object} boundingBox - The boudning box of the detected face. + * @param {Object} options - Set of options for smart cropping. + * @param {Object} metadata - Sharp image metadata. + */ + getCropArea(boundingBox, options, metadata) { + const padding = (options.padding !== undefined) ? parseFloat(options.padding) : 0; + // Calculate the smart crop area + const cropArea = { + left : parseInt((boundingBox.Left*metadata.width)-padding), + top : parseInt((boundingBox.Top*metadata.height)-padding), + width : parseInt((boundingBox.Width*metadata.width)+(padding*2)), + height : parseInt((boundingBox.Height*metadata.height)+(padding*2)), + } + // Return the crop area + return cropArea; + } + + /** + * Gets the bounding box of the specified face index within an image, if specified. + * @param {Sharp} imageBuffer - The original image. + * @param {Integer} faceIndex - The zero-based face index value, moving from 0 and up as + * confidence decreases for detected faces within the image. + */ + async getBoundingBox(imageBuffer, faceIndex) { + const rekognition = new AWS.Rekognition(); + const params = { Image: { Bytes: imageBuffer }}; + const faceIdx = (faceIndex !== undefined) ? faceIndex : 0; + try { + const response = await rekognition.detectFaces(params).promise(); + return response.FaceDetails[faceIdx].BoundingBox; + } catch (err) { + console.log(err); + if (err.message === "Cannot read property 'BoundingBox' of undefined") { + throw { + status: 400, + code: 'SmartCrop::FaceIndexOutOfRange', + message: 'You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range.' + }; + } else { + throw { + status: 500, + code: err.code, + message: err.message + }; + } + } + } +} + +// Exports +module.exports = ImageHandler; diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js new file mode 100644 index 000000000..154b92ced --- /dev/null +++ b/source/image-handler/image-request.js @@ -0,0 +1,303 @@ +/********************************************************************************************************************* + * Copyright 2020 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const ThumborMapping = require('./thumbor-mapping'); + +class ImageRequest { + + /** + * Initializer function for creating a new image request, used by the image + * handler to perform image modifications. + * @param {object} event - Lambda request body. + */ + async setup(event) { + try { + this.requestType = this.parseRequestType(event); + 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); + + /* 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 this; + } catch (err) { + console.error(err); + throw err; + } + } + + /** + * Gets the original image from an Amazon S3 bucket. + * @param {string} bucket - The name of the bucket containing the image. + * @param {string} key - The key name corresponding to the image. + * @return {Promise} - The original image or an error. + */ + async getOriginalImage(bucket, key) { + const S3 = require('aws-sdk/clients/s3'); + const s3 = new S3(); + const imageLocation = { Bucket: bucket, Key: key }; + try { + 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 originalImage.Body; + } catch(err) { + throw { + status: ('NoSuchKey' === err.code) ? 404 : 500, + code: err.code, + message: err.message + }; + } + } + + /** + * Parses the name of the appropriate Amazon S3 bucket to source the + * original image from. + * @param {string} event - Lambda request body. + * @param {string} requestType - Image handler request type. + */ + parseImageBucket(event, requestType) { + if (requestType === "Default") { + // Decode the image request + const decoded = this.decodeRequest(event); + if (decoded.bucket !== undefined) { + // Check the provided bucket against the whitelist + const sourceBuckets = this.getAllowedSourceBuckets(); + if (sourceBuckets.includes(decoded.bucket) || decoded.bucket.match(new RegExp('^' + sourceBuckets[0] + '$'))) { + return decoded.bucket; + } else { + throw ({ + status: 403, + code: 'ImageBucket::CannotAccessBucket', + message: 'The bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.' + }); + } + } else { + // Try to use the default image source bucket env var + const sourceBuckets = this.getAllowedSourceBuckets(); + return sourceBuckets[0]; + } + } else if (requestType === "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', + message: 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.' + }); + } + } + + /** + * Parses the edits to be made to the original image. + * @param {string} event - Lambda request body. + * @param {string} requestType - Image handler request type. + */ + parseImageEdits(event, requestType) { + if (requestType === "Default") { + const decoded = this.decodeRequest(event); + return decoded.edits; + } else if (requestType === "Thumbor") { + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + return thumborMapping.edits; + } else if (requestType === "Custom") { + const thumborMapping = new ThumborMapping(); + const parsedPath = thumborMapping.parseCustomPath(event.path); + thumborMapping.process(parsedPath); + return thumborMapping.edits; + } else { + throw ({ + status: 400, + 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.' + }); + } + } + + /** + * Parses the name of the appropriate Amazon S3 key corresponding to the + * original image. + * @param {String} event - Lambda request body. + * @param {String} requestType - Type, either "Default", "Thumbor", or "Custom". + */ + parseImageKey(event, requestType) { + if (requestType === "Default") { + // Decode the image request and return the image key + const decoded = this.decodeRequest(event); + return decoded.key; + } + + 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.' + }); + } + + /** + * Determines how to handle the request being made based on the URL path + * prefix to the image request. Categorizes a request as either "image" + * (uses the Sharp library), "thumbor" (uses Thumbor mapping), or "custom" + * (uses the rewrite function). + * @param {object} event - Lambda request body. + */ + parseRequestType(event) { + 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)$/i); + const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg)/i); + const definedEnvironmentVariables = ( + (process.env.REWRITE_MATCH_PATTERN !== "") && + (process.env.REWRITE_SUBSTITUTION !== "") && + (process.env.REWRITE_MATCH_PATTERN !== undefined) && + (process.env.REWRITE_SUBSTITUTION !== undefined) + ); + // ---- + if (matchDefault.test(path)) { // use sharp + return 'Default'; + } else if (matchCustom.test(path) && definedEnvironmentVariables) { // use rewrite function then thumbor mappings + return 'Custom'; + } else if (matchThumbor.test(path)) { // use thumbor mappings + return 'Thumbor'; + } else { + throw { + status: 400, + 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) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.' + }; + } + } + + /** + * Decodes the base64-encoded image request path associated with default + * image requests. Provides error handling for invalid or undefined path values. + * @param {object} event - The proxied request object. + */ + decodeRequest(event) { + const path = event["path"]; + if (path !== undefined) { + const encoded = path.charAt(0) === '/' ? path.slice(1) : path; + const toBuffer = Buffer.from(encoded, 'base64'); + try { + // To support European characters, 'ascii' was removed. + return JSON.parse(toBuffer.toString()); + } catch (e) { + throw ({ + status: 400, + code: 'DecodeRequest::CannotDecodeRequest', + message: 'The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance.' + }); + } + } else { + throw ({ + status: 400, + code: 'DecodeRequest::CannotReadPath', + message: 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.' + }); + } + } + + /** + * Returns a formatted image source bucket whitelist as specified in the + * SOURCE_BUCKETS environment variable of the image handler Lambda + * function. Provides error handling for missing/invalid values. + */ + getAllowedSourceBuckets() { + const sourceBuckets = process.env.SOURCE_BUCKETS; + if (sourceBuckets === undefined) { + throw ({ + status: 400, + 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.' + }); + } else { + const formatted = sourceBuckets.replace(/\s+/g, ''); + const buckets = formatted.split(','); + 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 === 'Yes' && 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 +module.exports = ImageRequest; \ No newline at end of file diff --git a/source/image-handler/test/test-image-handler.js b/source/image-handler/test/test-image-handler.js new file mode 100644 index 000000000..2085ff757 --- /dev/null +++ b/source/image-handler/test/test-image-handler.js @@ -0,0 +1,591 @@ +/********************************************************************************************************************* + * Copyright 2020 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const ImageHandler = require('../image-handler'); +const sharp = require('sharp'); +let assert = require('assert'); + +// ---------------------------------------------------------------------------- +// [async] process() +// ---------------------------------------------------------------------------- +describe('process()', function() { + describe('001/default', function() { + it(`Should pass if the output image is different from the input image with edits applied`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // ---- + const request = { + requestType: "default", + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { + grayscale: true, + flip: true + }, + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + } + // Act + const imageHandler = new ImageHandler(); + const result = await imageHandler.process(request); + // Assert + assert.deepEqual((request.originalImage !== result), true); + }); + }); + describe('002/withToFormat', function() { + it(`Should pass if the output image is in a different format than the original image`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // ---- + const request = { + requestType: "default", + bucket: "sample-bucket", + key: "sample-image-001.jpg", + outputFormat: "png", + edits: { + grayscale: true, + flip: true + }, + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + } + // Act + const imageHandler = new ImageHandler(); + const result = await imageHandler.process(request); + // Assert + assert.deepEqual((request.originalImage !== result), true); + }); + }); + describe('003/noEditsSpecified', function() { + it(`Should pass if no edits are specified and the original image is returned`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // ---- + const request = { + requestType: "default", + bucket: "sample-bucket", + key: "sample-image-001.jpg", + originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + } + // Act + const imageHandler = new ImageHandler(); + const result = await imageHandler.process(request); + // Assert + assert.deepEqual(result, 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); + }); + }); +}); + +// ---------------------------------------------------------------------------- +// [async] applyEdits() +// ---------------------------------------------------------------------------- +describe('applyEdits()', function() { + describe('001/standardEdits', function() { + it(`Should pass if a series of standard edits are provided to the + function`, async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + grayscale: true, + flip: true + } + // Act + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + // Assert + const expectedResult1 = (result.options.greyscale); + const expectedResult2 = (result.options.flip); + const combinedResults = (expectedResult1 && expectedResult2); + assert.deepEqual(combinedResults, true); + }); + }); + describe('002/overlay', function() { + it(`Should pass if an edit with the overlayWith keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + overlayWith: { + bucket: 'aaa', + key: 'bbb' + } + } + // Assert + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + assert.deepEqual(result.options.input.buffer, originalImage); + }); + }); + describe('003/overlay/options/smallerThanZero', function() { + it(`Should pass if an edit with the overlayWith keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const edits = { + overlayWith: { + bucket: 'aaa', + key: 'bbb', + options: { + left: '-1', + top: '-1' + } + } + } + // Assert + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + assert.deepEqual(result.options.input.buffer, originalImage); + }); + }); + describe('004/overlay/options/greaterThanZero', function() { + it(`Should pass if an edit with the overlayWith keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const edits = { + overlayWith: { + bucket: 'aaa', + key: 'bbb', + options: { + left: '1', + top: '1' + } + } + } + // Assert + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + assert.deepEqual(result.options.input.buffer, originalImage); + }); + }); + describe('005/overlay/options/percentage/greaterThanZero', function() { + it(`Should pass if an edit with the overlayWith keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const edits = { + overlayWith: { + bucket: 'aaa', + key: 'bbb', + options: { + left: '50p', + top: '50p' + } + } + } + // Assert + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + assert.deepEqual(result.options.input.buffer, originalImage); + }); + }); + describe('006/overlay/options/percentage/smallerThanZero', function() { + it(`Should pass if an edit with the overlayWith keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon S3 stub + const S3 = require('aws-sdk/clients/s3'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const edits = { + overlayWith: { + bucket: 'aaa', + key: 'bbb', + options: { + left: '-50p', + top: '-50p' + } + } + } + // Assert + const imageHandler = new ImageHandler(); + const result = await imageHandler.applyEdits(originalImage, edits); + assert.deepEqual(result.options.input.buffer, originalImage); + }); + }); + describe('007/smartCrop', function() { + it(`Should pass if an edit with the smartCrop keyname is passed to + the function`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon Rekognition stub + const rekognition = require('aws-sdk/clients/rekognition'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + detectFaces.returns({ + promise: () => { return { + FaceDetails: [{ + BoundingBox: { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + } + }] + }} + }) + // Act + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + smartCrop: { + faceIndex: 0, + padding: 0 + } + } + // Assert + const imageHandler = new ImageHandler(); + await imageHandler.applyEdits(originalImage, edits).then((result) => { + const originalImageData = sharp(originalImage); + assert.deepEqual((originalImageData.options.input !== result.options.input), true) + }).catch((err) => { + console.log(err) + }) + }); + }); + describe('008/smartCrop/paddingOutOfBoundsError', function() { + it(`Should pass if an excessive padding value is passed to the + smartCrop filter`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon Rekognition stub + const rekognition = require('aws-sdk/clients/rekognition'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + detectFaces.returns({ + promise: () => { return { + FaceDetails: [{ + BoundingBox: { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + } + }] + }} + }) + // Act + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + smartCrop: { + faceIndex: 0, + padding: 80 + } + } + // Assert + const imageHandler = new ImageHandler(); + await imageHandler.applyEdits(originalImage, edits).then((result) => { + const originalImageData = sharp(originalImage); + assert.deepEqual((originalImageData.options.input !== result.options.input), true) + }).catch((err) => { + console.log(err) + }) + }); + }); + describe('009/smartCrop/boundingBoxError', function() { + it(`Should pass if an excessive faceIndex value is passed to the + smartCrop filter`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon Rekognition stub + const rekognition = require('aws-sdk/clients/rekognition'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + detectFaces.returns({ + promise: () => { return { + FaceDetails: [{ + BoundingBox: { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + } + }] + }} + }) + // Act + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + smartCrop: { + faceIndex: 10, + padding: 0 + } + } + // Assert + const imageHandler = new ImageHandler(); + await imageHandler.applyEdits(originalImage, edits).then((result) => { + const originalImageData = sharp(originalImage); + assert.deepEqual((originalImageData.options.input !== result.options.input), true) + }).catch((err) => { + console.log(err) + }) + }); + }); + describe('010/smartCrop/faceIndexUndefined', function() { + it(`Should pass if a faceIndex value of undefined is passed to the + smartCrop filter`, async function() { + // Arrange + const sinon = require('sinon'); + // ---- Amazon Rekognition stub + const rekognition = require('aws-sdk/clients/rekognition'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + detectFaces.returns({ + promise: () => { return { + FaceDetails: [{ + BoundingBox: { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + } + }] + }} + }) + // Act + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const edits = { + smartCrop: true + } + // Assert + const imageHandler = new ImageHandler(); + await imageHandler.applyEdits(originalImage, edits).then((result) => { + const originalImageData = sharp(originalImage); + assert.deepEqual((originalImageData.options.input !== result.options.input), true) + }).catch((err) => { + console.log(err) + }) + }); + }); +}); + +// ---------------------------------------------------------------------------- +// [async] getOverlayImage() +// ---------------------------------------------------------------------------- +describe('getOverlayImage()', function() { + describe('001/validParameters', function() { + it(`Should pass if the proper bucket name and key are supplied, + simulating an image file that can be retrieved`, async function() { + // Arrange + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ + promise: () => { return { + Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + }} + }) + // Act + const imageHandler = new ImageHandler(); + 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('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsSAAALEgHS3X78AAAADUlEQVQI12P4z8CQCgAEZgFlTg0nBwAAAABJRU5ErkJggg==', 'base64')); + }); + }); + describe('002/imageDoesNotExist', async function() { + it(`Should throw an error if an invalid bucket or key name is provided, + simulating a non-existant overlay image`, 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: 500, + message: 'SimulatedInvalidParameterException' + }) + } + }); + // Act + const imageHandler = new ImageHandler(); + // Assert + imageHandler.getOverlayImage('invalidBucket', 'invalidKey').then((result) => { + assert.equal(typeof result, Error); + }).catch((err) => { + console.log(err) + }) + }); + }); +}); + +// ---------------------------------------------------------------------------- +// [async] getCropArea() +// ---------------------------------------------------------------------------- +describe('getCropArea()', function() { + describe('001/validParameters', function() { + it(`Should pass if the crop area can be calculated using a series of + valid inputs/parameters`, function() { + // Arrange + const boundingBox = { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + }; + const options = { padding: 20 }; + const metadata = { + width: 200, + height: 400 + }; + // Act + const imageHandler = new ImageHandler(); + const result = imageHandler.getCropArea(boundingBox, options, metadata); + // Assert + const expectedResult = { + left: 90, + top: 112, + width: 86, + height: 112 + } + assert.deepEqual(result, expectedResult); + }); + }); +}); + + +// ---------------------------------------------------------------------------- +// [async] getBoundingBox() +// ---------------------------------------------------------------------------- +describe('getBoundingBox()', function() { + describe('001/validParameters', function() { + it(`Should pass if the proper parameters are passed to the function`, + async function() { + // Arrange + const sinon = require('sinon'); + const rekognition = require('aws-sdk/clients/rekognition'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + // ---- + const imageBytes = Buffer.from('TestImageData'); + detectFaces.withArgs({Image: {Bytes: imageBytes}}).returns({ + promise: () => { return { + FaceDetails: [{ + BoundingBox: { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + } + }] + }} + }) + // ---- + const currentImage = imageBytes; + const faceIndex = 0; + // Act + const imageHandler = new ImageHandler(); + const result = await imageHandler.getBoundingBox(currentImage, faceIndex); + // Assert + const expectedResult = { + Height: 0.18, + Left: 0.55, + Top: 0.33, + Width: 0.23 + }; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/errorHandling', function() { + it(`Should simulate an error condition returned by Rekognition`, + async function() { + // Arrange + const rekognition = require('aws-sdk/clients/rekognition'); + const sinon = require('sinon'); + const detectFaces = rekognition.prototype.detectFaces = sinon.stub(); + detectFaces.returns({ + promise: () => { + return Promise.reject({ + code: 500, + message: 'SimulatedError' + }) + } + }) + // ---- + const currentImage = Buffer.from('NotTestImageData'); + const faceIndex = 0; + // Act + const imageHandler = new ImageHandler(); + // Assert + imageHandler.getBoundingBox(currentImage, faceIndex).then((result) => { + assert.equal(typeof result, Error); + }).catch((err) => { + console.log(err) + }) + }); + }); +}); \ No newline at end of file diff --git a/source/image-handler/test/test-image-request.js b/source/image-handler/test/test-image-request.js new file mode 100644 index 000000000..452a1e46e --- /dev/null +++ b/source/image-handler/test/test-image-request.js @@ -0,0 +1,864 @@ +/********************************************************************************************************************* + * Copyright 2020 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const ImageRequest = require('../image-request'); +let assert = require('assert'); + +// ---------------------------------------------------------------------------- +// [async] setup() +// ---------------------------------------------------------------------------- +describe('setup()', function() { + describe('001/defaultImageRequest', function() { + it(`Should pass when a default image request is provided and populate + the ImageRequest object with the proper values`, async function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9' + } + process.env = { + SOURCE_BUCKETS : "validBucket, validBucket2" + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + await imageRequest.setup(event); + const expectedResult = { + requestType: 'Default', + bucket: 'validBucket', + key: 'validKey', + edits: { grayscale: true }, + outputFormat: 'jpeg', + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + ContentType: 'image/jpeg' + } + // Assert + assert.deepEqual(imageRequest, expectedResult); + }); + }); + describe('002/defaultImageRequest/toFormat', function() { + it(`Should pass when a default image request is provided and populate + the ImageRequest object with the proper values`, async function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=', + } + process.env = { + SOURCE_BUCKETS : "validBucket, validBucket2" + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + await imageRequest.setup(event); + const expectedResult = { + requestType: 'Default', + bucket: 'validBucket', + key: 'validKey', + edits: { toFormat: 'png' }, + outputFormat: 'png', + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + ContentType: 'image/png' + } + // Assert + assert.deepEqual(imageRequest, expectedResult); + }); + }); + describe('003/thumborImageRequest', function() { + it(`Should pass when a thumbor image request is provided and populate + the ImageRequest object with the proper values`, async function() { + // Arrange + const event = { + path : "/filters:grayscale()/test-image-001.jpg" + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'allowedBucket001', Key: 'test-image-001.jpg'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + await imageRequest.setup(event); + const expectedResult = { + requestType: 'Thumbor', + bucket: 'allowedBucket001', + key: 'test-image-001.jpg', + edits: { grayscale: true }, + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + ContentType: 'image' + } + // Assert + assert.deepEqual(imageRequest, expectedResult); + }); + }); + describe('004/thumborImageRequest/quality', function() { + it(`Should pass when a thumbor image request is provided and populate + the ImageRequest object with the proper values`, async function() { + // Arrange + const event = { + path : "/filters:format(png)/filters:quality(50)/test-image-001.jpg" + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'allowedBucket001', Key: 'test-image-001.jpg'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + await imageRequest.setup(event); + const expectedResult = { + requestType: 'Thumbor', + bucket: 'allowedBucket001', + key: 'test-image-001.jpg', + edits: { + toFormat: 'png', + png: { quality: 50 } + }, + originalImage: Buffer.from('SampleImageContent\n'), + CacheControl: 'max-age=31536000,public', + outputFormat: 'png', + ContentType: 'image/png' + } + // Assert + assert.deepEqual(imageRequest, expectedResult); + }); + }); + describe('005/customImageRequest', function() { + it(`Should pass when a custom image request is provided and populate + the ImageRequest object with the proper values`, async function() { + // Arrange + const event = { + path : '/filters-rotate(90)/filters-grayscale()/custom-image.jpg' + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002", + REWRITE_MATCH_PATTERN: /(filters-)/gm, + REWRITE_SUBSTITUTION: 'filters:' + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + 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') + }} + }) + // Act + const imageRequest = new ImageRequest(); + await imageRequest.setup(event); + const expectedResult = { + requestType: 'Custom', + bucket: 'allowedBucket001', + key: 'custom-image.jpg', + edits: { + grayscale: true, + rotate: 90 + }, + 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); + }); + }); + describe('006/errorCase', function() { + it(`Should pass when an error is caught`, async function() { + // Assert + const event = { + path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfX0=' + } + // ---- + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + await imageRequest.setup(event); + } catch (error) { + console.log(error); + assert.deepEqual(error.code, 'ImageBucket::CannotAccessBucket'); + } + }); + }); +}); +// ---------------------------------------------------------------------------- +// getOriginalImage() +// ---------------------------------------------------------------------------- +describe('getOriginalImage()', function() { + describe('001/imageExists', function() { + it(`Should pass if the proper bucket name and key are supplied, + simulating an image file that can be retrieved`, async function() { + // Arrange + const S3 = require('aws-sdk/clients/s3'); + const sinon = require('sinon'); + const getObject = S3.prototype.getObject = sinon.stub(); + getObject.withArgs({Bucket: 'validBucket', Key: 'validKey'}).returns({ + promise: () => { return { + Body: Buffer.from('SampleImageContent\n') + }} + }) + // Act + const imageRequest = new ImageRequest(); + const result = await imageRequest.getOriginalImage('validBucket', 'validKey'); + // Assert + assert.deepEqual(result, Buffer.from('SampleImageContent\n')); + }); + }); + describe('002/imageDoesNotExist', async function() { + it(`Should throw an error if an invalid bucket or key name is provided, + simulating a non-existant original image`, 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: 'NoSuchKey', + message: 'SimulatedException' + }) + } + }); + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + await imageRequest.getOriginalImage('invalidBucket', 'invalidKey'); + } catch (error) { + assert.equal(error.status, 404); + } + }); + }); + 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 + try { + await imageRequest.getOriginalImage('invalidBucket', 'invalidKey'); + } catch (error) { + assert.equal(error.status, 500); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// parseImageBucket() +// ---------------------------------------------------------------------------- +describe('parseImageBucket()', function() { + describe('001/defaultRequestType/bucketSpecifiedInRequest/allowed', function() { + it(`Should pass if the bucket name is provided in the image request + and has been whitelisted in SOURCE_BUCKETS`, function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJhbGxvd2VkQnVja2V0MDAxIiwia2V5Ijoic2FtcGxlSW1hZ2VLZXkwMDEuanBnIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjoidHJ1ZSJ9fQ==' + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageBucket(event, 'Default'); + // Assert + const expectedResult = 'allowedBucket001'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/defaultRequestType/bucketSpecifiedInRequest/notAllowed', function() { + it(`Should throw an error if the bucket name is provided in the image request + but has not been whitelisted in SOURCE_BUCKETS`, function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJhbGxvd2VkQnVja2V0MDAxIiwia2V5Ijoic2FtcGxlSW1hZ2VLZXkwMDEuanBnIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjoidHJ1ZSJ9fQ==' + } + process.env = { + SOURCE_BUCKETS : "allowedBucket003, allowedBucket004" + } + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.parseImageBucket(event, 'Default'); + } catch (error) { + assert.deepEqual(error, { + status: 403, + code: 'ImageBucket::CannotAccessBucket', + message: 'The bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.' + }); + } + }); + }); + describe('003/defaultRequestType/bucketNotSpecifiedInRequest', function() { + it(`Should pass if the image request does not contain a source bucket + but SOURCE_BUCKETS contains at least one bucket that can be + used as a default`, function() { + // Arrange + const event = { + path : '/eyJrZXkiOiJzYW1wbGVJbWFnZUtleTAwMS5qcGciLCJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIn19==' + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageBucket(event, 'Default'); + // Assert + const expectedResult = 'allowedBucket001'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('004/thumborRequestType', function() { + it(`Should pass if there is at least one SOURCE_BUCKET specified that can + be used as the default for Thumbor requests`, function() { + // Arrange + const event = { + path : "/filters:grayscale()/test-image-001.jpg" + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageBucket(event, 'Thumbor'); + // Assert + const expectedResult = 'allowedBucket001'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('005/customRequestType', function() { + it(`Should pass if there is at least one SOURCE_BUCKET specified that can + be used as the default for Custom requests`, function() { + // Arrange + const event = { + path : "/filters:grayscale()/test-image-001.jpg" + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageBucket(event, 'Custom'); + // Assert + const expectedResult = 'allowedBucket001'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('006/invalidRequestType', function() { + it(`Should pass if there is at least one SOURCE_BUCKET specified that can + be used as the default for Custom requests`, function() { + // Arrange + const event = { + path : "/filters:grayscale()/test-image-001.jpg" + } + process.env = { + SOURCE_BUCKETS : "allowedBucket001, allowedBucket002" + } + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.parseImageBucket(event, undefined); + } catch (error) { + assert.deepEqual(error, { + 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.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// parseImageEdits() +// ---------------------------------------------------------------------------- +describe('parseImageEdits()', function() { + describe('001/defaultRequestType', function() { + it(`Should pass if the proper result is returned for a sample base64- + encoded image request`, function() { + // Arrange + const event = { + path : '/eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0=' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageEdits(event, 'Default'); + // Assert + const expectedResult = { + grayscale: 'true', + rotate: 90, + flip: 'true' + } + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/thumborRequestType', function() { + it(`Should pass if the proper result is returned for a sample thumbor- + type image request`, function() { + // Arrange + const event = { + path : '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageEdits(event, 'Thumbor'); + // Assert + const expectedResult = { + rotate: 90, + grayscale: true + } + assert.deepEqual(result, expectedResult); + }); + }); + describe('003/customRequestType', function() { + it(`Should pass if the proper result is returned for a sample custom- + type image request`, function() { + // Arrange + const event = { + path : '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg' + } + process.env.REWRITE_MATCH_PATTERN = /(filters-)/gm; + process.env.REWRITE_SUBSTITUTION = 'filters:'; + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageEdits(event, 'Custom'); + // Assert + const expectedResult = { + rotate: 90, + grayscale: true + } + assert.deepEqual(result, expectedResult); + }); + }); + describe('004/customRequestType', function() { + it(`Should throw an error if a requestType is not specified and/or the image edits + cannot be parsed`, function() { + // Arrange + const event = { + path : '/filters:rotate(90)/filters:grayscale()/other-image.jpg' + } + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.parseImageEdits(event, undefined); + } catch (error) { + assert.deepEqual(error, { + status: 400, + 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.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// parseImageKey() +// ---------------------------------------------------------------------------- +describe('parseImageKey()', function() { + describe('001/defaultRequestType', function() { + it(`Should pass if an image key value is provided in the default + request format`, function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5Ijoic2FtcGxlLWltYWdlLTAwMS5qcGcifQ==' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageKey(event, 'Default'); + // Assert + const expectedResult = 'sample-image-001.jpg'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/defaultRequestType/withSlashRequest', function () { + it(`should read image requests with base64 encoding having slash`, function () { + const event = { + path : '/eyJidWNrZXQiOiJlbGFzdGljYmVhbnN0YWxrLXVzLWVhc3QtMi0wNjY3ODQ4ODU1MTgiLCJrZXkiOiJlbnYtcHJvZC9nY2MvbGFuZGluZ3BhZ2UvMV81N19TbGltTl9MaWZ0LUNvcnNldC1Gb3ItTWVuLVNOQVAvYXR0YWNobWVudHMvZmZjMWYxNjAtYmQzOC00MWU4LThiYWQtZTNhMTljYzYxZGQzX1/Ys9mE2YrZhSDZhNmK2YHYqiAoMikuanBnIiwiZWRpdHMiOnsicmVzaXplIjp7IndpZHRoIjo0ODAsImZpdCI6ImNvdmVyIn19fQ==' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageKey(event, 'Default'); + // Assert + const expectedResult = 'env-prod/gcc/landingpage/1_57_SlimN_Lift-Corset-For-Men-SNAP/attachments/ffc1f160-bd38-41e8-8bad-e3a19cc61dd3__سليم ليفت (2).jpg'; + assert.deepEqual(result, expectedResult); + + }) + }); + describe('003/thumborRequestType', function() { + it(`Should pass if an image key value is provided in the thumbor + request format`, function() { + // Arrange + const event = { + path : '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageKey(event, 'Thumbor'); + // Assert + const expectedResult = 'thumbor-image.jpg'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('004/customRequestType', function() { + it(`Should pass if an image key value is provided in the custom + request format`, function() { + // Arrange + const event = { + path : '/filters-rotate(90)/filters-grayscale()/custom-image.jpg' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseImageKey(event, 'Custom'); + // Assert + const expectedResult = 'custom-image.jpg'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('005/elseCondition', function() { + it(`Should throw an error if an unrecognized requestType is passed into the + function as a parameter`, function() { + // Arrange + const event = { + path : '/filters:rotate(90)/filters:grayscale()/other-image.jpg' + } + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.parseImageKey(event, undefined); + } catch (error) { + assert.deepEqual(error, { + 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.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// parseRequestType() +// ---------------------------------------------------------------------------- +describe('parseRequestType()', function() { + describe('001/defaultRequestType', function() { + it(`Should pass if the method detects a default request`, function() { + // Arrange + const event = { + path: '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5IjoibXktc2FtcGxlLWtleSIsImVkaXRzIjp7ImdyYXlzY2FsZSI6dHJ1ZX19' + } + process.env = {}; + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseRequestType(event); + // Assert + const expectedResult = 'Default'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/thumborRequestType', function() { + it(`Should pass if the method detects a thumbor request`, function() { + // Arrange + const event = { + path: '/unsafe/filters:brightness(10):contrast(30)/https://upload.wikimedia.org/wikipedia/commons/thumb/7/79/Coffee_berries_1.jpg/1200px-Coffee_berries_1.jpg' + } + process.env = {}; + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseRequestType(event); + // Assert + const expectedResult = 'Thumbor'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('003/customRequestType', function() { + it(`Should pass if the method detects a custom request`, function() { + // Arrange + const event = { + path: '/additionalImageRequestParameters/image.jpg' + } + process.env = { + REWRITE_MATCH_PATTERN: 'matchPattern', + REWRITE_SUBSTITUTION: 'substitutionString' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.parseRequestType(event); + // Assert + const expectedResult = 'Custom'; + assert.deepEqual(result, expectedResult); + }); + }); + describe('004/elseCondition', function() { + it(`Should throw an error if the method cannot determine the request + type based on the three groups given`, function() { + // Arrange + const event = { + path : '12x12e24d234r2ewxsad123d34r' + } + process.env = {}; + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.parseRequestType(event); + } catch (error) { + assert.deepEqual(error, { + status: 400, + 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) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// decodeRequest() +// ---------------------------------------------------------------------------- +describe('decodeRequest()', function() { + describe('001/validRequestPathSpecified', function() { + it(`Should pass if a valid base64-encoded path has been specified`, + function() { + // Arrange + const event = { + path : '/eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.decodeRequest(event); + // Assert + const expectedResult = { + bucket: 'bucket-name-here', + key: 'key-name-here' + }; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/invalidRequestPathSpecified', function() { + it(`Should throw an error if a valid base64-encoded path has not been specified`, + function() { + // Arrange + const event = { + path : '/someNonBase64EncodedContentHere' + } + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.decodeRequest(event); + } catch (error) { + assert.deepEqual(error, { + status: 400, + code: 'DecodeRequest::CannotDecodeRequest', + message: 'The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance.' + }); + } + }); + }); + describe('003/noPathSpecified', function() { + it(`Should throw an error if no path is specified at all`, + function() { + // Arrange + const event = {} + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.decodeRequest(event); + } catch (error) { + assert.deepEqual(error, { + status: 400, + code: 'DecodeRequest::CannotReadPath', + message: 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// getAllowedSourceBuckets() +// ---------------------------------------------------------------------------- +describe('getAllowedSourceBuckets()', function() { + describe('001/sourceBucketsSpecified', function() { + it(`Should pass if the SOURCE_BUCKETS environment variable is not empty + and contains valid inputs`, function() { + // Arrange + process.env = { + SOURCE_BUCKETS: 'allowedBucket001, allowedBucket002' + } + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.getAllowedSourceBuckets(); + // Assert + const expectedResult = ['allowedBucket001', 'allowedBucket002']; + assert.deepEqual(result, expectedResult); + }); + }); + describe('002/noSourceBucketsSpecified', function() { + it(`Should throw an error if the SOURCE_BUCKETS environment variable is + empty or does not contain valid values`, function() { + // Arrange + process.env = {}; + // Act + const imageRequest = new ImageRequest(); + // Assert + try { + imageRequest.getAllowedSourceBuckets(); + } catch (error) { + assert.deepEqual(error, { + status: 400, + 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.' + }); + } + }); + }); +}); + +// ---------------------------------------------------------------------------- +// 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: 'Yes' + }; + 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(); + const 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: 'Yes' + }; + 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(); + const 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: 'No' + }; + 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(); + const 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(); + const 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 new file mode 100644 index 000000000..57d8d5e53 --- /dev/null +++ b/source/image-handler/test/test-thumbor-mapping.js @@ -0,0 +1,967 @@ +/********************************************************************************************************************* + * Copyright 2020 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const ThumborMapping = require('../thumbor-mapping'); +let assert = require('assert'); + +// ---------------------------------------------------------------------------- +// process() +// ---------------------------------------------------------------------------- +describe('process()', function() { + describe('001/thumborRequest', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/fit-in/200x300/filters:grayscale()/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: 200, + height: 300, + fit: 'inside' + }, + grayscale: true + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('002/resize/fit-in', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/fit-in/400x300/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: 300, + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('003/resize/fit-in/noResizeValues', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/fit-in/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { fit: 'inside' } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('004/resize/not-fit-in', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/400x300/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: 300 + } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('005/resize/widthIsZero', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/0x300/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: null, + height: 300, + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('006/resize/heightIsZero', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/400x0/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: null, + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); + describe('007/resize/widthAndHeightAreZero', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + // Arrange + const event = { + path : "/0x0/test-image-001.jpg" + } + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.process(event); + // Assert + const expectedResult = { + edits: { + resize: { + width: null, + height: null, + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping.edits, expectedResult.edits); + }); + }); +}); + +// ---------------------------------------------------------------------------- +// parseCustomPath() +// ---------------------------------------------------------------------------- +describe('parseCustomPath()', function() { + describe('001/validPath', function() { + it(`Should pass if the proper edit translations are applied and in the + correct order`, function() { + const event = { + path : '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg' + } + process.env.REWRITE_MATCH_PATTERN = /(filters-)/gm; + process.env.REWRITE_SUBSTITUTION = 'filters:'; + // Act + const thumborMapping = new ThumborMapping(); + const result = thumborMapping.parseCustomPath(event.path); + // Assert + const expectedResult = '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg'; + assert.deepEqual(result.path, expectedResult); + }); + }); + describe('002/undefinedEnvironmentVariables', function() { + it(`Should throw an error if the environment variables are left undefined`, function() { + const event = { + path : '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg' + } + process.env.REWRITE_MATCH_PATTERN = undefined; + process.env.REWRITE_SUBSTITUTION = undefined; + // Act + const thumborMapping = new ThumborMapping(); + // Assert + assert.throws(function() { + thumborMapping.parseCustomPath(event.path); + }, Error, 'ThumborMapping::ParseCustomPath::ParsingError'); + }); + }); + describe('003/undefinedPath', function() { + it(`Should throw an error if the path is not defined`, function() { + const event = {}; + process.env.REWRITE_MATCH_PATTERN = /(filters-)/gm; + process.env.REWRITE_SUBSTITUTION = 'filters:'; + // Act + const thumborMapping = new ThumborMapping(); + // Assert + assert.throws(function() { + thumborMapping.parseCustomPath(event.path); + }, Error, 'ThumborMapping::ParseCustomPath::ParsingError'); + }); + }); + describe('004/undefinedAll', function() { + it(`Should throw an error if the path is not defined`, function() { + const event = {}; + process.env.REWRITE_MATCH_PATTERN = undefined; + process.env.REWRITE_SUBSTITUTION = undefined; + // Act + const thumborMapping = new ThumborMapping(); + // Assert + assert.throws(function() { + thumborMapping.parseCustomPath(event.path); + }, Error, 'ThumborMapping::ParseCustomPath::ParsingError'); + }); + }); +}); + +// ---------------------------------------------------------------------------- +// mapFilter() +// ---------------------------------------------------------------------------- +describe('mapFilter()', function() { + describe('001/autojpg', function() { + it(`Should pass if the filter is successfully converted from + Thumbor:autojpg()`, function() { + // Arrange + const edit = 'filters:autojpg()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { toFormat: 'jpeg' } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('002/background_color', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:background_color()`, function() { + // Arrange + const edit = 'filters:background_color(ffff)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { flatten: { background: {r: 255, g: 255, b: 255}}} + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('003/blur/singleParameter', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:blur()`, function() { + // Arrange + const edit = 'filters:blur(60)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { blur: 30 } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('004/blur/doubleParameter', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:blur()`, function() { + // Arrange + const edit = 'filters:blur(60, 2)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { blur: 2 } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('005/convolution', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:convolution()`, function() { + // Arrange + const edit = 'filters:convolution(1;2;1;2;4;2;1;2;1,3,true)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { convolve: { + width: 3, + height: 3, + kernel: [1,2,1,2,4,2,1,2,1] + }} + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('006/equalize', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:equalize()`, function() { + // Arrange + const edit = 'filters:equalize()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { normalize: 'true' } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('007/fill/resizeUndefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:fill()`, function() { + // Arrange + const edit = 'filters:fill(fff)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' }} + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + + describe('008/fill/resizeDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:fill()`, function() { + // Arrange + const edit = 'filters:fill(fff)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = {}; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' }} + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('009/format/supportedFileType', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:format()`, function() { + // Arrange + const edit = 'filters:format(png)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { toFormat: 'png' } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('010/format/unsupportedFileType', function() { + it(`Should return undefined if an accepted file format is not specified` + , function() { + // Arrange + const edit = 'filters:format(test)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('011/no_upscale/resizeUndefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:no_upscale()`, function() { + // Arrange + const edit = 'filters:no_upscale()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + withoutEnlargement: true + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('012/no_upscale/resizeDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:no_upscale()`, function() { + // Arrange + const edit = 'filters:no_upscale()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = { + height: 400, + width: 300 + }; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + height: 400, + width: 300, + withoutEnlargement: true + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('013/proportion/resizeDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:proportion()`, function() { + // Arrange + const edit = 'filters:proportion(0.3)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits = { + resize: { + width: 200, + height: 200 + } + }; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + height: 60, + width: 60 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('014/proportion/resizeUndefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:resize()`, function() { + // Arrange + const edit = 'filters:proportion(0.3)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const actualResult = thumborMapping.edits.resize !== undefined; + const expectedResult = true; + assert.deepEqual(actualResult, expectedResult); + }); + }); + describe('015/quality/jpg', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:quality()`, function() { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + jpeg: { + quality: 50 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('016/quality/png', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:quality()`, function() { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = 'png'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + png: { + quality: 50 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('017/quality/webp', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:quality()`, function() { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = 'webp'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + webp: { + quality: 50 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('018/quality/tiff', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:quality()`, function() { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = 'tiff'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + tiff: { + quality: 50 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + 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 + const edit = 'filters:quality(50)'; + const filetype = 'xml'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('021/rgb', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:rgb()`, function() { + // Arrange + const edit = 'filters:rgb(10, 10, 10)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + tint: { + r: 25.5, + g: 25.5, + b: 25.5 + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('022/rotate', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:rotate()`, function() { + // Arrange + const edit = 'filters:rotate(75)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + rotate: 75 + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('023/sharpen', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:sharpen()`, function() { + // Arrange + const edit = 'filters:sharpen(75, 5)'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + sharpen: 3.5 + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('024/stretch/default', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:stretch()`, function() { + // Arrange + const edit = 'filters:stretch()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { fit: 'fill' } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('025/stretch/resizeDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:stretch()`, function() { + // Arrange + const edit = 'filters:stretch()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = { + width: 300, + height: 400 + }; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + width: 300, + height: 400, + fit: 'fill' + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('026/stretch/fit-in', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:stretch()`, function() { + // Arrange + const edit = 'filters:stretch()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = { + fit: 'inside' + }; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { fit: 'inside' } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('027/stretch/fit-in/resizeDefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:stretch()`, function() { + // Arrange + const edit = 'filters:stretch()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = { + width: 400, + height: 300, + fit: 'inside' + }; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: 300, + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('028/strip_exif', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:strip_exif()`, function() { + // Arrange + const edit = 'filters:strip_exif()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + rotate: 0 + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('029/strip_icc', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:strip_icc()`, function() { + // Arrange + const edit = 'filters:strip_icc()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + rotate: 0 + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('030/upscale', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:upscale()`, function() { + // Arrange + const edit = 'filters:upscale()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + describe('031/upscale/resizeNotUndefined', function() { + it(`Should pass if the filter is successfully translated from + Thumbor:upscale()`, function() { + // Arrange + const edit = 'filters:upscale()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.edits.resize = {}; + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { + edits: { + resize: { + fit: 'inside' + } + } + }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); + 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 + const edit = 'filters:notSupportedFilter()'; + const filetype = 'jpg'; + // Act + const thumborMapping = new ThumborMapping(); + thumborMapping.mapFilter(edit, filetype); + // Assert + const expectedResult = { edits: {} }; + assert.deepEqual(thumborMapping, expectedResult); + }); + }); +}) \ No newline at end of file diff --git a/source/image-handler/thumbor-mapping.js b/source/image-handler/thumbor-mapping.js new file mode 100644 index 000000000..a53861f1d --- /dev/null +++ b/source/image-handler/thumbor-mapping.js @@ -0,0 +1,263 @@ +/********************************************************************************************************************* + * 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. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + +const Color = require('color'); +const ColorName = require('color-name'); + +class ThumborMapping { + + // Constructor + constructor() { + this.edits = {}; + } + + /** + * Initializer function for creating a new Thumbor mapping, used by the image + * handler to perform image modifications based on legacy URL path requests. + * @param {object} event - The request body. + */ + process(event) { + // Setup + 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+)|(0x\d+)/g); + if (dimPath) { + // Assign dimenions from the first match only to avoid parsing dimension from image file names + const dims = dimPath[0].split('x'); + const width = Number(dims[0]); + const height = Number(dims[1]); + + // Set only if the dimensions provided are valid + if (!isNaN(width) && !isNaN(height)) { + this.edits.resize = {}; + + // If width or height is 0, fit would be inside. + if (width === 0 || height === 0) { + this.edits.resize.fit = 'inside'; + } + this.edits.resize.width = width === 0 ? null : width; + this.edits.resize.height = height === 0 ? null : height; + } + } + + // Parse the image path + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + if (edit === ('fit-in')) { + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + + this.edits.resize.fit = 'inside'; + } else if (edit.includes('filters:')) { + this.mapFilter(edit, filetype); + } + } + + return this; + } + + /** + * Enables users to migrate their current image request model to the SIH solution, + * without changing their legacy application code to accomodate new image requests. + * @param {string} path - The URL path extracted from the web request. + */ + parseCustomPath(path) { + // Setup from the environment variables + const matchPattern = process.env.REWRITE_MATCH_PATTERN; + const substitution = process.env.REWRITE_SUBSTITUTION; + // Perform the substitution and return + if (path !== undefined && matchPattern !== undefined && substitution !== undefined) { + const parsedPath = path.replace(matchPattern, substitution); + const output = { path: parsedPath }; + return output; + } else { + throw new Error('ThumborMapping::ParseCustomPath::ParsingError'); + } + } + + /** + * Scanner function for matching supported Thumbor filters and converting their + * capabilities into Sharp.js supported operations. + * @param {string} edit - The URL path filter. + * @param {string} filetype - The file type of the original image. + */ + mapFilter(edit, filetype) { + const matched = edit.match(/:(.+)\((.*)\)/); + const editKey = matched[1]; + let value = matched[2]; + // Find the proper filter + if (editKey === ('autojpg')) { + this.edits.toFormat = 'jpeg'; + } + else if (editKey === ('background_color')) { + if (!ColorName[value]) { + value = `#${value}` + } + 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')) { + const arr = value.split(','); + const strMatrix = (arr[0]).split(';'); + let matrix = []; + strMatrix.forEach(function(str) { + matrix.push(Number(str)); + }); + const matrixWidth = arr[1]; + let matrixHeight = 0; + let counter = 0; + for (let i = 0; i < matrix.length; i++) { + if (counter === (matrixWidth - 1)) { + matrixHeight++; + counter = 0; + } else { + counter++; + } + } + this.edits.convolve = { + width: Number(matrixWidth), + height: Number(matrixHeight), + kernel: matrix + } + } + else if (editKey === ('equalize')) { + this.edits.normalize = "true"; + } + else if (editKey === ('fill')) { + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + if (!ColorName[value]) { + value = `#${value}` + } + this.edits.resize.fit = 'contain'; + this.edits.resize.background = Color(value).object(); + } + 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']; + if (acceptedValues.includes(formattedValue)) { + this.edits.toFormat = formattedValue; + } + } + else if (editKey === ('grayscale')) { + this.edits.grayscale = true; + } + else if (editKey === ('no_upscale')) { + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + this.edits.resize.withoutEnlargement = true; + } + 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')) { + if (['jpg', 'jpeg'].includes(filetype)) { + this.edits.jpeg = { quality: Number(value) } + } else if (filetype === 'png') { + this.edits.png = { quality: Number(value) } + } else if (filetype === 'webp') { + 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 (editKey === ('rgb')) { + const percentages = value.split(','); + const values = []; + 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.rotate = Number(value); + } + else if (editKey === ('sharpen')) { + const sh = value.split(','); + const sigma = 1 + Number(sh[1]) / 2; + this.edits.sharpen = sigma; + } + else if (editKey === ('stretch')) { + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + + // If fit-in is not defined, fit parameter would be 'fill'. + if (this.edits.resize.fit !== 'inside') { + this.edits.resize.fit = 'fill'; + } + } + else if (editKey === ('strip_exif')) { + this.edits.rotate = 0; + } + else if (editKey === ('strip_icc')) { + this.edits.rotate = 0; + } + else if (editKey === ('upscale')) { + if (this.edits.resize === undefined) { + this.edits.resize = {}; + } + this.edits.resize.fit = "inside" + } + else if (editKey === ('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; + } + } +} + +// Exports +module.exports = ThumborMapping; \ No newline at end of file