diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..4d249c346 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** + + +**To Reproduce** + + +**Expected behavior** + + +**Please complete the following information about the solution:** +- [ ] Version: [e.g. v1.0.0] +- [ ] Region: [e.g. us-east-1] +- [ ] Was the solution modified from the version published on this repository? +- [ ] If the answer to the previous question was yes, are the changes available on GitHub? +- [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? +- [ ] Were there any errors in the CloudWatch Logs? + +**Screenshots** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..58119c3ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this solution +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** + + +**Describe the feature you'd like** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/general_question.md b/.github/ISSUE_TEMPLATE/general_question.md new file mode 100644 index 000000000..957f187fe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general_question.md @@ -0,0 +1,11 @@ +--- +name: General question +about: Ask a general question +title: '' +labels: question +assignees: '' + +--- + +**What is your question?** + \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6bdaa999f..502145e7b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,14 @@ -*Issue #, if available:* +**Issue #, if available:** + -*Description of changes:* +**Description of changes:** + + + +**Checklist** +- [ ] :wave: I have run the unit tests, and all unit tests have passed. +- [ ] :warning: This pull request migh incur a breaking change. + By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. diff --git a/CHANGELOG.md b/CHANGELOG.md index 89117c02d..bb1884b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.0] - 2020-08-31 +### Added +- AWS CDK and AWS Solutions Constructs to create AWS CloudFormation template + +### Fixed +- Auto WebP does not work properly: [#195](https://github.com/awslabs/serverless-image-handler/pull/195), [#200](https://github.com/awslabs/serverless-image-handler/issues/200), [#205](https://github.com/awslabs/serverless-image-handler/issues/205) +- A bug where base64 encoding containing slash: [#194](https://github.com/awslabs/serverless-image-handler/pull/194) +- Thumbor issues: + - `0` size support: [#183](https://github.com/awslabs/serverless-image-handler/issues/183) + - `convolution` filter does not work: [#187](https://github.com/awslabs/serverless-image-handler/issues/187) + - `fill` filter does not work: [#190](https://github.com/awslabs/serverless-image-handler/issues/190) +- __Note that__ duplicated features has been merged gracefully. + +### Removed +- AWS CloudFormation template: `serverless-image-handler.template` + +### Changed +- sharp base version (from 0.23.4 to 0.25.4) +- Remove `Promise` to return since `async` functions return promises: [#189](https://github.com/awslabs/serverless-image-handler/issues/189) +- Unit test statement coverage improvement: + - `image-handler.js`: `79.05%` to `100%` + - `image-request.js`: `93.58%` to `100%` + - `thumbor-mapping.js`: `99.29%` to `100%` + - `overall`: `91.55%` to `100%` + ## [4.2] - 2020-02-06 ### Added - Honor outputFormat Parameter from the pull request [#117](https://github.com/awslabs/serverless-image-handler/pull/117) @@ -16,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: DeprecationWarning: Buffer() is deprecated from the pull request [#174](https://github.com/awslabs/serverless-image-handler/pull/174) - Add hex color support for Thumbor ```filters:background_color``` and ```filters:fill``` [#154](https://github.com/awslabs/serverless-image-handler/issues/154) - Add format and watermark support for Thumbor [#109](https://github.com/awslabs/serverless-image-handler/issues/109), [#131](https://github.com/awslabs/serverless-image-handler/issues/131), [#109](https://github.com/awslabs/serverless-image-handler/issues/142) -* __Note that__ duplicated features has been merged gracefully. +- __Note that__ duplicated features has been merged gracefully. ### Changed - sharp base version (from 0.23.3 to 0.23.4) diff --git a/NOTICE.txt b/NOTICE.txt index c2836a8d1..da5ae742b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,6 +1,6 @@ Serverless Image Handler -Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. ********************** THIRD PARTY COMPONENTS diff --git a/README.md b/README.md index 283b49f8e..f008e53ad 100644 --- a/README.md +++ b/README.md @@ -7,44 +7,84 @@ Published version, additional details and documentation are available here: http _Note:_ it is recommend to build the application binary on Amazon Linux. -## Building distributable for customization -* Clone the repository, then make the desired code changes +## On This Page +- [Architecture Overview](#architecture-overview) +- [Creating a custom build](#creating-a-custom-build) +- [External Contributors](#external-contributors) +- [License](#license) + +## Architecture Overview +![Architecture](architecture.jpeg) + +The AWS CloudFormation template deploys an Amazon CloudFront distribution, Amazon API Gateway REST API, and an AWS Lambda function. Amazon CloudFront provides a caching layer to reduce the cost of image processing and the latency of subsequent image delivery. The Amazon API Gateway provides endpoint resources and triggers the AWS Lambda function. The AWS Lambda function retrieves the image from the customer's Amazon Simple Storage Service (Amazon S3) bucket and uses Sharp to return a modified version of the image to the API Gateway. Additionally, the solution generates a CloudFront domain name that provides cached access to the image handler API. + +_**Note**:_ From v5.0, all AWS CloudFormation template resources are created be [AWS CDK](https://aws.amazon.com/cdk/) and [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/). Since the AWS CloudFormation template resources have the same logical ID comparing to v4.x, it makes the solution upgradable mostly from v4.x to v5. + +## Creating a custom build +The solution can be deployed through the CloudFormation template available on the solution home page. +To make changes to the solution, download or clone this repo, update the source code and then run the deployment/build-s3-dist.sh script to deploy the updated Lambda code to an Amazon S3 bucket in your account. + +### Prerequisites: +* [AWS Command Line Interface](https://aws.amazon.com/cli/) +* Node.js 12.x or later + +### 1. Clone the repository ```bash git clone https://github.com/awslabs/serverless-image-handler.git ``` -* Run unit tests to make sure added customization passes the tests: -``` +### 2. Run unit tests for customization +Run unit tests to make sure added customization passes the tests: +```bash cd ./deployment chmod +x ./run-unit-tests.sh ./run-unit-tests.sh ``` -* Create an Amazon S3 Bucket +### 3. Declare environment variables +```bash +export REGION=aws-region-code # the AWS region to launch the solution (e.g. us-east-1) +export DIST_OUTPUT_BUCKET=my-bucket-name # bucket where customized code will reside +export SOLUTION_NAME=my-solution-name # the solution name +export VERSION=my-version # version number for the customized code ``` -aws s3 mb s3://my-bucket-us-east-1 --region us-east-1 + +### 4. Create an Amazon S3 Bucket +The CloudFormation template is configured to pull the Lambda deployment packages from Amazon S3 bucket in the region the template is being launched in. Create a bucket in the desired region with the region name appended to the name of the bucket. +```bash +aws s3 mb s3://$DIST_OUTPUT_BUCKET-$REGION --region $REGION ``` -* Navigate to the deployment folder and build the distributable +### 5. Create the deployment packages +Build the distributable: ```bash chmod +x ./build-s3-dist.sh -./build-s3-dist.sh my-bucket serverless-image-handler my-version +./build-s3-dist.sh $DIST_OUTPUT_BUCKET $SOLUTION_NAME $VERSION ``` -> Note: The build-s3-dist script expects the bucket name as one of its parameters, and this value should not include the region suffix. - -* Deploy the distributable to an Amazon S3 bucket in your account (you must have the AWS CLI installed) +Deploy the distributable to the Amazon S3 bucket in your account: ```bash -aws s3 cp ./regional-s3-assets/ s3://my-bucket-us-east-1/serverless-image-handler/my-version/ --recursive --acl bucket-owner-full-control +aws s3 sync ./regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control +aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control ``` -* Get the link of the serverless-image-handler.template uploaded to your Amazon S3 bucket +### 6. Launch the CloudFormation template. +* Get the link of the `serverless-image-handler.template` uploaded to your Amazon S3 bucket. +* Deploy the Serverless Image Handler solution to your account by launching a new AWS CloudFormation stack using the S3 link of the `serverless-image-handler.template`. -* Deploy the Serverless Image Handler solution to your account by launching a new AWS CloudFormation stack using the link of the serverless-image-handler.template +## External Contributors +- [@leviwilson](https://github.com/leviwilson) for [#117](https://github.com/awslabs/serverless-image-handler/pull/117) +- [@rpong](https://github.com/rpong) for [#130](https://github.com/awslabs/serverless-image-handler/pull/130) +- [@harriswong](https://github.com/harriswong) for [#138](https://github.com/awslabs/serverless-image-handler/pull/138) +- [@ganey](https://github.com/ganey) for [#139](https://github.com/awslabs/serverless-image-handler/pull/139) +- [@browniebroke](https://github.com/browniebroke) for [#151](https://github.com/awslabs/serverless-image-handler/pull/151), [#152](https://github.com/awslabs/serverless-image-handler/pull/152) +- [@john-shaffer](https://github.com/john-shaffer) for [#158](https://github.com/awslabs/serverless-image-handler/pull/158) +- [@toredash](https://github.com/toredash) for [#174](https://github.com/awslabs/serverless-image-handler/pull/174), [#195](https://github.com/awslabs/serverless-image-handler/pull/195) +- [@lith-imad](https://github.com/lith-imad) for [#194](https://github.com/awslabs/serverless-image-handler/pull/194) *** - -Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +## License +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. diff --git a/architecture.jpeg b/architecture.jpeg new file mode 100644 index 000000000..76baa00a3 Binary files /dev/null and b/architecture.jpeg differ diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh index d2bf65939..47bca09aa 100755 --- a/deployment/build-s3-dist.sh +++ b/deployment/build-s3-dist.sh @@ -40,23 +40,16 @@ rm -rf $build_dist_dir mkdir -p $build_dist_dir echo "------------------------------------------------------------------------------" -echo "CloudFormation Template" +echo "CloudFormation template with CDK and Constructs" echo "------------------------------------------------------------------------------" -cp $template_dir/serverless-image-handler.template $template_dist_dir/ +export BUCKET_NAME=$1 +export SOLUTION_NAME=$2 +export VERSION=$3 -replace="s/%%BUCKET_NAME%%/$1/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/serverless-image-handler.template - -replace="s/%%SOLUTION_NAME%%/$2/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/serverless-image-handler.template - -replace="s/%%VERSION%%/$3/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/serverless-image-handler.template - -cp $template_dist_dir/serverless-image-handler.template $build_dist_dir/ +cd $source_dir/constructs +npm install +npm run build && cdk synth --asset-metadata false --path-metadata false --version-reporting false --json true > serverless-image-handler.json +mv serverless-image-handler.json $template_dist_dir/serverless-image-handler.template echo "------------------------------------------------------------------------------" echo "Package the image-handler code" diff --git a/deployment/manifest-generator/app.js b/deployment/manifest-generator/app.js deleted file mode 100644 index 8baa8c416..000000000 --- a/deployment/manifest-generator/app.js +++ /dev/null @@ -1,63 +0,0 @@ -/********************************************************************************************************************* - * 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/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh index 48b501274..713fffff7 100755 --- a/deployment/run-unit-tests.sh +++ b/deployment/run-unit-tests.sh @@ -2,5 +2,12 @@ set -e -cd ../source/image-handler +current_dir=$PWD +source_dir=$current_dir/../source + +cd $source_dir/constructs +npm install +npm test + +cd $source_dir/image-handler npm test diff --git a/deployment/serverless-image-handler.template b/deployment/serverless-image-handler.template deleted file mode 100644 index 25277b42e..000000000 --- a/deployment/serverless-image-handler.template +++ /dev/null @@ -1,855 +0,0 @@ -{ - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "(SO0023) - Serverless Image Handler: This template deploys and configures a serverless architecture that is optimized for dynamic image manipulation and delivery at low latency and cost. Leverages SharpJS for image processing. Template version %%VERSION%%", - "Parameters": { - "CorsEnabled" : { - "Description" : "Would you like to enable Cross-Origin Resource Sharing (CORS) for the image handler API? Select 'Yes' if so.", - "Default" : "No", - "Type" : "String", - "AllowedValues" : [ "Yes", "No" ] - }, - "CorsOrigin" : { - "Description" : "If you selected 'Yes' above, please specify an origin value here. A wildcard (*) value will support any origin. We recommend specifying an origin (i.e. https://example.domain) to restrict cross-site access to your API.", - "Default" : "*", - "Type" : "String" - }, - "SourceBuckets" : { - "Description" : "(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will be the first bucket listed in this field.", - "Default" : "defaultBucket, bucketNo2, bucketNo3, ...", - "Type" : "String", - "AllowedPattern" : ".+" - }, - "DeployDemoUI" : { - "Description" : "Would you like to deploy a demo UI to explore the features and capabilities of this solution? This will create an additional Amazon S3 bucket and Amazon CloudFront distribution in your account.", - "Default" : "Yes", - "Type" : "String", - "AllowedValues" : [ "Yes", "No" ] - }, - "LogRetentionPeriod" : { - "Description" : "This solution automatically logs events to Amazon CloudWatch. Select the amount of time for CloudWatch logs from this solution to be retained (in days).", - "Default" : 1, - "Type" : "Number", - "AllowedValues" : [ 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653 ] - }, - "AutoWebP" : { - "Description" : "Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.", - "Default" : "No", - "Type" : "String", - "AllowedValues" : [ "Yes", "No" ] - } - }, - "Metadata": { - "AWS::CloudFormation::Interface": { - "ParameterGroups": [ - { - "Label": { "default": "CORS Options" }, - "Parameters": [ "CorsEnabled", "CorsOrigin" ] - }, - { - "Label": { "default": "Image Sources" }, - "Parameters": [ "SourceBuckets" ] - }, - { - "Label": { "default": "Demo UI" }, - "Parameters": [ "DeployDemoUI" ] - }, - { - "Label": { "default": "Event Logging" }, - "Parameters": [ "LogRetentionPeriod" ] - } - ] - } - }, - "Conditions": { - "DeployDemoUICondition": { "Fn::Equals": [ { "Ref": "DeployDemoUI" }, "Yes" ] }, - "EnableCorsCondition" : { "Fn::Equals": [ { "Ref": "CorsEnabled" }, "Yes" ] } - }, - "Mappings": { - "Send": { - "AnonymousUsage": { - "Data": "Yes" - } - }, - "SourceCode": { - "General": { - "S3Bucket": "%%BUCKET_NAME%%", - "KeyPrefix": "%%SOLUTION_NAME%%/%%VERSION%%" - } - } - }, - "Resources": { - "Logs": { - "DeletionPolicy": "Retain", - "UpdateReplacePolicy": "Retain", - "Type": "AWS::S3::Bucket", - "Properties": { - "AccessControl": "LogDeliveryWrite", - "BucketEncryption": { - "ServerSideEncryptionConfiguration": [ - { - "ServerSideEncryptionByDefault": { - "SSEAlgorithm": "AES256" - } - } - ] - }, - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W35", - "reason": "Used to store access logs for other buckets" - }, - { - "id": "W51", - "reason": "Bucket does not need a bucket policy" - } - ] - } - } - }, - "ImageHandlerDistribution": { - "Type": "AWS::CloudFront::Distribution", - "Properties": { - "DistributionConfig": { - "Origins": [{ - "DomainName": { "Fn::Sub": "${ImageHandlerApi}.execute-api.${AWS::Region}.amazonaws.com" }, - "Id": { "Ref": "ImageHandlerApi" }, - "OriginPath": "/image", - "CustomOriginConfig": { - "HTTPSPort": 443, - "OriginProtocolPolicy": "https-only", - "OriginSSLProtocols": [ "TLSv1", "TLSv1.1", "TLSv1.2" ] - } - }], - "Enabled": true, - "HttpVersion": "http2", - "Comment": "Image handler distribution", - "DefaultCacheBehavior": { - "AllowedMethods": [ "GET", "HEAD" ], - "TargetOriginId": { "Fn::Sub": "${ImageHandlerApi}" }, - "ForwardedValues": { - "QueryString": false, - "Headers": [ "Origin", "Accept" ], - "Cookies": { "Forward": "none" } - }, - "ViewerProtocolPolicy": "https-only" - }, - "CustomErrorResponses": [ - { - "ErrorCode": 500, - "ErrorCachingMinTTL": 10 - }, - { - "ErrorCode": 501, - "ErrorCachingMinTTL": 10 - }, - { - "ErrorCode": 502, - "ErrorCachingMinTTL": 10 - }, - { - "ErrorCode": 503, - "ErrorCachingMinTTL": 10 - }, - { - "ErrorCode": 504, - "ErrorCachingMinTTL": 10 - } - ], - "PriceClass": "PriceClass_All", - "Logging": { - "IncludeCookies": false, - "Bucket": { - "Fn::GetAtt": ["Logs", "DomainName"] - }, - "Prefix": "image-handler-cf-logs/" - } - } - } - }, - "ApiLoggingRole": { - "Type": "AWS::IAM::Role", - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W11", - "reason": "API Gateway requires these permissions for CloudWatch Logging (https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html#set-up-access-logging-permissions)" - } - ] - } - }, - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": [ - "apigateway.amazonaws.com" - ] - }, - "Action": [ - "sts:AssumeRole" - ] - } - ] - }, - "Path": "/", - "Policies": [ - { - "PolicyName": { - "Fn::Sub": "${AWS::StackName}-api-logging-policy" - }, - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:DescribeLogGroups", - "logs:DescribeLogStreams", - "logs:PutLogEvents", - "logs:GetLogEvents", - "logs:FilterLogEvents" - ], - "Resource": { - "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" - } - } - ] - } - } - ] - } - }, - "ApiAccountConfig": { - "Type": "AWS::ApiGateway::Account", - "Properties": { - "CloudWatchRoleArn": { - "Fn::GetAtt": [ - "ApiLoggingRole", - "Arn" - ] - } - } - }, - "ApiLogs": { - "Type": "AWS::Logs::LogGroup" - }, - "ImageHandlerApi": { - "Type": "AWS::ApiGateway::RestApi", - "Properties": { - "EndpointConfiguration": { - "Types" : [ "REGIONAL" ] - }, - "Body": { - "swagger": "2.0", - "info": { - "version": "2017-01-27T21:44:58Z", - "title": "ServerlessImageHandler" - }, - "basePath": "/image", - "schemes": [ "https" ], - "paths": { - "/{proxy+}": { - "x-amazon-apigateway-any-method": { - "produces": [ "application/json" ], - "parameters": [ - { - "name": "proxy", - "in": "path", - "required": true, - "type": "string" - } - ], - "responses": {}, - "x-amazon-apigateway-integration": { - "responses": { - "default": { "statusCode": "200" } - }, - "uri": { - "Fn::Join": [ - "", - [ - "arn:aws:apigateway:", - { - "Ref": "AWS::Region" - }, - ":", - "lambda:path/2015-03-31/functions/", - { - "Fn::GetAtt": [ - "ImageHandlerFunction", - "Arn" - ] - }, - "/invocations" - ] - ] - }, - "passthroughBehavior": "when_no_match", - "httpMethod": "POST", - "cacheNamespace": "xh7gp9", - "cacheKeyParameters": [ "method.request.path.proxy" ], - "contentHandling": "CONVERT_TO_TEXT", - "type": "aws_proxy" - } - } - } - }, - "x-amazon-apigateway-binary-media-types": [ - "*/*" - ] - } - } - }, - "ImageHandlerApiDeployment": { - "Type": "AWS::ApiGateway::Deployment", - "DependsOn": "ApiAccountConfig", - "Properties": { - "RestApiId": { "Ref": "ImageHandlerApi" }, - "StageName": "image", - "StageDescription": { - "AccessLogSetting": { - "DestinationArn": { - "Fn::GetAtt": [ - "ApiLogs", - "Arn" - ] - }, - "Format": "$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] \"$context.httpMethod $context.resourcePath $context.protocol\" $context.status $context.responseLength $context.requestId" - } - } - } - }, - "ImageHandlerFunction": { - "Type": "AWS::Lambda::Function", - "Properties": { - "Description": "Serverless Image Handler - Function for performing image edits and manipulations.", - "Handler": "index.handler", - "Role": { "Fn::GetAtt": [ "ImageHandlerFunctionRole", "Arn" ] }, - "Code": { - "S3Bucket": { - "Fn::Join": [ - "-", - [ - { - "Fn::FindInMap": [ - "SourceCode", - "General", - "S3Bucket" - ] - }, - { - "Ref": "AWS::Region" - } - ] - ] - }, - "S3Key": { - "Fn::Join": [ - "/", - [ - { - "Fn::FindInMap": [ - "SourceCode", - "General", - "KeyPrefix" - ] - }, - "image-handler.zip" - ] - ] - } - }, - "Runtime": "nodejs12.x", - "MemorySize": 1024, - "Timeout": 30, - "Environment" : { - "Variables" : { - "AUTO_WEBP" : { - "Ref" : "AutoWebP" - }, - "CORS_ENABLED" : { - "Ref" : "CorsEnabled" - }, - "CORS_ORIGIN" : { - "Ref" : "CorsOrigin" - }, - "SOURCE_BUCKETS" : { - "Ref" : "SourceBuckets" - }, - "REWRITE_MATCH_PATTERN" : "", - "REWRITE_SUBSTITUTION" : "" - } - } - } - }, - "ImageHandlerFunctionRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": [ "lambda.amazonaws.com" ] - }, - "Action": [ "sts:AssumeRole" ] - } - ] - }, - "Path": "/", - "RoleName": { - "Fn::Join": [ - "", [ { "Ref": "AWS::StackName" }, "ImageHandlerFunctionRole", "-", {"Ref": "AWS::Region"} ] ] - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W28", - "reason": "Resource name validated and found to pose no risk to updates that require replacement of this resource." - } - ] - } - } - }, - "ImageHandlerPolicy" : { - "Type" : "AWS::IAM::Policy", - "Properties" : { - "PolicyDocument" : { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogStream", - "logs:CreateLogGroup", - "logs:PutLogEvents" - ], - "Resource": { "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" } - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket" - ], - "Resource": [ - "arn:aws:s3:::*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "rekognition:DetectFaces" - ], - "Resource": [ - "*" - ] - } - ] - }, - "PolicyName" : { "Fn::Join": [ "", [ { "Ref": "AWS::StackName" }, "ImageHandlerPolicy" ] ] }, - "Roles" : [ { "Ref": "ImageHandlerFunctionRole" }] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "F3", - "reason": "The rekognition:DetectFaces action requires the wildcard ('*') resource identifier to function properly. Supporting documentation available at (https://docs.aws.amazon.com/rekognition/latest/dg/using-identity-based-policies.html) and (https://docs.aws.amazon.com/rekognition/latest/dg/api-permissions-reference.html)." - }, - { - "id": "W12", - "reason": "The ImageHandlerPolicy provides access to all Amazon S3 buckets within the user's account to enable sourcing image files from multiple origins." - } - ] - } - } - }, - "ImageHandlerPermission": { - "Type": "AWS::Lambda::Permission", - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": { - "Fn::GetAtt": ["ImageHandlerFunction","Arn"] - }, - "Principal": "apigateway.amazonaws.com", - "SourceArn": { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "ImageHandlerApi" - }, - "/*/*/*" - ] - ] - } - } - }, - "ImageHandlerLogGroup": { - "Type": "AWS::Logs::LogGroup", - "Properties": { - "LogGroupName": { "Fn::Join": ["/", ["/aws/lambda", { "Ref" : "ImageHandlerFunction" }]]}, - "RetentionInDays": { "Ref" : "LogRetentionPeriod" } - } - }, - "DemoBucket": { - "Type": "AWS::S3::Bucket", - "Condition": "DeployDemoUICondition", - "DeletionPolicy": "Retain", - "UpdateReplacePolicy": "Retain", - "Properties": { - "BucketEncryption": { - "ServerSideEncryptionConfiguration": [ - { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } } - ] - }, - "WebsiteConfiguration": { - "IndexDocument": "index.html", - "ErrorDocument": "index.html" - }, - "AccessControl": "Private", - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W35", - "reason": "This S3 bucket does not require access logging. API calls and image operations are logged to CloudWatch with custom reporting." - } - ] - } - } - }, - "DemoBucketPolicy": { - "Type": "AWS::S3::BucketPolicy", - "Condition": "DeployDemoUICondition", - "Properties": { - "Bucket": { "Ref": "DemoBucket" }, - "PolicyDocument": { - "Statement": [ - { - "Action": [ "s3:GetObject" ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:aws:s3:::", - { "Ref": "DemoBucket" }, - "/*" - ] - ] - }, - "Principal": { - "CanonicalUser": { - "Fn::GetAtt": [ - "DemoOriginAccessIdentity", - "S3CanonicalUserId" - ] - } - } - } - ] - } - } - }, - "DemoOriginAccessIdentity": { - "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity", - "Condition": "DeployDemoUICondition", - "Properties": { - "CloudFrontOriginAccessIdentityConfig": { - "Comment": { - "Fn::Sub": "access-identity-${DemoBucket}" - } - } - } - }, - "DemoDistribution": { - "Type": "AWS::CloudFront::Distribution", - "Condition": "DeployDemoUICondition", - "Properties": { - "DistributionConfig": { - "Comment": "Website distribution for solution", - "Origins": [ - { - "Id": "S3-solution-website", - "DomainName": { "Fn::Sub": "${DemoBucket}.s3.${AWS::Region}.amazonaws.com" }, - "S3OriginConfig": { - "OriginAccessIdentity": { "Fn::Sub": "origin-access-identity/cloudfront/${DemoOriginAccessIdentity}" } - } - } - ], - "DefaultCacheBehavior": { - "TargetOriginId": "S3-solution-website", - "AllowedMethods": [ "GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE" ], - "CachedMethods": [ "GET", "HEAD", "OPTIONS" ], - "ForwardedValues": { - "QueryString": false - }, - "ViewerProtocolPolicy": "redirect-to-https" - }, - "IPV6Enabled": true, - "ViewerCertificate": { "CloudFrontDefaultCertificate": true }, - "Enabled": true, - "HttpVersion": "http2", - "Logging": { - "IncludeCookies": false, - "Bucket": { - "Fn::GetAtt": ["Logs", "DomainName"] - }, - "Prefix": "demo-cf-logs/" - } - } - } - }, - "CustomResourceFunction": { - "Type": "AWS::Lambda::Function", - "Properties": { - "Code": { - "S3Bucket": { - "Fn::Join": [ - "-", - [ - { - "Fn::FindInMap": [ - "SourceCode", - "General", - "S3Bucket" - ] - }, - { - "Ref": "AWS::Region" - } - ] - ] - }, - "S3Key": { - "Fn::Join": [ - "/", - [ - { - "Fn::FindInMap": [ - "SourceCode", - "General", - "KeyPrefix" - ] - }, - "custom-resource.zip" - ] - ] - } - }, - "Timeout": 30, - "Runtime": "nodejs12.x", - "Role": { "Fn::GetAtt": [ "CustomResourceRole", "Arn" ] }, - "Handler": "index.handler" - } - }, - "CustomResourceRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": [ "lambda.amazonaws.com" ] - }, - "Action": [ "sts:AssumeRole" ] - } - ] - }, - "Path": "/", - "RoleName": { - "Fn::Join": [ - "", [ { "Ref": "AWS::StackName" }, "CustomResourceRole", "-", {"Ref": "AWS::Region"} ] ] - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W28", - "reason": "Resource name validated and found to pose no risk to updates that require replacement of this resource." - } - ] - } - } - }, - "CustomResourcePolicy" : { - "Type" : "AWS::IAM::Policy", - "Properties" : { - "PolicyDocument" : { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogStream", - "logs:CreateLogGroup", - "logs:PutLogEvents" - ], - "Resource": { "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" } - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket" - ], - "Resource": [ - "arn:aws:s3:::*" - ] - } - ] - }, - "PolicyName" : { "Fn::Join": [ "", [ { "Ref": "AWS::StackName" }, "CustomResourcePolicy" ] ] }, - "Roles" : [ { "Ref": "CustomResourceRole" } ] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W12", - "reason": "The CustomResourcePolicy requires the * resource to validate that input from the SourceBuckets template parameter matches existing buckets within the user's account." - } - ] - } - } - }, - "CustomResourceCopyS3": { - "Type": "Custom::CustomResource", - "Condition" : "DeployDemoUICondition", - "Properties": { - "ServiceToken": { "Fn::GetAtt" : [ "CustomResourceFunction", "Arn" ]}, - "Region": { "Ref" : "AWS::Region" }, - "manifestKey": { "Fn::Join": [ "/", [ { "Fn::FindInMap": [ "SourceCode", "General", "KeyPrefix" ]}, "demo-ui-manifest.json" ]]}, - "sourceS3Bucket": { "Fn::Join": [ "-", [{ "Fn::FindInMap": [ "SourceCode", "General", "S3Bucket" ]}, { "Ref": "AWS::Region" }]]}, - "sourceS3key": { "Fn::Join": ["/", [{ "Fn::FindInMap": [ "SourceCode", "General", "KeyPrefix" ]}, "demo-ui" ]]}, - "destS3Bucket": { "Ref" : "DemoBucket" }, - "version": "%%VERSION%%", - "customAction": "copyS3assets" - }, - "DependsOn": [ "CustomResourcePolicy", "CustomResourceRole" ] - }, - "CustomResourceConfig": { - "Type": "Custom::CustomResource", - "Condition" : "DeployDemoUICondition", - "Properties": { - "ServiceToken": { "Fn::GetAtt" : [ "CustomResourceFunction", "Arn" ]}, - "Region": { "Ref" : "AWS::Region" }, - "configItem" : { - "apiEndpoint" : { "Fn::Sub" : "https://${ImageHandlerDistribution.DomainName}" } - } , - "destS3Bucket" : { "Ref" : "DemoBucket" }, - "destS3key" : "demo-ui-config.js", - "customAction": "putConfigFile" - }, - "DependsOn": [ "CustomResourcePolicy", "CustomResourceRole" ] - }, - "CustomResourceLogGroup": { - "Type": "AWS::Logs::LogGroup", - "Properties": { - "LogGroupName": { "Fn::Join": ["/", ["/aws/lambda", { "Ref" : "CustomResourceFunction" }]]}, - "RetentionInDays": { "Ref" : "LogRetentionPeriod" } - } - }, - "CustomResourceUuid": { - "Type": "Custom::CustomResource", - "Properties": { - "ServiceToken": { "Fn::GetAtt": ["CustomResourceFunction", "Arn"] }, - "Region": [{ "Ref": "AWS::Region" }], - "customAction": "createUuid" - }, - "DependsOn": [ "CustomResourcePolicy", "CustomResourceRole" ] - }, - "CustomResourceAnonymousMetric": { - "Type": "Custom::CustomResource", - "Properties": { - "ServiceToken": {"Fn::GetAtt": ["CustomResourceFunction", "Arn"]}, - "Region": [{ "Ref": "AWS::Region" }], - "solutionId": "SO0023", - "UUID": {"Fn::GetAtt": ["CustomResourceUuid", "UUID"]}, - "version": "%%VERSION%%", - "anonymousData": {"Fn::FindInMap": ["Send", "AnonymousUsage", "Data"]}, - "customAction": "sendMetric" - }, - "DependsOn": [ "CustomResourcePolicy", "CustomResourceRole" ] - }, - "CustomResourceCheckSourceBuckets": { - "Type": "Custom::CustomResource", - "Properties": { - "ServiceToken": { "Fn::GetAtt": ["CustomResourceFunction", "Arn"] }, - "Region": [{ "Ref": "AWS::Region" }], - "customAction": "checkSourceBuckets", - "sourceBuckets": { "Ref": "SourceBuckets" } - }, - "DependsOn": [ "CustomResourcePolicy", "CustomResourceRole" ] - } - }, - "Outputs" : { - "ApiEndpoint" : { - "Description" : "Link to API endpoint for sending image requests to.", - "Value" : { "Fn::Sub" : "https://${ImageHandlerDistribution.DomainName}" } - }, - "DemoUrl" : { - "Condition" : "DeployDemoUICondition", - "Description" : "Link to the demo user interface for the solution.", - "Value" : { "Fn::Sub" : "https://${DemoDistribution.DomainName}/index.html" } - }, - "SourceBuckets" : { - "Description" : "Amazon S3 bucket location containing original image files.", - "Value" : { "Ref" : "SourceBuckets"} - }, - "CorsEnabled" : { - "Description" : "Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.", - "Value" : { "Ref" : "CorsEnabled" } - }, - "CorsOrigin" : { - "Condition" : "EnableCorsCondition", - "Description" : "Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.", - "Value" : { "Ref" : "CorsOrigin" } - }, - "LogRetentionPeriod" : { - "Description" : "Number of days for event logs from Lambda to be retained in CloudWatch.", - "Value" : { "Ref" : "LogRetentionPeriod" } - } - } -} \ No newline at end of file diff --git a/source/constructs/.gitignore b/source/constructs/.gitignore new file mode 100644 index 000000000..ad34eb457 --- /dev/null +++ b/source/constructs/.gitignore @@ -0,0 +1,13 @@ +*.js +!jest.config.js +!issue-fixer/index.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out + +# Parcel build directories +.cache +.build diff --git a/source/constructs/.npmignore b/source/constructs/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/source/constructs/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/source/constructs/bin/constructs.ts b/source/constructs/bin/constructs.ts new file mode 100644 index 000000000..b2a269b69 --- /dev/null +++ b/source/constructs/bin/constructs.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as cdk from '@aws-cdk/core'; +import { ConstructsStack } from '../lib/constructs-stack'; + +const app = new cdk.App(); +new ConstructsStack(app, 'ConstructsStack'); \ No newline at end of file diff --git a/source/constructs/cdk.json b/source/constructs/cdk.json new file mode 100644 index 000000000..ef09a7a9e --- /dev/null +++ b/source/constructs/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "npx ts-node bin/constructs.ts", + "context": { + "@aws-cdk/core:enableStackNameDuplicates": "false", + "aws-cdk:enableDiffNoFail": "true" + } +} diff --git a/source/constructs/jest.config.js b/source/constructs/jest.config.js new file mode 100644 index 000000000..772f97490 --- /dev/null +++ b/source/constructs/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/source/constructs/lib/api.json b/source/constructs/lib/api.json new file mode 100644 index 000000000..8a64455d6 --- /dev/null +++ b/source/constructs/lib/api.json @@ -0,0 +1,58 @@ +{ + "swagger": "2.0", + "info": { + "title": "ServerlessImageHandler" + }, + "basePath": "/image", + "schemes": [ "https" ], + "paths": { + "/{proxy+}": { + "x-amazon-apigateway-any-method": { + "produces": [ "application/json" ], + "parameters": [ + { + "name": "proxy", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": {}, + "x-amazon-apigateway-integration": { + "responses": { + "default": { "statusCode": "200" } + }, + "uri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":", + "lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ImageHandlerFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "cacheNamespace": "xh7gp9", + "cacheKeyParameters": [ "method.request.path.proxy" ], + "contentHandling": "CONVERT_TO_TEXT", + "type": "aws_proxy" + } + } + } + }, + "x-amazon-apigateway-binary-media-types": [ + "*/*" + ] +} \ No newline at end of file diff --git a/source/constructs/lib/constructs-stack.ts b/source/constructs/lib/constructs-stack.ts new file mode 100644 index 000000000..102081755 --- /dev/null +++ b/source/constructs/lib/constructs-stack.ts @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as cdk from '@aws-cdk/core'; +import { ServerlessImageHandler, ServerlessImageHandlerProps } from './serverless-image-handler'; +import { CfnParameter } from '@aws-cdk/core'; + +const { VERSION } = process.env; + +/** + * @class ConstructsStack + */ +export class ConstructsStack extends cdk.Stack { + constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // CFN parameters + const corsEnabledParameter = new CfnParameter(this, 'CorsEnabled', { + type: 'String', + description: `Would you like to enable Cross-Origin Resource Sharing (CORS) for the image handler API? Select 'Yes' if so.`, + default: 'No', + allowedValues: [ 'Yes', 'No' ] + }); + const corsOriginParameter = new CfnParameter(this, 'CorsOrigin', { + type: 'String', + description: `If you selected 'Yes' above, please specify an origin value here. A wildcard (*) value will support any origin. We recommend specifying an origin (i.e. https://example.domain) to restrict cross-site access to your API.`, + default: '*' + }); + const sourceBucketsParameter = new CfnParameter(this, 'SourceBuckets', { + type: 'String', + description: '(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will be the first bucket listed in this field.', + default: 'defaultBucket, bucketNo2, bucketNo3, ...', + allowedPattern: '.+' + }); + const deployDemoUiParameter = new CfnParameter(this, 'DeployDemoUI', { + type: 'String', + description: 'Would you like to deploy a demo UI to explore the features and capabilities of this solution? This will create an additional Amazon S3 bucket and Amazon CloudFront distribution in your account.', + default: 'Yes', + allowedValues: [ 'Yes', 'No' ] + }); + const logRetentionPeriodParameter = new CfnParameter(this, 'LogRetentionPeriod', { + type: 'Number', + description: 'This solution automatically logs events to Amazon CloudWatch. Select the amount of time for CloudWatch logs from this solution to be retained (in days).', + default: '1', + allowedValues: [ '1', '3', '5', '7', '14', '30', '60', '90', '120', '150', '180', '365', '400', '545', '731', '1827', '3653' ] + }); + const autoWebPParameter = new CfnParameter(this, 'AutoWebP', { + type: 'String', + description: `Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.`, + default: 'No', + allowedValues: [ 'Yes', 'No' ] + }); + + // CFN descrption + this.templateOptions.description = `(SO0023) - Serverless Image Handler with aws-solutions-constructs: This template deploys and configures a serverless architecture that is optimized for dynamic image manipulation and delivery at low latency and cost. Leverages SharpJS for image processing. Template version ${VERSION}`; + + // CFN template format version + this.templateOptions.templateFormatVersion = '2010-09-09'; + + // CFN metadata + this.templateOptions.metadata = { + 'AWS::CloudFormation::Interface': { + ParameterGroups: [ + { + Label: { default: 'CORS Options' }, + Parameters: [ corsEnabledParameter.logicalId, corsOriginParameter.logicalId ] + }, + { + Label: { default: 'Image Sources' }, + Parameters: [ sourceBucketsParameter.logicalId ] + }, + { + Label: { default: 'Demo UI' }, + Parameters: [ deployDemoUiParameter.logicalId ] + }, + { + Label: { default: 'Event Logging' }, + Parameters: [ logRetentionPeriodParameter.logicalId ] + } + ] + } + }; + + // Mappings + new cdk.CfnMapping(this, 'Send', { + mapping: { + AnonymousUsage: { + Data: 'Yes' + } + } + }); + + // Serverless Image Handler props + const sihProps: ServerlessImageHandlerProps = { + corsEnabledParameter, + corsOriginParameter, + sourceBucketsParameter, + deployDemoUiParameter, + logRetentionPeriodParameter, + autoWebPParameter + }; + + // Serverless Image Handler Construct + const serverlessImageHander = new ServerlessImageHandler(this, 'ServerlessImageHandler', sihProps); + + // Outputs + new cdk.CfnOutput(this, 'ApiEndpoint', { + value: cdk.Fn.sub('https://${ImageHandlerDistribution.DomainName}'), + description: 'Link to API endpoint for sending image requests to.' + }); + new cdk.CfnOutput(this, 'DemoUrl', { + value: cdk.Fn.sub('https://${DemoDistribution.DomainName}/index.html'), + description: 'Link to the demo user interface for the solution.', + condition: serverlessImageHander.node.findChild('DeployDemoUICondition') as cdk.CfnCondition + }); + new cdk.CfnOutput(this, 'SourceBucketsOutput', { + value: sourceBucketsParameter.valueAsString, + description: 'Amazon S3 bucket location containing original image files.' + }).overrideLogicalId('SourceBuckets'); + new cdk.CfnOutput(this, 'CorsEnabledOutput', { + value: corsEnabledParameter.valueAsString, + description: 'Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.' + }).overrideLogicalId('CorsEnabled'); + new cdk.CfnOutput(this, 'CorsOriginOutput', { + value: corsOriginParameter.valueAsString, + description: 'Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.', + condition: serverlessImageHander.node.findChild('EnableCorsCondition') as cdk.CfnCondition + }).overrideLogicalId('CorsOrigin'); + new cdk.CfnOutput(this, 'LogRetentionPeriodOutput', { + value: cdk.Fn.ref('LogRetentionPeriod'), + description: 'Number of days for event logs from Lambda to be retained in CloudWatch.' + }).overrideLogicalId('LogRetentionPeriod'); + } +} diff --git a/source/constructs/lib/serverless-image-handler.ts b/source/constructs/lib/serverless-image-handler.ts new file mode 100644 index 000000000..dfbeb630c --- /dev/null +++ b/source/constructs/lib/serverless-image-handler.ts @@ -0,0 +1,581 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Construct, CfnParameter } from "@aws-cdk/core"; +import * as cdkLambda from '@aws-cdk/aws-lambda'; +import * as cdkS3 from '@aws-cdk/aws-s3'; +import * as cdkIam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as cdkCloudFront from '@aws-cdk/aws-cloudfront'; +import * as cdkApiGateway from '@aws-cdk/aws-apigateway'; +import * as cdkLogs from '@aws-cdk/aws-logs'; +import { CloudFrontToApiGatewayToLambda } from '@aws-solutions-constructs/aws-cloudfront-apigateway-lambda'; +import { CloudFrontToS3 } from '@aws-solutions-constructs/aws-cloudfront-s3'; +import apiBody from './api.json'; + +const { BUCKET_NAME, SOLUTION_NAME, VERSION } = process.env; + +/** + * Serverless Image Handler props interface + * These props are AWS CloudFormation parameters. + */ +export interface ServerlessImageHandlerProps { + readonly corsEnabledParameter: CfnParameter; + readonly corsOriginParameter: CfnParameter; + readonly sourceBucketsParameter: CfnParameter; + readonly deployDemoUiParameter: CfnParameter; + readonly logRetentionPeriodParameter: CfnParameter; + readonly autoWebPParameter: CfnParameter; +} + +/** + * Serverless Image Handler custom resource config interface + */ +interface CustomResourceConfig { + readonly properties?: { path: string, value: any }[]; + readonly condition?: cdk.CfnCondition; + readonly dependencies?: cdk.CfnResource[]; +} + +/** + * cfn-nag suppression rule interface + */ +interface CfnNagSuppressRule { + readonly id: string; + readonly reason: string; +} + +/** + * Serverless Image Handler Construct using AWS Solutions Constructs patterns and AWS CDK + * @version 5.0.0 + */ +export class ServerlessImageHandler extends Construct { + constructor(scope: Construct, id: string, props: ServerlessImageHandlerProps) { + super(scope, id); + + try { + // CFN Conditions + const deployDemoUiCondition = new cdk.CfnCondition(this, 'DeployDemoUICondition', { + expression: cdk.Fn.conditionEquals(props.deployDemoUiParameter.valueAsString, 'Yes') + }); + deployDemoUiCondition.overrideLogicalId('DeployDemoUICondition'); + + const enableCorsCondition = new cdk.CfnCondition(this, 'EnableCorsCondition', { + expression: cdk.Fn.conditionEquals(props.corsEnabledParameter.valueAsString, 'Yes') + }); + enableCorsCondition.overrideLogicalId('EnableCorsCondition'); + + // ImageHandlerFunctionRole + const imageHandlerFunctionRole = new cdkIam.Role(this, 'ImageHandlerFunctionRole', { + assumedBy: new cdkIam.ServicePrincipal('lambda.amazonaws.com'), + path: '/', + roleName: `${cdk.Aws.STACK_NAME}ImageHandlerFunctionRole-${cdk.Aws.REGION}` + }); + const cfnImageHandlerFunctionRole = imageHandlerFunctionRole.node.defaultChild as cdkIam.CfnRole; + this.addCfnNagSuppressRules(cfnImageHandlerFunctionRole, [ + { + id: 'W28', + reason: 'Resource name validated and found to pose no risk to updates that require replacement of this resource.' + } + ]); + cfnImageHandlerFunctionRole.overrideLogicalId('ImageHandlerFunctionRole'); + + // ImageHandlerPolicy + const imageHandlerPolicy = new cdkIam.Policy(this, 'ImageHandlerPolicy', { + policyName: `${cdk.Aws.STACK_NAME}ImageHandlerPolicy`, + statements: [ + new cdkIam.PolicyStatement({ + actions: [ + 'logs:CreateLogStream', + 'logs:CreateLogGroup', + 'logs:PutLogEvents' + ], + resources: [ + `arn:${cdk.Aws.PARTITION}:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/*` + ] + }), + new cdkIam.PolicyStatement({ + actions: [ + 's3:GetObject', + 's3:PutObject', + 's3:ListBucket' + ], + resources: [ + `arn:${cdk.Aws.PARTITION}:s3:::*` + ] + }), + new cdkIam.PolicyStatement({ + actions: [ + 'rekognition:DetectFaces' + ], + resources: [ + '*' + ] + }) + ] + }); + imageHandlerPolicy.attachToRole(imageHandlerFunctionRole); + const cfnImageHandlerPolicy = imageHandlerPolicy.node.defaultChild as cdkIam.CfnPolicy; + this.addCfnNagSuppressRules(cfnImageHandlerPolicy, [ + { + id: 'W12', + reason: 'rekognition:DetectFaces requires \'*\' resources.' + } + ]); + cfnImageHandlerPolicy.overrideLogicalId('ImageHandlerPolicy'); + + // ImageHandlerFunction + const imageHandlerFunction = new cdkLambda.Function(this, 'ImageHanlderFunction', { + description: 'Serverless Image Handler - Function for performing image edits and manipulations.', + code: new cdkLambda.S3Code( + cdkS3.Bucket.fromBucketArn(this, 'ImageHandlerLambdaSource', `arn:${cdk.Aws.PARTITION}:s3:::${BUCKET_NAME}-${cdk.Aws.REGION}`), + `${SOLUTION_NAME}/${VERSION}/image-handler.zip` + ), + handler: 'index.handler', + runtime: cdkLambda.Runtime.NODEJS_12_X, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + role: imageHandlerFunctionRole, + environment: { + AUTO_WEBP: props.autoWebPParameter.valueAsString, + CORS_ENABLED: props.corsEnabledParameter.valueAsString, + CORS_ORIGIN: props.corsOriginParameter.valueAsString, + SOURCE_BUCKETS: props.sourceBucketsParameter.valueAsString, + REWRITE_MATCH_PATTERN: '', + REWRITE_SUBSTITUTION: '' + } + }); + const cfnImageHandlerFunction = imageHandlerFunction.node.defaultChild as cdkLambda.CfnFunction; + this.addCfnNagSuppressRules(cfnImageHandlerFunction, [ + { + id: 'W58', + reason: 'False alarm: The Lambda function does have the permission to write CloudWatch Logs.' + } + ]); + cfnImageHandlerFunction.overrideLogicalId('ImageHandlerFunction'); + + // ImageHandlerLogGroup + const lambdaFunctionLogs = new cdkLogs.LogGroup(this, 'ImageHandlerLogGroup', { + logGroupName: `/aws/lambda/${imageHandlerFunction.functionName}` + }); + const cfnLambdaFunctionLogs = lambdaFunctionLogs.node.defaultChild as cdkLogs.CfnLogGroup; + cfnLambdaFunctionLogs.retentionInDays = props.logRetentionPeriodParameter.valueAsNumber; + cfnLambdaFunctionLogs.overrideLogicalId('ImageHandlerLogGroup'); + + // CloudFrontToApiGatewayToLambda pattern + const cloudFrontApiGatewayLambda = new CloudFrontToApiGatewayToLambda(this, 'CloudFrontApiGatewayLambda', { + existingLambdaObj: imageHandlerFunction, + insertHttpSecurityHeaders: false + }); + const { apiGatewayLogGroup, apiGateway, cloudFrontWebDistribution } = cloudFrontApiGatewayLambda; + + // ApiLogs + const cfnApiGatewayLogGroup = apiGatewayLogGroup.node.defaultChild as cdkLogs.CfnLogGroup; + cfnApiGatewayLogGroup.overrideLogicalId('ApiLogs'); + + // ImageHandlerApi + this.removeChildren(apiGateway, [ 'Endpoint', 'UsagePlan', 'Deployment', 'Default', 'DeploymentStage.prod' ]); + const cfnApiGateway = apiGateway.node.defaultChild as cdkApiGateway.CfnRestApi; + cfnApiGateway.name = 'ServerlessImageHandler'; + cfnApiGateway.body = apiBody; + cfnApiGateway.overrideLogicalId('ImageHandlerApi'); + + // ImageHandlerPermission + imageHandlerFunction.addPermission('ImageHandlerPermission', { + action: 'lambda:InvokeFunction', + sourceArn: `arn:${cdk.Aws.PARTITION}:execute-api:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${apiGateway.restApiId}/*/*/*`, + principal: new cdkIam.ServicePrincipal('apigateway.amazonaws.com') + }); + (imageHandlerFunction.node.findChild('ImageHandlerPermission') as cdkLambda.CfnPermission).overrideLogicalId('ImageHandlerPermission'); + + // ApiLoggingRole + const cfnApiGatewayLogRole = cloudFrontApiGatewayLambda.apiGatewayCloudWatchRole.node.defaultChild as cdkIam.CfnRole; + cfnApiGatewayLogRole.overrideLogicalId('ApiLoggingRole'); + + // ApiAccountConfig + const cfnApiGatewayAccount = cloudFrontApiGatewayLambda.node.findChild('LambdaRestApiAccount') as cdkApiGateway.CfnAccount; + cfnApiGatewayAccount.overrideLogicalId('ApiAccountConfig'); + + // ImageHandlerApiDeployment + const cfnApiGatewayDeployment = new cdkApiGateway.CfnDeployment(this, 'ImageHanlderApiDeployment', { + restApiId: apiGateway.restApiId, + stageName: 'image', + stageDescription: { + accessLogSetting: { + destinationArn: cfnApiGatewayLogGroup.attrArn, + format: '$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] "$context.httpMethod $context.resourcePath $context.protocol" $context.status $context.responseLength $context.requestId' + } + } + }); + this.addCfnNagSuppressRules(cfnApiGatewayDeployment, [ + { + id: 'W68', + reason: 'The solution does not require the usage plan.' + } + ]); + this.addDependencies(cfnApiGatewayDeployment, [ cfnApiGatewayAccount ]); + cfnApiGatewayDeployment.overrideLogicalId('ImageHandlerApiDeployment'); + + // Logs + const cloudFrontToApiGateway = cloudFrontApiGatewayLambda.node.findChild('CloudFrontToApiGateway'); + const accessLogBucket = cloudFrontToApiGateway.node.findChild('CloudfrontLoggingBucket') as cdkS3.Bucket; + const cfnAccessLogBucket = accessLogBucket.node.defaultChild as cdkS3.CfnBucket; + this.addCfnNagSuppressRules(cfnAccessLogBucket, [ + { + "id": "W35", + "reason": "Used to store access logs for other buckets" + } + ]); + cfnAccessLogBucket.overrideLogicalId('Logs'); + + // LogsBucketPolicy + const accessLogBucketPolicy = accessLogBucket.node.findChild('Policy') as cdkS3.BucketPolicy; + (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).overrideLogicalId('LogsBucketPolicy'); + + // ImageHandlerDistribution + const cfnCloudFrontDistribution = cloudFrontWebDistribution.node.defaultChild as cdkCloudFront.CfnDistribution; + cfnCloudFrontDistribution.distributionConfig = { + origins: [{ + domainName: `${apiGateway.restApiId}.execute-api.${cdk.Aws.REGION}.amazonaws.com`, + id: apiGateway.restApiId, + originPath: '/image', + customOriginConfig: { + httpsPort: 443, + originProtocolPolicy: 'https-only', + originSslProtocols: [ 'TLSv1.1', 'TLSv1.2' ] + } + }], + enabled: true, + httpVersion: 'http2', + comment: 'Image handler distribution', + defaultCacheBehavior: { + allowedMethods: [ 'GET', 'HEAD' ], + targetOriginId: apiGateway.restApiId, + forwardedValues: { + queryString: false, + headers: [ 'Origin', 'Accept' ], + cookies: { forward: 'none' } + }, + viewerProtocolPolicy: 'https-only' + }, + customErrorResponses: [ + { errorCode: 500, errorCachingMinTtl: 10 }, + { errorCode: 501, errorCachingMinTtl: 10 }, + { errorCode: 502, errorCachingMinTtl: 10 }, + { errorCode: 503, errorCachingMinTtl: 10 }, + { errorCode: 504, errorCachingMinTtl: 10 } + ], + priceClass: 'PriceClass_All', + logging: { + includeCookies: false, + bucket: accessLogBucket.bucketDomainName, + prefix: 'image-handler-cf-logs/' + } + }; + cfnCloudFrontDistribution.overrideLogicalId('ImageHandlerDistribution'); + + // CloudFrontToS3 pattern + const cloudFrontToS3 = new CloudFrontToS3(this, 'CloudFrontToS3', { + bucketProps: { + versioned: false, + websiteIndexDocument: 'index.html', + websiteErrorDocument: 'index.html', + serverAccessLogsBucket: undefined, + accessControl: cdkS3.BucketAccessControl.PRIVATE + }, + insertHttpSecurityHeaders: false + }); + this.removeChildren(cloudFrontToS3, [ 'S3LoggingBucket', 'CloudfrontLoggingBucket' ]); + + // DemoBucket + const demoBucket = cloudFrontToS3.s3Bucket as cdkS3.Bucket; + const cfnDemoBucket = demoBucket.node.defaultChild as cdkS3.CfnBucket; + cfnDemoBucket.cfnOptions.condition = deployDemoUiCondition; + this.addCfnNagSuppressRules(cfnDemoBucket, [ + { + id: 'W35', + reason: 'This S3 bucket does not require access logging. API calls and image operations are logged to CloudWatch with custom reporting.' + } + ]) + cfnDemoBucket.overrideLogicalId('DemoBucket'); + + // DemoOriginAccessIdentity + const cfnDemoOriginAccessIdentity = cloudFrontToS3.node.findChild('CloudFrontOriginAccessIdentity') as cdkCloudFront.CfnCloudFrontOriginAccessIdentity; + cfnDemoOriginAccessIdentity.cloudFrontOriginAccessIdentityConfig = { + comment: `access-identity-${demoBucket.bucketName}` + }; + cfnDemoOriginAccessIdentity.cfnOptions.condition = deployDemoUiCondition; + cfnDemoOriginAccessIdentity.overrideLogicalId('DemoOriginAccessIdentity'); + + // DemoBucketPolicy + const demoBucketPolicy = demoBucket.node.findChild('Policy'); + const cfnDemoBucketPolicy = demoBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy; + cfnDemoBucketPolicy.policyDocument = { + Statement: [ + { + Action: [ 's3:GetObject' ], + Effect: 'Allow', + Resource: `${demoBucket.bucketArn}/*`, + Principal: { + CanonicalUser: cfnDemoOriginAccessIdentity.attrS3CanonicalUserId + } + } + ] + }; + cfnDemoBucketPolicy.cfnOptions.condition = deployDemoUiCondition; + cfnDemoBucketPolicy.cfnOptions.metadata = {}; + cfnDemoBucketPolicy.overrideLogicalId('DemoBucketPolicy'); + + // DemoDistribution + const demoDistribution = cloudFrontToS3.cloudFrontWebDistribution; + const cfnDemoDistribution = demoDistribution.node.defaultChild as cdkCloudFront.CfnDistribution; + cfnDemoDistribution.distributionConfig = { + comment: 'Website distribution for solution', + origins: [{ + id: 'S3-solution-website', + domainName: demoBucket.bucketRegionalDomainName, + s3OriginConfig: { + originAccessIdentity: `origin-access-identity/cloudfront/${cfnDemoOriginAccessIdentity.ref}` + } + }], + defaultCacheBehavior: { + targetOriginId: 'S3-solution-website', + allowedMethods: [ 'GET', 'HEAD' ], + cachedMethods: [ 'GET', 'HEAD' ], + forwardedValues: { + queryString: false + }, + viewerProtocolPolicy: 'redirect-to-https' + }, + ipv6Enabled: true, + viewerCertificate: { + cloudFrontDefaultCertificate: true + }, + enabled: true, + httpVersion: 'http2', + logging: { + includeCookies: false, + bucket: accessLogBucket.bucketDomainName, + prefix: 'demo-cf-logs/' + } + }; + cfnDemoDistribution.cfnOptions.condition = deployDemoUiCondition; + cfnDemoDistribution.overrideLogicalId('DemoDistribution'); + + // CustomResourceRole + const customResourceRole = new cdkIam.Role(this, 'CustomResourceRole', { + assumedBy: new cdkIam.ServicePrincipal('lambda.amazonaws.com'), + path: '/', + roleName: `${cdk.Aws.STACK_NAME}CustomResourceRole-${cdk.Aws.REGION}` + }); + const cfnCustomResourceRole = customResourceRole.node.defaultChild as cdkIam.CfnRole; + this.addCfnNagSuppressRules(cfnCustomResourceRole, [ + { + id: 'W28', + reason: 'Resource name validated and found to pose no risk to updates that require replacement of this resource.' + } + ]); + cfnCustomResourceRole.overrideLogicalId('CustomResourceRole'); + + // CustomResourcePolicy + const customResourcePolicy = new cdkIam.Policy(this, 'CustomResourcePolicy', { + policyName: `${cdk.Aws.STACK_NAME}CustomResourcePolicy`, + statements: [ + new cdkIam.PolicyStatement({ + actions: [ + 'logs:CreateLogStream', + 'logs:CreateLogGroup', + 'logs:PutLogEvents' + ], + resources: [ + `arn:${cdk.Aws.PARTITION}:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/*` + ] + }), + new cdkIam.PolicyStatement({ + actions: [ + 's3:GetObject', + 's3:PutObject', + 's3:ListBucket' + ], + resources: [ + `arn:${cdk.Aws.PARTITION}:s3:::*` + ] + }) + ] + }); + customResourcePolicy.attachToRole(customResourceRole); + const cfnCustomResourcePolicy = customResourcePolicy.node.defaultChild as cdkIam.CfnPolicy; + cfnCustomResourcePolicy.overrideLogicalId('CustomResourcePolicy'); + + // CustomResourceFunction + const customResourceFunction = new cdkLambda.Function(this, 'CustomResourceFunction', { + description: 'Serverless Image Handler - Custom resource', + code: new cdkLambda.S3Code( + cdkS3.Bucket.fromBucketArn(this, 'CustomResourceLambdaSource', `arn:${cdk.Aws.PARTITION}:s3:::${BUCKET_NAME}-${cdk.Aws.REGION}`), + `${SOLUTION_NAME}/${VERSION}/custom-resource.zip` + ), + handler: 'index.handler', + runtime: cdkLambda.Runtime.NODEJS_12_X, + timeout: cdk.Duration.seconds(30), + memorySize: 128, + role: customResourceRole + }); + const cfnCustomResourceFuction = customResourceFunction.node.defaultChild as cdkLambda.CfnFunction; + this.addCfnNagSuppressRules(cfnCustomResourceFuction, [ + { + id: 'W58', + reason: 'False alarm: The Lambda function does have the permission to write CloudWatch Logs.' + } + ]); + cfnCustomResourceFuction.overrideLogicalId('CustomResourceFunction'); + + // CustomResourceLogGroup + const customResourceLogGroup = new cdkLogs.LogGroup(this, 'CustomResourceLogGroup', { + logGroupName: `/aws/lambda/${customResourceFunction.functionName}` + }); + const cfnCustomResourceLogGroup = customResourceLogGroup.node.defaultChild as cdkLogs.CfnLogGroup; + cfnCustomResourceLogGroup.retentionInDays = props.logRetentionPeriodParameter.valueAsNumber; + cfnCustomResourceLogGroup.overrideLogicalId('CustomResourceLogGroup'); + + // CustomResourceCopyS3 + this.createCustomResource('CustomResourceCopyS3', customResourceFunction, { + properties: [ + { path: 'Region', value: cdk.Aws.REGION }, + { path: 'manifestKey', value: `${SOLUTION_NAME}/${VERSION}/demo-ui-manifest.json` }, + { path: 'sourceS3Bucket', value: `${BUCKET_NAME}-${cdk.Aws.REGION}` }, + { path: 'sourceS3key', value: `${SOLUTION_NAME}/${VERSION}/demo-ui` }, + { path: 'destS3Bucket', value: demoBucket.bucketName }, + { path: 'version', value: VERSION }, + { path: 'customAction', value: 'copyS3assets' }, + ], + condition: deployDemoUiCondition, + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + }); + + // CustomResourceConfig + this.createCustomResource('CustomResourceConfig', customResourceFunction, { + properties: [ + { path: 'Region', value: cdk.Aws.REGION }, + { path: 'configItem', value: { apiEndpoint: `https://${cloudFrontWebDistribution.domainName}` } }, + { path: 'destS3Bucket', value: demoBucket.bucketName }, + { path: 'destS3key', value: 'demo-ui-config.js' }, + { path: 'customAction', value: 'putConfigFile' }, + ], + condition: deployDemoUiCondition, + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + }); + + // CustomResourceUuid + const customResourceUuid = this.createCustomResource('CustomResourceUuid', customResourceFunction, { + properties: [ + { path: 'Region', value: cdk.Aws.REGION }, + { path: 'customAction', value: 'createUuid' } + ], + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + }); + + // CustomResourceAnonymousMetric + this.createCustomResource('CustomResourceAnonymousMetric', customResourceFunction, { + properties: [ + { path: 'Region', value: cdk.Aws.REGION }, + { path: 'solutionId', value: 'SO0023' }, + { path: 'UUID', value: cdk.Fn.getAtt(customResourceUuid.logicalId, 'UUID').toString() }, + { path: 'version', value: VERSION }, + { path: 'anonymousData', value: cdk.Fn.findInMap('Send', 'AnonymousUsage', 'Data') }, + { path: 'customAction', value: 'sendMetric' } + ], + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + }); + + // CustomResourceCheckSourceBuckets + this.createCustomResource('CustomResourceCheckSourceBuckets', customResourceFunction, { + properties: [ + { path: 'Region', value: cdk.Aws.REGION }, + { path: 'sourceBuckets', value: props.sourceBucketsParameter.valueAsString }, + { path: 'customAction', value: 'checkSourceBuckets' }, + ], + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + }); + } catch (error) { + console.error(error); + } + } + + /** + * Adds cfn-nag suppression rules to the AWS CloudFormation resource metadata. + * @param {cdk.CfnResource} resource Resource to add cfn-nag suppression rules + * @param {CfnNagSuppressRule[]} rules Rules to suppress + */ + addCfnNagSuppressRules(resource: cdk.CfnResource, rules: CfnNagSuppressRule[]) { + resource.addMetadata('cfn_nag', { + rules_to_suppress: rules + }); + } + + /** + * Adds dependencies to the AWS CloudFormation resource. + * @param {cdk.CfnResource} resource Resource to add AWS CloudFormation dependencies + * @param {cdk.CfnResource[]} dependencies Dependencies to be added to the AWS CloudFormation resource + */ + addDependencies(resource: cdk.CfnResource, dependencies: cdk.CfnResource[]) { + for (let dependency of dependencies) { + resource.addDependsOn(dependency); + } + } + + /** + * Removes AWS CDK created children from the AWS CloudFormation resource. + * @param {cdk.IConstruct} resource Resource to delete children + * @param {string[]} children The list of children to delete from the resource + */ + removeChildren(resource: cdk.IConstruct, children: string[]) { + for (let child of children) { + resource.node.tryRemoveChild(child); + } + } + + /** + * Removes all dependent children of the resource. + * @param {cdk.IConstruct} resource Resource to delete all dependent children + */ + removeAllChildren(resource: cdk.IConstruct) { + let children = resource.node.children; + for (let child of children) { + this.removeAllChildren(child); + resource.node.tryRemoveChild(child.node.id); + } + } + + /** + * Creates custom resource to the AWS CloudFormation template. + * @param {string} id Custom resource ID + * @param {cdkLambda.Function} customResourceFunction Custom resource Lambda function + * @param {CustomResourceConfig} config Custom resource configuration + * @return {cdk.CfnCustomResource} + */ + createCustomResource(id: string, customResourceFunction: cdkLambda.Function, config?: CustomResourceConfig): cdk.CfnCustomResource { + const customResource = new cdk.CfnCustomResource(this, id, { + serviceToken: customResourceFunction.functionArn + }); + customResource.addOverride('Type', 'Custom::CustomResource'); + customResource.overrideLogicalId(id); + + if (config) { + const { properties, condition, dependencies } = config; + + if (properties) { + for (let property of properties) { + customResource.addPropertyOverride(property.path, property.value); + } + } + + if (dependencies) { + this.addDependencies(customResource, dependencies); + } + + customResource.cfnOptions.condition = condition; + } + + return customResource; + } +} \ No newline at end of file diff --git a/source/constructs/package.json b/source/constructs/package.json new file mode 100644 index 000000000..905e03b7f --- /dev/null +++ b/source/constructs/package.json @@ -0,0 +1,37 @@ +{ + "name": "constructs", + "description": "Serverless Image Handler Constructs", + "version": "5.0.0", + "license": "Apache-2.0", + "bin": { + "constructs": "bin/constructs.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "export BUCKET_NAME=TEST && export SOLUTION_NAME=serverless-image-handler && export VERSION=TEST_VERSION && jest", + "cdk": "cdk" + }, + "devDependencies": { + "@aws-cdk/assert": "1.57.0", + "@types/jest": "^26.0.8", + "@types/node": "^12.12.53", + "aws-cdk": "1.57.0", + "jest": "^25.5.4", + "ts-jest": "^25.5.1", + "ts-node": "^8.10.2", + "typescript": "~3.9.6" + }, + "dependencies": { + "@aws-cdk/aws-apigateway": "1.57.0", + "@aws-cdk/aws-cloudfront": "1.57.0", + "@aws-cdk/aws-iam": "1.57.0", + "@aws-cdk/aws-lambda": "1.57.0", + "@aws-cdk/aws-s3": "1.57.0", + "@aws-cdk/core": "1.57.0", + "@aws-solutions-constructs/aws-apigateway-lambda": "1.57.0", + "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "1.57.0", + "@aws-solutions-constructs/aws-cloudfront-s3": "1.57.0", + "@aws-solutions-constructs/core": "1.57.0" + } +} diff --git a/source/constructs/test/constructs.test.ts b/source/constructs/test/constructs.test.ts new file mode 100644 index 000000000..c40e53c6f --- /dev/null +++ b/source/constructs/test/constructs.test.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect as expectCDK, matchTemplate, MatchStyle } from '@aws-cdk/assert'; +import * as cdk from '@aws-cdk/core'; +import * as Constructs from '../lib/constructs-stack'; +import TestTemplate from './serverless-image-handler-test.json'; + +test('Serverless Image Handler Stack', () => { + const app = new cdk.App(); + // WHEN + const stack = new Constructs.ConstructsStack(app, 'MyTestStack'); + // THEN + expectCDK(stack).to(matchTemplate(TestTemplate, MatchStyle.EXACT)); +}); diff --git a/source/constructs/test/serverless-image-handler-test.json b/source/constructs/test/serverless-image-handler-test.json new file mode 100644 index 000000000..21a07f8f2 --- /dev/null +++ b/source/constructs/test/serverless-image-handler-test.json @@ -0,0 +1,1338 @@ +{ + "Description": "(SO0023) - Serverless Image Handler with aws-solutions-constructs: This template deploys and configures a serverless architecture that is optimized for dynamic image manipulation and delivery at low latency and cost. Leverages SharpJS for image processing. Template version TEST_VERSION", + "AWSTemplateFormatVersion": "2010-09-09", + "Metadata": { + "AWS::CloudFormation::Interface": { + "ParameterGroups": [ + { + "Label": { + "default": "CORS Options" + }, + "Parameters": [ + "CorsEnabled", + "CorsOrigin" + ] + }, + { + "Label": { + "default": "Image Sources" + }, + "Parameters": [ + "SourceBuckets" + ] + }, + { + "Label": { + "default": "Demo UI" + }, + "Parameters": [ + "DeployDemoUI" + ] + }, + { + "Label": { + "default": "Event Logging" + }, + "Parameters": [ + "LogRetentionPeriod" + ] + } + ] + } + }, + "Parameters": { + "CorsEnabled": { + "Type": "String", + "Default": "No", + "AllowedValues": [ + "Yes", + "No" + ], + "Description": "Would you like to enable Cross-Origin Resource Sharing (CORS) for the image handler API? Select 'Yes' if so." + }, + "CorsOrigin": { + "Type": "String", + "Default": "*", + "Description": "If you selected 'Yes' above, please specify an origin value here. A wildcard (*) value will support any origin. We recommend specifying an origin (i.e. https://example.domain) to restrict cross-site access to your API." + }, + "SourceBuckets": { + "Type": "String", + "Default": "defaultBucket, bucketNo2, bucketNo3, ...", + "AllowedPattern": ".+", + "Description": "(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will be the first bucket listed in this field." + }, + "DeployDemoUI": { + "Type": "String", + "Default": "Yes", + "AllowedValues": [ + "Yes", + "No" + ], + "Description": "Would you like to deploy a demo UI to explore the features and capabilities of this solution? This will create an additional Amazon S3 bucket and Amazon CloudFront distribution in your account." + }, + "LogRetentionPeriod": { + "Type": "Number", + "Default": "1", + "AllowedValues": [ + "1", + "3", + "5", + "7", + "14", + "30", + "60", + "90", + "120", + "150", + "180", + "365", + "400", + "545", + "731", + "1827", + "3653" + ], + "Description": "This solution automatically logs events to Amazon CloudWatch. Select the amount of time for CloudWatch logs from this solution to be retained (in days)." + }, + "AutoWebP": { + "Type": "String", + "Default": "No", + "AllowedValues": [ + "Yes", + "No" + ], + "Description": "Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so." + } + }, + "Mappings": { + "Send": { + "AnonymousUsage": { + "Data": "Yes" + } + } + }, + "Conditions": { + "DeployDemoUICondition": { + "Fn::Equals": [ + { + "Ref": "DeployDemoUI" + }, + "Yes" + ] + }, + "EnableCorsCondition": { + "Fn::Equals": [ + { + "Ref": "CorsEnabled" + }, + "Yes" + ] + } + }, + "Resources": { + "ImageHandlerFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Path": "/", + "RoleName": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "ImageHandlerFunctionRole-", + { + "Ref": "AWS::Region" + } + ] + ] + } + }, + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W28", + "reason": "Resource name validated and found to pose no risk to updates that require replacement of this resource." + } + ] + } + } + }, + "ImageHandlerPolicy": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":log-group:/aws/lambda/*" + ] + ] + } + }, + { + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::*" + ] + ] + } + }, + { + "Action": "rekognition:DetectFaces", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "ImageHandlerPolicy" + ] + ] + }, + "Roles": [ + { + "Ref": "ImageHandlerFunctionRole" + } + ] + }, + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W12", + "reason": "rekognition:DetectFaces requires '*' resources." + } + ] + } + } + }, + "ImageHandlerFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::TEST-", + { + "Ref": "AWS::Region" + } + ] + ] + } + ] + } + ] + } + ] + } + ] + }, + "S3Key": "serverless-image-handler/TEST_VERSION/image-handler.zip" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ImageHandlerFunctionRole", + "Arn" + ] + }, + "Runtime": "nodejs12.x", + "Description": "Serverless Image Handler - Function for performing image edits and manipulations.", + "Environment": { + "Variables": { + "AUTO_WEBP": { + "Ref": "AutoWebP" + }, + "CORS_ENABLED": { + "Ref": "CorsEnabled" + }, + "CORS_ORIGIN": { + "Ref": "CorsOrigin" + }, + "SOURCE_BUCKETS": { + "Ref": "SourceBuckets" + }, + "REWRITE_MATCH_PATTERN": "", + "REWRITE_SUBSTITUTION": "" + } + }, + "MemorySize": 256, + "Timeout": 30 + }, + "DependsOn": [ + "ImageHandlerFunctionRole" + ], + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W58", + "reason": "False alarm: The Lambda function does have the permission to write CloudWatch Logs." + } + ] + } + } + }, + "ImageHandlerPermission": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ImageHandlerFunction", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "ImageHandlerApi" + }, + "/*/*/*" + ] + ] + } + } + }, + "ImageHandlerLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ImageHandlerFunction" + } + ] + ] + }, + "RetentionInDays": { + "Ref": "LogRetentionPeriod" + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "ApiLogs": { + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "ImageHandlerApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Body": { + "swagger": "2.0", + "info": { + "title": "ServerlessImageHandler" + }, + "basePath": "/image", + "schemes": [ + "https" + ], + "paths": { + "/{proxy+}": { + "x-amazon-apigateway-any-method": { + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "proxy", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": {}, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":", + "lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ImageHandlerFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "cacheNamespace": "xh7gp9", + "cacheKeyParameters": [ + "method.request.path.proxy" + ], + "contentHandling": "CONVERT_TO_TEXT", + "type": "aws_proxy" + } + } + } + }, + "x-amazon-apigateway-binary-media-types": [ + "*/*" + ] + }, + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "Name": "ServerlessImageHandler" + } + }, + "ApiLoggingRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + "logs:GetLogEvents", + "logs:FilterLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:logs:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "LambdaRestApiCloudWatchRolePolicy" + } + ] + } + }, + "ApiAccountConfig": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "ApiLoggingRole", + "Arn" + ] + } + }, + "DependsOn": [ + "ImageHandlerApi" + ] + }, + "Logs": { + "Type": "AWS::S3::Bucket", + "Properties": { + "AccessControl": "LogDeliveryWrite", + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + }, + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "VersioningConfiguration": { + "Status": "Enabled" + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W35", + "reason": "Used to store access logs for other buckets" + } + ] + } + } + }, + "LogsBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Logs" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + }, + "Effect": "Deny", + "Principal": "*", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Logs", + "Arn" + ] + }, + "/*" + ] + ] + }, + "Sid": "HttpsOnly" + } + ], + "Version": "2012-10-17" + } + } + }, + "ImageHandlerDistribution": { + "Type": "AWS::CloudFront::Distribution", + "Properties": { + "DistributionConfig": { + "Comment": "Image handler distribution", + "CustomErrorResponses": [ + { + "ErrorCachingMinTTL": 10, + "ErrorCode": 500 + }, + { + "ErrorCachingMinTTL": 10, + "ErrorCode": 501 + }, + { + "ErrorCachingMinTTL": 10, + "ErrorCode": 502 + }, + { + "ErrorCachingMinTTL": 10, + "ErrorCode": 503 + }, + { + "ErrorCachingMinTTL": 10, + "ErrorCode": 504 + } + ], + "DefaultCacheBehavior": { + "AllowedMethods": [ + "GET", + "HEAD" + ], + "ForwardedValues": { + "Cookies": { + "Forward": "none" + }, + "Headers": [ + "Origin", + "Accept" + ], + "QueryString": false + }, + "TargetOriginId": { + "Ref": "ImageHandlerApi" + }, + "ViewerProtocolPolicy": "https-only" + }, + "Enabled": true, + "HttpVersion": "http2", + "Logging": { + "Bucket": { + "Fn::GetAtt": [ + "Logs", + "DomainName" + ] + }, + "IncludeCookies": false, + "Prefix": "image-handler-cf-logs/" + }, + "Origins": [ + { + "CustomOriginConfig": { + "HTTPSPort": 443, + "OriginProtocolPolicy": "https-only", + "OriginSSLProtocols": [ + "TLSv1.1", + "TLSv1.2" + ] + }, + "DomainName": { + "Fn::Join": [ + "", + [ + { + "Ref": "ImageHandlerApi" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com" + ] + ] + }, + "Id": { + "Ref": "ImageHandlerApi" + }, + "OriginPath": "/image" + } + ], + "PriceClass": "PriceClass_All" + } + }, + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W70", + "reason": "Since the distribution uses the CloudFront domain name, CloudFront automatically sets the security policy to TLSv1 regardless of the value of MinimumProtocolVersion" + } + ] + } + } + }, + "ImageHandlerApiDeployment": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "ImageHandlerApi" + }, + "StageDescription": { + "AccessLogSetting": { + "DestinationArn": { + "Fn::GetAtt": [ + "ApiLogs", + "Arn" + ] + }, + "Format": "$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] \"$context.httpMethod $context.resourcePath $context.protocol\" $context.status $context.responseLength $context.requestId" + } + }, + "StageName": "image" + }, + "DependsOn": [ + "ApiAccountConfig" + ], + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W68", + "reason": "The solution does not require the usage plan." + } + ] + } + } + }, + "DemoBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "AccessControl": "Private", + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + }, + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "WebsiteConfiguration": { + "ErrorDocument": "index.html", + "IndexDocument": "index.html" + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W35", + "reason": "This S3 bucket does not require access logging. API calls and image operations are logged to CloudWatch with custom reporting." + } + ] + } + }, + "Condition": "DeployDemoUICondition" + }, + "DemoBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "DemoBucket" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "DemoBucket", + "Arn" + ] + }, + "/*" + ] + ] + }, + "Principal": { + "CanonicalUser": { + "Fn::GetAtt": [ + "DemoOriginAccessIdentity", + "S3CanonicalUserId" + ] + } + } + } + ] + } + }, + "Condition": "DeployDemoUICondition" + }, + "DemoOriginAccessIdentity": { + "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity", + "Properties": { + "CloudFrontOriginAccessIdentityConfig": { + "Comment": { + "Fn::Join": [ + "", + [ + "access-identity-", + { + "Ref": "DemoBucket" + } + ] + ] + } + } + }, + "Condition": "DeployDemoUICondition" + }, + "DemoDistribution": { + "Type": "AWS::CloudFront::Distribution", + "Properties": { + "DistributionConfig": { + "Comment": "Website distribution for solution", + "DefaultCacheBehavior": { + "AllowedMethods": [ + "GET", + "HEAD" + ], + "CachedMethods": [ + "GET", + "HEAD" + ], + "ForwardedValues": { + "QueryString": false + }, + "TargetOriginId": "S3-solution-website", + "ViewerProtocolPolicy": "redirect-to-https" + }, + "Enabled": true, + "HttpVersion": "http2", + "IPV6Enabled": true, + "Logging": { + "Bucket": { + "Fn::GetAtt": [ + "Logs", + "DomainName" + ] + }, + "IncludeCookies": false, + "Prefix": "demo-cf-logs/" + }, + "Origins": [ + { + "DomainName": { + "Fn::GetAtt": [ + "DemoBucket", + "RegionalDomainName" + ] + }, + "Id": "S3-solution-website", + "S3OriginConfig": { + "OriginAccessIdentity": { + "Fn::Join": [ + "", + [ + "origin-access-identity/cloudfront/", + { + "Ref": "DemoOriginAccessIdentity" + } + ] + ] + } + } + } + ], + "ViewerCertificate": { + "CloudFrontDefaultCertificate": true + } + } + }, + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W70", + "reason": "Since the distribution uses the CloudFront domain name, CloudFront automatically sets the security policy to TLSv1 regardless of the value of MinimumProtocolVersion" + } + ] + } + }, + "Condition": "DeployDemoUICondition" + }, + "CustomResourceRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Path": "/", + "RoleName": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "CustomResourceRole-", + { + "Ref": "AWS::Region" + } + ] + ] + } + }, + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W28", + "reason": "Resource name validated and found to pose no risk to updates that require replacement of this resource." + } + ] + } + } + }, + "CustomResourcePolicy": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":log-group:/aws/lambda/*" + ] + ] + } + }, + { + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "CustomResourcePolicy" + ] + ] + }, + "Roles": [ + { + "Ref": "CustomResourceRole" + } + ] + } + }, + "CustomResourceFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::TEST-", + { + "Ref": "AWS::Region" + } + ] + ] + } + ] + } + ] + } + ] + } + ] + }, + "S3Key": "serverless-image-handler/TEST_VERSION/custom-resource.zip" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "CustomResourceRole", + "Arn" + ] + }, + "Runtime": "nodejs12.x", + "Description": "Serverless Image Handler - Custom resource", + "MemorySize": 128, + "Timeout": 30 + }, + "DependsOn": [ + "CustomResourceRole" + ], + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W58", + "reason": "False alarm: The Lambda function does have the permission to write CloudWatch Logs." + } + ] + } + } + }, + "CustomResourceLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "CustomResourceFunction" + } + ] + ] + }, + "RetentionInDays": { + "Ref": "LogRetentionPeriod" + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "CustomResourceCopyS3": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "Region": { + "Ref": "AWS::Region" + }, + "manifestKey": "serverless-image-handler/TEST_VERSION/demo-ui-manifest.json", + "sourceS3Bucket": { + "Fn::Join": [ + "", + [ + "TEST-", + { + "Ref": "AWS::Region" + } + ] + ] + }, + "sourceS3key": "serverless-image-handler/TEST_VERSION/demo-ui", + "destS3Bucket": { + "Ref": "DemoBucket" + }, + "version": "TEST_VERSION", + "customAction": "copyS3assets" + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ], + "Condition": "DeployDemoUICondition" + }, + "CustomResourceConfig": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "Region": { + "Ref": "AWS::Region" + }, + "configItem": { + "apiEndpoint": { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::GetAtt": [ + "ImageHandlerDistribution", + "DomainName" + ] + } + ] + ] + } + }, + "destS3Bucket": { + "Ref": "DemoBucket" + }, + "destS3key": "demo-ui-config.js", + "customAction": "putConfigFile" + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ], + "Condition": "DeployDemoUICondition" + }, + "CustomResourceUuid": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "Region": { + "Ref": "AWS::Region" + }, + "customAction": "createUuid" + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ] + }, + "CustomResourceAnonymousMetric": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "Region": { + "Ref": "AWS::Region" + }, + "solutionId": "SO0023", + "UUID": { + "Fn::GetAtt": [ + "CustomResourceUuid", + "UUID" + ] + }, + "version": "TEST_VERSION", + "anonymousData": { + "Fn::FindInMap": [ + "Send", + "AnonymousUsage", + "Data" + ] + }, + "customAction": "sendMetric" + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ] + }, + "CustomResourceCheckSourceBuckets": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "Region": { + "Ref": "AWS::Region" + }, + "sourceBuckets": { + "Ref": "SourceBuckets" + }, + "customAction": "checkSourceBuckets" + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ] + } + }, + "Outputs": { + "ApiEndpoint": { + "Description": "Link to API endpoint for sending image requests to.", + "Value": { + "Fn::Sub": "https://${ImageHandlerDistribution.DomainName}" + } + }, + "DemoUrl": { + "Description": "Link to the demo user interface for the solution.", + "Value": { + "Fn::Sub": "https://${DemoDistribution.DomainName}/index.html" + }, + "Condition": "DeployDemoUICondition" + }, + "SourceBuckets": { + "Description": "Amazon S3 bucket location containing original image files.", + "Value": { + "Ref": "SourceBuckets" + } + }, + "CorsEnabled": { + "Description": "Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.", + "Value": { + "Ref": "CorsEnabled" + } + }, + "CorsOrigin": { + "Description": "Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.", + "Value": { + "Ref": "CorsOrigin" + }, + "Condition": "EnableCorsCondition" + }, + "LogRetentionPeriod": { + "Description": "Number of days for event logs from Lambda to be retained in CloudWatch.", + "Value": { + "Ref": "LogRetentionPeriod" + } + } + } +} \ No newline at end of file diff --git a/source/constructs/tsconfig.json b/source/constructs/tsconfig.json new file mode 100644 index 000000000..a09f01808 --- /dev/null +++ b/source/constructs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "commonjs", + "lib": ["es2018"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "exclude": ["cdk.out"] +} diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js index f90c1e3ca..e4c867de3 100644 --- a/source/custom-resource/index.js +++ b/source/custom-resource/index.js @@ -15,7 +15,6 @@ console.log('Loading function'); -const AWS = require('aws-sdk'); const https = require('https'); const url = require('url'); const moment = require('moment'); @@ -201,6 +200,41 @@ exports.handler = (event, context, callback) => { sendResponse(event, callback, context.logStreamName, responseStatus, responseData, responseData.Error); }); + } else if (event.ResourceProperties.customAction === 'createUuid') { + responseStatus = 'SUCCESS'; + responseData = { + UUID: uuidv4() + }; + sendResponse(event, callback, context.logStreamName, responseStatus, responseData); + } else if (event.ResourceProperties.customAction === 'sendMetric') { + responseStatus = 'SUCCESS'; + + if (event.ResourceProperties.anonymousData === 'Yes') { + let _metric = { + Solution: event.ResourceProperties.solutionId, + UUID: event.ResourceProperties.UUID, + TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), + Data: { + Version: event.ResourceProperties.version, + Updated: moment().utc().format() + } + }; + + let _usageMetrics = new UsageMetrics(); + _usageMetrics.sendAnonymousMetric(_metric).then((data) => { + console.log(data); + console.log('Annonymous metrics successfully sent.'); + sendResponse(event, callback, context.logStreamName, responseStatus, responseData); + }).catch((err) => { + responseData = { + Error: 'Sending anonymous delete metric failed' + }; + console.log([responseData.Error, ':\n', err].join('')); + sendResponse(event, callback, context.logStreamName, responseStatus, responseData); + }); + } else { + sendResponse(event, callback, context.logStreamName, 'SUCCESS'); + } } else { sendResponse(event, callback, context.logStreamName, 'SUCCESS'); } diff --git a/source/custom-resource/lib/s3-helper.js b/source/custom-resource/lib/s3-helper.js deleted file mode 100644 index 5d403a7bb..000000000 --- a/source/custom-resource/lib/s3-helper.js +++ /dev/null @@ -1,281 +0,0 @@ -/********************************************************************************************************************* - * 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 deleted file mode 100644 index c0705b5dd..000000000 --- a/source/custom-resource/lib/usage-metrics/metrics.common.js +++ /dev/null @@ -1,74 +0,0 @@ -/********************************************************************************************************************* - * 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 deleted file mode 100644 index 6c7bb160f..000000000 --- a/source/custom-resource/lib/usage-metrics/test-setup.spec.js +++ /dev/null @@ -1,28 +0,0 @@ -/********************************************************************************************************************* - * 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/custom-resource/package.json b/source/custom-resource/package.json index b65cca379..c8bb4e70a 100644 --- a/source/custom-resource/package.json +++ b/source/custom-resource/package.json @@ -5,7 +5,7 @@ "author": { "name": "aws-solutions-builder" }, - "version": "0.0.1", + "version": "5.0.0", "private": true, "dependencies": { "moment": "^2.24.0", @@ -14,12 +14,12 @@ }, "devDependencies": { "aws-sdk": "*", + "aws-sdk-mock": "*", "chai": "*", + "mocha": "^8.1.1", + "npm-run-all": "*", "sinon": "*", - "sinon-chai": "*", - "mocha": "*", - "aws-sdk-mock": "*", - "npm-run-all": "*" + "sinon-chai": "*" }, "scripts": { "pretest": "npm install", diff --git a/source/demo-ui/scripts.js b/source/demo-ui/scripts.js deleted file mode 100644 index 00437a669..000000000 --- a/source/demo-ui/scripts.js +++ /dev/null @@ -1,116 +0,0 @@ -/********************************************************************************************************************* - * 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 deleted file mode 100644 index 49bf4b615..000000000 --- a/source/image-handler/image-handler.js +++ /dev/null @@ -1,240 +0,0 @@ -/********************************************************************************************************************* - * 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 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(); - const keys = Object.keys(edits); - const values = Object.values(edits); - - // Apply the image edits - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const value = values[i]; - if (key === 'overlayWith') { - let imageMetadata = metadata; - if (edits.resize) { - let imageBuffer = await image.toBuffer(); - imageMetadata = await sharp(imageBuffer).resize({ edits: { resize: edits.resize }}).metadata(); - } - - const { bucket, key, wRatio, hRatio, alpha } = value; - const overlay = await this.getOverlayImage(bucket, key, wRatio, hRatio, alpha, imageMetadata); - const overlayMetadata = await sharp(overlay).metadata(); - - let { options } = value; - if (options) { - if (options.left) { - let left = options.left; - if (left.endsWith('p')) { - left = parseInt(left.replace('p', '')); - if (left < 0) { - left = imageMetadata.width + (imageMetadata.width * left / 100) - overlayMetadata.width; - } else { - left = imageMetadata.width * left / 100; - } - } else { - left = parseInt(left); - if (left < 0) { - left = imageMetadata.width + left - overlayMetadata.width; - } - } - options.left = parseInt(left); - } - if (options.top) { - let top = options.top; - if (top.endsWith('p')) { - top = parseInt(top.replace('p', '')); - if (top < 0) { - top = imageMetadata.height + (imageMetadata.height * top / 100) - overlayMetadata.height; - } else { - top = imageMetadata.height * top / 100; - } - } else { - top = parseInt(top); - if (top < 0) { - top = imageMetadata.height + top - overlayMetadata.height; - } - } - options.top = parseInt(top); - } - } - - const params = [{ ...options, input: overlay }]; - image.composite(params); - } else if (key === 'smartCrop') { - const options = value; - const imageBuffer = await image.toBuffer(); - const 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[key](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 keyname corresponding to the overlay. - */ - 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 Promise.resolve(convertedImage); - } catch (err) { - return Promise.reject({ - 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 Promise.resolve(response.FaceDetails[faceIdx].BoundingBox); - } catch (err) { - console.log(err); - if (err.message === "Cannot read property 'BoundingBox' of undefined") { - return Promise.reject({ - 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 { - return Promise.reject({ - 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 deleted file mode 100644 index 2a47530da..000000000 --- a/source/image-handler/image-request.js +++ /dev/null @@ -1,303 +0,0 @@ -/********************************************************************************************************************* - * 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 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 Promise.resolve(this); - } catch (err) { - return Promise.reject(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 Promise.resolve(originalImage.Body); - } catch(err) { - return Promise.reject({ - 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 splitPath = path.split("/"); - const encoded = splitPath[splitPath.length - 1]; - 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 && 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/index.js b/source/image-handler/index.js index 98b6f8773..1a0f20788 100755 --- a/source/image-handler/index.js +++ b/source/image-handler/index.js @@ -1,5 +1,5 @@ /********************************************************************************************************************* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * 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 * diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 66201a474..21a178be1 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -5,15 +5,15 @@ "author": { "name": "aws-solutions-builder" }, - "version": "0.0.1", + "version": "5.0.0", "private": true, "dependencies": { - "sharp": "^0.23.4", + "sharp": "^0.25.4", "color": "3.1.2", "color-name": "1.1.4" }, "devDependencies": { - "aws-sdk": "^2.437.0", + "aws-sdk": "2.631.0", "aws-sdk-mock": "^4.4.0", "mocha": "^6.1.4", "sinon": "^7.3.2", @@ -22,7 +22,7 @@ "scripts": { "pretest": "npm run build:init && npm install", "test": "nyc --reporter=html --reporter=text mocha", - "build:init": "rm -rf package-lock.json && rm -rf dist && rm -rf node_modules", + "build:init": "rm -rf package-lock.json dist/ node_modules/", "build:zip": "zip -rq image-handler.zip .", "build:dist": "mkdir dist && mv image-handler.zip dist/", "build": "npm run build:init && npm install --arch=x64 --platform=linux --production && npm run build:zip && npm run build:dist" diff --git a/source/image-handler/test/test-image-handler.js b/source/image-handler/test/test-image-handler.js deleted file mode 100644 index a42c2a918..000000000 --- a/source/image-handler/test/test-image-handler.js +++ /dev/null @@ -1,476 +0,0 @@ -/********************************************************************************************************************* - * 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 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(); - await imageHandler.applyEdits(originalImage, edits).then((result) => { - assert.deepEqual(result.options.input.buffer, originalImage); - }); - }); - }); - describe('003/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) => { - //console.log(result); - const sharp = require('sharp'); - const originalImageData = sharp(originalImage); - assert.deepEqual((originalImageData.options.input !== result.options.input), true) - }).catch((err) => { - console.log(err) - }) - }); - }); - describe('004/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) => { - //console.log(result); - const sharp = require('sharp'); - const originalImageData = sharp(originalImage); - assert.deepEqual((originalImageData.options.input !== result.options.input), true) - }).catch((err) => { - console.log(err) - }) - }); - }); - describe('005/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) => { - //console.log(result); - const sharp = require('sharp'); - const originalImageData = sharp(originalImage); - assert.deepEqual((originalImageData.options.input !== result.options.input), true) - }).catch((err) => { - console.log(err) - }) - }); - }); - describe('006/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) => { - //console.log(result); - const sharp = require('sharp'); - 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 deleted file mode 100644 index 9507dacef..000000000 --- a/source/image-handler/test/test-image-request.js +++ /dev/null @@ -1,757 +0,0 @@ -/********************************************************************************************************************* - * 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 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/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('003/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('004/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 - await imageRequest.setup(event).then(() => { - console.log(data); - }).catch((err) => { - console.log(err); - assert.deepEqual(err.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 - imageRequest.getOriginalImage('invalidBucket', 'invalidKey').then((result) => { - assert.equal(typeof result, Error); - assert.equal(result.status, 404); - }).catch((err) => console.log(err)); - }); - }); - describe('003/unknownError', async function() { - it(`Should throw an error if an unkown problem happens when getting an object`, async function() { - // Arrange - const S3 = require('aws-sdk/clients/s3'); - const sinon = require('sinon'); - const getObject = S3.prototype.getObject = sinon.stub(); - getObject.withArgs({Bucket: 'invalidBucket', Key: 'invalidKey'}).returns({ - promise: () => { - return Promise.reject({ - code: 'InternalServerError', - message: 'SimulatedException' - }) - } - }); - // Act - const imageRequest = new ImageRequest(); - // Assert - imageRequest.getOriginalImage('invalidBucket', 'invalidKey').then((result) => { - assert.equal(typeof result, Error); - assert.equal(result.status, 500); - }).catch((err) => console.log(err)); - }); - }); -}); - -// ---------------------------------------------------------------------------- -// 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 - assert.throws(function() { - imageRequest.parseImageBucket(event, 'Default'); - }, Object, { - 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 - assert.throws(function() { - imageRequest.parseImageBucket(event, undefined); - }, Object, { - status: 400, - 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((typeof result !== undefined), !undefined) - }); - }); - 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 - assert.throws(function() { - imageRequest.parseImageEdits(event, undefined); - }, Object, { - 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/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('003/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('004/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 - assert.throws(function() { - imageRequest.parseImageKey(event, undefined); - }, Object, { - status: 400, - code: 'ImageEdits::CannotFindImage', - message: 'The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists.' - }); - }); - }); -}); - -// ---------------------------------------------------------------------------- -// 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 - assert.throws(function() { - const a = imageRequest.parseRequestType(event); - }, Object, { - status: 400, - code: 'RequestType::CannotDetermineRequestType', - message: 'The type of request you are making could not be properly routed. Please check your request syntax and refer to the documentation for additional guidance.' - }); - }); - }); -}); - -// ---------------------------------------------------------------------------- -// 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 - assert.throws(function() { - imageRequest.decodeRequest(event); - }, Object, { - 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 - assert.throws(function() { - imageRequest.decodeRequest(event); - }, Object, { - 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 - assert.throws(function() { - imageRequest.getAllowedSourceBuckets(); - }, Object, { - 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: true - }; - const event = { - headers: { - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" - } - }; - // Act - const imageRequest = new ImageRequest(); - var result = imageRequest.getOutputFormat(event); - // Assert - assert.deepEqual(result, 'webp'); - }); - }); - describe('002/AcceptsHeaderDoesNotIncludeWebP', function () { - it(`Should pass if it returns null for an accepts header which does not include webp`, function () { - // Arrange - process.env = { - AUTO_WEBP: true - }; - const event = { - headers: { - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" - } - }; - // Act - const imageRequest = new ImageRequest(); - var result = imageRequest.getOutputFormat(event); - // Assert - assert.deepEqual(result, null); - }); - }); - describe('003/AutoWebPDisabled', function () { - it(`Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp`, function () { - // Arrange - process.env = { - AUTO_WEBP: false - }; - const event = { - headers: { - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" - } - }; - // Act - const imageRequest = new ImageRequest(); - var result = imageRequest.getOutputFormat(event); - // Assert - assert.deepEqual(result, null); - }); - }); - describe('004/AutoWebPUnset', function () { - it(`Should pass if it returns null when AUTO_WEBP is not set with accepts header including webp`, function () { - // Arrange - const event = { - headers: { - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" - } - }; - // Act - const imageRequest = new ImageRequest(); - var result = imageRequest.getOutputFormat(event); - // Assert - assert.deepEqual(result, null); - }); - }); -}); diff --git a/source/image-handler/test/test-thumbor-mapping.js b/source/image-handler/test/test-thumbor-mapping.js deleted file mode 100644 index 1186bc5d3..000000000 --- a/source/image-handler/test/test-thumbor-mapping.js +++ /dev/null @@ -1,842 +0,0 @@ -/********************************************************************************************************************* - * 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 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); - }); - }); -}); - -// ---------------------------------------------------------------------------- -// 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 } }} - }; - 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 } }} - }; - 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 = (typeof(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 = {}; - thumborMapping.mapFilter(edit, filetype); - // Assert - const expectedResult = { - edits: { - resize: { fit: 'fill' } - } - }; - assert.deepEqual(thumborMapping, expectedResult); - }); - }); - describe('026/stretch/sizingMethodUndefined', 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 = {}; - thumborMapping.sizingMethod = undefined; - thumborMapping.mapFilter(edit, filetype); - // Assert - const expectedResult = { - edits: { - resize: { fit: 'fill' } - }, - sizingMethod: undefined - }; - assert.deepEqual(thumborMapping, expectedResult); - }); - }); - describe('027/stretch/sizingMethodNotFitIn', 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 = {}; - thumborMapping.sizingMethod = "cover"; - thumborMapping.mapFilter(edit, filetype); - // Assert - const expectedResult = { - edits: { - resize: { fit: 'fill' } - }, - sizingMethod: "cover" - }; - assert.deepEqual(thumborMapping, expectedResult); - }); - }); - describe('028/stretch/sizingMethodFitIn', 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 = {}; - thumborMapping.sizingMethod = "fit-in"; - thumborMapping.mapFilter(edit, filetype); - // Assert - const expectedResult = { - edits: { - resize: {} - }, - sizingMethod: "fit-in" - }; - assert.deepEqual(thumborMapping, expectedResult); - }); - }); - describe('029/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('030/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('031/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('032/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 deleted file mode 100644 index 4dca04f57..000000000 --- a/source/image-handler/thumbor-mapping.js +++ /dev/null @@ -1,256 +0,0 @@ -/********************************************************************************************************************* - * 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 = {}; - this.sizingMethod; - } - - /** - * 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+/g); - if (dimPath) { - const dims = dimPath[0].split('x'); - // Set only if the dimensions provided are valid - if (!isNaN(dims[0]) && !isNaN(dims[1])) { - this.edits.resize = {}; - this.edits.resize.fit = 'fill'; - - // Assign dimenions from the first match only to avoid parsing dimension from image file names - this.edits.resize.width = Number(dims[0]); - this.edits.resize.height = Number(dims[1]); - } - } - - // Parse the image path - for (let i = 0; i < edits.length; i++) { - const edit = edits[i]; - if (edit === ('fit-in')) { - if (this.edits.resize === undefined) { - this.edits.resize = {}; - } - - this.edits.resize.fit = 'inside'; - this.sizingMethod = edit; - } 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 key = matched[1]; - let value = matched[2]; - // Find the proper filter - if (key === ('autojpg')) { - this.edits.toFormat = 'jpeg'; - } - else if (key === ('background_color')) { - if (!ColorName[value]) { - value = `#${value}` - } - this.edits.flatten = { background: Color(value).object() }; - } - else if (key === ('blur')) { - const val = value.split(','); - this.edits.blur = (val.length > 1) ? Number(val[1]) : Number(val[0]) / 2; - } - else if (key === ('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 (key === ('equalize')) { - this.edits.normalize = "true"; - } - else if (key === ('fill')) { - if (this.edits.resize === undefined) { - this.edits.resize = {}; - } - if (!ColorName[value]) { - value = `#${value}` - } - this.edits.resize.background = Color(value).object(); - } - else if (key === ('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 (key === ('grayscale')) { - this.edits.grayscale = true; - } - else if (key === ('no_upscale')) { - if (this.edits.resize === undefined) { - this.edits.resize = {}; - } - this.edits.resize.withoutEnlargement = true; - } - else if (key === ('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 (key === ('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 (key === ('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 (key === ('rotate')) { - this.edits.rotate = Number(value); - } - else if (key === ('sharpen')) { - const sh = value.split(','); - const sigma = 1 + Number(sh[1]) / 2; - this.edits.sharpen = sigma; - } - else if (key === ('stretch')) { - if (this.edits.resize === undefined) { - this.edits.resize = {}; - } - if (this.sizingMethod === undefined || this.sizingMethod !== 'fit-in') { - this.edits.resize.fit = "fill"; - } - } - else if (key === ('strip_exif')) { - this.edits.rotate = 0; - } - else if (key === ('strip_icc')) { - this.edits.rotate = 0; - } - else if (key === ('upscale')) { - if (this.edits.resize === undefined) { - this.edits.resize = {}; - } - this.edits.resize.fit = "inside" - } - else if (key === ('watermark')) { - const options = value.replace(/\s+/g, '').split(','); - const bucket = options[0]; - const key = options[1]; - const xPos = options[2]; - const yPos = options[3]; - const alpha = options[4]; - const wRatio = options[5]; - const hRatio = options[6]; - - this.edits.overlayWith = { - bucket, - key, - alpha, - wRatio, - hRatio, - options: {} - } - const allowedPosPattern = /^(100|[1-9]?[0-9]|-(100|[1-9][0-9]?))p$/; - if (allowedPosPattern.test(xPos) || !isNaN(xPos)) { - this.edits.overlayWith.options['left'] = xPos; - } - if (allowedPosPattern.test(yPos) || !isNaN(yPos)) { - this.edits.overlayWith.options['top'] = yPos; - } - } - else { - return undefined; - } - } -} - -// Exports -module.exports = ThumborMapping; \ No newline at end of file