diff --git a/.gitignore b/.gitignore
index ff9a9a40b..e2823d860 100755
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
# test assets
**/coverage
+**/coverage-reports
**/.nyc_output
# misc
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 350513e8b..39bb1b72e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,78 +1,121 @@
# Change Log
+
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).
+## [6.0.0] - 2021-12-21
+
+### โ BREAKING CHANGES
+
+Version 6.0.0 does not support upgrading from previous versions due to the update that uses the AWS CDK to generate the AWS CloudFormation template.
+
+### Added
+
+- Crop feature in Thumbor URLs: [#202](https://github.com/aws-solutions/serverless-image-handler/pull/202)
+- TypeScript typings: [#293](https://github.com/aws-solutions/serverless-image-handler/issues/293)
+- Reduction effort support: [#289](https://github.com/aws-solutions/serverless-image-handler/issues/289)
+- Allow custom requests for keys without file extensions: [#273](https://github.com/aws-solutions/serverless-image-handler/issues/273)
+
+### Fixed
+
+- Unexpected behavior after adding support for images without extension: [#307](https://github.com/aws-solutions/serverless-image-handler/issues/307)
+- Quality filter does not work with format filter (thumbor): [#266](https://github.com/aws-solutions/serverless-image-handler/issues/266)
+- Auto WebP activated, `Content-Type: image/webp` returned, but still it's JPG encoded: [#305](https://github.com/aws-solutions/serverless-image-handler/issues/305)
+- `inferImageType` doesn't support binary/octet-stream but not application/octet-stream: [#306](https://github.com/aws-solutions/serverless-image-handler/issues/306)
+- SmartCrop boundary exceeded: [#263](https://github.com/aws-solutions/serverless-image-handler/issues/263)
+- Custom rewrite does not work without file extensions: [#268](https://github.com/aws-solutions/serverless-image-handler/issues/268)
+- Secrets manager cost issue: [#291](https://github.com/aws-solutions/serverless-image-handler/issues/291)
+- `inferImageType` is slow: [#303](https://github.com/aws-solutions/serverless-image-handler/issues/303)
+- If the file name contain `()`๏ผthe API will return 404,NoSuchKey,The specified key does not exist: [#299](https://github.com/aws-solutions/serverless-image-handler/issues/299)
+- `fit-in` segment in URL path generates 404: [#281](https://github.com/aws-solutions/serverless-image-handler/issues/281)
+- `overlayWith` top/left return int after percent conversion: [#276](https://github.com/aws-solutions/serverless-image-handler/issues/276)
+
## [5.2.0] - 2021-01-29
+
### Added
-- Support for ap-east-1 and me-south-1 regions: [#192](https://github.com/awslabs/serverless-image-handler/issues/192), [#228](https://github.com/awslabs/serverless-image-handler/issues/228), [#232](https://github.com/awslabs/serverless-image-handler/issues/232)
+
+- Support for ap-east-1 and me-south-1 regions: [#192](https://github.com/aws-solutions/serverless-image-handler/issues/192), [#228](https://github.com/aws-solutions/serverless-image-handler/issues/228), [#232](https://github.com/aws-solutions/serverless-image-handler/issues/232)
- Unit tests for custom-resource: `100%` coverage
-- Cloudfront cache policy and origin request policy: [#229](https://github.com/awslabs/serverless-image-handler/issues/229)
-- Circular cropping feature: [#214](https://github.com/awslabs/serverless-image-handler/issues/214), [#216](https://github.com/awslabs/serverless-image-handler/issues/216)
+- Cloudfront cache policy and origin request policy: [#229](https://github.com/aws-solutions/serverless-image-handler/issues/229)
+- Circular cropping feature: [#214](https://github.com/aws-solutions/serverless-image-handler/issues/214), [#216](https://github.com/aws-solutions/serverless-image-handler/issues/216)
- Unit tests for image-handler: `100%` coverage
-- Support for files without extension on thumbor requests: [#169](https://github.com/awslabs/serverless-image-handler/issues/169), [#188](https://github.com/awslabs/serverless-image-handler/issues/188)
-- Inappropriate content detection feature: [#243](https://github.com/awslabs/serverless-image-handler/issues/243)
+- Support for files without extension on thumbor requests: [#169](https://github.com/aws-solutions/serverless-image-handler/issues/169), [#188](https://github.com/aws-solutions/serverless-image-handler/issues/188)
+- Inappropriate content detection feature: [#243](https://github.com/aws-solutions/serverless-image-handler/issues/243)
- Unit tests for image-request: `100%` coverage
### Fixed
-- Graceful failure when no faces are detected using smartCrop and fail on resizing before smartCrop: [#132](https://github.com/awslabs/serverless-image-handler/issues/132), [#133](https://github.com/awslabs/serverless-image-handler/issues/133)
-- Broken SVG returned if no edits specified and Auto-WebP enabled: [#247](https://github.com/awslabs/serverless-image-handler/issues/247)
-- Removed "--recursive" from README.md: [#255](https://github.com/awslabs/serverless-image-handler/pull/255)
-- fixed issue with failure on resize if width or height is float: [#254](https://github.com/awslabs/serverless-image-handler/issues/254)
-### Changed
+- Graceful failure when no faces are detected using smartCrop and fail on resizing before smartCrop: [#132](https://github.com/aws-solutions/serverless-image-handler/issues/132), [#133](https://github.com/aws-solutions/serverless-image-handler/issues/133)
+- Broken SVG returned if no edits specified and Auto-WebP enabled: [#247](https://github.com/aws-solutions/serverless-image-handler/issues/247)
+- Removed "--recursive" from README.md: [#255](https://github.com/aws-solutions/serverless-image-handler/pull/255)
+- fixed issue with failure on resize if width or height is float: [#254](https://github.com/aws-solutions/serverless-image-handler/issues/254)
+
+### Changed
+
- Constructs test template for constructs unit test: `100%` coverage
## [5.1.0] - 2020-11-19
+
### โ BREAKING CHANGES
+
- **Image URL Signature**: When image URL signature is enabled, all URLs including existing URLs should have `signature` query parameter.
### Added
-- Image URL signature: [#111](https://github.com/awslabs/serverless-image-handler/issues/111), [#203](https://github.com/awslabs/serverless-image-handler/issues/203), [#221](https://github.com/awslabs/serverless-image-handler/issues/221), [#227](https://github.com/awslabs/serverless-image-handler/pull/227)
-- AWS Lambda `413` error handling. When the response payload is bigger than 6MB, it throws `TooLargeImageException`: [#35](https://github.com/awslabs/serverless-image-handler/issues/35), [#97](https://github.com/awslabs/serverless-image-handler/issues/97), [#193](https://github.com/awslabs/serverless-image-handler/issues/193), [#204](https://github.com/awslabs/serverless-image-handler/issues/204)
-- Default fallback image: [#137](https://github.com/awslabs/serverless-image-handler/issues/137)
+
+- Image URL signature: [#111](https://github.com/aws-solutions/serverless-image-handler/issues/111), [#203](https://github.com/aws-solutions/serverless-image-handler/issues/203), [#221](https://github.com/aws-solutions/serverless-image-handler/issues/221), [#227](https://github.com/aws-solutions/serverless-image-handler/pull/227)
+- AWS Lambda `413` error handling. When the response payload is bigger than 6MB, it throws `TooLargeImageException`: [#35](https://github.com/aws-solutions/serverless-image-handler/issues/35), [#97](https://github.com/aws-solutions/serverless-image-handler/issues/97), [#193](https://github.com/aws-solutions/serverless-image-handler/issues/193), [#204](https://github.com/aws-solutions/serverless-image-handler/issues/204)
+- Default fallback image: [#137](https://github.com/aws-solutions/serverless-image-handler/issues/137)
- Unit tests for custom resource: `100%` coverage
-- Add `SVG` support. When any edits are used, the output would be automatically `PNG` unless the output format is specified: [#31](https://github.com/awslabs/serverless-image-handler/issues/31), [#234](https://github.com/awslabs/serverless-image-handler/issues/234)
-- Custom headers: [#182](https://github.com/awslabs/serverless-image-handler/pull/182)
-- Enabling ALB Support : [#201](https://github.com/awslabs/serverless-image-handler/pull/201)
+- Add `SVG` support. When any edits are used, the output would be automatically `PNG` unless the output format is specified: [#31](https://github.com/aws-solutions/serverless-image-handler/issues/31), [#234](https://github.com/aws-solutions/serverless-image-handler/issues/234)
+- Custom headers: [#182](https://github.com/aws-solutions/serverless-image-handler/pull/182)
+- Enabling ALB Support : [#201](https://github.com/aws-solutions/serverless-image-handler/pull/201)
### Fixed
-- Thumbor paths broken if they include "-" and "100x100": [#208](https://github.com/awslabs/serverless-image-handler/issues/208)
-- Rewrite doesn't seem to be working: [#121](https://github.com/awslabs/serverless-image-handler/issues/121)
-- Correct EXIF: [#197](https://github.com/awslabs/serverless-image-handler/issues/197), [#220](https://github.com/awslabs/serverless-image-handler/issues/220), [#235](https://github.com/awslabs/serverless-image-handler/issues/235), [#236](https://github.com/awslabs/serverless-image-handler/issues/236), [#240](https://github.com/awslabs/serverless-image-handler/issues/240)
-- Sub folder support in Thumbor `watermark` filter: [#231](https://github.com/awslabs/serverless-image-handler/issues/231)
+
+- Thumbor paths broken if they include "-" and "100x100": [#208](https://github.com/aws-solutions/serverless-image-handler/issues/208)
+- Rewrite doesn't seem to be working: [#121](https://github.com/aws-solutions/serverless-image-handler/issues/121)
+- Correct EXIF: [#197](https://github.com/aws-solutions/serverless-image-handler/issues/197), [#220](https://github.com/aws-solutions/serverless-image-handler/issues/220), [#235](https://github.com/aws-solutions/serverless-image-handler/issues/235), [#236](https://github.com/aws-solutions/serverless-image-handler/issues/236), [#240](https://github.com/aws-solutions/serverless-image-handler/issues/240)
+- Sub folder support in Thumbor `watermark` filter: [#231](https://github.com/aws-solutions/serverless-image-handler/issues/231)
### Changed
+
- AWS CDK and AWS Solutions Constructs version (from 1.57.0 to 1.64.1)
- sharp base version (from 0.25.4 to 0.26.1)
- Refactors the custom resource Lambda source code
- Migrate unit tests to use `jest`
-- Move all `aws-sdk` in `ImageHandler` Labmda function to `index.js` for the best practice
-- Enhance the default error message not to show empty JSON: [#206](https://github.com/awslabs/serverless-image-handler/issues/206)
+- Move all `aws-sdk` in `ImageHandler` Lambda function to `index.js` for the best practice
+- Enhance the default error message not to show empty JSON: [#206](https://github.com/aws-solutions/serverless-image-handler/issues/206)
### Removed
+
- Remove `manifest-generator`
## [5.0.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)
+
+- Auto WebP does not work properly: [#195](https://github.com/aws-solutions/serverless-image-handler/pull/195), [#200](https://github.com/aws-solutions/serverless-image-handler/issues/200), [#205](https://github.com/aws-solutions/serverless-image-handler/issues/205)
+- A bug where base64 encoding containing slash: [#194](https://github.com/aws-solutions/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.
+ - `0` size support: [#183](https://github.com/aws-solutions/serverless-image-handler/issues/183)
+ - `convolution` filter does not work: [#187](https://github.com/aws-solutions/serverless-image-handler/issues/187)
+ - `fill` filter does not work: [#190](https://github.com/aws-solutions/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)
+- Remove `Promise` to return since `async` functions return promises: [#189](https://github.com/aws-solutions/serverless-image-handler/issues/189)
- Unit test statement coverage improvement:
- `image-handler.js`: `79.05%` to `100%`
- `image-request.js`: `93.58%` to `100%`
@@ -80,44 +123,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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)
-- Support serving images under s3 subdirectories, Fix to make /fit-in/ work; Fix for VipsJpeg: Invalid SOS error plus several other critical fixes from the pull request [#130](https://github.com/awslabs/serverless-image-handler/pull/130)
-- Allow regex in SOURCE_BUCKETS for environment variable from the pull request [#138](https://github.com/awslabs/serverless-image-handler/pull/138)
-- Fix build script on other platforms from the pull request [#139](https://github.com/awslabs/serverless-image-handler/pull/139)
-- Add Cache-Control response header from the pull request [#151](https://github.com/awslabs/serverless-image-handler/pull/151)
-- Add AUTO_WEBP option to automatically serve WebP if the client supports it from the pull request [#152](https://github.com/awslabs/serverless-image-handler/pull/152)
-- Use HTTP 404 & forward Cache-Control, Content-Type, Expires, and Last-Modified headers from S3 from the pull request [#158](https://github.com/awslabs/serverless-image-handler/pull/158)
-- fix: DeprecationWarning: Buffer() is deprecated from the pull request [#174](https://github.com/awslabs/serverless-image-handler/pull/174)
-- Add hex color support for Thumbor ```filters:background_color``` and ```filters:fill``` [#154](https://github.com/awslabs/serverless-image-handler/issues/154)
-- Add format and watermark support for Thumbor [#109](https://github.com/awslabs/serverless-image-handler/issues/109), [#131](https://github.com/awslabs/serverless-image-handler/issues/131), [#109](https://github.com/awslabs/serverless-image-handler/issues/142)
-- __Note that__ duplicated features has been merged gracefully.
+
+- Honor outputFormat Parameter from the pull request [#117](https://github.com/aws-solutions/serverless-image-handler/pull/117)
+- Support serving images under s3 subdirectories, Fix to make /fit-in/ work; Fix for VipsJpeg: Invalid SOS error plus several other critical fixes from the pull request [#130](https://github.com/aws-solutions/serverless-image-handler/pull/130)
+- Allow regex in SOURCE_BUCKETS for environment variable from the pull request [#138](https://github.com/aws-solutions/serverless-image-handler/pull/138)
+- Fix build script on other platforms from the pull request [#139](https://github.com/aws-solutions/serverless-image-handler/pull/139)
+- Add Cache-Control response header from the pull request [#151](https://github.com/aws-solutions/serverless-image-handler/pull/151)
+- Add AUTO_WEBP option to automatically serve WebP if the client supports it from the pull request [#152](https://github.com/aws-solutions/serverless-image-handler/pull/152)
+- Use HTTP 404 & forward Cache-Control, Content-Type, Expires, and Last-Modified headers from S3 from the pull request [#158](https://github.com/aws-solutions/serverless-image-handler/pull/158)
+- fix: DeprecationWarning: Buffer() is deprecated from the pull request [#174](https://github.com/aws-solutions/serverless-image-handler/pull/174)
+- Add hex color support for Thumbor `filters:background_color` and `filters:fill` [#154](https://github.com/aws-solutions/serverless-image-handler/issues/154)
+- Add format and watermark support for Thumbor [#109](https://github.com/aws-solutions/serverless-image-handler/issues/109), [#131](https://github.com/aws-solutions/serverless-image-handler/issues/131), [#109](https://github.com/aws-solutions/serverless-image-handler/issues/142)
+- **Note that** duplicated features has been merged gracefully.
### Changed
+
- sharp base version (from 0.23.3 to 0.23.4)
-- Image handler Amazon CloudFront distribution ```DefaultCacheBehavior.ForwaredValues.Header``` to ```["Origin", "Accept"]``` for webp
-- Image resize process change for ```filters:no_upscale()``` handling by ```withoutEnlargement``` edit key [#144](https://github.com/awslabs/serverless-image-handler/issues/144)
+- Image handler Amazon CloudFront distribution `DefaultCacheBehavior.ForwardedValues.Header` to `["Origin", "Accept"]` for WebP
+- Image resize process change for `filters:no_upscale()` handling by `withoutEnlargement` edit key [#144](https://github.com/aws-solutions/serverless-image-handler/issues/144)
### Fixed
-- Add and fix Cache-control, Content-Type, Expires, and Last-Modified headers to response: [#103](https://github.com/awslabs/serverless-image-handler/issues/103), [#107](https://github.com/awslabs/serverless-image-handler/issues/107), [#120](https://github.com/awslabs/serverless-image-handler/issues/120)
-- Fix Amazon S3 bucket subfolder issue: [#106](https://github.com/awslabs/serverless-image-handler/issues/106), [#112](https://github.com/awslabs/serverless-image-handler/issues/112), [#119](https://github.com/awslabs/serverless-image-handler/issues/119), [#123](https://github.com/awslabs/serverless-image-handler/issues/123), [#167](https://github.com/awslabs/serverless-image-handler/issues/167), [#175](https://github.com/awslabs/serverless-image-handler/issues/175)
-- Fix HTTP status code for missing images from 500 to 404: [#159](https://github.com/awslabs/serverless-image-handler/issues/159)
-- Fix European character in filename issue: [#149](https://github.com/awslabs/serverless-image-handler/issues/149)
-- Fix image scaling issue for filename containing 'x' character: [#163](https://github.com/awslabs/serverless-image-handler/issues/163), [#176](https://github.com/awslabs/serverless-image-handler/issues/176)
-- Fix regular expression issue: [#114](https://github.com/awslabs/serverless-image-handler/issues/114), [#121](https://github.com/awslabs/serverless-image-handler/issues/121), [#125](https://github.com/awslabs/serverless-image-handler/issues/125)
-- Fix not working quality parameter: [#129](https://github.com/awslabs/serverless-image-handler/issues/129)
+
+- Add and fix Cache-control, Content-Type, Expires, and Last-Modified headers to response: [#103](https://github.com/aws-solutions/serverless-image-handler/issues/103), [#107](https://github.com/aws-solutions/serverless-image-handler/issues/107), [#120](https://github.com/aws-solutions/serverless-image-handler/issues/120)
+- Fix Amazon S3 bucket subfolder issue: [#106](https://github.com/aws-solutions/serverless-image-handler/issues/106), [#112](https://github.com/aws-solutions/serverless-image-handler/issues/112), [#119](https://github.com/aws-solutions/serverless-image-handler/issues/119), [#123](https://github.com/aws-solutions/serverless-image-handler/issues/123), [#167](https://github.com/aws-solutions/serverless-image-handler/issues/167), [#175](https://github.com/aws-solutions/serverless-image-handler/issues/175)
+- Fix HTTP status code for missing images from 500 to 404: [#159](https://github.com/aws-solutions/serverless-image-handler/issues/159)
+- Fix European character in filename issue: [#149](https://github.com/aws-solutions/serverless-image-handler/issues/149)
+- Fix image scaling issue for filename containing 'x' character: [#163](https://github.com/aws-solutions/serverless-image-handler/issues/163), [#176](https://github.com/aws-solutions/serverless-image-handler/issues/176)
+- Fix regular expression issue: [#114](https://github.com/aws-solutions/serverless-image-handler/issues/114), [#121](https://github.com/aws-solutions/serverless-image-handler/issues/121), [#125](https://github.com/aws-solutions/serverless-image-handler/issues/125)
+- Fix not working quality parameter: [#129](https://github.com/aws-solutions/serverless-image-handler/issues/129)
## [4.1] - 2019-12-31
+
### Added
+
- CHANGELOG file
- Access logging to API Gateway
### Changed
+
- Lambda functions runtime to nodejs12.x
- sharp version (from 0.21.3 to 0.23.3)
- Image handler function to use Composite API (https://sharp.pixelplumbing.com/en/stable/api-composite/)
- License to Apache-2.0
### Removed
+
- Reference to deprecated sharp function (overlayWith)
- Capability to resize images proportionally if width or height is set to 0 (sharp v0.23.1 and later check that the width and height - if present - are positive integers)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99f3ecffb..86a7e3019 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,4 +1,5 @@
# Contributing Guidelines
+
Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
documentation, we greatly value feedback and contributions from our community.
@@ -6,20 +7,21 @@ Please read through this document before submitting any issues or pull requests
information to effectively respond to your bug report or contribution.
## Reporting Bugs/Feature Requests
+
We welcome you to use the GitHub issue tracker to report bugs or suggest features.
-When filing an issue, please check [existing open](https://github.com/awslabs/serverless-image-handler/issues), or [recently closed](https://github.com/awslabs/serverless-image-handler/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already
-reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
+When filing an issue, please check [existing open](https://github.com/aws-solutions/serverless-image-handler/issues), or [recently closed](https://github.com/aws-solutions/serverless-image-handler/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
-* A reproducible test case or series of steps
-* The version of our code being used
-* Any modifications you've made relevant to the bug
-* Anything unusual about your environment or deployment
+- A reproducible test case or series of steps
+- The version of our code being used
+- Any modifications you've made relevant to the bug
+- Anything unusual about your environment or deployment
## Contributing via Pull Requests
+
Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
-1. You are working against the latest source on the *master* branch.
+1. You are working against the latest source on the _main_ branch.
2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
@@ -32,20 +34,22 @@ To send us a pull request, please:
5. Send us a pull request, answering any default questions in the pull request interface.
6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
-GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
-[creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
+GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
## Finding contributions to work on
-Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/awslabs/serverless-image-handler/labels/help%20wanted) issues is a great place to start.
+
+Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-solutions/serverless-image-handler/labels/help%20wanted) issues is a great place to start.
## Code of Conduct
+
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
-For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
-opensource-codeofconduct@amazon.com with any additional questions or comments.
+For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments.
## Security issue notifications
+
If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
## Licensing
-See the [LICENSE](https://github.com/awslabs/serverless-image-handler/blob/master/LICENSE.txt) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
+
+See the [LICENSE](https://github.com/aws-solutions/serverless-image-handler/blob/main/LICENSE.txt) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes.
diff --git a/NOTICE.txt b/NOTICE.txt
index a772ce15e..b48f50867 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -6,13 +6,45 @@ THIRD PARTY COMPONENTS
**********************
This software includes third party software subject to the following copyrights:
-aws-sdk under the Apache License Version 2.0
+@aws-cdk/assert under the Apache License 2.0
+@aws-cdk/aws-apigateway under the Apache License 2.0
+@aws-cdk/aws-cloudfront under the Apache License 2.0
+@aws-cdk/aws-iam under the Apache License 2.0
+@aws-cdk/aws-lambda under the Apache License 2.0
+@aws-cdk/aws-s3 under the Apache License 2.0
+@aws-cdk/core under the Apache License 2.0
+@aws-solutions-constructs/aws-apigateway-lambda under the Apache License 2.0
+@aws-solutions-constructs/aws-cloudfront-apigateway-lambda under the Apache License 2.0
+@aws-solutions-constructs/aws-cloudfront-s3 under the Apache License 2.0
+@aws-solutions-constructs/core under the Apache License 2.0
+@popperjs/core under the Massachusetts Institute of Technology (MIT) license
+@types/color under the Massachusetts Institute of Technology (MIT) license
+@types/color-name under the Massachusetts Institute of Technology (MIT) license
+@types/jest under the Massachusetts Institute of Technology (MIT) license
+@types/node under the Massachusetts Institute of Technology (MIT) license
+@types/sharp under the Massachusetts Institute of Technology (MIT) license
+@types/uuid under the Massachusetts Institute of Technology (MIT) license
+@typescript-eslint/eslint-plugin under the Massachusetts Institute of Technology (MIT) license
+@typescript-eslint/parser under the BSD 2-Clause license
+aws-cdk under the Apache License 2.0
+aws-sdk under the Apache License 2.0
axios under the Massachusetts Institute of Technology (MIT) license
-axios-mock-adapter under the Massachusetts Institute of Technology (MIT) license
bootstrap under the Massachusetts Institute of Technology (MIT) license
color under the Massachusetts Institute of Technology (MIT) license
color-name under the Massachusetts Institute of Technology (MIT) license
+eslint under the Massachusetts Institute of Technology (MIT) license
+eslint-config-prettier under the Massachusetts Institute of Technology (MIT) license
+eslint-config-standard under the Massachusetts Institute of Technology (MIT) license
+eslint-plugin-header under the Massachusetts Institute of Technology (MIT) license
+eslint-plugin-import under the Massachusetts Institute of Technology (MIT) license
+eslint-plugin-jsdoc under the BSD 2-Clause license
+eslint-plugin-node under the Massachusetts Institute of Technology (MIT) license
+eslint-plugin-prettier under the Massachusetts Institute of Technology (MIT) license
jest under the Massachusetts Institute of Technology (MIT) license
-mocha under the Massachusetts Institute of Technology (MIT) license
-sharp under the Apache License Version 2.0
-uuid under the Massachusetts Institute of Technology (MIT) license
\ No newline at end of file
+jquery under the Massachusetts Institute of Technology (MIT) license
+moment under the Massachusetts Institute of Technology (MIT) license
+sharp under the Apache License 2.0
+ts-jest under the Massachusetts Institute of Technology (MIT) license
+ts-node under the Massachusetts Institute of Technology (MIT) license
+typescript under the Apache License 2.0
+uuid under the Massachusetts Institute of Technology (MIT) license
diff --git a/README.md b/README.md
index 636ecdc2f..2783d0956 100644
--- a/README.md
+++ b/README.md
@@ -1,97 +1,118 @@
-**_Important Notice:_**
-Due to a [change in the AWS Lambda execution environment](https://aws.amazon.com/blogs/compute/upcoming-updates-to-the-aws-lambda-execution-environment/), Serverless Image Handler v3 deployments are functionally broken. To address the issue we have released [minor version update v3.1.1](https://solutions-reference.s3.amazonaws.com/serverless-image-handler/v3.1.1/serverless-image-handler.template). We recommend all users of v3 to run cloudformation stack update with v3.1.1. Additionally, we suggest you to look at v5 of the solution and migrate to v5 if it addresses all of your use cases.
+**[Serverless Image Handler](https://aws.amazon.com/solutions/implementations/serverless-image-handler/)** | **[๐ง Feature request](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=)** | **[๐ Bug Report](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=bug&template=bug_report.md&title=)** | **[โ General Question](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=question&template=general_question.md&title=)**
+
+**Note**: If you want to use the solution without building from source, navigate to [Solution Landing Page](https://aws.amazon.com/solutions/implementations/serverless-image-handler/).
+
+## Table of Content
+
+- [Solution Overview](#solution-overview)
+- [Architecture Diagram](#architecture-diagram)
+- [AWS CDK and Solutions Constructs](#aws-cdk-and-solutions-constructs)
+- [Customizing the Solution](#customizing-the-solution)
+ - [Prerequisites for Customization](#prerequisites-for-customization)
+ - [1. Clone the repository](#1-clone-the-repository)
+ - [2. Declare environment variables](#2-declare-environment-variables)
+ - [Unit Test](#unit-test)
+ - [Build](#build)
+ - [Deploy](#deploy)
+- [Collection of operational metrics](#collection-of-operational-metrics)
+- [External Contributors](#external-contributors)
+- [License](#license)
-# AWS Serverless Image Handler Lambda wrapper for SharpJS
-A solution to dynamically handle images on the fly, utilizing Sharp (https://sharp.pixelplumbing.com/en/stable/).
-Published version, additional details and documentation are available here: https://aws.amazon.com/solutions/serverless-image-handler/
+# Solution Overview
-_Note:_ it is recommended to build the application binary on Amazon Linux.
+The Serverless Image Handler solution helps to embed images on websites and mobile applications to drive user engagement. It uses [Sharp](https://sharp.pixelplumbing.com/en/stable/) to provide high-speed image processing without sacrificing image quality. To minimize costs of image optimization, manipulation, and processing, this solution automates version control and provides flexible storage and compute options for file reprocessing.
-## On This Page
-- [Architecture Overview](#architecture-overview)
-- [Creating a custom build](#creating-a-custom-build)
-- [External Contributors](#external-contributors)
-- [License](#license)
+This solution automatically deploys and configures a serverless architecture optimized for dynamic image manipulation. Images can be rendered and returned spontaneously. For example, an image can be resized based on different screen sizes by adding code on a website that leverages this solution to resize the image before being sent to the screen using the image. It uses [Amazon CloudFront](https://aws.amazon.com/cloudfront) for global content delivery and [Amazon Simple Storage Service](https://aws.amazon.com/s3) (Amazon S3) for reliable and durable cloud storage.
-## Architecture Overview
-![Architecture](architecture.png)
+For more information and a detailed deployment guide, visit the [Serverless Image Handler](https://aws.amazon.com/solutions/implementations/serverless-image-handler/) solution page.
+
+# Architecture Diagram
+
+![Architecture Diagram](./deployment/architecture.png)
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.
+# AWS CDK and Solutions Constructs
+
+[AWS Cloud Development Kit (AWS CDK)](https://aws.amazon.com/cdk/) and [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/) make it easier to consistently create well-architected infrastructure applications. All AWS Solutions Constructs are reviewed by AWS and use best practices established by the AWS Well-Architected Framework. This solution uses the following AWS Solutions Constructs:
-## 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.
+- [aws-cloudfront-s3](https://docs.aws.amazon.com/solutions/latest/constructs/aws-cloudfront-s3.html)
+- [aws-cloudfront-apigateway-lambda](https://docs.aws.amazon.com/solutions/latest/constructs/aws-cloudfront-apigateway-lambda.html)
-### Prerequisites:
-* [AWS Command Line Interface](https://aws.amazon.com/cli/)
-* Node.js 12.x or later
+In addition to the AWS Solutions Constructs, the solution uses AWS CDK directly to create infrastructure resources.
+
+# Customizing the Solution
+
+## Prerequisites for Customization
+
+- [AWS Command Line Interface](https://aws.amazon.com/cli/)
+- Node.js 14.x or later
### 1. Clone the repository
-```bash
-git clone https://github.com/awslabs/serverless-image-handler.git
-```
-### 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
+git clone https://github.com/aws-solutions/serverless-image-handler.git
+cd serverless-image-handler
+export MAIN_DIRECTORY=$PWD
```
-### 3. Declare environment variables
+### 2. 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 DIST_OUTPUT_BUCKET=my-bucket-name # bucket where customized code will reside, randomized name recommended
export SOLUTION_NAME=my-solution-name # the solution name
export VERSION=my-version # version number for the customized code
```
-### 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.
+## Unit Test
+
+After making changes, run unit tests to make sure added customization passes the tests:
+
```bash
-aws s3 mb s3://$DIST_OUTPUT_BUCKET-$REGION --region $REGION
+cd $MAIN_DIRECTORY/deployment
+chmod +x run-unit-tests.sh
+./run-unit-tests.sh
```
-### 5. Create the deployment packages
-Build the distributable:
+## Build
+
```bash
-chmod +x ./build-s3-dist.sh
+cd $MAIN_DIRECTORY/deployment
+chmod +x build-s3-dist.sh
./build-s3-dist.sh $DIST_OUTPUT_BUCKET $SOLUTION_NAME $VERSION
```
-Deploy the distributable to the Amazon S3 bucket in your account:
-```bash
-aws s3 sync ./regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control
-aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control
-```
+## Deploy
+
+- Deploy the distributable to the Amazon S3 bucket in your account. Make sure you are uploading the distributable to the `$DIST_OUTPUT_BUCKET-$REGION` bucket.
+- Get the link of the solution template uploaded to your Amazon S3 bucket.
+- Deploy the solution to your account by launching a new AWS CloudFormation stack using the link of the solution template in Amazon S3.
+
+# Collection of operational metrics
-### 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`.
-
-## 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)
-- [@pch](https://github.com/pch) for [#227](https://github.com/awslabs/serverless-image-handler/pull/227)
-- [@atrope](https://github.com/atrope) for [#201](https://github.com/awslabs/serverless-image-handler/pull/201)
-- [@bretto36](https://github.com/bretto36) for [#182](https://github.com/awslabs/serverless-image-handler/pull/182)
-- [@makoncline](https://github.com/makoncline) for [#255](https://github.com/awslabs/serverless-image-handler/pull/255)
-
-
-## Collection of operational metrics
This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/op-metrics.html).
+# External Contributors
+
+- [@leviwilson](https://github.com/leviwilson) for [#117](https://github.com/aws-solutions/serverless-image-handler/pull/117)
+- [@rpong](https://github.com/rpong) for [#130](https://github.com/aws-solutions/serverless-image-handler/pull/130)
+- [@harriswong](https://github.com/harriswong) for [#138](https://github.com/aws-solutions/serverless-image-handler/pull/138)
+- [@ganey](https://github.com/ganey) for [#139](https://github.com/aws-solutions/serverless-image-handler/pull/139)
+- [@browniebroke](https://github.com/browniebroke) for [#151](https://github.com/aws-solutions/serverless-image-handler/pull/151), [#152](https://github.com/aws-solutions/serverless-image-handler/pull/152)
+- [@john-shaffer](https://github.com/john-shaffer) for [#158](https://github.com/aws-solutions/serverless-image-handler/pull/158)
+- [@toredash](https://github.com/toredash) for [#174](https://github.com/aws-solutions/serverless-image-handler/pull/174), [#195](https://github.com/aws-solutions/serverless-image-handler/pull/195)
+- [@lith-imad](https://github.com/lith-imad) for [#194](https://github.com/aws-solutions/serverless-image-handler/pull/194)
+- [@pch](https://github.com/pch) for [#227](https://github.com/aws-solutions/serverless-image-handler/pull/227)
+- [@atrope](https://github.com/atrope) for [#201](https://github.com/aws-solutions/serverless-image-handler/pull/201), [#202](https://github.com/aws-solutions/serverless-image-handler/pull/202)
+- [@bretto36](https://github.com/bretto36) for [#182](https://github.com/aws-solutions/serverless-image-handler/pull/182)
+- [@makoncline](https://github.com/makoncline) for [#255](https://github.com/aws-solutions/serverless-image-handler/pull/255)
+- [@frankenbubble](https://github.com/frankenbubble) for [#302](https://github.com/aws-solutions/serverless-image-handler/pull/302)
+- [@guidev](https://github.com/guidev) for [#309](https://github.com/aws-solutions/serverless-image-handler/pull/309)
+- [@njtmead](https://github.com/njtmead) for [#276](https://github.com/aws-solutions/serverless-image-handler/pull/276)
+
+# License
+
+Copyright 2019-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
-***
-## License
-Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh
index 844da2bb3..a6d566823 100755
--- a/deployment/build-s3-dist.sh
+++ b/deployment/build-s3-dist.sh
@@ -23,6 +23,7 @@ if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then
exit 1
fi
+# Exit immediately if a command exits with a non-zero status.
set -e
# Get reference for all important folders
@@ -30,53 +31,70 @@ template_dir="$PWD"
template_dist_dir="$template_dir/global-s3-assets"
build_dist_dir="$template_dir/regional-s3-assets"
source_dir="$template_dir/../source"
+cdk_source_dir="$source_dir/constructs"
echo "------------------------------------------------------------------------------"
-echo "Rebuild distribution"
+echo "[Init] Clean old dist folders"
echo "------------------------------------------------------------------------------"
+echo "rm -rf $template_dist_dir"
rm -rf $template_dist_dir
+echo "mkdir -p $template_dist_dir"
mkdir -p $template_dist_dir
+echo "rm -rf $build_dist_dir"
rm -rf $build_dist_dir
+echo "mkdir -p $build_dist_dir"
mkdir -p $build_dist_dir
echo "------------------------------------------------------------------------------"
-echo "CloudFormation template with CDK and Constructs"
+echo "Synthesize the CDK project into a CloudFormation template"
echo "------------------------------------------------------------------------------"
-export BUCKET_NAME=$1
-export SOLUTION_NAME=$2
-export VERSION=$3
+export SOLUTION_BUCKET_NAME_PLACEHOLDER=$1
+export SOLUTION_NAME_PLACEHOLDER=$2
+export SOLUTION_VERSION_PLACEHOLDER=$3
+export overrideWarningsEnabled=false
-cd $source_dir/constructs
+cd $cdk_source_dir
+npm run clean
npm install
-npm run build && cdk synth --asset-metadata false --path-metadata false --json true > serverless-image-handler.json
-mv serverless-image-handler.json $template_dist_dir/serverless-image-handler.template
+node_modules/aws-cdk/bin/cdk synth --asset-metadata false --path-metadata false >$template_dist_dir/$2.template
-echo "------------------------------------------------------------------------------"
-echo "Package the image-handler code"
-echo "------------------------------------------------------------------------------"
-cd $source_dir/image-handler
-npm install
-npm run build
-cp dist/image-handler.zip $build_dist_dir/image-handler.zip
+declare -a lambda_packages=(
+ "image-handler"
+ "custom-resource"
+)
+
+for lambda_package in "${lambda_packages[@]}"; do
+ echo "------------------------------------------------------------------------------"
+ echo "Building Lambda package: $lambda_package"
+ echo "------------------------------------------------------------------------------"
+ cd $source_dir/$lambda_package
+ npm run package
+ # Check the result of the package step and exit if a failure is identified
+ if [ $? -eq 0 ]; then
+ echo "Package for $lambda_package built successfully"
+ else
+ echo "******************************************************************************"
+ echo "Lambda package build FAILED for $lambda_package"
+ echo "******************************************************************************"
+ exit 1
+ fi
+ mv dist/package.zip $build_dist_dir/$lambda_package.zip
+ rm -rf dist
+done
echo "------------------------------------------------------------------------------"
-echo "Package the demo-ui assets"
+echo "Package Serverless Image Handler Demo UI"
echo "------------------------------------------------------------------------------"
mkdir $build_dist_dir/demo-ui/
cp -r $source_dir/demo-ui/** $build_dist_dir/demo-ui/
echo "------------------------------------------------------------------------------"
-echo "Package the custom-resource code"
-echo "------------------------------------------------------------------------------"
-cd $source_dir/custom-resource
-npm install
-npm run build
-cp dist/custom-resource.zip $build_dist_dir/custom-resource.zip
-
-echo "------------------------------------------------------------------------------"
-echo "Generate the demo-ui manifest document"
+echo "[Create] Console manifest"
echo "------------------------------------------------------------------------------"
cd $source_dir/demo-ui
-manifest=(`find * -type f ! -iname ".DS_Store"`)
-manifest_json=$(IFS=,;printf "%s" "${manifest[*]}")
-echo "{\"files\":[\"$manifest_json\"]}" | sed 's/,/","/g' >> $build_dist_dir/demo-ui-manifest.json
+manifest=($(find * -type f ! -iname ".DS_Store"))
+manifest_json=$(
+ IFS=,
+ printf "%s" "${manifest[*]}"
+)
+echo "{\"files\":[\"$manifest_json\"]}" | sed 's/,/","/g' >>$build_dist_dir/demo-ui-manifest.json
diff --git a/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh
index 59e7933cb..76d56a1af 100755
--- a/deployment/run-unit-tests.sh
+++ b/deployment/run-unit-tests.sh
@@ -1,16 +1,72 @@
#!/bin/bash
+#
+# This assumes all of the OS-level configuration has been completed and git repo has already been cloned
+#
+# This script should be run from the repo's deployment directory
+# cd deployment
+# ./run-unit-tests.sh
+#
+[ "$DEBUG" == 'true' ] && set -x
set -e
-current_dir=$PWD
-source_dir=$current_dir/../source
+prepare_jest_coverage_report() {
+ local component_name=$1
-cd $source_dir/constructs
-npm install
-npm test
+ if [ ! -d "coverage" ]; then
+ echo "ValidationError: Missing required directory coverage after running unit tests"
+ exit 129
+ fi
-cd $source_dir/image-handler
-npm test
+ # prepare coverage reports
+ rm -fr coverage/lcov-report
+ mkdir -p $coverage_reports_top_path/jest
+ coverage_report_path=$coverage_reports_top_path/jest/$component_name
+ rm -fr $coverage_report_path
+ mv coverage $coverage_report_path
+}
-cd $source_dir/custom-resource
-npm test
\ No newline at end of file
+run_javascript_test() {
+ local component_path=$1
+ local component_name=$2
+
+ echo "------------------------------------------------------------------------------"
+ echo "[Test] Run javascript unit test with coverage for $component_name"
+ echo "------------------------------------------------------------------------------"
+ echo "cd $component_path"
+ cd $component_path
+
+ # run unit tests
+ npm test
+
+ # prepare coverage reports
+ prepare_jest_coverage_report $component_name
+}
+
+# Get reference for all important folders
+template_dir="$PWD"
+source_dir="$template_dir/../source"
+coverage_reports_top_path=$source_dir/test/coverage-reports
+
+# Test the attached Lambda function
+declare -a lambda_packages=(
+ "constructs"
+ "image-handler"
+ "custom-resource"
+)
+
+export overrideWarningsEnabled=false
+
+for lambda_package in "${lambda_packages[@]}"; do
+ run_javascript_test $source_dir/$lambda_package $lambda_package
+
+ # Check the result of the test and exit if a failure is identified
+ if [ $? -eq 0 ]; then
+ echo "Test for $lambda_package passed"
+ else
+ echo "******************************************************************************"
+ echo "Lambda test FAILED for $lambda_package"
+ echo "******************************************************************************"
+ exit 1
+ fi
+done
diff --git a/source/.eslintignore b/source/.eslintignore
new file mode 100644
index 000000000..6a1b42805
--- /dev/null
+++ b/source/.eslintignore
@@ -0,0 +1,4 @@
+**/*.js
+*.d.ts
+node_modules
+coverage
diff --git a/source/.eslintrc.json b/source/.eslintrc.json
new file mode 100644
index 000000000..3e9797a85
--- /dev/null
+++ b/source/.eslintrc.json
@@ -0,0 +1,43 @@
+{
+ "root": true,
+ "env": {
+ "jest": true,
+ "node": true
+ },
+ "plugins": ["@typescript-eslint", "import", "header"],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module",
+ "project": "**/tsconfig.json"
+ },
+ "ignorePatterns": ["**/*.js"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:import/recommended",
+ "plugin:import/typescript",
+ "standard",
+ "plugin:jsdoc/recommended",
+ "plugin:prettier/recommended"
+ ],
+ "rules": {
+ "arrow-body-style": ["warn", "as-needed"],
+ "prefer-arrow-callback": ["warn"],
+ "no-inferrable-types": ["off", "ignore-params"],
+ "no-unused-vars": ["off"],
+ "no-useless-constructor": ["off"],
+ "no-throw-literal": ["off"],
+
+ "header/header": ["error", "line", [" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.", " SPDX-License-Identifier: Apache-2.0"], 2],
+
+ "@typescript-eslint/no-inferrable-types": ["off", { "ignoreParameters": true, "ignoreProperties": true }],
+ "@typescript-eslint/no-useless-constructor": ["off"],
+ "@typescript-eslint/no-unused-vars": ["error", { "args": "none", "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
+ "@typescript-eslint/no-throw-literal": ["error"],
+
+ "jsdoc/require-param-type": ["off"],
+ "jsdoc/require-returns-type": ["off"],
+ "jsdoc/newline-after-description": ["off"]
+ }
+}
diff --git a/source/.prettierrc b/source/.prettierrc
new file mode 100644
index 000000000..376110920
--- /dev/null
+++ b/source/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "arrowParens": "avoid",
+ "bracketSpacing": true,
+ "printWidth": 180,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "none"
+}
diff --git a/source/constructs/.gitignore b/source/constructs/.gitignore
index ad34eb457..f60797b6a 100644
--- a/source/constructs/.gitignore
+++ b/source/constructs/.gitignore
@@ -1,13 +1,8 @@
*.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/bin/constructs.ts b/source/constructs/bin/constructs.ts
index b2a269b69..fdac5bd36 100644
--- a/source/constructs/bin/constructs.ts
+++ b/source/constructs/bin/constructs.ts
@@ -1,8 +1,42 @@
// 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';
+import { App } from '@aws-cdk/core';
+import { ServerlessImageHandlerStack, ServerlessImageHandlerStackProps } from '../lib/serverless-image-stack';
-const app = new cdk.App();
-new ConstructsStack(app, 'ConstructsStack');
\ No newline at end of file
+const getProps = (): ServerlessImageHandlerStackProps => {
+ const { SOLUTION_BUCKET_NAME_PLACEHOLDER, SOLUTION_NAME_PLACEHOLDER, SOLUTION_VERSION_PLACEHOLDER } = process.env;
+
+ if (typeof SOLUTION_BUCKET_NAME_PLACEHOLDER !== 'string' || SOLUTION_BUCKET_NAME_PLACEHOLDER.trim() === '') {
+ throw new Error('Missing required environment variable: SOLUTION_BUCKET_NAME_PLACEHOLDER');
+ }
+
+ if (typeof SOLUTION_NAME_PLACEHOLDER !== 'string' || SOLUTION_NAME_PLACEHOLDER.trim() === '') {
+ throw new Error('Missing required environment variable: SOLUTION_NAME_PLACEHOLDER');
+ }
+
+ if (typeof SOLUTION_VERSION_PLACEHOLDER !== 'string' || SOLUTION_VERSION_PLACEHOLDER.trim() === '') {
+ throw new Error('Missing required environment variable: SOLUTION_VERSION_PLACEHOLDER');
+ }
+
+ const solutionId = 'SO0023';
+ const solutionDisplayName = 'Serverless Image Handler';
+ const solutionVersion = SOLUTION_VERSION_PLACEHOLDER;
+ const solutionName = SOLUTION_NAME_PLACEHOLDER;
+ const solutionAssetHostingBucketNamePrefix = SOLUTION_BUCKET_NAME_PLACEHOLDER;
+ const description = `(${solutionId}) - ${solutionDisplayName}. Version ${solutionVersion}`;
+
+ return {
+ description,
+ solutionId,
+ solutionName,
+ solutionDisplayName,
+ solutionVersion,
+ solutionAssetHostingBucketNamePrefix
+ };
+};
+
+const app = new App();
+
+// eslint-disable-next-line no-new
+new ServerlessImageHandlerStack(app, 'ServerlessImageHandlerStack', getProps());
diff --git a/source/constructs/cdk.json b/source/constructs/cdk.json
index ef09a7a9e..532f95add 100644
--- a/source/constructs/cdk.json
+++ b/source/constructs/cdk.json
@@ -1,5 +1,5 @@
{
- "app": "npx ts-node bin/constructs.ts",
+ "app": "npx ts-node --prefer-ts-exts 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
index 772f97490..25173761c 100644
--- a/source/constructs/jest.config.js
+++ b/source/constructs/jest.config.js
@@ -3,5 +3,9 @@ module.exports = {
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
- }
+ },
+ coverageReporters: [
+ 'text',
+ ['lcov', { 'projectRoot': '../' }]
+ ]
};
diff --git a/source/constructs/lib/api.json b/source/constructs/lib/api.json
deleted file mode 100644
index 29844eb8e..000000000
--- a/source/constructs/lib/api.json
+++ /dev/null
@@ -1,65 +0,0 @@
-{
- "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"
- },
- {
- "name": "signature",
- "in": "query",
- "description": "Signature of the image",
- "required": false,
- "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/back-end/back-end-construct.ts b/source/constructs/lib/back-end/back-end-construct.ts
new file mode 100644
index 000000000..fff24a925
--- /dev/null
+++ b/source/constructs/lib/back-end/back-end-construct.ts
@@ -0,0 +1,174 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { LambdaRestApiProps, RestApi } from '@aws-cdk/aws-apigateway';
+import { AllowedMethods, CachePolicy, DistributionProps, IOrigin, OriginRequestPolicy, OriginSslPolicy, PriceClass, ViewerProtocolPolicy } from '@aws-cdk/aws-cloudfront';
+import { HttpOrigin } from '@aws-cdk/aws-cloudfront-origins';
+import { Policy, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
+import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda';
+import { LogGroup, RetentionDays } from '@aws-cdk/aws-logs';
+import { Bucket, IBucket } from '@aws-cdk/aws-s3';
+import { ArnFormat, Aws, Construct, Duration, Lazy, Stack } from '@aws-cdk/core';
+import { CloudFrontToApiGatewayToLambda } from '@aws-solutions-constructs/aws-cloudfront-apigateway-lambda';
+
+import { addCfnSuppressRules } from '../../utils/utils';
+import { SolutionConstructProps } from '../types';
+
+export interface BackEndProps extends SolutionConstructProps {
+ readonly solutionVersion: string;
+ readonly solutionDisplayName: string;
+ readonly sourceCodeBucketName: string;
+ readonly sourceCodeKeyPrefix: string;
+ readonly secretsManagerPolicy: Policy;
+ readonly logsBucket: IBucket;
+ readonly uuid: string;
+ readonly cloudFrontPriceClass: string;
+}
+
+export class BackEnd extends Construct {
+ public domainName: string;
+
+ constructor(scope: Construct, id: string, props: BackEndProps) {
+ super(scope, id);
+
+ const sourceCodeBucket = Bucket.fromBucketName(this, 'ImageHandlerLambdaSource', props.sourceCodeBucketName);
+
+ const imageHandlerLambdaFunctionRole = new Role(this, 'ImageHandlerFunctionRole', {
+ assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+ path: '/'
+ });
+ props.secretsManagerPolicy.attachToRole(imageHandlerLambdaFunctionRole);
+
+ const imageHandlerLambdaFunctionRolePolicy = new Policy(this, 'ImageHandlerFunctionPolicy', {
+ statements: [
+ new PolicyStatement({
+ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
+ resources: [Stack.of(this).formatArn({ service: 'logs', resource: 'log-group', resourceName: '/aws/lambda/*', arnFormat: ArnFormat.COLON_RESOURCE_NAME })]
+ }),
+ new PolicyStatement({
+ actions: ['s3:GetObject', 's3:PutObject', 's3:ListBucket'],
+ resources: [Stack.of(this).formatArn({ service: 's3', resource: '*', region: '', account: '' })]
+ }),
+ new PolicyStatement({
+ actions: ['rekognition:DetectFaces', 'rekognition:DetectModerationLabels'],
+ resources: ['*']
+ })
+ ]
+ });
+
+ addCfnSuppressRules(imageHandlerLambdaFunctionRolePolicy, [{ id: 'W12', reason: "rekognition:DetectFaces requires '*' resources." }]);
+ imageHandlerLambdaFunctionRole.attachInlinePolicy(imageHandlerLambdaFunctionRolePolicy);
+
+ const imageHandlerLambdaFunction = new LambdaFunction(this, 'ImageHandlerLambdaFunction', {
+ description: `${props.solutionDisplayName} (${props.solutionVersion}): Performs image edits and manipulations`,
+ runtime: Runtime.NODEJS_14_X,
+ handler: 'image-handler/index.handler',
+ timeout: Duration.minutes(15),
+ memorySize: 1_024,
+ code: Code.fromBucket(sourceCodeBucket, [props.sourceCodeKeyPrefix, 'image-handler.zip'].join('/')),
+ role: imageHandlerLambdaFunctionRole,
+ environment: {
+ AUTO_WEBP: props.autoWebP,
+ CORS_ENABLED: props.corsEnabled,
+ CORS_ORIGIN: props.corsOrigin,
+ SOURCE_BUCKETS: props.sourceBuckets,
+ REWRITE_MATCH_PATTERN: '',
+ REWRITE_SUBSTITUTION: '',
+ ENABLE_SIGNATURE: props.enableSignature,
+ SECRETS_MANAGER: props.secretsManager,
+ SECRET_KEY: props.secretsManagerKey,
+ ENABLE_DEFAULT_FALLBACK_IMAGE: props.enableDefaultFallbackImage,
+ DEFAULT_FALLBACK_IMAGE_BUCKET: props.fallbackImageS3Bucket,
+ DEFAULT_FALLBACK_IMAGE_KEY: props.fallbackImageS3KeyBucket
+ }
+ });
+
+ const imageHandlerLogGroup = new LogGroup(this, 'ImageHandlerLogGroup', {
+ logGroupName: `/aws/lambda/${imageHandlerLambdaFunction.functionName}`,
+ retention: props.logRetentionPeriod as RetentionDays
+ });
+
+ addCfnSuppressRules(imageHandlerLogGroup, [{ id: 'W84', reason: 'CloudWatch log group is always encrypted by default.' }]);
+
+ const cachePolicy = new CachePolicy(this, 'CachePolicy', {
+ cachePolicyName: `ServerlessImageHandler-${props.uuid}`,
+ defaultTtl: Duration.days(1),
+ minTtl: Duration.seconds(1),
+ maxTtl: Duration.days(365),
+ enableAcceptEncodingGzip: true,
+ headerBehavior: {
+ behavior: 'whitelist',
+ headers: ['origin', 'accept']
+ },
+ queryStringBehavior: {
+ behavior: 'whitelist',
+ queryStrings: ['signature']
+ }
+ });
+
+ const originRequestPolicy = new OriginRequestPolicy(this, 'OriginRequestPolicy', {
+ originRequestPolicyName: `ServerlessImageHandler-${props.uuid}`,
+ headerBehavior: {
+ behavior: 'whitelist',
+ headers: ['origin', 'accept']
+ },
+ queryStringBehavior: {
+ behavior: 'whitelist',
+ queryStrings: ['signature']
+ }
+ });
+
+ const apiGatewayRestApi = RestApi.fromRestApiId(this, 'ApiGatewayRestApi', Lazy.string({ produce: () => imageHandlerCloudFrontApiGatewayLambda.apiGateway.restApiId }));
+
+ const origin: IOrigin = new HttpOrigin(`${apiGatewayRestApi.restApiId}.execute-api.${Aws.REGION}.amazonaws.com`, {
+ originPath: '/image',
+ originSslProtocols: [OriginSslPolicy.TLS_V1_1, OriginSslPolicy.TLS_V1_2]
+ });
+
+ const cloudFrontDistributionProps: DistributionProps = {
+ comment: 'Image Handler Distribution for Serverless Image Handler',
+ defaultBehavior: {
+ origin: origin,
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
+ viewerProtocolPolicy: ViewerProtocolPolicy.HTTPS_ONLY,
+ originRequestPolicy: originRequestPolicy,
+ cachePolicy: cachePolicy
+ },
+ priceClass: props.cloudFrontPriceClass as PriceClass,
+ enableLogging: true,
+ logBucket: props.logsBucket,
+ logFilePrefix: 'api-cloudfront/',
+ errorResponses: [
+ { httpStatus: 500, ttl: Duration.minutes(10) },
+ { httpStatus: 501, ttl: Duration.minutes(10) },
+ { httpStatus: 502, ttl: Duration.minutes(10) },
+ { httpStatus: 503, ttl: Duration.minutes(10) },
+ { httpStatus: 504, ttl: Duration.minutes(10) }
+ ]
+ };
+
+ const logGroupProps = {
+ retention: props.logRetentionPeriod as RetentionDays
+ };
+
+ const apiGatewayProps: LambdaRestApiProps = {
+ handler: imageHandlerLambdaFunction,
+ deployOptions: {
+ stageName: 'image'
+ },
+ binaryMediaTypes: ['*/*']
+ };
+
+ const imageHandlerCloudFrontApiGatewayLambda = new CloudFrontToApiGatewayToLambda(this, 'ImageHandlerCloudFrontApiGatewayLambda', {
+ existingLambdaObj: imageHandlerLambdaFunction,
+ insertHttpSecurityHeaders: false,
+ logGroupProps: logGroupProps,
+ cloudFrontDistributionProps: cloudFrontDistributionProps,
+ apiGatewayProps: apiGatewayProps
+ });
+
+ imageHandlerCloudFrontApiGatewayLambda.apiGateway.node.tryRemoveChild('Endpoint'); // we don't need the RestApi endpoint in the outputs
+
+ this.domainName = imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.distributionDomainName;
+ }
+}
diff --git a/source/constructs/lib/common-resources/common-resources-construct.ts b/source/constructs/lib/common-resources/common-resources-construct.ts
new file mode 100644
index 000000000..fceb8e921
--- /dev/null
+++ b/source/constructs/lib/common-resources/common-resources-construct.ts
@@ -0,0 +1,82 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Policy, PolicyStatement } from '@aws-cdk/aws-iam';
+import { IBucket } from '@aws-cdk/aws-s3';
+import { ArnFormat, Aws, CfnCondition, Construct, Fn, Stack } from '@aws-cdk/core';
+
+import { addCfnCondition } from '../../utils/utils';
+import { SolutionConstructProps } from '../types';
+import { CustomResourcesConstruct } from './custom-resources/custom-resource-construct';
+
+export interface CommonResourcesProps extends SolutionConstructProps {
+ readonly solutionId: string;
+ readonly solutionVersion: string;
+ readonly solutionDisplayName: string;
+ readonly sourceCodeBucketName: string;
+ readonly sourceCodeKeyPrefix: string;
+}
+
+export interface Conditions {
+ readonly deployUICondition: CfnCondition;
+ readonly enableSignatureCondition: CfnCondition;
+ readonly enableDefaultFallbackImageCondition: CfnCondition;
+ readonly enableCorsCondition: CfnCondition;
+}
+
+/**
+ * Construct that creates Common Resources for the solution.
+ */
+export class CommonResources extends Construct {
+ public readonly conditions: Conditions;
+ public readonly logsBucket: IBucket;
+ public readonly secretsManagerPolicy: Policy;
+ public readonly customResources: CustomResourcesConstruct;
+
+ constructor(scope: Construct, id: string, props: CommonResourcesProps) {
+ super(scope, id);
+
+ this.conditions = {
+ deployUICondition: new CfnCondition(this, 'DeployDemoUICondition', {
+ expression: Fn.conditionEquals(props.deployUI, 'Yes')
+ }),
+ enableSignatureCondition: new CfnCondition(this, 'EnableSignatureCondition', {
+ expression: Fn.conditionEquals(props.enableSignature, 'Yes')
+ }),
+ enableDefaultFallbackImageCondition: new CfnCondition(this, 'EnableDefaultFallbackImageCondition', {
+ expression: Fn.conditionEquals(props.enableDefaultFallbackImage, 'Yes')
+ }),
+ enableCorsCondition: new CfnCondition(this, 'EnableCorsCondition', {
+ expression: Fn.conditionEquals(props.corsEnabled, 'Yes')
+ })
+ };
+
+ this.secretsManagerPolicy = new Policy(this, 'SecretsManagerPolicy', {
+ statements: [
+ new PolicyStatement({
+ actions: ['secretsmanager:GetSecretValue'],
+ resources: [
+ Stack.of(this).formatArn({
+ partition: Aws.PARTITION,
+ service: 'secretsmanager',
+ region: Aws.REGION,
+ account: Aws.ACCOUNT_ID,
+ resource: 'secret',
+ resourceName: `${props.secretsManager}*`,
+ arnFormat: ArnFormat.COLON_RESOURCE_NAME
+ })
+ ]
+ })
+ ]
+ });
+ addCfnCondition(this.secretsManagerPolicy, this.conditions.enableSignatureCondition);
+
+ this.customResources = new CustomResourcesConstruct(this, 'CustomResources', {
+ conditions: this.conditions,
+ secretsManagerPolicy: this.secretsManagerPolicy,
+ ...props
+ });
+
+ this.logsBucket = this.customResources.createLogBucket();
+ }
+}
diff --git a/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts b/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts
new file mode 100644
index 000000000..64b488f73
--- /dev/null
+++ b/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts
@@ -0,0 +1,226 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
+import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda';
+import { Bucket, IBucket } from '@aws-cdk/aws-s3';
+import { ArnFormat, Aws, CfnCondition, CfnResource, Construct, CustomResource, Duration, Lazy, Stack } from '@aws-cdk/core';
+import { addCfnSuppressRules } from '../../../utils/utils';
+
+import { SolutionConstructProps } from '../../types';
+import { CommonResourcesProps, Conditions } from '../common-resources-construct';
+
+export interface CustomResourcesConstructProps extends CommonResourcesProps {
+ readonly conditions: Conditions;
+ readonly secretsManagerPolicy: Policy;
+}
+
+export interface AnonymousMetricCustomResourceProps extends SolutionConstructProps {
+ readonly anonymousData: string;
+}
+
+export interface ValidateSourceAndFallbackImageBucketsCustomResourceProps {
+ readonly sourceBuckets: string;
+ readonly fallbackImageS3Bucket: string;
+ readonly fallbackImageS3Key: string;
+}
+
+export interface SetupCopyWebsiteCustomResourceProps {
+ readonly hostingBucket: Bucket;
+}
+
+export interface SetupPutWebsiteConfigCustomResourceProps {
+ readonly hostingBucket: Bucket;
+ readonly apiEndpoint: string;
+}
+
+export interface SetupValidateSecretsManagerProps {
+ readonly secretsManager: string;
+ readonly secretsManagerKey: string;
+}
+
+export class CustomResourcesConstruct extends Construct {
+ private readonly solutionVersion: string;
+ private readonly sourceCodeBucket: IBucket;
+ private readonly sourceCodeKeyPrefix: string;
+ private readonly conditions: Conditions;
+ private readonly customResourceRole: Role;
+ private readonly customResourceLambda: LambdaFunction;
+ public readonly uuid: string;
+
+ constructor(scope: Construct, id: string, props: CustomResourcesConstructProps) {
+ super(scope, id);
+
+ this.sourceCodeBucket = Bucket.fromBucketName(this, 'ImageHandlerLambdaSource', props.sourceCodeBucketName);
+ this.sourceCodeKeyPrefix = props.sourceCodeKeyPrefix;
+ this.solutionVersion = props.solutionVersion;
+ this.conditions = props.conditions;
+
+ this.customResourceRole = new Role(this, 'CustomResourceRole', {
+ assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+ path: '/',
+ inlinePolicies: {
+ CloudWatchLogsPolicy: new PolicyDocument({
+ statements: [
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
+ resources: [Stack.of(this).formatArn({ service: 'logs', resource: 'log-group', resourceName: '/aws/lambda/*', arnFormat: ArnFormat.COLON_RESOURCE_NAME })]
+ }),
+ new PolicyStatement({
+ actions: ['s3:putBucketAcl', 's3:putEncryptionConfiguration', 's3:putBucketPolicy', 's3:CreateBucket', 's3:GetObject', 's3:PutObject', 's3:ListBucket'],
+ resources: [Stack.of(this).formatArn({ partition: Aws.PARTITION, service: 's3', region: '', account: '', resource: '*', arnFormat: ArnFormat.COLON_RESOURCE_NAME })]
+ })
+ ]
+ }),
+ EC2Policy: new PolicyDocument({
+ statements: [
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['ec2:DescribeRegions'],
+ resources: ['*']
+ })
+ ]
+ })
+ }
+ });
+
+ addCfnSuppressRules(this.customResourceRole, [
+ {
+ id: 'W11',
+ reason: "Allow '*' because it is required for making DescribeRegions API call as it doesn't support resource-level permissions and require to choose all resources."
+ }
+ ]);
+
+ props.secretsManagerPolicy.attachToRole(this.customResourceRole);
+
+ this.customResourceLambda = new LambdaFunction(this, 'CustomResourceFunction', {
+ description: `${props.solutionDisplayName} (${props.solutionVersion}): Custom resource`,
+ runtime: Runtime.NODEJS_14_X,
+ handler: 'custom-resource/index.handler',
+ timeout: Duration.minutes(1),
+ memorySize: 128,
+ code: Code.fromBucket(this.sourceCodeBucket, [props.sourceCodeKeyPrefix, 'custom-resource.zip'].join('/')),
+ role: this.customResourceRole,
+ environment: {
+ SOLUTION_ID: props.solutionId,
+ RETRY_SECONDS: '5',
+ SOLUTION_VERSION: props.solutionVersion
+ }
+ });
+
+ const customResourceUuid = this.createCustomResource('CustomResourceUuid', this.customResourceLambda, { Region: Aws.REGION, CustomAction: 'createUuid' });
+ this.uuid = customResourceUuid.getAttString('UUID');
+ }
+
+ public setupAnonymousMetric(props: AnonymousMetricCustomResourceProps) {
+ this.createCustomResource('CustomResourceAnonymousMetric', this.customResourceLambda, {
+ CustomAction: 'sendMetric',
+ Region: Aws.REGION,
+ UUID: this.uuid,
+ AnonymousData: props.anonymousData,
+ CorsEnabled: props.corsEnabled,
+ SourceBuckets: props.sourceBuckets,
+ DeployDemoUi: props.deployUI,
+ LogRetentionPeriod: props.logRetentionPeriod,
+ AutoWebP: props.autoWebP,
+ EnableSignature: props.enableSignature,
+ EnableDefaultFallbackImage: props.enableDefaultFallbackImage
+ });
+ }
+
+ public setupValidateSourceAndFallbackImageBuckets(props: ValidateSourceAndFallbackImageBucketsCustomResourceProps) {
+ this.createCustomResource('CustomResourceCheckSourceBuckets', this.customResourceLambda, {
+ CustomAction: 'checkSourceBuckets',
+ Region: Aws.REGION,
+ SourceBuckets: props.sourceBuckets
+ });
+
+ this.createCustomResource(
+ 'CustomResourceCheckFallbackImage',
+ this.customResourceLambda,
+ {
+ CustomAction: 'checkFallbackImage',
+ FallbackImageS3Bucket: props.fallbackImageS3Bucket,
+ FallbackImageS3Key: props.fallbackImageS3Key
+ },
+ this.conditions.enableDefaultFallbackImageCondition
+ );
+ }
+
+ public setupCopyWebsiteCustomResource(props: SetupCopyWebsiteCustomResourceProps) {
+ // Allows the custom resource to read the static assets for the front-end from the source code bucket
+ this.sourceCodeBucket.grantRead(this.customResourceLambda, `${this.sourceCodeKeyPrefix}/*`);
+
+ this.createCustomResource(
+ 'CopyWebsite',
+ this.customResourceLambda,
+ {
+ CustomAction: 'copyS3assets',
+ Region: Aws.REGION,
+ ManifestKey: [this.sourceCodeKeyPrefix, 'demo-ui-manifest.json'].join('/'),
+ SourceS3Bucket: this.sourceCodeBucket.bucketName,
+ SourceS3key: [this.sourceCodeKeyPrefix, 'demo-ui'].join('/'),
+ DestS3Bucket: props.hostingBucket.bucketName,
+ Version: this.solutionVersion
+ },
+ this.conditions.deployUICondition
+ );
+ }
+
+ public setupPutWebsiteConfigCustomResource(props: SetupPutWebsiteConfigCustomResourceProps) {
+ this.createCustomResource(
+ 'PutWebsiteConfig',
+ this.customResourceLambda,
+ {
+ CustomAction: 'putConfigFile',
+ Region: Aws.REGION,
+ ConfigItem: { apiEndpoint: `https://${props.apiEndpoint}` },
+ DestS3Bucket: props.hostingBucket.bucketName,
+ DestS3key: 'demo-ui-config.js'
+ },
+ this.conditions.deployUICondition
+ );
+ }
+
+ public setupValidateSecretsManager(props: SetupValidateSecretsManagerProps) {
+ this.createCustomResource(
+ 'CustomResourceCheckSecretsManager',
+ this.customResourceLambda,
+ {
+ CustomAction: 'checkSecretsManager',
+ SecretsManagerName: props.secretsManager,
+ SecretsManagerKey: props.secretsManagerKey
+ },
+ this.conditions.enableSignatureCondition
+ );
+ }
+
+ public createLogBucket(): IBucket {
+ const bucketSuffix = `${Aws.STACK_NAME}-${Aws.REGION}-${Aws.ACCOUNT_ID}`;
+ const logBucketCreationResult = this.createCustomResource('LogBucketCustomResource', this.customResourceLambda, {
+ CustomAction: 'createCloudFrontLoggingBucket',
+ BucketSuffix: bucketSuffix
+ });
+
+ const optInRegionAccessLogBucket = Bucket.fromBucketAttributes(this, 'CloudFrontLoggingBucket', {
+ bucketName: Lazy.string({ produce: () => logBucketCreationResult.getAttString('BucketName') }),
+ region: Lazy.string({ produce: () => logBucketCreationResult.getAttString('Region') })
+ });
+
+ return optInRegionAccessLogBucket;
+ }
+
+ private createCustomResource(id: string, customResourceFunction: LambdaFunction, props?: Record, condition?: CfnCondition): CustomResource {
+ const customResource = new CustomResource(this, id, {
+ serviceToken: customResourceFunction.functionArn,
+ properties: props
+ });
+
+ if (condition) {
+ (customResource.node.defaultChild as CfnResource).cfnOptions.condition = condition;
+ }
+
+ return customResource;
+ }
+}
diff --git a/source/constructs/lib/constructs-stack.ts b/source/constructs/lib/constructs-stack.ts
deleted file mode 100644
index f313b9e8a..000000000
--- a/source/constructs/lib/constructs-stack.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-// 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' ]
- });
- const enableSignatureParameter = new CfnParameter(this, 'EnableSignature', {
- type: 'String',
- description: `Would you like to enable the signature? If so, select 'Yes' and provide SecretsManagerSecret and SecretsManagerKey values.`,
- default: 'No',
- allowedValues: [ 'Yes', 'No' ]
- });
- const secretsManagerParameter = new CfnParameter(this, 'SecretsManagerSecret', {
- type: 'String',
- description: 'The name of AWS Secrets Manager secret. You need to create your secret under this name.',
- default: ''
- });
- const secretsManagerKeyParameter = new CfnParameter(this, 'SecretsManagerKey', {
- type: 'String',
- description: 'The name of AWS Secrets Manager secret key. You need to create secret key with this key name. The secret value would be used to check signature.',
- default: ''
- });
- const enableDefaultFallbackImageParameter = new CfnParameter(this, 'EnableDefaultFallbackImage', {
- type: 'String',
- description: `Would you like to enable the default fallback image? If so, select 'Yes' and provide FallbackImageS3Bucket and FallbackImageS3Key values.`,
- default: 'No',
- allowedValues: [ 'Yes', 'No' ]
- });
- const fallbackImageS3BucketParameter = new CfnParameter(this, 'FallbackImageS3Bucket', {
- type: 'String',
- description: 'The name of the Amazon S3 bucket which contains the default fallback image. e.g. my-fallback-image-bucket',
- default: ''
- });
- const fallbackImageS3KeyParameter = new CfnParameter(this, 'FallbackImageS3Key', {
- type: 'String',
- description: 'The name of the default fallback image object key including prefix. e.g. prefix/image.jpg',
- default: ''
- });
-
- // 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 ]
- },
- {
- Label: { default: 'Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)' },
- Parameters: [ enableSignatureParameter.logicalId, secretsManagerParameter.logicalId, secretsManagerKeyParameter.logicalId ]
- },
- {
- Label: { default: 'Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)' },
- Parameters: [ enableDefaultFallbackImageParameter.logicalId, fallbackImageS3BucketParameter.logicalId, fallbackImageS3KeyParameter.logicalId ]
- },
- {
- Label: { default: 'Auto WebP' },
- Parameters: [ autoWebPParameter.logicalId ]
- }
- ]
- }
- };
-
- // Mappings
- new cdk.CfnMapping(this, 'Send', {
- mapping: {
- AnonymousUsage: {
- Data: 'Yes'
- }
- }
- });
-
- // Serverless Image Handler props
- const sihProps: ServerlessImageHandlerProps = {
- corsEnabledParameter,
- corsOriginParameter,
- sourceBucketsParameter,
- deployDemoUiParameter,
- logRetentionPeriodParameter,
- autoWebPParameter,
- enableSignatureParameter,
- secretsManagerParameter,
- secretsManagerKeyParameter,
- enableDefaultFallbackImageParameter,
- fallbackImageS3BucketParameter,
- fallbackImageS3KeyParameter
- };
-
- // 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/front-end/front-end-construct.ts b/source/constructs/lib/front-end/front-end-construct.ts
new file mode 100644
index 000000000..f57514470
--- /dev/null
+++ b/source/constructs/lib/front-end/front-end-construct.ts
@@ -0,0 +1,52 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Bucket, IBucket } from '@aws-cdk/aws-s3';
+import { Aspects, Construct } from '@aws-cdk/core';
+import { CloudFrontToS3 } from '@aws-solutions-constructs/aws-cloudfront-s3';
+
+import { ConditionAspect } from '../../utils/aspects';
+import { addCfnSuppressRules } from '../../utils/utils';
+import { Conditions } from '../common-resources/common-resources-construct';
+
+export interface FrontEndProps {
+ readonly logsBucket: IBucket;
+ readonly conditions: Conditions;
+}
+
+/**
+ * Construct that creates the front-end resources for the solution. A CloudFront Distribution, S3 bucket.
+ */
+export class FrontEndConstruct extends Construct {
+ public readonly domainName: string;
+ public readonly websiteHostingBucket: Bucket;
+
+ constructor(scope: Construct, id: string, props: FrontEndProps) {
+ super(scope, id);
+
+ const cloudFrontToS3 = new CloudFrontToS3(this, 'DistributionToS3', {
+ bucketProps: { serverAccessLogsBucket: undefined },
+ cloudFrontDistributionProps: {
+ comment: 'Demo UI Distribution for Serverless Image Handler',
+ enableLogging: true,
+ logBucket: props.logsBucket,
+ logFilePrefix: 'ui-cloudfront/',
+ errorResponses: [
+ { httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
+ { httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' }
+ ]
+ },
+ insertHttpSecurityHeaders: false
+ });
+
+ // S3 bucket does not require access logging, calls are logged by CloudFront
+ cloudFrontToS3.node.tryRemoveChild('S3LoggingBucket');
+ addCfnSuppressRules(cloudFrontToS3.s3Bucket, [{ id: 'W35', reason: 'This S3 bucket does not require access logging.' }]);
+
+ this.domainName = cloudFrontToS3.cloudFrontWebDistribution.domainName;
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.websiteHostingBucket = cloudFrontToS3.s3Bucket!;
+
+ Aspects.of(this).add(new ConditionAspect(props.conditions.deployUICondition));
+ }
+}
diff --git a/source/constructs/lib/serverless-image-handler.ts b/source/constructs/lib/serverless-image-handler.ts
deleted file mode 100644
index da60248fa..000000000
--- a/source/constructs/lib/serverless-image-handler.ts
+++ /dev/null
@@ -1,771 +0,0 @@
-// 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;
- readonly enableSignatureParameter: CfnParameter;
- readonly secretsManagerParameter: CfnParameter;
- readonly secretsManagerKeyParameter: CfnParameter;
- readonly enableDefaultFallbackImageParameter: CfnParameter;
- readonly fallbackImageS3BucketParameter: CfnParameter;
- readonly fallbackImageS3KeyParameter: 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.1.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');
-
- const enableSignatureCondition = new cdk.CfnCondition(this, 'EnableSignatureCondition', {
- expression: cdk.Fn.conditionEquals(props.enableSignatureParameter.valueAsString, 'Yes')
- });
- enableSignatureCondition.overrideLogicalId('EnableSignatureCondition');
-
- const enableDefaultFallbackImageCondition = new cdk.CfnCondition(this, 'EnableDefaultFallbackImageCondition', {
- expression: cdk.Fn.conditionEquals(props.enableDefaultFallbackImageParameter.valueAsString, 'Yes')
- });
- enableDefaultFallbackImageCondition.overrideLogicalId('EnableDefaultFallbackImageCondition');
-
- const isOptInRegion = new cdk.CfnCondition(this, 'IsOptInRegion', {
- expression: cdk.Fn.conditionOr(
- cdk.Fn.conditionEquals("af-south-1", cdk.Aws.REGION),
- cdk.Fn.conditionEquals("ap-east-1", cdk.Aws.REGION),
- cdk.Fn.conditionEquals("eu-south-1" , cdk.Aws.REGION),
- cdk.Fn.conditionEquals("me-south-1" , cdk.Aws.REGION)
- )
- });
- isOptInRegion.overrideLogicalId('IsOptInRegion');
-
- const isNotOptInRegion = new cdk.CfnCondition(this, 'IsNotOptInRegion', {
- expression: cdk.Fn.conditionNot(isOptInRegion)
- });
- isNotOptInRegion.overrideLogicalId('IsNotOptInRegion')
-
- // 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',
- 'rekognition:DetectModerationLabels'
- ],
- 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: 1024,
- 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: '',
- ENABLE_SIGNATURE: props.enableSignatureParameter.valueAsString,
- SECRETS_MANAGER: props.secretsManagerParameter.valueAsString,
- SECRET_KEY: props.secretsManagerKeyParameter.valueAsString,
- ENABLE_DEFAULT_FALLBACK_IMAGE: props.enableDefaultFallbackImageParameter.valueAsString,
- DEFAULT_FALLBACK_IMAGE_BUCKET: props.fallbackImageS3BucketParameter.valueAsString,
- DEFAULT_FALLBACK_IMAGE_KEY: props.fallbackImageS3KeyParameter.valueAsString
- }
- });
- 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;
- this.addCfnNagSuppressRules(cfnLambdaFunctionLogs, [
- {
- "id": "W84",
- "reason": "Used to store store function info"
- }
- ]);
- 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;
- this.addCfnNagSuppressRules(cfnApiGatewayLogGroup, [
- {
- "id": "W84",
- "reason": "Used to store store api log info, not using kms"
- },
- {
- "id": "W86",
- "reason": "Log retention specified in CloudFromation parameters."
- }
- ]);
- 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;
- cfnAccessLogBucket.cfnOptions.condition = isNotOptInRegion;
- 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;
- const cfnAccessLogBucketPolicy = accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy;
- (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).cfnOptions.condition = isNotOptInRegion;
- (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).overrideLogicalId('LogsBucketPolicy');
-
- //OptInRegionLogBucket
- const optInRegionAccessLogBucket = cdkS3.Bucket.fromBucketAttributes(this, 'CloudFrontLoggingBucket', {
- bucketName:
- cdk.Fn.getAtt(
- cdk.Lazy.stringValue({
- produce(context) {
- return cfLoggingBucket.logicalId}
- }),
- 'bucketName').toString(),
- region: 'us-east-1'
- });
-
- //OptInRegionLogBucketPolicy
- const optInRegionPolicyStatement = cfnAccessLogBucketPolicy.policyDocument.toJSON().Statement[0];
- optInRegionPolicyStatement.Resource = "";
-
- //Choose Log Bucket
- const cloudFrontLogsBucket = cdk.Fn.conditionIf(isOptInRegion.logicalId, optInRegionAccessLogBucket.bucketRegionalDomainName, accessLogBucket.bucketRegionalDomainName).toString();
-
-
- //ImagehandlerCachePolicy
- const cfnCachePolicy = new cdkCloudFront.CfnCachePolicy(
- this,
- 'CachePolicy',
- {
- cachePolicyConfig: {
- name: `${cdk.Aws.STACK_NAME}-${cdk.Aws.REGION}-ImageHandlerCachePolicy`,
- defaultTtl: 86400,
- minTtl: 1,
- maxTtl: 31536000,
- parametersInCacheKeyAndForwardedToOrigin: {
- cookiesConfig: {cookieBehavior: "none"},
- enableAcceptEncodingGzip: true,
- headersConfig: {
- headerBehavior: "whitelist",
- headers:['origin', 'accept']
- },
- queryStringsConfig: {
- queryStringBehavior: "whitelist",
- queryStrings: ["signature"]
- },
- }
- }
- });
- cfnCachePolicy.overrideLogicalId("ImageHandlerCachePolicy");
-
- //ImageHandlerOriginRequestPolicy
- const cfnOriginRequestPolicy = new cdkCloudFront.CfnOriginRequestPolicy(
- this,
- "OriginRequestPolicy",
- {
- originRequestPolicyConfig: {
- cookiesConfig: {cookieBehavior: "none"},
- headersConfig: {
- headerBehavior: "whitelist",
- headers: ['origin', 'accept']
- },
- name: `${cdk.Aws.STACK_NAME}-${cdk.Aws.REGION}-ImageHandlerOriginRequestPolicy`,
- queryStringsConfig: {
- queryStringBehavior: "whitelist",
- queryStrings: ["signature"]
- },
- }
- });
- cfnOriginRequestPolicy.overrideLogicalId("ImageHandlerOriginRequestPolicy");
-
- // 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,
- viewerProtocolPolicy: 'https-only',
- cachePolicyId: cfnCachePolicy.ref,
- originRequestPolicyId: cfnOriginRequestPolicy.ref
-
- },
- 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: cloudFrontLogsBucket,
- 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: cloudFrontLogsBucket,
- 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:putBucketAcl',
- 's3:putEncryptionConfiguration',
- 's3:putBucketPolicy',
- 's3:CreateBucket',
- '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(60),
- memorySize: 128,
- role: customResourceRole,
- environment: {
- RETRY_SECONDS: '5'
- }
- });
- 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;
- this.addCfnNagSuppressRules(cfnCustomResourceLogGroup, [
- {
- "id": "W84",
- "reason": "Used to store store function info, no kms used"
- }
- ]);
- 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.distributionDomainName}` } },
- { 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: 'enableSignature', value: props.enableSignatureParameter.valueAsString },
- { path: 'enableDefaultFallbackImage', value: props.enableDefaultFallbackImageParameter.valueAsString },
- { 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 ]
- });
-
- // SecretsManagerPolicy
- const secretsManagerPolicy = new cdkIam.Policy(this, 'secretsManagerPolicy', {
- statements: [
- new cdkIam.PolicyStatement({
- actions: [
- 'secretsmanager:GetSecretValue'
- ],
- resources: [
- `arn:${cdk.Aws.PARTITION}:secretsmanager:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:secret:${props.secretsManagerParameter.valueAsString}*`
- ]
- })
- ]
- });
- secretsManagerPolicy.attachToRole(customResourceRole);
- secretsManagerPolicy.attachToRole(imageHandlerFunctionRole);
- const cfnSecretsManagerPolicy = secretsManagerPolicy.node.defaultChild as cdkIam.CfnPolicy;
- cfnSecretsManagerPolicy.cfnOptions.condition = enableSignatureCondition;
- cfnSecretsManagerPolicy.overrideLogicalId('SecretsManagerPolicy');
-
- // CustomResourceCheckSecretsManager
- this.createCustomResource('CustomResourceCheckSecretsManager', customResourceFunction, {
- properties: [
- { path: 'customAction', value: 'checkSecretsManager' },
- { path: 'secretsManagerName', value: props.secretsManagerParameter.valueAsString },
- { path: 'secretsManagerKey', value: props.secretsManagerKeyParameter.valueAsString }
- ],
- condition: enableSignatureCondition,
- dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy, cfnSecretsManagerPolicy ]
- });
-
- // CustomResourceCheckFallbackImage
- this.createCustomResource('CustomResourceCheckFallbackImage', customResourceFunction, {
- properties: [
- { path: 'customAction', value: 'checkFallbackImage' },
- { path: 'fallbackImageS3Bucket', value: props.fallbackImageS3BucketParameter.valueAsString },
- { path: 'fallbackImageS3Key', value: props.fallbackImageS3KeyParameter.valueAsString }
- ],
- condition: enableDefaultFallbackImageCondition,
- dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ]
- });
-
- const bucketSuffix = cdk.Aws.STACK_NAME + cdk.Aws.REGION + cdk.Aws.ACCOUNT_ID;
- const cfLoggingBucket = this.createCustomResource('CustomCFLoggingBucket', customResourceFunction, {
- properties: [
- { path: 'customAction', value: 'createCFLoggingBucket' },
- { path: 'stackName', value: cdk.Aws.STACK_NAME },
- { path: 'bucketSuffix', value: bucketSuffix },
- { path: 'policy', value: optInRegionPolicyStatement }
- ],
- condition: isOptInRegion,
- 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/lib/serverless-image-stack.ts b/source/constructs/lib/serverless-image-stack.ts
new file mode 100644
index 000000000..a902866f9
--- /dev/null
+++ b/source/constructs/lib/serverless-image-stack.ts
@@ -0,0 +1,278 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { PriceClass } from '@aws-cdk/aws-cloudfront';
+import { Aspects, Aws, CfnMapping, CfnOutput, CfnParameter, Construct, Stack, StackProps, Tags } from '@aws-cdk/core';
+
+import { SuppressLambdaFunctionCfnRulesAspect } from '../utils/aspects';
+import { BackEnd } from './back-end/back-end-construct';
+import { CommonResources } from './common-resources/common-resources-construct';
+import { FrontEndConstruct as FrontEnd } from './front-end/front-end-construct';
+import { SolutionConstructProps, YesNo } from './types';
+
+export interface ServerlessImageHandlerStackProps extends StackProps {
+ readonly description: string;
+ readonly solutionId: string;
+ readonly solutionName: string;
+ readonly solutionVersion: string;
+ readonly solutionDisplayName: string;
+ readonly solutionAssetHostingBucketNamePrefix: string;
+}
+
+export class ServerlessImageHandlerStack extends Stack {
+ constructor(scope: Construct, id: string, props: ServerlessImageHandlerStackProps) {
+ super(scope, id, props);
+
+ const corsEnabledParameter = new CfnParameter(this, 'CorsEnabledParameter', {
+ type: 'String',
+ description: `Would you like to enable Cross-Origin Resource Sharing (CORS) for the image handler API? Select 'Yes' if so.`,
+ allowedValues: ['Yes', 'No'],
+ default: 'No'
+ });
+
+ const corsOriginParameter = new CfnParameter(this, 'CorsOriginParameter', {
+ 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, 'SourceBucketsParameter', {
+ 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.',
+ allowedPattern: '.+',
+ default: 'defaultBucket, bucketNo2, bucketNo3, ...'
+ });
+
+ const deployDemoUIParameter = new CfnParameter(this, 'DeployDemoUIParameter', {
+ 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.',
+ allowedValues: ['Yes', 'No'],
+ default: 'Yes'
+ });
+
+ const logRetentionPeriodParameter = new CfnParameter(this, 'LogRetentionPeriodParameter', {
+ 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).',
+ allowedValues: ['1', '3', '5', '7', '14', '30', '60', '90', '120', '150', '180', '365', '400', '545', '731', '1827', '3653'],
+ default: '1'
+ });
+
+ const autoWebPParameter = new CfnParameter(this, 'AutoWebPParameter', {
+ type: 'String',
+ description: `Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.`,
+ allowedValues: ['Yes', 'No'],
+ default: 'No'
+ });
+
+ const enableSignatureParameter = new CfnParameter(this, 'EnableSignatureParameter', {
+ type: 'String',
+ description: `Would you like to enable the signature? If so, select 'Yes' and provide SecretsManagerSecret and SecretsManagerKey values.`,
+ allowedValues: ['Yes', 'No'],
+ default: 'No'
+ });
+
+ const secretsManagerSecretParameter = new CfnParameter(this, 'SecretsManagerSecretParameter', {
+ type: 'String',
+ description: 'The name of AWS Secrets Manager secret. You need to create your secret under this name.',
+ default: ''
+ });
+
+ const secretsManagerKeyParameter = new CfnParameter(this, 'SecretsManagerKeyParameter', {
+ type: 'String',
+ description: 'The name of AWS Secrets Manager secret key. You need to create secret key with this key name. The secret value would be used to check signature.',
+ default: ''
+ });
+
+ const enableDefaultFallbackImageParameter = new CfnParameter(this, 'EnableDefaultFallbackImageParameter', {
+ type: 'String',
+ description: `Would you like to enable the default fallback image? If so, select 'Yes' and provide FallbackImageS3Bucket and FallbackImageS3Key values.`,
+ allowedValues: ['Yes', 'No'],
+ default: 'No'
+ });
+
+ const fallbackImageS3BucketParameter = new CfnParameter(this, 'FallbackImageS3BucketParameter', {
+ type: 'String',
+ description: 'The name of the Amazon S3 bucket which contains the default fallback image. e.g. my-fallback-image-bucket',
+ default: ''
+ });
+
+ const fallbackImageS3KeyParameter = new CfnParameter(this, 'FallbackImageS3KeyParameter', {
+ type: 'String',
+ description: 'The name of the default fallback image object key including prefix. e.g. prefix/image.jpg',
+ default: ''
+ });
+
+ const cloudFrontPriceClassParameter = new CfnParameter(this, 'CloudFrontPriceClassParameter', {
+ type: 'String',
+ description: 'The AWS CloudFront price class to use. For more information see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html',
+ allowedValues: [PriceClass.PRICE_CLASS_ALL, PriceClass.PRICE_CLASS_200, PriceClass.PRICE_CLASS_100],
+ default: PriceClass.PRICE_CLASS_ALL
+ });
+
+ const solutionMapping = new CfnMapping(this, 'Solution', {
+ mapping: {
+ Config: {
+ AnonymousUsage: 'Yes',
+ SolutionId: props.solutionId,
+ Version: props.solutionVersion,
+ S3BucketPrefix: props.solutionAssetHostingBucketNamePrefix,
+ S3KeyPrefix: `${props.solutionName}/${props.solutionVersion}`
+ }
+ },
+ lazy: true
+ });
+
+ const anonymousUsage = `${solutionMapping.findInMap('Config', 'AnonymousUsage')}`;
+ const sourceCodeBucketName = `${solutionMapping.findInMap('Config', 'S3BucketPrefix')}-${Aws.REGION}`;
+ const sourceCodeKeyPrefix = solutionMapping.findInMap('Config', 'S3KeyPrefix');
+
+ const solutionConstructProps: SolutionConstructProps = {
+ corsEnabled: corsEnabledParameter.valueAsString,
+ corsOrigin: corsOriginParameter.valueAsString,
+ sourceBuckets: sourceBucketsParameter.valueAsString,
+ deployUI: deployDemoUIParameter.valueAsString as YesNo,
+ logRetentionPeriod: logRetentionPeriodParameter.valueAsNumber,
+ autoWebP: autoWebPParameter.valueAsString,
+ enableSignature: enableSignatureParameter.valueAsString as YesNo,
+ secretsManager: secretsManagerSecretParameter.valueAsString,
+ secretsManagerKey: secretsManagerKeyParameter.valueAsString,
+ enableDefaultFallbackImage: enableDefaultFallbackImageParameter.valueAsString as YesNo,
+ fallbackImageS3Bucket: fallbackImageS3BucketParameter.valueAsString,
+ fallbackImageS3KeyBucket: fallbackImageS3KeyParameter.valueAsString
+ };
+
+ const commonResources = new CommonResources(this, 'CommonResources', {
+ solutionId: props.solutionId,
+ solutionVersion: props.solutionVersion,
+ solutionDisplayName: props.solutionDisplayName,
+ sourceCodeBucketName: sourceCodeBucketName,
+ sourceCodeKeyPrefix: sourceCodeKeyPrefix,
+ ...solutionConstructProps
+ });
+
+ const frontEnd = new FrontEnd(this, 'FrontEnd', {
+ logsBucket: commonResources.logsBucket,
+ conditions: commonResources.conditions
+ });
+
+ const backEnd = new BackEnd(this, 'BackEnd', {
+ sourceCodeBucketName: sourceCodeBucketName,
+ sourceCodeKeyPrefix: sourceCodeKeyPrefix,
+ solutionVersion: props.solutionVersion,
+ solutionDisplayName: props.solutionDisplayName,
+ secretsManagerPolicy: commonResources.secretsManagerPolicy,
+ logsBucket: commonResources.logsBucket,
+ uuid: commonResources.customResources.uuid,
+ cloudFrontPriceClass: cloudFrontPriceClassParameter.valueAsString,
+ ...solutionConstructProps
+ });
+
+ commonResources.customResources.setupAnonymousMetric({ anonymousData: anonymousUsage, ...solutionConstructProps });
+
+ commonResources.customResources.setupValidateSourceAndFallbackImageBuckets({
+ sourceBuckets: sourceBucketsParameter.valueAsString,
+ fallbackImageS3Bucket: fallbackImageS3BucketParameter.valueAsString,
+ fallbackImageS3Key: fallbackImageS3KeyParameter.valueAsString
+ });
+
+ commonResources.customResources.setupValidateSecretsManager({
+ secretsManager: secretsManagerSecretParameter.valueAsString,
+ secretsManagerKey: secretsManagerKeyParameter.valueAsString
+ });
+
+ commonResources.customResources.setupCopyWebsiteCustomResource({
+ hostingBucket: frontEnd.websiteHostingBucket
+ });
+
+ commonResources.customResources.setupPutWebsiteConfigCustomResource({
+ hostingBucket: frontEnd.websiteHostingBucket,
+ apiEndpoint: backEnd.domainName
+ });
+
+ 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]
+ },
+ {
+ Label: {
+ default:
+ 'Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)'
+ },
+ Parameters: [enableSignatureParameter.logicalId, secretsManagerSecretParameter.logicalId, secretsManagerKeyParameter.logicalId]
+ },
+ {
+ Label: {
+ default:
+ 'Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)'
+ },
+ Parameters: [enableDefaultFallbackImageParameter.logicalId, fallbackImageS3BucketParameter.logicalId, fallbackImageS3KeyParameter.logicalId]
+ },
+ {
+ Label: { default: 'Auto WebP' },
+ Parameters: [autoWebPParameter.logicalId]
+ }
+ ],
+ ParameterLabels: {
+ [corsEnabledParameter.logicalId]: { default: 'CORS Enabled' },
+ [corsOriginParameter.logicalId]: { default: 'CORS Origin' },
+ [sourceBucketsParameter.logicalId]: { default: 'Source Buckets' },
+ [deployDemoUIParameter.logicalId]: { default: 'Deploy Demo UI' },
+ [logRetentionPeriodParameter.logicalId]: { default: 'Log Retention Period' },
+ [autoWebPParameter.logicalId]: { default: 'AutoWebP' },
+ [enableSignatureParameter.logicalId]: { default: 'Enable Signature' },
+ [secretsManagerSecretParameter.logicalId]: { default: 'SecretsManager Secret' },
+ [secretsManagerKeyParameter.logicalId]: { default: 'SecretsManager Key' },
+ [enableDefaultFallbackImageParameter.logicalId]: { default: 'Enable Default Fallback Image' },
+ [fallbackImageS3BucketParameter.logicalId]: { default: 'Fallback Image S3 Bucket' },
+ [fallbackImageS3KeyParameter.logicalId]: { default: 'Fallback Image S3 Key' },
+ [cloudFrontPriceClassParameter.logicalId]: { default: 'CloudFront PriceClass' }
+ }
+ }
+ };
+
+ /* eslint-disable no-new */
+ new CfnOutput(this, 'ApiEndpoint', { value: `https://${backEnd.domainName}`, description: 'Link to API endpoint for sending image requests to.' });
+ new CfnOutput(this, 'DemoUrl', {
+ value: `https://${frontEnd.domainName}/index.html`,
+ description: 'Link to the demo user interface for the solution.',
+ condition: commonResources.conditions.deployUICondition
+ });
+ new CfnOutput(this, 'SourceBuckets', {
+ value: sourceBucketsParameter.valueAsString,
+ description: 'Amazon S3 bucket location containing original image files.'
+ });
+ new CfnOutput(this, 'CorsEnabled', {
+ value: corsEnabledParameter.valueAsString,
+ description: 'Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.'
+ });
+ new CfnOutput(this, 'CorsOrigin', {
+ value: corsOriginParameter.valueAsString,
+ description: 'Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.',
+ condition: commonResources.conditions.enableCorsCondition
+ });
+ new CfnOutput(this, 'LogRetentionPeriod', {
+ value: logRetentionPeriodParameter.valueAsString,
+ description: 'Number of days for event logs from Lambda to be retained in CloudWatch.'
+ });
+
+ Aspects.of(this).add(new SuppressLambdaFunctionCfnRulesAspect());
+ Tags.of(this).add('SolutionId', props.solutionId);
+ }
+}
diff --git a/source/constructs/lib/types.ts b/source/constructs/lib/types.ts
new file mode 100644
index 000000000..4afeb7d36
--- /dev/null
+++ b/source/constructs/lib/types.ts
@@ -0,0 +1,19 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export type YesNo = 'Yes' | 'No';
+
+export interface SolutionConstructProps {
+ readonly corsEnabled: string;
+ readonly corsOrigin: string;
+ readonly sourceBuckets: string;
+ readonly deployUI: YesNo;
+ readonly logRetentionPeriod: number;
+ readonly autoWebP: string;
+ readonly enableSignature: YesNo;
+ readonly secretsManager: string;
+ readonly secretsManagerKey: string;
+ readonly enableDefaultFallbackImage: YesNo;
+ readonly fallbackImageS3Bucket: string;
+ readonly fallbackImageS3KeyBucket: string;
+}
diff --git a/source/constructs/package.json b/source/constructs/package.json
index 546f8e93d..b05c4b777 100644
--- a/source/constructs/package.json
+++ b/source/constructs/package.json
@@ -1,37 +1,36 @@
{
"name": "constructs",
+ "version": "6.0.0",
"description": "Serverless Image Handler Constructs",
- "version": "5.2.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"
+ "cdk": "cdk",
+ "clean": "rm -rf node_modules/ cdk.out/ coverage/ package-lock.json",
+ "clean-synth": "npm run clean && npm install && npm run cdk synth --asset-metadata false --path-metadata false --json false",
+ "pretest": "npm run clean && npm install",
+ "test": "jest --coverage"
},
"devDependencies": {
- "@aws-cdk/assert": "1.64.1",
- "@types/jest": "^26.0.14",
- "@types/node": "^14.11.2",
- "aws-cdk": "1.64.1",
- "jest": "^26.4.2",
- "ts-jest": "^26.4.0",
- "ts-node": "^9.0.0",
- "typescript": "~4.0.3"
- },
- "dependencies": {
- "@aws-cdk/aws-apigateway": "1.64.1",
- "@aws-cdk/aws-cloudfront": "1.64.1",
- "@aws-cdk/aws-iam": "1.64.1",
- "@aws-cdk/aws-lambda": "1.64.1",
- "@aws-cdk/aws-s3": "1.64.1",
- "@aws-cdk/core": "1.64.1",
- "@aws-solutions-constructs/aws-apigateway-lambda": "1.64.1",
- "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "1.64.1",
- "@aws-solutions-constructs/aws-cloudfront-s3": "1.64.1",
- "@aws-solutions-constructs/core": "1.64.1"
+ "@aws-cdk/assert": " 1.136.0",
+ "@aws-cdk/aws-apigateway": " 1.136.0",
+ "@aws-cdk/aws-cloudfront": " 1.136.0",
+ "@aws-cdk/aws-iam": " 1.136.0",
+ "@aws-cdk/aws-lambda": " 1.136.0",
+ "@aws-cdk/aws-s3": " 1.136.0",
+ "@aws-cdk/core": " 1.136.0",
+ "@aws-solutions-constructs/aws-apigateway-lambda": "1.136.0",
+ "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "1.136.0",
+ "@aws-solutions-constructs/aws-cloudfront-s3": "1.136.0",
+ "@aws-solutions-constructs/core": "1.136.0",
+ "@types/jest": "^27.0.0",
+ "@types/node": "^16.10.3",
+ "aws-cdk": "1.136.0",
+ "jest": "^27.0.0",
+ "ts-jest": "^27.0.0",
+ "ts-node": "^10.2.1",
+ "typescript": "^4.4.3"
}
}
diff --git a/source/constructs/test/__snapshots__/constructs.test.ts.snap b/source/constructs/test/__snapshots__/constructs.test.ts.snap
new file mode 100644
index 000000000..62131bdd5
--- /dev/null
+++ b/source/constructs/test/__snapshots__/constructs.test.ts.snap
@@ -0,0 +1,2001 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Serverless Image Handler Stack Snapshot 1`] = `
+Object {
+ "Conditions": Object {
+ "CommonResourcesDeployDemoUICondition308D3B09": Object {
+ "Fn::Equals": Array [
+ Object {
+ "Ref": "DeployDemoUIParameter",
+ },
+ "Yes",
+ ],
+ },
+ "CommonResourcesEnableCorsConditionA0615348": Object {
+ "Fn::Equals": Array [
+ Object {
+ "Ref": "CorsEnabledParameter",
+ },
+ "Yes",
+ ],
+ },
+ "CommonResourcesEnableDefaultFallbackImageConditionD1A10983": Object {
+ "Fn::Equals": Array [
+ Object {
+ "Ref": "EnableDefaultFallbackImageParameter",
+ },
+ "Yes",
+ ],
+ },
+ "CommonResourcesEnableSignatureCondition909DC7A1": Object {
+ "Fn::Equals": Array [
+ Object {
+ "Ref": "EnableSignatureParameter",
+ },
+ "Yes",
+ ],
+ },
+ },
+ "Description": "Serverless Image Handler Stack",
+ "Metadata": Object {
+ "AWS::CloudFormation::Interface": Object {
+ "ParameterGroups": Array [
+ Object {
+ "Label": Object {
+ "default": "CORS Options",
+ },
+ "Parameters": Array [
+ "CorsEnabledParameter",
+ "CorsOriginParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Image Sources",
+ },
+ "Parameters": Array [
+ "SourceBucketsParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Demo UI",
+ },
+ "Parameters": Array [
+ "DeployDemoUIParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Event Logging",
+ },
+ "Parameters": Array [
+ "LogRetentionPeriodParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)",
+ },
+ "Parameters": Array [
+ "EnableSignatureParameter",
+ "SecretsManagerSecretParameter",
+ "SecretsManagerKeyParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)",
+ },
+ "Parameters": Array [
+ "EnableDefaultFallbackImageParameter",
+ "FallbackImageS3BucketParameter",
+ "FallbackImageS3KeyParameter",
+ ],
+ },
+ Object {
+ "Label": Object {
+ "default": "Auto WebP",
+ },
+ "Parameters": Array [
+ "AutoWebPParameter",
+ ],
+ },
+ ],
+ "ParameterLabels": Object {
+ "AutoWebPParameter": Object {
+ "default": "AutoWebP",
+ },
+ "CloudFrontPriceClassParameter": Object {
+ "default": "CloudFront PriceClass",
+ },
+ "CorsEnabledParameter": Object {
+ "default": "CORS Enabled",
+ },
+ "CorsOriginParameter": Object {
+ "default": "CORS Origin",
+ },
+ "DeployDemoUIParameter": Object {
+ "default": "Deploy Demo UI",
+ },
+ "EnableDefaultFallbackImageParameter": Object {
+ "default": "Enable Default Fallback Image",
+ },
+ "EnableSignatureParameter": Object {
+ "default": "Enable Signature",
+ },
+ "FallbackImageS3BucketParameter": Object {
+ "default": "Fallback Image S3 Bucket",
+ },
+ "FallbackImageS3KeyParameter": Object {
+ "default": "Fallback Image S3 Key",
+ },
+ "LogRetentionPeriodParameter": Object {
+ "default": "Log Retention Period",
+ },
+ "SecretsManagerKeyParameter": Object {
+ "default": "SecretsManager Key",
+ },
+ "SecretsManagerSecretParameter": Object {
+ "default": "SecretsManager Secret",
+ },
+ "SourceBucketsParameter": Object {
+ "default": "Source Buckets",
+ },
+ },
+ },
+ },
+ "Outputs": Object {
+ "ApiEndpoint": Object {
+ "Description": "Link to API endpoint for sending image requests to.",
+ "Value": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "https://",
+ Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2",
+ "DomainName",
+ ],
+ },
+ ],
+ ],
+ },
+ },
+ "CorsEnabled": Object {
+ "Description": "Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.",
+ "Value": Object {
+ "Ref": "CorsEnabledParameter",
+ },
+ },
+ "CorsOrigin": Object {
+ "Condition": "CommonResourcesEnableCorsConditionA0615348",
+ "Description": "Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.",
+ "Value": Object {
+ "Ref": "CorsOriginParameter",
+ },
+ },
+ "DemoUrl": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "Description": "Link to the demo user interface for the solution.",
+ "Value": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "https://",
+ Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3CloudFrontDistribution15FE13D0",
+ "DomainName",
+ ],
+ },
+ "/index.html",
+ ],
+ ],
+ },
+ },
+ "LogRetentionPeriod": Object {
+ "Description": "Number of days for event logs from Lambda to be retained in CloudWatch.",
+ "Value": Object {
+ "Ref": "LogRetentionPeriodParameter",
+ },
+ },
+ "SourceBuckets": Object {
+ "Description": "Amazon S3 bucket location containing original image files.",
+ "Value": Object {
+ "Ref": "SourceBucketsParameter",
+ },
+ },
+ },
+ "Parameters": Object {
+ "AutoWebPParameter": Object {
+ "AllowedValues": Array [
+ "Yes",
+ "No",
+ ],
+ "Default": "No",
+ "Description": "Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.",
+ "Type": "String",
+ },
+ "CloudFrontPriceClassParameter": Object {
+ "AllowedValues": Array [
+ "PriceClass_All",
+ "PriceClass_200",
+ "PriceClass_100",
+ ],
+ "Default": "PriceClass_All",
+ "Description": "The AWS CloudFront price class to use. For more information see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html",
+ "Type": "String",
+ },
+ "CorsEnabledParameter": Object {
+ "AllowedValues": Array [
+ "Yes",
+ "No",
+ ],
+ "Default": "No",
+ "Description": "Would you like to enable Cross-Origin Resource Sharing (CORS) for the image handler API? Select 'Yes' if so.",
+ "Type": "String",
+ },
+ "CorsOriginParameter": Object {
+ "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.",
+ "Type": "String",
+ },
+ "DeployDemoUIParameter": Object {
+ "AllowedValues": Array [
+ "Yes",
+ "No",
+ ],
+ "Default": "Yes",
+ "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.",
+ "Type": "String",
+ },
+ "EnableDefaultFallbackImageParameter": Object {
+ "AllowedValues": Array [
+ "Yes",
+ "No",
+ ],
+ "Default": "No",
+ "Description": "Would you like to enable the default fallback image? If so, select 'Yes' and provide FallbackImageS3Bucket and FallbackImageS3Key values.",
+ "Type": "String",
+ },
+ "EnableSignatureParameter": Object {
+ "AllowedValues": Array [
+ "Yes",
+ "No",
+ ],
+ "Default": "No",
+ "Description": "Would you like to enable the signature? If so, select 'Yes' and provide SecretsManagerSecret and SecretsManagerKey values.",
+ "Type": "String",
+ },
+ "FallbackImageS3BucketParameter": Object {
+ "Default": "",
+ "Description": "The name of the Amazon S3 bucket which contains the default fallback image. e.g. my-fallback-image-bucket",
+ "Type": "String",
+ },
+ "FallbackImageS3KeyParameter": Object {
+ "Default": "",
+ "Description": "The name of the default fallback image object key including prefix. e.g. prefix/image.jpg",
+ "Type": "String",
+ },
+ "LogRetentionPeriodParameter": Object {
+ "AllowedValues": Array [
+ "1",
+ "3",
+ "5",
+ "7",
+ "14",
+ "30",
+ "60",
+ "90",
+ "120",
+ "150",
+ "180",
+ "365",
+ "400",
+ "545",
+ "731",
+ "1827",
+ "3653",
+ ],
+ "Default": "1",
+ "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).",
+ "Type": "Number",
+ },
+ "SecretsManagerKeyParameter": Object {
+ "Default": "",
+ "Description": "The name of AWS Secrets Manager secret key. You need to create secret key with this key name. The secret value would be used to check signature.",
+ "Type": "String",
+ },
+ "SecretsManagerSecretParameter": Object {
+ "Default": "",
+ "Description": "The name of AWS Secrets Manager secret. You need to create your secret under this name.",
+ "Type": "String",
+ },
+ "SourceBucketsParameter": Object {
+ "AllowedPattern": ".+",
+ "Default": "defaultBucket, bucketNo2, bucketNo3, ...",
+ "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.",
+ "Type": "String",
+ },
+ },
+ "Resources": Object {
+ "BackEndCachePolicy1DCE9B1B": Object {
+ "Properties": Object {
+ "CachePolicyConfig": Object {
+ "DefaultTTL": 86400,
+ "MaxTTL": 31536000,
+ "MinTTL": 1,
+ "Name": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "ServerlessImageHandler-",
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD",
+ "UUID",
+ ],
+ },
+ ],
+ ],
+ },
+ "ParametersInCacheKeyAndForwardedToOrigin": Object {
+ "CookiesConfig": Object {
+ "CookieBehavior": "none",
+ },
+ "EnableAcceptEncodingBrotli": false,
+ "EnableAcceptEncodingGzip": true,
+ "HeadersConfig": Object {
+ "HeaderBehavior": "whitelist",
+ "Headers": Array [
+ "origin",
+ "accept",
+ ],
+ },
+ "QueryStringsConfig": Object {
+ "QueryStringBehavior": "whitelist",
+ "QueryStrings": Array [
+ "signature",
+ ],
+ },
+ },
+ },
+ },
+ "Type": "AWS::CloudFront::CachePolicy",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaApiAccessLogGroup9B786692": Object {
+ "DeletionPolicy": "Retain",
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W84",
+ "reason": "By default CloudWatchLogs LogGroups data is encrypted using the CloudWatch server-side encryption keys (AWS Managed Keys)",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "RetentionInDays": Object {
+ "Ref": "LogRetentionPeriodParameter",
+ },
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::Logs::LogGroup",
+ "UpdateReplacePolicy": "Retain",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2": Object {
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "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",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "DistributionConfig": Object {
+ "Comment": "Image Handler Distribution for Serverless Image Handler",
+ "CustomErrorResponses": Array [
+ Object {
+ "ErrorCachingMinTTL": 600,
+ "ErrorCode": 500,
+ },
+ Object {
+ "ErrorCachingMinTTL": 600,
+ "ErrorCode": 501,
+ },
+ Object {
+ "ErrorCachingMinTTL": 600,
+ "ErrorCode": 502,
+ },
+ Object {
+ "ErrorCachingMinTTL": 600,
+ "ErrorCode": 503,
+ },
+ Object {
+ "ErrorCachingMinTTL": 600,
+ "ErrorCode": 504,
+ },
+ ],
+ "DefaultCacheBehavior": Object {
+ "AllowedMethods": Array [
+ "GET",
+ "HEAD",
+ ],
+ "CachePolicyId": Object {
+ "Ref": "BackEndCachePolicy1DCE9B1B",
+ },
+ "Compress": true,
+ "OriginRequestPolicyId": Object {
+ "Ref": "BackEndOriginRequestPolicy771345D7",
+ },
+ "TargetOriginId": "TestStackBackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistributionOrigin1A053AEB7",
+ "ViewerProtocolPolicy": "https-only",
+ },
+ "Enabled": true,
+ "HttpVersion": "http2",
+ "IPV6Enabled": true,
+ "Logging": Object {
+ "Bucket": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB",
+ "BucketName",
+ ],
+ },
+ ".s3.",
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB",
+ "Region",
+ ],
+ },
+ ".",
+ Object {
+ "Ref": "AWS::URLSuffix",
+ },
+ ],
+ ],
+ },
+ "Prefix": "api-cloudfront/",
+ },
+ "Origins": Array [
+ Object {
+ "CustomOriginConfig": Object {
+ "OriginProtocolPolicy": "https-only",
+ "OriginSSLProtocols": Array [
+ "TLSv1.1",
+ "TLSv1.2",
+ ],
+ },
+ "DomainName": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ ".execute-api.",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ".amazonaws.com",
+ ],
+ ],
+ },
+ "Id": "TestStackBackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistributionOrigin1A053AEB7",
+ "OriginPath": "/image",
+ },
+ ],
+ "PriceClass": Object {
+ "Ref": "CloudFrontPriceClassParameter",
+ },
+ },
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::CloudFront::Distribution",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109": Object {
+ "Properties": Object {
+ "BinaryMediaTypes": Array [
+ "*/*",
+ ],
+ "EndpointConfiguration": Object {
+ "Types": Array [
+ "REGIONAL",
+ ],
+ },
+ "Name": "LambdaRestApi",
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::ApiGateway::RestApi",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYApiPermissionTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANY979F1429": Object {
+ "Properties": Object {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "/",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeploymentStageimageB55D20E3",
+ },
+ "/*/",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYApiPermissionTestTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANY932D3700": Object {
+ "Properties": Object {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "/test-invoke-stage/*/",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYE4494B31": Object {
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W59",
+ "reason": "AWS::ApiGateway::Method AuthorizationType is set to 'NONE' because API Gateway behind CloudFront does not support AWS_IAM authentication",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "AuthorizationType": "NONE",
+ "HttpMethod": "ANY",
+ "Integration": Object {
+ "IntegrationHttpMethod": "POST",
+ "Type": "AWS_PROXY",
+ "Uri": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":apigateway:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":lambda:path/2015-03-31/functions/",
+ Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "/invocations",
+ ],
+ ],
+ },
+ },
+ "ResourceId": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ "RootResourceId",
+ ],
+ },
+ "RestApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ },
+ "Type": "AWS::ApiGateway::Method",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiAccountE5522E5D": Object {
+ "DependsOn": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ ],
+ "Properties": Object {
+ "CloudWatchRoleArn": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiCloudWatchRole12575C4D",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::ApiGateway::Account",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiCloudWatchRole12575C4D": Object {
+ "Properties": Object {
+ "AssumeRolePolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": Object {
+ "Service": "apigateway.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "Policies": Array [
+ Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": Array [
+ "logs:CreateLogGroup",
+ "logs:CreateLogStream",
+ "logs:DescribeLogGroups",
+ "logs:DescribeLogStreams",
+ "logs:PutLogEvents",
+ "logs:GetLogEvents",
+ "logs:FilterLogEvents",
+ ],
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":logs:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":*",
+ ],
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "LambdaRestApiCloudWatchRolePolicy",
+ },
+ ],
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D6235994dc47b5d82fe8cf9199608ba4c9": Object {
+ "DependsOn": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANY8F9763E1",
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyBDF0A131",
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYE4494B31",
+ ],
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W45",
+ "reason": "ApiGateway has AccessLogging enabled in AWS::ApiGateway::Stage resource, but cfn_nag checkes for it in AWS::ApiGateway::Deployment resource",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "Description": "Automatically created by the RestApi construct",
+ "RestApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ },
+ "Type": "AWS::ApiGateway::Deployment",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeploymentStageimageB55D20E3": Object {
+ "Properties": Object {
+ "AccessLogSetting": Object {
+ "DestinationArn": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaApiAccessLogGroup9B786692",
+ "Arn",
+ ],
+ },
+ "Format": "{\\"requestId\\":\\"$context.requestId\\",\\"ip\\":\\"$context.identity.sourceIp\\",\\"user\\":\\"$context.identity.user\\",\\"caller\\":\\"$context.identity.caller\\",\\"requestTime\\":\\"$context.requestTime\\",\\"httpMethod\\":\\"$context.httpMethod\\",\\"resourcePath\\":\\"$context.resourcePath\\",\\"status\\":\\"$context.status\\",\\"protocol\\":\\"$context.protocol\\",\\"responseLength\\":\\"$context.responseLength\\"}",
+ },
+ "DeploymentId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D6235994dc47b5d82fe8cf9199608ba4c9",
+ },
+ "MethodSettings": Array [
+ Object {
+ "DataTraceEnabled": false,
+ "HttpMethod": "*",
+ "LoggingLevel": "INFO",
+ "ResourcePath": "/*",
+ },
+ ],
+ "RestApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "StageName": "image",
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ "TracingEnabled": true,
+ },
+ "Type": "AWS::ApiGateway::Stage",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiUsagePlan76CA1E70": Object {
+ "Properties": Object {
+ "ApiStages": Array [
+ Object {
+ "ApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "Stage": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeploymentStageimageB55D20E3",
+ },
+ "Throttle": Object {},
+ },
+ ],
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::ApiGateway::UsagePlan",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANY8F9763E1": Object {
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W59",
+ "reason": "AWS::ApiGateway::Method AuthorizationType is set to 'NONE' because API Gateway behind CloudFront does not support AWS_IAM authentication",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "AuthorizationType": "NONE",
+ "HttpMethod": "ANY",
+ "Integration": Object {
+ "IntegrationHttpMethod": "POST",
+ "Type": "AWS_PROXY",
+ "Uri": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":apigateway:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":lambda:path/2015-03-31/functions/",
+ Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "/invocations",
+ ],
+ ],
+ },
+ },
+ "ResourceId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyBDF0A131",
+ },
+ "RestApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ },
+ "Type": "AWS::ApiGateway::Method",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANYApiPermissionTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANYproxyB5CBD1F7": Object {
+ "Properties": Object {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "/",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeploymentStageimageB55D20E3",
+ },
+ "/*/*",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANYApiPermissionTestTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANYproxyAEADD71A": Object {
+ "Properties": Object {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ "/test-invoke-stage/*/*",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyBDF0A131": Object {
+ "Properties": Object {
+ "ParentId": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ "RootResourceId",
+ ],
+ },
+ "PathPart": "{proxy+}",
+ "RestApiId": Object {
+ "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109",
+ },
+ },
+ "Type": "AWS::ApiGateway::Resource",
+ },
+ "BackEndImageHandlerFunctionPolicy437940B5": Object {
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W12",
+ "reason": "rekognition:DetectFaces requires '*' resources.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": Array [
+ "logs:CreateLogGroup",
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ],
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":logs:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":log-group:/aws/lambda/*",
+ ],
+ ],
+ },
+ },
+ Object {
+ "Action": Array [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:ListBucket",
+ ],
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":s3:::*",
+ ],
+ ],
+ },
+ },
+ Object {
+ "Action": Array [
+ "rekognition:DetectFaces",
+ "rekognition:DetectModerationLabels",
+ ],
+ "Effect": "Allow",
+ "Resource": "*",
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "BackEndImageHandlerFunctionPolicy437940B5",
+ "Roles": Array [
+ Object {
+ "Ref": "BackEndImageHandlerFunctionRoleABF81E5C",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "BackEndImageHandlerFunctionRoleABF81E5C": Object {
+ "Properties": Object {
+ "AssumeRolePolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": Object {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "Path": "/",
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "BackEndImageHandlerLambdaFunctionADEF7FF2": Object {
+ "DependsOn": Array [
+ "BackEndImageHandlerFunctionRoleABF81E5C",
+ ],
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W58",
+ "reason": "The function does have permission to write CloudWatch Logs.",
+ },
+ Object {
+ "id": "W89",
+ "reason": "The Lambda function does not require any VPC connection at all.",
+ },
+ Object {
+ "id": "W92",
+ "reason": "The Lambda function does not require ReservedConcurrentExecutions.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "Code": Object {
+ "S3Bucket": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "hosting-bucket-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ],
+ ],
+ },
+ "S3Key": "sih/v6.0.0/image-handler.zip",
+ },
+ "Description": "Serverless Image Handler Test (v6.0.0): Performs image edits and manipulations",
+ "Environment": Object {
+ "Variables": Object {
+ "AUTO_WEBP": Object {
+ "Ref": "AutoWebPParameter",
+ },
+ "CORS_ENABLED": Object {
+ "Ref": "CorsEnabledParameter",
+ },
+ "CORS_ORIGIN": Object {
+ "Ref": "CorsOriginParameter",
+ },
+ "DEFAULT_FALLBACK_IMAGE_BUCKET": Object {
+ "Ref": "FallbackImageS3BucketParameter",
+ },
+ "DEFAULT_FALLBACK_IMAGE_KEY": Object {
+ "Ref": "FallbackImageS3KeyParameter",
+ },
+ "ENABLE_DEFAULT_FALLBACK_IMAGE": Object {
+ "Ref": "EnableDefaultFallbackImageParameter",
+ },
+ "ENABLE_SIGNATURE": Object {
+ "Ref": "EnableSignatureParameter",
+ },
+ "REWRITE_MATCH_PATTERN": "",
+ "REWRITE_SUBSTITUTION": "",
+ "SECRETS_MANAGER": Object {
+ "Ref": "SecretsManagerSecretParameter",
+ },
+ "SECRET_KEY": Object {
+ "Ref": "SecretsManagerKeyParameter",
+ },
+ "SOURCE_BUCKETS": Object {
+ "Ref": "SourceBucketsParameter",
+ },
+ },
+ },
+ "Handler": "image-handler/index.handler",
+ "MemorySize": 1024,
+ "Role": Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerFunctionRoleABF81E5C",
+ "Arn",
+ ],
+ },
+ "Runtime": "nodejs14.x",
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ "Timeout": 900,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "BackEndImageHandlerLogGroupA0941EEC": Object {
+ "DeletionPolicy": "Retain",
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W84",
+ "reason": "CloudWatch log group is always encrypted by default.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "LogGroupName": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "/aws/lambda/",
+ Object {
+ "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2",
+ },
+ ],
+ ],
+ },
+ "RetentionInDays": Object {
+ "Ref": "LogRetentionPeriodParameter",
+ },
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::Logs::LogGroup",
+ "UpdateReplacePolicy": "Retain",
+ },
+ "BackEndOriginRequestPolicy771345D7": Object {
+ "Properties": Object {
+ "OriginRequestPolicyConfig": Object {
+ "CookiesConfig": Object {
+ "CookieBehavior": "none",
+ },
+ "HeadersConfig": Object {
+ "HeaderBehavior": "whitelist",
+ "Headers": Array [
+ "origin",
+ "accept",
+ ],
+ },
+ "Name": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "ServerlessImageHandler-",
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD",
+ "UUID",
+ ],
+ },
+ ],
+ ],
+ },
+ "QueryStringsConfig": Object {
+ "QueryStringBehavior": "whitelist",
+ "QueryStrings": Array [
+ "signature",
+ ],
+ },
+ },
+ },
+ "Type": "AWS::CloudFront::OriginRequestPolicy",
+ },
+ "CommonResourcesCustomResourcesCopyWebsite83738AA9": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "CustomAction": "copyS3assets",
+ "DestS3Bucket": Object {
+ "Ref": "FrontEndDistributionToS3S3Bucket3A171D78",
+ },
+ "ManifestKey": "sih/v6.0.0/demo-ui-manifest.json",
+ "Region": Object {
+ "Ref": "AWS::Region",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ "SourceS3Bucket": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "hosting-bucket-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ],
+ ],
+ },
+ "SourceS3key": "sih/v6.0.0/demo-ui",
+ "Version": "v6.0.0",
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesCustomResourceAnonymousMetric51363F57": Object {
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "AnonymousData": "Yes",
+ "AutoWebP": Object {
+ "Ref": "AutoWebPParameter",
+ },
+ "CorsEnabled": Object {
+ "Ref": "CorsEnabledParameter",
+ },
+ "CustomAction": "sendMetric",
+ "DeployDemoUi": Object {
+ "Ref": "DeployDemoUIParameter",
+ },
+ "EnableDefaultFallbackImage": Object {
+ "Ref": "EnableDefaultFallbackImageParameter",
+ },
+ "EnableSignature": Object {
+ "Ref": "EnableSignatureParameter",
+ },
+ "LogRetentionPeriod": Object {
+ "Ref": "LogRetentionPeriodParameter",
+ },
+ "Region": Object {
+ "Ref": "AWS::Region",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ "SourceBuckets": Object {
+ "Ref": "SourceBucketsParameter",
+ },
+ "UUID": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD",
+ "UUID",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesCustomResourceCheckFallbackImage6CE45571": Object {
+ "Condition": "CommonResourcesEnableDefaultFallbackImageConditionD1A10983",
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "CustomAction": "checkFallbackImage",
+ "FallbackImageS3Bucket": Object {
+ "Ref": "FallbackImageS3BucketParameter",
+ },
+ "FallbackImageS3Key": Object {
+ "Ref": "FallbackImageS3KeyParameter",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesCustomResourceCheckSecretsManagerAEEEC776": Object {
+ "Condition": "CommonResourcesEnableSignatureCondition909DC7A1",
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "CustomAction": "checkSecretsManager",
+ "SecretsManagerKey": Object {
+ "Ref": "SecretsManagerKeyParameter",
+ },
+ "SecretsManagerName": Object {
+ "Ref": "SecretsManagerSecretParameter",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesCustomResourceCheckSourceBucketsA313C9B7": Object {
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "CustomAction": "checkSourceBuckets",
+ "Region": Object {
+ "Ref": "AWS::Region",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ "SourceBuckets": Object {
+ "Ref": "SourceBucketsParameter",
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235": Object {
+ "DependsOn": Array [
+ "CommonResourcesCustomResourcesCustomResourceRoleDefaultPolicy5AE1B0FC",
+ "CommonResourcesCustomResourcesCustomResourceRole8958A1ED",
+ ],
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W58",
+ "reason": "The function does have permission to write CloudWatch Logs.",
+ },
+ Object {
+ "id": "W89",
+ "reason": "The Lambda function does not require any VPC connection at all.",
+ },
+ Object {
+ "id": "W92",
+ "reason": "The Lambda function does not require ReservedConcurrentExecutions.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "Code": Object {
+ "S3Bucket": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "hosting-bucket-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ],
+ ],
+ },
+ "S3Key": "sih/v6.0.0/custom-resource.zip",
+ },
+ "Description": "Serverless Image Handler Test (v6.0.0): Custom resource",
+ "Environment": Object {
+ "Variables": Object {
+ "RETRY_SECONDS": "5",
+ "SOLUTION_ID": "S0ABC",
+ "SOLUTION_VERSION": "v6.0.0",
+ },
+ },
+ "Handler": "custom-resource/index.handler",
+ "MemorySize": 128,
+ "Role": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceRole8958A1ED",
+ "Arn",
+ ],
+ },
+ "Runtime": "nodejs14.x",
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ "Timeout": 60,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "CommonResourcesCustomResourcesCustomResourceRole8958A1ED": Object {
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W11",
+ "reason": "Allow '*' because it is required for making DescribeRegions API call as it doesn't support resource-level permissions and require to choose all resources.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "AssumeRolePolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": Object {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "Path": "/",
+ "Policies": Array [
+ Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": Array [
+ "logs:CreateLogGroup",
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ],
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":logs:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":log-group:/aws/lambda/*",
+ ],
+ ],
+ },
+ },
+ Object {
+ "Action": Array [
+ "s3:putBucketAcl",
+ "s3:putEncryptionConfiguration",
+ "s3:putBucketPolicy",
+ "s3:CreateBucket",
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:ListBucket",
+ ],
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":s3:::*",
+ ],
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "CloudWatchLogsPolicy",
+ },
+ Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "ec2:DescribeRegions",
+ "Effect": "Allow",
+ "Resource": "*",
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "EC2Policy",
+ },
+ ],
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "CommonResourcesCustomResourcesCustomResourceRoleDefaultPolicy5AE1B0FC": Object {
+ "Properties": Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": Array [
+ "s3:GetObject*",
+ "s3:GetBucket*",
+ "s3:List*",
+ ],
+ "Effect": "Allow",
+ "Resource": Array [
+ Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":s3:::hosting-bucket-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ],
+ ],
+ },
+ Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":s3:::hosting-bucket-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ "/sih/v6.0.0/*",
+ ],
+ ],
+ },
+ ],
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "CommonResourcesCustomResourcesCustomResourceRoleDefaultPolicy5AE1B0FC",
+ "Roles": Array [
+ Object {
+ "Ref": "CommonResourcesCustomResourcesCustomResourceRole8958A1ED",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD": Object {
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "CustomAction": "createUuid",
+ "Region": Object {
+ "Ref": "AWS::Region",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB": Object {
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "BucketSuffix": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Ref": "AWS::StackName",
+ },
+ "-",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ "-",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ],
+ ],
+ },
+ "CustomAction": "createCloudFrontLoggingBucket",
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesCustomResourcesPutWebsiteConfigC4E435F3": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "DeletionPolicy": "Delete",
+ "Properties": Object {
+ "ConfigItem": Object {
+ "apiEndpoint": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "https://",
+ Object {
+ "Fn::GetAtt": Array [
+ "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2",
+ "DomainName",
+ ],
+ },
+ ],
+ ],
+ },
+ },
+ "CustomAction": "putConfigFile",
+ "DestS3Bucket": Object {
+ "Ref": "FrontEndDistributionToS3S3Bucket3A171D78",
+ },
+ "DestS3key": "demo-ui-config.js",
+ "Region": Object {
+ "Ref": "AWS::Region",
+ },
+ "ServiceToken": Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesCustomResourceFunction0D924235",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::CloudFormation::CustomResource",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "CommonResourcesSecretsManagerPolicy45FE005E": Object {
+ "Condition": "CommonResourcesEnableSignatureCondition909DC7A1",
+ "Properties": Object {
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "secretsmanager:GetSecretValue",
+ "Effect": "Allow",
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "arn:",
+ Object {
+ "Ref": "AWS::Partition",
+ },
+ ":secretsmanager:",
+ Object {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ Object {
+ "Ref": "AWS::AccountId",
+ },
+ ":secret:",
+ Object {
+ "Ref": "SecretsManagerSecretParameter",
+ },
+ "*",
+ ],
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "CommonResourcesSecretsManagerPolicy45FE005E",
+ "Roles": Array [
+ Object {
+ "Ref": "CommonResourcesCustomResourcesCustomResourceRole8958A1ED",
+ },
+ Object {
+ "Ref": "BackEndImageHandlerFunctionRoleABF81E5C",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "FrontEndDistributionToS3CloudFrontDistribution15FE13D0": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "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",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "DistributionConfig": Object {
+ "Comment": "Demo UI Distribution for Serverless Image Handler",
+ "CustomErrorResponses": Array [
+ Object {
+ "ErrorCode": 403,
+ "ResponseCode": 200,
+ "ResponsePagePath": "/index.html",
+ },
+ Object {
+ "ErrorCode": 404,
+ "ResponseCode": 200,
+ "ResponsePagePath": "/index.html",
+ },
+ ],
+ "DefaultCacheBehavior": Object {
+ "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
+ "Compress": true,
+ "TargetOriginId": "TestStackFrontEndDistributionToS3CloudFrontDistributionOrigin12FCDC222",
+ "ViewerProtocolPolicy": "redirect-to-https",
+ },
+ "DefaultRootObject": "index.html",
+ "Enabled": true,
+ "HttpVersion": "http2",
+ "IPV6Enabled": true,
+ "Logging": Object {
+ "Bucket": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB",
+ "BucketName",
+ ],
+ },
+ ".s3.",
+ Object {
+ "Fn::GetAtt": Array [
+ "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB",
+ "Region",
+ ],
+ },
+ ".",
+ Object {
+ "Ref": "AWS::URLSuffix",
+ },
+ ],
+ ],
+ },
+ "Prefix": "ui-cloudfront/",
+ },
+ "Origins": Array [
+ Object {
+ "DomainName": Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3S3Bucket3A171D78",
+ "RegionalDomainName",
+ ],
+ },
+ "Id": "TestStackFrontEndDistributionToS3CloudFrontDistributionOrigin12FCDC222",
+ "S3OriginConfig": Object {
+ "OriginAccessIdentity": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ "origin-access-identity/cloudfront/",
+ Object {
+ "Ref": "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E",
+ },
+ ],
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ },
+ "Type": "AWS::CloudFront::Distribution",
+ },
+ "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "Properties": Object {
+ "CloudFrontOriginAccessIdentityConfig": Object {
+ "Comment": "Identity for TestStackFrontEndDistributionToS3CloudFrontDistributionOrigin12FCDC222",
+ },
+ },
+ "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity",
+ },
+ "FrontEndDistributionToS3S3Bucket3A171D78": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "DeletionPolicy": "Retain",
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "W35",
+ "reason": "This S3 bucket does not require access logging.",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "BucketEncryption": Object {
+ "ServerSideEncryptionConfiguration": Array [
+ Object {
+ "ServerSideEncryptionByDefault": Object {
+ "SSEAlgorithm": "AES256",
+ },
+ },
+ ],
+ },
+ "LifecycleConfiguration": Object {
+ "Rules": Array [
+ Object {
+ "NoncurrentVersionTransitions": Array [
+ Object {
+ "StorageClass": "GLACIER",
+ "TransitionInDays": 90,
+ },
+ ],
+ "Status": "Enabled",
+ },
+ ],
+ },
+ "PublicAccessBlockConfiguration": Object {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true,
+ },
+ "Tags": Array [
+ Object {
+ "Key": "SolutionId",
+ "Value": "S0ABC",
+ },
+ ],
+ "VersioningConfiguration": Object {
+ "Status": "Enabled",
+ },
+ },
+ "Type": "AWS::S3::Bucket",
+ "UpdateReplacePolicy": "Retain",
+ },
+ "FrontEndDistributionToS3S3BucketPolicyF3A0315A": Object {
+ "Condition": "CommonResourcesDeployDemoUICondition308D3B09",
+ "Metadata": Object {
+ "cfn_nag": Object {
+ "rules_to_suppress": Array [
+ Object {
+ "id": "F16",
+ "reason": "Public website bucket policy requires a wildcard principal",
+ },
+ ],
+ },
+ },
+ "Properties": Object {
+ "Bucket": Object {
+ "Ref": "FrontEndDistributionToS3S3Bucket3A171D78",
+ },
+ "PolicyDocument": Object {
+ "Statement": Array [
+ Object {
+ "Action": "*",
+ "Condition": Object {
+ "Bool": Object {
+ "aws:SecureTransport": "false",
+ },
+ },
+ "Effect": "Deny",
+ "Principal": Object {
+ "AWS": "*",
+ },
+ "Resource": Array [
+ Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3S3Bucket3A171D78",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3S3Bucket3A171D78",
+ "Arn",
+ ],
+ },
+ ],
+ "Sid": "HttpsOnly",
+ },
+ Object {
+ "Action": "s3:GetObject",
+ "Effect": "Allow",
+ "Principal": Object {
+ "CanonicalUser": Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E",
+ "S3CanonicalUserId",
+ ],
+ },
+ },
+ "Resource": Object {
+ "Fn::Join": Array [
+ "",
+ Array [
+ Object {
+ "Fn::GetAtt": Array [
+ "FrontEndDistributionToS3S3Bucket3A171D78",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::S3::BucketPolicy",
+ },
+ },
+}
+`;
diff --git a/source/constructs/test/constructs.test.ts b/source/constructs/test/constructs.test.ts
index c40e53c6f..4fb55ed78 100644
--- a/source/constructs/test/constructs.test.ts
+++ b/source/constructs/test/constructs.test.ts
@@ -1,15 +1,24 @@
// 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));
+import '@aws-cdk/assert/jest';
+import { SynthUtils } from '@aws-cdk/assert';
+import { App } from '@aws-cdk/core';
+
+import { ServerlessImageHandlerStack } from '../lib/serverless-image-stack';
+
+test('Serverless Image Handler Stack Snapshot', () => {
+ const app = new App();
+
+ const stack = new ServerlessImageHandlerStack(app, 'TestStack', {
+ description: 'Serverless Image Handler Stack',
+ solutionId: 'S0ABC',
+ solutionName: 'sih',
+ solutionVersion: 'v6.0.0',
+ solutionDisplayName: 'Serverless Image Handler Test',
+ solutionAssetHostingBucketNamePrefix: 'hosting-bucket'
+ });
+
+ expect.assertions(1);
+ expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
});
diff --git a/source/constructs/test/serverless-image-handler-test.json b/source/constructs/test/serverless-image-handler-test.json
deleted file mode 100644
index 50497f483..000000000
--- a/source/constructs/test/serverless-image-handler-test.json
+++ /dev/null
@@ -1,1760 +0,0 @@
-{
- "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"
- ]
- },
- {
- "Label": {
- "default": "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)"
- },
- "Parameters": [
- "EnableSignature",
- "SecretsManagerSecret",
- "SecretsManagerKey"
- ]
- },
- {
- "Label": {
- "default": "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)"
- },
- "Parameters": [
- "EnableDefaultFallbackImage",
- "FallbackImageS3Bucket",
- "FallbackImageS3Key"
- ]
- },
- {
- "Label": {
- "default": "Auto WebP"
- },
- "Parameters": [
- "AutoWebP"
- ]
- }
- ]
- }
- },
- "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."
- },
- "EnableSignature": {
- "Type": "String",
- "Default": "No",
- "AllowedValues": [
- "Yes",
- "No"
- ],
- "Description": "Would you like to enable the signature? If so, select 'Yes' and provide SecretsManagerSecret and SecretsManagerKey values."
- },
- "SecretsManagerSecret": {
- "Type": "String",
- "Default": "",
- "Description": "The name of AWS Secrets Manager secret. You need to create your secret under this name."
- },
- "SecretsManagerKey": {
- "Type": "String",
- "Default": "",
- "Description": "The name of AWS Secrets Manager secret key. You need to create secret key with this key name. The secret value would be used to check signature."
- },
- "EnableDefaultFallbackImage": {
- "Type": "String",
- "Default": "No",
- "AllowedValues": [
- "Yes",
- "No"
- ],
- "Description": "Would you like to enable the default fallback image? If so, select 'Yes' and provide FallbackImageS3Bucket and FallbackImageS3Key values."
- },
- "FallbackImageS3Bucket": {
- "Type": "String",
- "Default": "",
- "Description": "The name of the Amazon S3 bucket which contains the default fallback image. e.g. my-fallback-image-bucket"
- },
- "FallbackImageS3Key": {
- "Type": "String",
- "Default": "",
- "Description": "The name of the default fallback image object key including prefix. e.g. prefix/image.jpg"
- }
- },
- "Mappings": {
- "Send": {
- "AnonymousUsage": {
- "Data": "Yes"
- }
- }
- },
- "Conditions": {
- "DeployDemoUICondition": {
- "Fn::Equals": [
- {
- "Ref": "DeployDemoUI"
- },
- "Yes"
- ]
- },
- "EnableCorsCondition": {
- "Fn::Equals": [
- {
- "Ref": "CorsEnabled"
- },
- "Yes"
- ]
- },
- "EnableSignatureCondition": {
- "Fn::Equals": [
- {
- "Ref": "EnableSignature"
- },
- "Yes"
- ]
- },
- "EnableDefaultFallbackImageCondition": {
- "Fn::Equals": [
- {
- "Ref": "EnableDefaultFallbackImage"
- },
- "Yes"
- ]
- },
- "IsOptInRegion": {
- "Fn::Or": [
- {
- "Fn::Equals": [
- "af-south-1",
- {
- "Ref": "AWS::Region"
- }
- ]
- },
- {
- "Fn::Equals": [
- "ap-east-1",
- {
- "Ref": "AWS::Region"
- }
- ]
- },
- {
- "Fn::Equals": [
- "eu-south-1",
- {
- "Ref": "AWS::Region"
- }
- ]
- },
- {
- "Fn::Equals": [
- "me-south-1",
- {
- "Ref": "AWS::Region"
- }
- ]
- }
- ]
- },
- "IsNotOptInRegion": {
- "Fn::Not": [
- {
- "Condition": "IsOptInRegion"
- }
- ]
- }
- },
- "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",
- "rekognition:DetectModerationLabels"
- ],
- "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::Join": [
- "",
- [
- "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": "",
- "ENABLE_SIGNATURE": {
- "Ref": "EnableSignature"
- },
- "SECRETS_MANAGER": {
- "Ref": "SecretsManagerSecret"
- },
- "SECRET_KEY": {
- "Ref": "SecretsManagerKey"
- },
- "ENABLE_DEFAULT_FALLBACK_IMAGE": {
- "Ref": "EnableDefaultFallbackImage"
- },
- "DEFAULT_FALLBACK_IMAGE_BUCKET": {
- "Ref": "FallbackImageS3Bucket"
- },
- "DEFAULT_FALLBACK_IMAGE_KEY": {
- "Ref": "FallbackImageS3Key"
- }
- }
- },
- "MemorySize": 1024,
- "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",
- "Metadata": {
- "cfn_nag": {
- "rules_to_suppress": [
- {
- "id": "W84",
- "reason": "Used to store store function info"
- }
- ]
- }
- }
- },
- "ApiLogs": {
- "Type": "AWS::Logs::LogGroup",
- "UpdateReplacePolicy": "Retain",
- "DeletionPolicy": "Retain",
- "Metadata": {
- "cfn_nag": {
- "rules_to_suppress": [
- {
- "id": "W84",
- "reason": "Used to store store api log info, not using kms"
- },
- {
- "id": "W86",
- "reason": "Log retention specified in CloudFromation parameters."
- }
- ]
- }
- }
- },
- "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"
- },
- {
- "name": "signature",
- "in": "query",
- "description": "Signature of the image",
- "required": false,
- "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:",
- {
- "Ref": "AWS::Partition"
- },
- ":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
- }
- },
- "UpdateReplacePolicy": "Retain",
- "DeletionPolicy": "Retain",
- "Metadata": {
- "cfn_nag": {
- "rules_to_suppress": [
- {
- "id": "W35",
- "reason": "Used to store access logs for other buckets"
- }
- ]
- }
- },
- "Condition": "IsNotOptInRegion"
- },
- "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"
- }
- },
- "Condition": "IsNotOptInRegion"
- },
- "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"
- ],
- "CachePolicyId": {
- "Ref": "ImageHandlerCachePolicy"
- },
- "OriginRequestPolicyId": {
- "Ref": "ImageHandlerOriginRequestPolicy"
- },
- "TargetOriginId": {
- "Ref": "ImageHandlerApi"
- },
- "ViewerProtocolPolicy": "https-only"
- },
- "Enabled": true,
- "HttpVersion": "http2",
- "Logging": {
- "Bucket": {
- "Fn::If": [
- "IsOptInRegion",
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "CustomCFLoggingBucket",
- "bucketName"
- ]
- },
- ".s3.us-east-1.",
- {
- "Ref": "AWS::URLSuffix"
- }
- ]
- ]
- },
- {
- "Fn::GetAtt": [
- "Logs",
- "RegionalDomainName"
- ]
- }
- ]
- },
- "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."
- }
- ]
- }
- }
- },
- "ImageHandlerCachePolicy": {
- "Type": "AWS::CloudFront::CachePolicy",
- "Properties": {
- "CachePolicyConfig": {
- "DefaultTTL": 86400,
- "MaxTTL": 31536000,
- "MinTTL": 1,
- "Name": {
- "Fn::Join": [
- "",
- [
- {
- "Ref": "AWS::StackName"
- },
- "-",
- {
- "Ref": "AWS::Region"
- },
- "-ImageHandlerCachePolicy"
- ]
- ]
- },
- "ParametersInCacheKeyAndForwardedToOrigin": {
- "CookiesConfig": {
- "CookieBehavior": "none"
- },
- "EnableAcceptEncodingGzip": true,
- "HeadersConfig": {
- "HeaderBehavior": "whitelist",
- "Headers": [
- "origin",
- "accept"
- ]
- },
- "QueryStringsConfig": {
- "QueryStringBehavior": "whitelist",
- "QueryStrings": [
- "signature"
- ]
- }
- }
- }
- }
- },
- "ImageHandlerOriginRequestPolicy": {
- "Type": "AWS::CloudFront::OriginRequestPolicy",
- "Properties": {
- "OriginRequestPolicyConfig": {
- "CookiesConfig": {
- "CookieBehavior": "none"
- },
- "HeadersConfig": {
- "HeaderBehavior": "whitelist",
- "Headers": [
- "origin",
- "accept"
- ]
- },
- "Name": {
- "Fn::Join": [
- "",
- [
- {
- "Ref": "AWS::StackName"
- },
- "-",
- {
- "Ref": "AWS::Region"
- },
- "-ImageHandlerOriginRequestPolicy"
- ]
- ]
- },
- "QueryStringsConfig": {
- "QueryStringBehavior": "whitelist",
- "QueryStrings": [
- "signature"
- ]
- }
- }
- }
- },
- "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::If": [
- "IsOptInRegion",
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "CustomCFLoggingBucket",
- "bucketName"
- ]
- },
- ".s3.us-east-1.",
- {
- "Ref": "AWS::URLSuffix"
- }
- ]
- ]
- },
- {
- "Fn::GetAtt": [
- "Logs",
- "RegionalDomainName"
- ]
- }
- ]
- },
- "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:putBucketAcl",
- "s3:putEncryptionConfiguration",
- "s3:putBucketPolicy",
- "s3:CreateBucket",
- "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::Join": [
- "",
- [
- "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",
- "Environment": {
- "Variables": {
- "RETRY_SECONDS": "5"
- }
- },
- "MemorySize": 128,
- "Timeout": 60
- },
- "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",
- "Metadata": {
- "cfn_nag": {
- "rules_to_suppress": [
- {
- "id": "W84",
- "reason": "Used to store store function info, no kms used"
- }
- ]
- }
- }
- },
- "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"
- ]
- },
- "enableSignature": {
- "Ref": "EnableSignature"
- },
- "enableDefaultFallbackImage": {
- "Ref": "EnableDefaultFallbackImage"
- },
- "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"
- ]
- },
- "SecretsManagerPolicy": {
- "Type": "AWS::IAM::Policy",
- "Properties": {
- "PolicyDocument": {
- "Statement": [
- {
- "Action": "secretsmanager:GetSecretValue",
- "Effect": "Allow",
- "Resource": {
- "Fn::Join": [
- "",
- [
- "arn:",
- {
- "Ref": "AWS::Partition"
- },
- ":secretsmanager:",
- {
- "Ref": "AWS::Region"
- },
- ":",
- {
- "Ref": "AWS::AccountId"
- },
- ":secret:",
- {
- "Ref": "SecretsManagerSecret"
- },
- "*"
- ]
- ]
- }
- }
- ],
- "Version": "2012-10-17"
- },
- "PolicyName": "SecretsManagerPolicy",
- "Roles": [
- {
- "Ref": "CustomResourceRole"
- },
- {
- "Ref": "ImageHandlerFunctionRole"
- }
- ]
- },
- "Condition": "EnableSignatureCondition"
- },
- "CustomResourceCheckSecretsManager": {
- "Type": "Custom::CustomResource",
- "Properties": {
- "ServiceToken": {
- "Fn::GetAtt": [
- "CustomResourceFunction",
- "Arn"
- ]
- },
- "customAction": "checkSecretsManager",
- "secretsManagerName": {
- "Ref": "SecretsManagerSecret"
- },
- "secretsManagerKey": {
- "Ref": "SecretsManagerKey"
- }
- },
- "DependsOn": [
- "CustomResourcePolicy",
- "CustomResourceRole",
- "SecretsManagerPolicy"
- ],
- "Condition": "EnableSignatureCondition"
- },
- "CustomResourceCheckFallbackImage": {
- "Type": "Custom::CustomResource",
- "Properties": {
- "ServiceToken": {
- "Fn::GetAtt": [
- "CustomResourceFunction",
- "Arn"
- ]
- },
- "customAction": "checkFallbackImage",
- "fallbackImageS3Bucket": {
- "Ref": "FallbackImageS3Bucket"
- },
- "fallbackImageS3Key": {
- "Ref": "FallbackImageS3Key"
- }
- },
- "DependsOn": [
- "CustomResourcePolicy",
- "CustomResourceRole"
- ],
- "Condition": "EnableDefaultFallbackImageCondition"
- },
- "CustomCFLoggingBucket": {
- "Type": "Custom::CustomResource",
- "Properties": {
- "ServiceToken": {
- "Fn::GetAtt": [
- "CustomResourceFunction",
- "Arn"
- ]
- },
- "customAction": "createCFLoggingBucket",
- "stackName": {
- "Ref": "AWS::StackName"
- },
- "bucketSuffix": {
- "Fn::Join": [
- "",
- [
- {
- "Ref": "AWS::StackName"
- },
- {
- "Ref": "AWS::Region"
- },
- {
- "Ref": "AWS::AccountId"
- }
- ]
- ]
- },
- "policy": {
- "Action": "*",
- "Condition": {
- "Bool": {
- "aws:SecureTransport": "false"
- }
- },
- "Effect": "Deny",
- "Principal": "*",
- "Resource": "",
- "Sid": "HttpsOnly"
- }
- },
- "DependsOn": [
- "CustomResourcePolicy",
- "CustomResourceRole"
- ],
- "Condition": "IsOptInRegion"
- }
- },
- "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
index a09f01808..f7f875c4c 100644
--- a/source/constructs/tsconfig.json
+++ b/source/constructs/tsconfig.json
@@ -17,9 +17,9 @@
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
- "typeRoots": ["./node_modules/@types"],
"resolveJsonModule": true,
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "typeRoots": ["./node_modules/@types"]
},
"exclude": ["cdk.out"]
}
diff --git a/source/constructs/utils/aspects.ts b/source/constructs/utils/aspects.ts
new file mode 100644
index 000000000..5feab781d
--- /dev/null
+++ b/source/constructs/utils/aspects.ts
@@ -0,0 +1,51 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CfnFunction } from '@aws-cdk/aws-lambda';
+import { CfnCondition, CfnResource, IAspect, IConstruct } from '@aws-cdk/core';
+
+import { addCfnSuppressRules } from './utils';
+
+/**
+ * CDK Aspect to add common CFN Nag rule suppressions to Lambda functions.
+ */
+export class SuppressLambdaFunctionCfnRulesAspect implements IAspect {
+ /**
+ * Implements IAspect.visit to suppress rules specific to a Lambda function.
+ * @param node Construct node to visit
+ */
+ visit(node: IConstruct): void {
+ const resource = node as CfnResource;
+ if (resource instanceof CfnFunction) {
+ const rules = [
+ { id: 'W58', reason: 'The function does have permission to write CloudWatch Logs.' },
+ { id: 'W89', reason: 'The Lambda function does not require any VPC connection at all.' },
+ { id: 'W92', reason: 'The Lambda function does not require ReservedConcurrentExecutions.' }
+ ];
+
+ addCfnSuppressRules(resource, rules);
+ }
+ }
+}
+
+/**
+ * CDK Aspect implementation to set up conditions to the entire Construct resources.
+ */
+export class ConditionAspect implements IAspect {
+ private readonly condition: CfnCondition;
+
+ constructor(condition: CfnCondition) {
+ this.condition = condition;
+ }
+
+ /**
+ * Implements IAspect.visit to set the condition to whole resources in Construct.
+ * @param node Construct node to visit
+ */
+ visit(node: IConstruct): void {
+ const resource = node as CfnResource;
+ if (resource.cfnOptions) {
+ resource.cfnOptions.condition = this.condition;
+ }
+ }
+}
diff --git a/source/constructs/utils/utils.ts b/source/constructs/utils/utils.ts
new file mode 100644
index 000000000..d925b8b7c
--- /dev/null
+++ b/source/constructs/utils/utils.ts
@@ -0,0 +1,43 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CfnCondition, CfnResource, Resource } from '@aws-cdk/core';
+
+interface CfnNagSuppressRule {
+ id: string;
+ reason: string;
+}
+
+/**
+ * Adds CFN NAG suppress rules to the CDK resource.
+ * @param resource The CDK resource.
+ * @param rules The CFN NAG suppress rules.
+ */
+export function addCfnSuppressRules(resource: Resource | CfnResource | undefined, rules: CfnNagSuppressRule[]) {
+ if (typeof resource === 'undefined') return;
+
+ if (resource instanceof Resource) {
+ resource = resource.node.defaultChild as CfnResource;
+ }
+
+ if (resource.cfnOptions.metadata?.cfn_nag?.rules_to_suppress) {
+ resource.cfnOptions.metadata.cfn_nag.rules_to_suppress.push(...rules);
+ } else {
+ resource.addMetadata('cfn_nag', { rules_to_suppress: rules });
+ }
+}
+
+/**
+ * Adds CDK condition to the CDK resource.
+ * @param resource The CDK resource.
+ * @param condition The CDK condition.
+ */
+export function addCfnCondition(resource: Resource | CfnResource | undefined, condition: CfnCondition) {
+ if (typeof resource === 'undefined') return;
+
+ if (resource instanceof Resource) {
+ resource = resource.node.defaultChild as CfnResource;
+ }
+
+ (resource as CfnResource).cfnOptions.condition = condition;
+}
diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js
deleted file mode 100644
index b3961fd08..000000000
--- a/source/custom-resource/index.js
+++ /dev/null
@@ -1,534 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const AWS = require('aws-sdk');
-const axios = require('axios');
-const uuid = require('uuid');
-const crypto = require('crypto');
-
-const s3 = new AWS.S3();
-const s3USEast = new AWS.S3({apiVersion: '2006-03-01', region: 'us-east-1'});
-const secretsManager = new AWS.SecretsManager();
-const METRICS_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic';
-
-/**
- * Request handler.
- */
-exports.handler = async (event, context) => {
- console.log('Received event:', JSON.stringify(event, null, 2));
-
- const properties = event.ResourceProperties;
- let response = {
- status: 'SUCCESS',
- data: {}
- };
-
- try {
- switch (properties.customAction) {
- case 'sendMetric':
- if (properties.anonymousData === 'Yes') {
- const anonymousProperties = {
- SolutionId: properties.solutionId,
- UUID: properties.UUID,
- Version: properties.version,
- EnableSignature: properties.enableSignature,
- EnableDefaultFallbackImage: properties.enableDefaultFallbackImage,
- Type: event.RequestType
- };
-
- response.data = await sendAnonymousUsage(anonymousProperties);
- }
- break;
- case 'putConfigFile':
- if (['Create', 'Update'].includes(event.RequestType)) {
- const { configItem, destS3Bucket, destS3key } = properties;
- response.data = await putConfigFile(configItem, destS3Bucket, destS3key);
- }
- break;
- case 'copyS3assets':
- if (['Create', 'Update'].includes(event.RequestType)) {
- const { manifestKey, sourceS3Bucket, sourceS3key, destS3Bucket } = properties;
- response.data = await copyAssets(manifestKey, sourceS3Bucket, sourceS3key, destS3Bucket);
- }
- break;
- case 'createUuid':
- if (['Create', 'Update'].includes(event.RequestType)) {
- response.data = { UUID: uuid.v4() };
- }
- break;
- case 'checkSourceBuckets':
- if (['Create', 'Update'].includes(event.RequestType)) {
- const { sourceBuckets } = properties;
- response.data = await validateBuckets(sourceBuckets);
- }
- break;
- case 'checkSecretsManager':
- if (['Create', 'Update'].includes(event.RequestType)) {
- const { secretsManagerName, secretsManagerKey } = properties;
- response.data = await checkSecretsManager(secretsManagerName, secretsManagerKey);
- }
- break;
- case 'checkFallbackImage':
- if (['Create', 'Update'].includes(event.RequestType)) {
- const { fallbackImageS3Bucket, fallbackImageS3Key } = properties;
- response.data = await checkFallbackImage(fallbackImageS3Bucket, fallbackImageS3Key);
- }
- break;
- case 'createCFLoggingBucket':
- if(['Create'].includes(event.RequestType)) {
- const stackName = properties.stackName;
- let bucketPolicyStatement = properties.policy;
- const currentDate = new Date();
- const bucketSuffix = crypto.createHash('md5').update(properties.bucketSuffix + currentDate.getTime()).digest("hex");
- response.data = await createLoggingBucket(stackName, bucketSuffix, bucketPolicyStatement);
- }
- break;
- default:
- break;
- }
- } catch (error) {
- console.error(`Error occurred at ${event.RequestType}::${properties.customAction}`, error);
- response = {
- status: 'FAILED',
- data: {
- Error: {
- code: error.code ? error.code : 'CustomResourceError',
- message: error.message ? error.message : 'Custom resource error occurred.'
- }
- }
- }
- } finally {
- await sendResponse(event, context.logStreamName, response);
- }
-
- return response;
-}
-
-/**
- * Sends a response to the pre-signed S3 URL
- * @param {object} event - Custom resource event
- * @param {string} logStreamName - Custom resource log stream name
- * @param {object} response - Response object { status: "SUCCESS|FAILED", data: any }
- */
-async function sendResponse(event, logStreamName, response) {
- let reason = `See the details in CloudWatch Log Stream: ${logStreamName}`;
- if (response.status === 'FAILED') {
- reason = `[${response.data.Error.code}] ${reason}`;
- }
-
- const responseBody = JSON.stringify({
- Status: response.status,
- Reason: reason,
- PhysicalResourceId: logStreamName,
- StackId: event.StackId,
- RequestId: event.RequestId,
- LogicalResourceId: event.LogicalResourceId,
- Data: response.data,
- });
-
- console.log(`RESPONSE BODY: ${responseBody}`);
-
- const config = {
- headers: {
- 'Content-Type': '',
- 'Content-Length': responseBody.length
- }
- };
-
- return await axios.put(event.ResponseURL, responseBody, config);
-}
-
-/**
- * Sends anonymous usage.
- * @param {object} properties - Anonymous properties object { SolutionId: string, UUID: string, Version: String, Type: "Create|Update|Delete" }
- * @return {Promise} - Promise mesage object
- */
-async function sendAnonymousUsage(properties) {
- const config = {
- headers: {
- 'Content-Type': 'application/json'
- }
- };
- const data = {
- Solution: properties.SolutionId,
- TimeStamp: `${new Date().toISOString().replace(/T/, ' ')}`,
- UUID: properties.UUID,
- Version: properties.Version,
- Data: {
- Region: process.env.AWS_REGION,
- Type: properties.Type,
- EnableSignature: properties.EnableSignature,
- EnableDefaultFallbackImage: properties.EnableDefaultFallbackImage
- }
- };
-
- try {
- await axios.post(METRICS_ENDPOINT, data, config);
- return {
- Message: 'Anonymous data was sent successfully.',
- Data: data
- };
- } catch (error) {
- console.error('Error to send anonymous usage.');
- return {
- Message: 'Anonymous data was sent failed.',
- Data: data
- };
- }
-}
-
-/**
- * Checks if AWS Secrets Manager secret is valid.
- * @param {string} secretName AWS Secrets Manager secret name
- * @param {string} secretKey AWS Secrets Manager secret's key name
- * @return {Promise} ARN of the AWS Secrets Manager secret
- */
-async function checkSecretsManager(secretName, secretKey) {
- if (!secretName || secretName.replace(/\s/g, '') === '') {
- throw {
- code: 'SecretNotProvided',
- message: 'You need to provide AWS Secrets Manager secert.'
- };
- }
- if (!secretKey || secretKey.replace(/\s/g, '') === '') {
- throw {
- code: 'SecretKeyNotProvided',
- message: 'You need to provide AWS Secrets Manager secert key.'
- };
- }
-
- const retryCount = 3;
- let arn = '';
-
- for (let retry = 1; retry <= retryCount; retry++) {
- try {
- const response = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
- const secretString = JSON.parse(response.SecretString);
-
- if (secretString[secretKey] === undefined) {
- throw {
- code: 'SecretKeyNotFound',
- message: `AWS Secrets Manager secret requries ${secretKey} key.`
- };
- }
-
- arn = response.ARN;
- break;
- } catch (error) {
- if (retry === retryCount) {
- console.error(`AWS Secrets Manager secret or signature might not exist: ${secretName}/${secretKey}`);
- throw error;
- } else {
- console.log('Waiting for retry...');
- await sleep(retry);
- }
- }
- }
-
- return {
- Message: 'Secrets Manager validated.',
- ARN: arn
- };
-}
-
-/**
- * Puts the config file into S3 bucket.
- * @param {string} config The config of the config file
- * @param {string} bucket Bucket to put the config file
- * @param {string} objectKey The config file key
- * @return {Promise} Result of the putting config file
- */
-async function putConfigFile(config, bucket, objectKey) {
- console.log(`Attempting to save content blob destination location: ${bucket}/${objectKey}`);
- console.log(JSON.stringify(config, null, 2));
-
- let content = `'use strict';\n\nconst appVariables = {\nCONTENT\n};`;
- let stringBuilder = [];
-
- for (let key in config) {
- stringBuilder.push(`${key}: '${config[key]}'`);
- }
- content = stringBuilder.length > 0 ? content.replace('CONTENT', stringBuilder.join(',\n')) : content.replace('CONTENT', '');
-
- const retryCount = 3;
- const params = {
- Bucket: bucket,
- Body: content,
- Key: objectKey,
- ContentType: getContentType(objectKey)
- };
-
- for (let retry = 1; retry <= retryCount; retry++) {
- try {
- await s3.putObject(params).promise();
- break;
- } catch (error) {
- if (retry === retryCount || error.code !== 'AccessDenied') {
- console.error(`Error creating ${bucket}/${objectKey} content`, error);
- throw {
- code: 'ConfigFileCreationFailure',
- message: `Saving config file to ${bucket}/${objectKey} failed.`
- };
- } else {
- console.log('Waiting for retry...');
- await sleep(retry);
- }
- }
- }
-
- return {
- Message: 'Config file uploaded.',
- Content: content
- };
-}
-
-/**
- * Copies assets from the source S3 bucket to the destination S3 bucket.
- * @param {string} manifestKey Assets manifest key
- * @param {string} sourceS3Bucket Source S3 bucket
- * @param {string} sourceS3prefix Source S3 prefix
- * @param {string} destS3Bucket Destination S3 bucket
- * @return {Promise} The result of copying assets
- */
-async function copyAssets(manifestKey, sourceS3Bucket, sourceS3prefix, destS3Bucket) {
- console.log(`source bucket: ${sourceS3Bucket}`);
- console.log(`source prefix: ${sourceS3prefix}`);
- console.log(`destination bucket: ${destS3Bucket}`);
-
- const retryCount = 3;
- let manifest = {};
-
- // Download manifest
- for (let retry = 1; retry <= retryCount; retry++) {
- try {
- const params = {
- Bucket: sourceS3Bucket,
- Key: manifestKey
- };
- const response = await s3.getObject(params).promise();
- manifest = JSON.parse(response.Body.toString());
-
- break;
- } catch (error) {
- if (retry === retryCount || error.code !== 'AccessDenied') {
- console.error('Error occurred while getting manifest file.', error);
- throw {
- code: 'GetManifestFailure',
- message: 'Copy of website assets failed.'
- };
- } else {
- console.log('Waiting for retry...');
- await sleep(retry);
- }
- }
- }
-
- // Copy asset files
- let promises = [];
- try {
- for (let filename of manifest.files) {
- const params = {
- Bucket: destS3Bucket,
- CopySource: `${sourceS3Bucket}/${sourceS3prefix}/${filename}`,
- Key: filename,
- ContentType: getContentType(filename)
- };
- promises.push(s3.copyObject(params).promise());
- }
-
- if (promises.length > 0) {
- await Promise.all(promises);
- }
-
- return {
- Message: 'Copy assets completed.',
- Manifest: manifest
- };
- } catch (error) {
- console.error('Error occurred while copying assets.', error);
- throw {
- code: 'CopyAssetsFailure',
- message: 'Copy of website assets failed.'
- };
- }
-}
-
-/**
- * Gets content type by file name.
- * @param {string} filename - File name
- * @return {string} - Content type
- */
-function getContentType(filename) {
- let contentType = '';
- if (filename.endsWith('.html')) {
- contentType = 'text/html';
- } else if (filename.endsWith('.css')) {
- contentType = 'text/css';
- } else if (filename.endsWith('.png')) {
- contentType = 'image/png';
- } else if (filename.endsWith('.svg')) {
- contentType = 'image/svg+xml';
- } else if (filename.endsWith('.jpg')) {
- contentType = 'image/jpeg';
- } else if (filename.endsWith('.js')) {
- contentType = 'application/javascript';
- } else {
- contentType = 'binary/octet-stream';
- }
- return contentType;
-}
-
-/**
- * Validates if buckets exist in the account.
- * @param {string} buckets Comma-separated bucket names
- * @return {Promise} The result of validation
- */
-async function validateBuckets(buckets) {
- buckets = buckets.replace(/\s/g, '');
- console.log(`Attempting to check if the following buckets exist: ${buckets}`);
- const checkBuckets = buckets.split(',');
- const errorBuckets = [];
-
- for (let bucket of checkBuckets) {
- const params = { Bucket: bucket };
- try {
- await s3.headBucket(params).promise();
- console.log(`Found bucket: ${bucket}`);
- } catch (error) {
- console.error(`Could not find bucket: ${bucket}`);
- console.error(error);
- errorBuckets.push(bucket);
- }
- }
-
- if (errorBuckets.length === 0) {
- return { Message: 'Buckets validated.' };
- } else {
- throw {
- code: 'BucketNotFound',
- message: `Could not find the following source bucket(s) in your account: ${errorBuckets.join(',')}. Please specify at least one source bucket that exists within your account and try again. If specifying multiple source buckets, please ensure that they are comma-separated.`
- };
- }
-}
-
-/**
- *
- * @param {string} bucket - Bucket name to check if key exists
- * @param {string} key - Key to check if it exists in the bucket
- * @return {Promise} The result of validation
- */
-async function checkFallbackImage(bucket, key) {
- if (!bucket || bucket.replace(/\s/g, '') === '') {
- throw {
- code: 'S3BucketNotProvided',
- message: 'You need to provide the default fallback image bucket.'
- };
- }
- if (!key || key.replace(/\s/g, '') === '') {
- throw {
- code: 'S3KeyNotProvided',
- message: 'You need to provide the default fallback image object key.'
- };
- }
-
- const retryCount = 3;
- let data = {};
-
- for (let retry = 1; retry <= retryCount; retry++) {
- try {
- data = await s3.headObject({ Bucket: bucket, Key: key }).promise();
- break;
- } catch (error) {
- if (retry === retryCount || !['AccessDenied', 'Forbidden'].includes(error.code)) {
- console.error(`Either the object does not exist or you don't have permission to access the object: ${bucket}/${key}`);
- throw {
- code: 'FallbackImageError',
- message: `Either the object does not exist or you don't have permission to access the object: ${bucket}/${key}`
- };
- } else {
- console.log('Waiting for retry...');
- await sleep(retry);
- }
- }
- }
-
- return {
- Message: 'The default fallback image validated.',
- Data: data
- };
-}
-
-/**
- * Sleeps for some seconds.
- * @param {number} retry - Retry count
- * @return {Promise} - Sleep promise
- */
-async function sleep(retry) {
- const retrySeconds = Number(process.env.RETRY_SECONDS);
- return new Promise(resolve => setTimeout(resolve, retrySeconds * 1000 * retry));
-}
-
-/**
- * Creates a bucket with settings for cloudfront logging.
- * @param {String} stackName - Name of CloudFormation stack
- * @param {JSON} bucketPolicyStatement - S3 bucket policy statement
- * @return {Promise} - Bucket name of the created bucket
- */
-async function createLoggingBucket(stackName, bucketSuffix, bucketPolicyStatement){
- const bucketParams = {
- Bucket: `${stackName}-logs-${bucketSuffix.substring(0,8)}`.toLowerCase(),
- ACL: "log-delivery-write"
- };
-
- //create bucket
- try{
- await s3USEast.createBucket(bucketParams).promise()
- console.log(`Successfully created bucket: ${bucketParams.Bucket}`);
- } catch(error) {
- console.error(`Could not create bucket: ${bucketParams.Bucket}`, error);
- throw error;
- }
-
- const encryptionParams = {
- Bucket: bucketParams.Bucket,
- ServerSideEncryptionConfiguration: {
- Rules: [{
- ApplyServerSideEncryptionByDefault: {
- SSEAlgorithm: 'AES256'
- },
- },]
- }
- };
-
- //Add encryption to bucket
- console.log("Adding Encryption...")
- try {
- await s3USEast.putBucketEncryption(encryptionParams).promise();
- console.log(`Successfully enabled encryptoin on bucket: ${bucketParams.Bucket}`);
- } catch(error) {
- console.error(`Failed to add encryption to bucket: ${bucketParams.Bucket}`, error);
- throw error;
- }
-
- //Update resource attribute of policy statement
- bucketPolicyStatement.Resource = `arn:aws:s3:::${bucketParams.Bucket}/*`
- bucketPolicy = {"Version": "2012-10-17", "Statement":[ bucketPolicyStatement ]};
-
- //Define policy parameters
- const policyParams = {
- Bucket: bucketParams.Bucket,
- Policy: JSON.stringify(bucketPolicy)
- };
-
- //Add Policy to bucket
- console.log("Adding policy...")
- try {
- await s3USEast.putBucketPolicy(policyParams).promise();
- console.log(`Successfully added policy added to bucket: ${bucketParams.Bucket}`);
- } catch(error) {
- console.error(`Failed to add policy to bucket ${bucketParams.Bucket}`, error);
- throw error;
- }
-
- return {bucketName: bucketParams.Bucket};
-}
\ No newline at end of file
diff --git a/source/custom-resource/index.ts b/source/custom-resource/index.ts
new file mode 100644
index 000000000..d3c4fe4bc
--- /dev/null
+++ b/source/custom-resource/index.ts
@@ -0,0 +1,594 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import EC2, { DescribeRegionsRequest } from 'aws-sdk/clients/ec2';
+import S3, { CreateBucketRequest, PutBucketEncryptionRequest, PutBucketPolicyRequest } from 'aws-sdk/clients/s3';
+import SecretsManager from 'aws-sdk/clients/secretsmanager';
+import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
+import { createHash } from 'crypto';
+import moment from 'moment';
+import { v4 } from 'uuid';
+
+import { getOptions } from '../solution-utils/get-options';
+import { isNullOrWhiteSpace } from '../solution-utils/helpers';
+import {
+ CheckFallbackImageRequestProperties,
+ CheckSecretManagerRequestProperties,
+ CheckSourceBucketsRequestProperties,
+ CompletionStatus,
+ CopyS3AssetsRequestProperties,
+ CreateLoggingBucketRequestProperties,
+ CustomResourceActions,
+ CustomResourceError,
+ CustomResourceRequest,
+ CustomResourceRequestTypes,
+ ErrorCodes,
+ LambdaContext,
+ MetricPayload,
+ PutConfigRequestProperties,
+ SendMetricsRequestProperties,
+ StatusTypes
+} from './lib';
+
+const awsSdkOptions = getOptions();
+const s3Client = new S3(awsSdkOptions);
+const ec2Client = new EC2(awsSdkOptions);
+const secretsManager = new SecretsManager(awsSdkOptions);
+
+const { SOLUTION_ID, SOLUTION_VERSION, AWS_REGION, RETRY_SECONDS } = process.env;
+const METRICS_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic';
+const RETRY_COUNT = 3;
+
+/**
+ * Custom resource Lambda handler.
+ * @param event The custom resource request.
+ * @param context The custom resource context.
+ * @returns Processed request response.
+ */
+export async function handler(event: CustomResourceRequest, context: LambdaContext) {
+ console.info('Received event:', JSON.stringify(event, null, 2));
+
+ const { RequestType, ResourceProperties } = event;
+ const response: CompletionStatus = {
+ Status: StatusTypes.SUCCESS,
+ Data: {}
+ };
+
+ try {
+ switch (ResourceProperties.CustomAction) {
+ case CustomResourceActions.SEND_ANONYMOUS_METRIC: {
+ const requestProperties: SendMetricsRequestProperties = ResourceProperties as SendMetricsRequestProperties;
+ if (requestProperties.AnonymousData === 'Yes') {
+ response.Data = await sendAnonymousMetric(requestProperties, RequestType);
+ }
+ break;
+ }
+ case CustomResourceActions.PUT_CONFIG_FILE:
+ if ([CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE].includes(RequestType)) {
+ response.Data = await putConfigFile(ResourceProperties as PutConfigRequestProperties);
+ }
+ break;
+ case CustomResourceActions.COPY_S3_ASSETS:
+ if ([CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE].includes(RequestType)) {
+ response.Data = await copyS3Assets(ResourceProperties as CopyS3AssetsRequestProperties);
+ }
+ break;
+ case CustomResourceActions.CREATE_UUID:
+ if ([CustomResourceRequestTypes.CREATE].includes(RequestType)) {
+ response.Data = await generateUUID();
+ }
+ break;
+ case CustomResourceActions.CHECK_SOURCE_BUCKETS:
+ if ([CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE].includes(RequestType)) {
+ response.Data = await validateBuckets(ResourceProperties as CheckSourceBucketsRequestProperties);
+ }
+ break;
+ case CustomResourceActions.CHECK_SECRETS_MANAGER:
+ if ([CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE].includes(RequestType)) {
+ response.Data = await checkSecretsManager(ResourceProperties as CheckSecretManagerRequestProperties);
+ }
+ break;
+ case CustomResourceActions.CHECK_FALLBACK_IMAGE:
+ if ([CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE].includes(RequestType)) {
+ response.Data = await checkFallbackImage(ResourceProperties as CheckFallbackImageRequestProperties);
+ }
+ break;
+ case CustomResourceActions.CREATE_LOGGING_BUCKET:
+ if ([CustomResourceRequestTypes.CREATE].includes(RequestType)) {
+ response.Data = await createCloudFrontLoggingBucket(ResourceProperties as CreateLoggingBucketRequestProperties);
+ }
+ break;
+ default:
+ break;
+ }
+ } catch (error) {
+ console.error(`Error occurred at ${event.RequestType}::${ResourceProperties.CustomAction}`, error);
+
+ response.Status = StatusTypes.FAILED;
+ response.Data.Error = {
+ Code: error.code ?? 'CustomResourceError',
+ Message: error.message ?? 'Custom resource error occurred.'
+ };
+ } finally {
+ await sendCloudFormationResponse(event, context.logStreamName, response);
+ }
+
+ return response;
+}
+
+/**
+ * Suspends for the specified amount of seconds.
+ * @param timeOut The number of seconds for which the call is suspended.
+ * @returns Sleep promise.
+ */
+async function sleep(timeOut: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, timeOut));
+}
+
+/**
+ * Gets retry timeout based on the current retry attempt in seconds.
+ * @param attempt Retry attempt.
+ * @returns Timeout in seconds.
+ */
+function getRetryTimeout(attempt: number): number {
+ const retrySeconds = Number(RETRY_SECONDS);
+ return retrySeconds * 1000 * attempt;
+}
+
+/**
+ * Get content type by file name.
+ * @param filename File name.
+ * @returns Content type.
+ */
+function getContentType(filename: string): string {
+ let contentType = '';
+ if (filename.endsWith('.html')) {
+ contentType = 'text/html';
+ } else if (filename.endsWith('.css')) {
+ contentType = 'text/css';
+ } else if (filename.endsWith('.png')) {
+ contentType = 'image/png';
+ } else if (filename.endsWith('.svg')) {
+ contentType = 'image/svg+xml';
+ } else if (filename.endsWith('.jpg')) {
+ contentType = 'image/jpeg';
+ } else if (filename.endsWith('.js')) {
+ contentType = 'application/javascript';
+ } else {
+ contentType = 'binary/octet-stream';
+ }
+ return contentType;
+}
+
+/**
+ * Send custom resource response.
+ * @param event Custom resource event.
+ * @param logStreamName Custom resource log stream name.
+ * @param response Response completion status.
+ * @returns The promise of the sent request.
+ */
+async function sendCloudFormationResponse(event: CustomResourceRequest, logStreamName: string, response: CompletionStatus): Promise {
+ const responseBody = JSON.stringify({
+ Status: response.Status,
+ Reason: `See the details in CloudWatch Log Stream: ${logStreamName}`,
+ PhysicalResourceId: event.LogicalResourceId,
+ StackId: event.StackId,
+ RequestId: event.RequestId,
+ LogicalResourceId: event.LogicalResourceId,
+ Data: response.Data
+ });
+
+ const config: AxiosRequestConfig = {
+ headers: {
+ 'Content-Type': '',
+ 'Content-Length': responseBody.length
+ }
+ };
+
+ return axios.put(event.ResponseURL, responseBody, config);
+}
+
+/**
+ * Sends anonymous metrics.
+ * @param requestProperties The send metrics request properties.
+ * @param requestType The request type.
+ * @returns Promise message object.
+ */
+async function sendAnonymousMetric(requestProperties: SendMetricsRequestProperties, requestType: CustomResourceRequestTypes): Promise<{ Message: string; Data: MetricPayload }> {
+ const result: { Message: string; Data: MetricPayload } = { Message: '', Data: undefined };
+
+ try {
+ const numberOfSourceBuckets =
+ requestProperties.SourceBuckets?.split(',')
+ .map(x => x.trim())
+ .filter(x => !isNullOrWhiteSpace(x)).length || 0;
+ const payload: MetricPayload = {
+ Solution: SOLUTION_ID,
+ Version: SOLUTION_VERSION,
+ UUID: requestProperties.UUID,
+ TimeStamp: moment.utc().format('YYYY-MM-DD HH:mm:ss.S'),
+ Data: {
+ Region: AWS_REGION,
+ Type: requestType,
+ CorsEnabled: requestProperties.CorsEnabled,
+ NumberOfSourceBuckets: numberOfSourceBuckets,
+ DeployDemoUi: requestProperties.DeployDemoUi,
+ LogRetentionPeriod: requestProperties.LogRetentionPeriod,
+ AutoWebP: requestProperties.AutoWebP,
+ EnableSignature: requestProperties.EnableSignature,
+ EnableDefaultFallbackImage: requestProperties.EnableDefaultFallbackImage
+ }
+ };
+
+ result.Data = payload;
+
+ const payloadStr = JSON.stringify(payload);
+
+ const config: AxiosRequestConfig = {
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': payloadStr.length
+ }
+ };
+
+ console.info('Sending anonymous metric', payloadStr);
+ const response = await axios.post(METRICS_ENDPOINT, payloadStr, config);
+ console.info(`Anonymous metric response: ${response.statusText} (${response.status})`);
+
+ result.Message = 'Anonymous data was sent successfully.';
+ } catch (err) {
+ console.error('Error sending anonymous metric');
+ console.error(err);
+
+ result.Message = 'Anonymous data was sent failed.';
+ }
+
+ return result;
+}
+
+/**
+ * Puts the config file into S3 bucket.
+ * @param requestProperties The request properties.
+ * @returns Result of the putting config file.
+ */
+async function putConfigFile(requestProperties: PutConfigRequestProperties): Promise<{ Message: string; Content: string }> {
+ const { ConfigItem, DestS3Bucket, DestS3key } = requestProperties;
+
+ console.info(`Attempting to save content blob destination location: ${DestS3Bucket}/${DestS3key}`);
+ console.info(JSON.stringify(ConfigItem, null, 2));
+
+ const configFieldValues = Object.entries(ConfigItem)
+ .map(([key, value]) => `${key}: '${value}'`)
+ .join(',\n');
+
+ const content = `'use strict';\n\nconst appVariables = {\n${configFieldValues}\n};`;
+
+ // In case getting object fails due to asynchronous IAM permission creation, it retries.
+ const params = {
+ Bucket: DestS3Bucket,
+ Body: content,
+ Key: DestS3key,
+ ContentType: getContentType(DestS3key)
+ };
+
+ for (let retry = 1; retry <= RETRY_COUNT; retry++) {
+ try {
+ console.info(`Putting ${DestS3key}... Try count: ${retry}`);
+
+ await s3Client.putObject(params).promise();
+
+ console.info(`Putting ${DestS3key} completed.`);
+ break;
+ } catch (error) {
+ if (retry === RETRY_COUNT || error.code !== ErrorCodes.ACCESS_DENIED) {
+ console.info(`Error occurred while putting ${DestS3key} into ${DestS3Bucket} bucket.`, error);
+ throw new CustomResourceError('ConfigFileCreationFailure', `Saving config file to ${DestS3Bucket}/${DestS3key} failed.`);
+ } else {
+ console.info('Waiting for retry...');
+ await sleep(getRetryTimeout(retry));
+ }
+ }
+ }
+
+ return {
+ Message: 'Config file uploaded.',
+ Content: content
+ };
+}
+
+/**
+ * Copies assets from the source S3 bucket to the destination S3 bucket.
+ * @param requestProperties The request properties.
+ * @returns The result of copying assets.
+ */
+async function copyS3Assets(requestProperties: CopyS3AssetsRequestProperties): Promise<{ Message: string; Manifest: { Files: string[] } }> {
+ const { ManifestKey, SourceS3Bucket, SourceS3key, DestS3Bucket } = requestProperties;
+
+ console.info(`Source bucket: ${SourceS3Bucket}`);
+ console.info(`Source prefix: ${SourceS3key}`);
+ console.info(`Destination bucket: ${DestS3Bucket}`);
+
+ let manifest: { files: string[] };
+
+ // Download manifest
+ for (let retry = 1; retry <= RETRY_COUNT; retry++) {
+ try {
+ const getParams = {
+ Bucket: SourceS3Bucket,
+ Key: ManifestKey
+ };
+ const response = await s3Client.getObject(getParams).promise();
+ manifest = JSON.parse(response.Body.toString());
+
+ break;
+ } catch (error) {
+ if (retry === RETRY_COUNT || error.code !== ErrorCodes.ACCESS_DENIED) {
+ console.error('Error occurred while getting manifest file.');
+ console.error(error);
+
+ throw new CustomResourceError('GetManifestFailure', 'Copy of website assets failed.');
+ } else {
+ console.info('Waiting for retry...');
+
+ await sleep(getRetryTimeout(retry));
+ }
+ }
+ }
+
+ // Copy asset files
+ try {
+ await Promise.all(
+ manifest.files.map(async (fileName: string) => {
+ const copyObjectParams = {
+ Bucket: DestS3Bucket,
+ CopySource: `${SourceS3Bucket}/${SourceS3key}/${fileName}`,
+ Key: fileName,
+ ContentType: getContentType(fileName)
+ };
+
+ console.debug(`Copying ${fileName} to ${DestS3Bucket}`);
+ return s3Client.copyObject(copyObjectParams).promise();
+ })
+ );
+
+ return {
+ Message: 'Copy assets completed.',
+ Manifest: { Files: manifest.files }
+ };
+ } catch (error) {
+ console.error('Error occurred while copying assets.');
+ console.error(error);
+
+ throw new CustomResourceError('CopyAssetsFailure', 'Copy of website assets failed.');
+ }
+}
+
+/**
+ * Generates UUID.
+ * @returns Generated UUID.
+ */
+async function generateUUID(): Promise<{ UUID: string }> {
+ return Promise.resolve({ UUID: v4() });
+}
+
+/**
+ * Validates if buckets exist in the account.
+ * @param requestProperties The request properties.
+ * @returns The result of validation.
+ */
+async function validateBuckets(requestProperties: CheckSourceBucketsRequestProperties): Promise<{ Message: string }> {
+ const { SourceBuckets } = requestProperties;
+ const buckets = SourceBuckets.replace(/\s/g, '');
+
+ console.info(`Attempting to check if the following buckets exist: ${buckets}`);
+
+ const checkBuckets = buckets.split(',');
+ const errorBuckets = [];
+
+ for (const bucket of checkBuckets) {
+ const params = { Bucket: bucket };
+ try {
+ await s3Client.headBucket(params).promise();
+
+ console.info(`Found bucket: ${bucket}`);
+ } catch (error) {
+ console.error(`Could not find bucket: ${bucket}`);
+ console.error(error);
+ errorBuckets.push(bucket);
+ }
+ }
+
+ if (errorBuckets.length === 0) {
+ return { Message: 'Buckets validated.' };
+ } else {
+ const commaSeparatedErrors = errorBuckets.join(',');
+
+ throw new CustomResourceError(
+ 'BucketNotFound',
+ `Could not find the following source bucket(s) in your account: ${commaSeparatedErrors}. Please specify at least one source bucket that exists within your account and try again. If specifying multiple source buckets, please ensure that they are comma-separated.`
+ );
+ }
+}
+
+/**
+ * Checks if AWS Secrets Manager secret is valid.
+ * @param requestProperties The request properties.
+ * @returns ARN of the AWS Secrets Manager secret.
+ */
+async function checkSecretsManager(requestProperties: CheckSecretManagerRequestProperties): Promise<{ Message: string; ARN: string }> {
+ const { SecretsManagerName, SecretsManagerKey } = requestProperties;
+
+ if (isNullOrWhiteSpace(SecretsManagerName)) {
+ throw new CustomResourceError('SecretNotProvided', 'You need to provide AWS Secrets Manager secret.');
+ }
+
+ if (isNullOrWhiteSpace(SecretsManagerKey)) {
+ throw new CustomResourceError('SecretKeyNotProvided', 'You need to provide AWS Secrets Manager secret key.');
+ }
+
+ let arn = '';
+
+ for (let retry = 1; retry <= RETRY_COUNT; retry++) {
+ try {
+ const response = await secretsManager.getSecretValue({ SecretId: SecretsManagerName }).promise();
+ const secretString = JSON.parse(response.SecretString);
+
+ if (!Object.prototype.hasOwnProperty.call(secretString, SecretsManagerKey)) {
+ throw new CustomResourceError('SecretKeyNotFound', `AWS Secrets Manager secret requires ${SecretsManagerKey} key.`);
+ }
+
+ arn = response.ARN;
+ break;
+ } catch (error) {
+ if (retry === RETRY_COUNT) {
+ console.error(`AWS Secrets Manager secret or signature might not exist: ${SecretsManagerName}/${SecretsManagerKey}`);
+
+ throw error;
+ } else {
+ console.info('Waiting for retry...');
+
+ await sleep(getRetryTimeout(retry));
+ }
+ }
+ }
+
+ return {
+ Message: 'Secrets Manager validated.',
+ ARN: arn
+ };
+}
+
+/**
+ * Checks fallback image.
+ * @param requestProperties The request properties.
+ * @returns The result of validation.
+ */
+async function checkFallbackImage(requestProperties: CheckFallbackImageRequestProperties): Promise<{ Message: string; Data: unknown }> {
+ const { FallbackImageS3Bucket, FallbackImageS3Key } = requestProperties as CheckFallbackImageRequestProperties;
+
+ if (isNullOrWhiteSpace(FallbackImageS3Bucket)) {
+ throw new CustomResourceError('S3BucketNotProvided', 'You need to provide the default fallback image bucket.');
+ }
+
+ if (isNullOrWhiteSpace(FallbackImageS3Key)) {
+ throw new CustomResourceError('S3KeyNotProvided', 'You need to provide the default fallback image object key.');
+ }
+
+ let data = {};
+
+ for (let retry = 1; retry <= RETRY_COUNT; retry++) {
+ try {
+ data = await s3Client.headObject({ Bucket: FallbackImageS3Bucket, Key: FallbackImageS3Key }).promise();
+ break;
+ } catch (error) {
+ if (retry === RETRY_COUNT || ![ErrorCodes.ACCESS_DENIED, ErrorCodes.FORBIDDEN].includes(error.code)) {
+ console.error(`Either the object does not exist or you don't have permission to access the object: ${FallbackImageS3Bucket}/${FallbackImageS3Key}`);
+
+ throw new CustomResourceError(
+ 'FallbackImageError',
+ `Either the object does not exist or you don't have permission to access the object: ${FallbackImageS3Bucket}/${FallbackImageS3Key}`
+ );
+ } else {
+ console.info('Waiting for retry...');
+
+ await sleep(getRetryTimeout(retry));
+ }
+ }
+ }
+
+ return {
+ Message: 'The default fallback image validated.',
+ Data: data
+ };
+}
+
+/**
+ * Creates a bucket with settings for cloudfront logging.
+ * @param requestProperties The request properties.
+ * @returns Bucket name of the created bucket.
+ */
+async function createCloudFrontLoggingBucket(requestProperties: CreateLoggingBucketRequestProperties) {
+ const logBucketSuffix = createHash('md5').update(`${requestProperties.BucketSuffix}${moment.utc().valueOf()}`).digest('hex');
+ const bucketName = `serverless-image-handler-logs-${logBucketSuffix.substring(0, 8)}`.toLowerCase();
+
+ // the S3 bucket will be created in 'us-east-1' if the current region is in opt-in regions,
+ // because CloudFront does not currently deliver access logs to opt-in region buckets
+ const isOptInRegion = await checkRegionOptInStatus(AWS_REGION);
+ const targetRegion = isOptInRegion ? 'us-east-1' : AWS_REGION;
+ console.info(`The opt-in status of the '${AWS_REGION}' region is '${isOptInRegion ? 'opted-in' : 'opt-in-not-required'}'`);
+
+ // create bucket
+ try {
+ const s3Client = new S3({ ...awsSdkOptions, apiVersion: '2006-03-01', region: targetRegion });
+
+ const createBucketRequestParams: CreateBucketRequest = { Bucket: bucketName, ACL: 'log-delivery-write' };
+ await s3Client.createBucket(createBucketRequestParams).promise();
+
+ console.info(`Successfully created bucket '${bucketName}' in '${targetRegion}' region`);
+ } catch (error) {
+ console.error(`Could not create bucket '${bucketName}'`);
+ console.error(error);
+
+ throw error;
+ }
+
+ // add encryption to bucket
+ console.info('Adding Encryption...');
+ try {
+ const putBucketEncryptionRequestParams: PutBucketEncryptionRequest = {
+ Bucket: bucketName,
+ ServerSideEncryptionConfiguration: { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }] }
+ };
+
+ await s3Client.putBucketEncryption(putBucketEncryptionRequestParams).promise();
+
+ console.info(`Successfully enabled encryption on bucket '${bucketName}'`);
+ } catch (error) {
+ console.error(`Failed to add encryption to bucket '${bucketName}'`);
+ console.error(error);
+
+ throw error;
+ }
+
+ // add policy to bucket
+ try {
+ console.info('Adding policy...');
+
+ const bucketPolicyStatement = {
+ Resource: `arn:aws:s3:::${bucketName}/*`,
+ Action: '*',
+ Effect: 'Deny',
+ Principal: '*',
+ Sid: 'HttpsOnly',
+ Condition: { Bool: { 'aws:SecureTransport': 'false' } }
+ };
+ const bucketPolicy = { Version: '2012-10-17', Statement: [bucketPolicyStatement] };
+ const putBucketPolicyRequestParams: PutBucketPolicyRequest = { Bucket: bucketName, Policy: JSON.stringify(bucketPolicy) };
+
+ await s3Client.putBucketPolicy(putBucketPolicyRequestParams).promise();
+
+ console.info(`Successfully added policy added to bucket '${bucketName}'`);
+ } catch (error) {
+ console.error(`Failed to add policy to bucket '${bucketName}'`);
+ console.error(error);
+
+ throw error;
+ }
+
+ return { BucketName: bucketName, Region: targetRegion };
+}
+
+/**
+ * Checks if the region is opted-in or not.
+ * @param region The region to check.
+ * @returns The result of check.
+ */
+async function checkRegionOptInStatus(region: string): Promise {
+ const describeRegionsRequestParams: DescribeRegionsRequest = {
+ RegionNames: [region],
+ Filters: [{ Name: 'opt-in-status', Values: ['opted-in'] }]
+ };
+ const describeRegionsResponse = await ec2Client.describeRegions(describeRegionsRequestParams).promise();
+
+ return describeRegionsResponse.Regions.length > 0;
+}
diff --git a/source/custom-resource/jest.config.js b/source/custom-resource/jest.config.js
new file mode 100644
index 000000000..a4e4b8635
--- /dev/null
+++ b/source/custom-resource/jest.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ roots: ['/test'],
+ testMatch: ['**/*.spec.ts'],
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest'
+ },
+ coverageReporters: [
+ 'text',
+ ['lcov', { 'projectRoot': '../' }]
+ ],
+ setupFiles: ['./test/setJestEnvironmentVariables.ts']
+};
diff --git a/source/custom-resource/lib/enums.ts b/source/custom-resource/lib/enums.ts
new file mode 100644
index 000000000..2b74ff4be
--- /dev/null
+++ b/source/custom-resource/lib/enums.ts
@@ -0,0 +1,29 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export enum CustomResourceActions {
+ SEND_ANONYMOUS_METRIC = 'sendMetric',
+ PUT_CONFIG_FILE = 'putConfigFile',
+ COPY_S3_ASSETS = 'copyS3assets',
+ CREATE_UUID = 'createUuid',
+ CHECK_SOURCE_BUCKETS = 'checkSourceBuckets',
+ CHECK_SECRETS_MANAGER = 'checkSecretsManager',
+ CHECK_FALLBACK_IMAGE = 'checkFallbackImage',
+ CREATE_LOGGING_BUCKET = 'createCloudFrontLoggingBucket'
+}
+
+export enum CustomResourceRequestTypes {
+ CREATE = 'Create',
+ UPDATE = 'Update',
+ DELETE = 'Delete'
+}
+
+export enum StatusTypes {
+ SUCCESS = 'SUCCESS',
+ FAILED = 'FAILED'
+}
+
+export enum ErrorCodes {
+ ACCESS_DENIED = 'AccessDenied',
+ FORBIDDEN = 'Forbidden'
+}
diff --git a/source/custom-resource/lib/index.ts b/source/custom-resource/lib/index.ts
new file mode 100644
index 000000000..5cc5faa64
--- /dev/null
+++ b/source/custom-resource/lib/index.ts
@@ -0,0 +1,6 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './enums';
+export * from './interfaces';
+export * from './types';
diff --git a/source/custom-resource/lib/interfaces.ts b/source/custom-resource/lib/interfaces.ts
new file mode 100644
index 000000000..bb2e88ca9
--- /dev/null
+++ b/source/custom-resource/lib/interfaces.ts
@@ -0,0 +1,102 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CustomResourceActions, CustomResourceRequestTypes, StatusTypes } from './enums';
+import { ResourcePropertyTypes } from './types';
+
+export interface CustomResourceRequestPropertiesBase {
+ CustomAction: CustomResourceActions;
+}
+
+export interface SendMetricsRequestProperties extends CustomResourceRequestPropertiesBase {
+ AnonymousData: 'Yes' | 'No';
+ UUID: string;
+ CorsEnabled: string;
+ SourceBuckets: string;
+ DeployDemoUi: string;
+ LogRetentionPeriod: number;
+ AutoWebP: string;
+ EnableSignature: string;
+ EnableDefaultFallbackImage: string;
+}
+
+export interface PutConfigRequestProperties extends CustomResourceRequestPropertiesBase {
+ ConfigItem: unknown;
+ DestS3Bucket: string;
+ DestS3key: string;
+}
+
+export interface CopyS3AssetsRequestProperties extends CustomResourceRequestPropertiesBase {
+ ManifestKey: string;
+ SourceS3Bucket: string;
+ SourceS3key: string;
+ DestS3Bucket: string;
+}
+
+export interface CheckSourceBucketsRequestProperties extends CustomResourceRequestPropertiesBase {
+ SourceBuckets: string;
+}
+
+export interface CheckSecretManagerRequestProperties extends CustomResourceRequestPropertiesBase {
+ SecretsManagerName: string;
+ SecretsManagerKey: string;
+}
+
+export interface CheckFallbackImageRequestProperties extends CustomResourceRequestPropertiesBase {
+ FallbackImageS3Bucket: string;
+ FallbackImageS3Key: string;
+}
+
+export interface PolicyStatement {
+ Action?: string;
+ Resource?: string;
+ Effect?: string;
+ Principal?: string;
+ Sid?: string;
+ Condition?: Record;
+}
+
+export interface CreateLoggingBucketRequestProperties extends CustomResourceRequestPropertiesBase {
+ BucketSuffix: string;
+}
+
+export interface CustomResourceRequest {
+ RequestType: CustomResourceRequestTypes;
+ PhysicalResourceId: string;
+ StackId: string;
+ ServiceToken: string;
+ RequestId: string;
+ LogicalResourceId: string;
+ ResponseURL: string;
+ ResourceType: string;
+ ResourceProperties: ResourcePropertyTypes;
+}
+
+export interface CompletionStatus {
+ Status: StatusTypes;
+ Data: Record | { Error?: { Code: string; Message: string } };
+}
+
+export interface LambdaContext {
+ logStreamName: string;
+}
+
+export interface MetricsPayloadData {
+ Region: string;
+ Type: CustomResourceRequestTypes;
+ CorsEnabled: string;
+ NumberOfSourceBuckets: number;
+ DeployDemoUi: string;
+ LogRetentionPeriod: number;
+ AutoWebP: string;
+ EnableSignature: string;
+ EnableDefaultFallbackImage: string;
+}
+
+export interface MetricPayload {
+ Solution: string;
+ Version: string;
+ UUID: string;
+ TimeStamp: string;
+ Data: MetricsPayloadData;
+}
diff --git a/source/custom-resource/lib/types.ts b/source/custom-resource/lib/types.ts
new file mode 100644
index 000000000..e8acae3a9
--- /dev/null
+++ b/source/custom-resource/lib/types.ts
@@ -0,0 +1,29 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ CheckFallbackImageRequestProperties,
+ CheckSecretManagerRequestProperties,
+ CheckSourceBucketsRequestProperties,
+ CopyS3AssetsRequestProperties,
+ CreateLoggingBucketRequestProperties,
+ CustomResourceRequestPropertiesBase,
+ PutConfigRequestProperties,
+ SendMetricsRequestProperties
+} from './interfaces';
+
+export type ResourcePropertyTypes =
+ | CustomResourceRequestPropertiesBase
+ | SendMetricsRequestProperties
+ | PutConfigRequestProperties
+ | CopyS3AssetsRequestProperties
+ | CheckSourceBucketsRequestProperties
+ | CheckSecretManagerRequestProperties
+ | CheckFallbackImageRequestProperties
+ | CreateLoggingBucketRequestProperties;
+
+export class CustomResourceError extends Error {
+ constructor(public readonly code: string, public readonly message: string) {
+ super();
+ }
+}
diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json
index 599d9b010..2ef90ee0f 100644
--- a/source/custom-resource/package.json
+++ b/source/custom-resource/package.json
@@ -1,28 +1,36 @@
{
- "name": "custom-resource",
- "description": "Serverless Image Handler custom resource",
- "main": "index.js",
- "author": {
- "name": "aws-solutions-builder"
- },
- "version": "5.2.0",
- "private": true,
- "dependencies": {
- "axios": "^0.21.1",
- "uuid": "^8.3.0"
- },
- "devDependencies": {
- "aws-sdk": "2.771.0",
- "axios-mock-adapter": "^1.18.2",
- "jest": "^26.4.2"
- },
- "scripts": {
- "pretest": "npm run build:init && npm install",
- "test": "jest test/*.spec.js --coverage --silent",
- "build:init": "rm -rf dist && rm -rf node_modules",
- "build:zip": "zip -rq custom-resource.zip .",
- "build:dist": "mkdir dist && mv custom-resource.zip dist/",
- "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist"
- },
- "license": "Apache-2.0"
+ "name": "custom-resource",
+ "version": "6.0.0",
+ "private": true,
+ "description": "Serverless Image Handler custom resource",
+ "license": "Apache-2.0",
+ "author": "AWS Solutions",
+ "main": "index.js",
+ "scripts": {
+ "prebuild": "npm run clean && npm install",
+ "build": "tsc --build tsconfig.json",
+ "clean": "rm -rf node_modules/ dist/ coverage/ package-lock.json",
+ "package": "npm run build && npm prune --production && rsync -avrq ./node_modules ./dist && npm run include-solution-utils && npm run package:zip",
+ "package:zip": "cd dist && zip -q -r9 ./package.zip * -x '**/test/*' && cd ..",
+ "pretest": "npm run clean && npm install",
+ "test": "jest --coverage --silent",
+ "include-solution-utils": "npm run solution-utils:prep && npm run solution-utils:package",
+ "solution-utils:prep": "rm -rf dist/solution-utils && mkdir dist/solution-utils",
+ "solution-utils:package": "cd ../solution-utils && npm run package && cd dist/ && rsync -avrq . ../../$npm_package_name/dist/solution-utils/ && cd ../../$npm_package_name"
+ },
+ "dependencies": {
+ "axios": "^0.21.1",
+ "moment": "^2.29.1",
+ "uuid": "^8.3.0"
+ },
+ "devDependencies": {
+ "@types/jest": "^27.0.0",
+ "@types/node": "^16.10.3",
+ "@types/uuid": "^8.3.1",
+ "aws-sdk": "^2.1031.0",
+ "jest": "^27.0.0",
+ "ts-jest": "^27.0.0",
+ "ts-node": "^10.2.1",
+ "typescript": "^4.4.3"
+ }
}
diff --git a/source/custom-resource/test/check-fallback-image.spec.ts b/source/custom-resource/test/check-fallback-image.spec.ts
new file mode 100644
index 000000000..f6b83d582
--- /dev/null
+++ b/source/custom-resource/test/check-fallback-image.spec.ts
@@ -0,0 +1,153 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsS3, mockContext } from './mock';
+import { CheckFallbackImageRequestProperties, CustomResourceActions, CustomResourceError, CustomResourceRequest, CustomResourceRequestTypes, ErrorCodes } from '../lib';
+import { handler } from '../index';
+
+describe('CHECK_FALLBACK_IMAGE', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.CHECK_FALLBACK_IMAGE,
+ FallbackImageS3Bucket: 'fallback-image-bucket',
+ FallbackImageS3Key: 'fallback-image.jpg'
+ }
+ };
+ const head = {
+ AcceptRanges: 'bytes',
+ LastModified: '2020-01-23T18:52:47.000Z',
+ ContentLength: 200237,
+ ContentType: 'image/jpeg'
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should return success when the default fallback image exists', async () => {
+ mockAwsS3.headObject.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve(head);
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(mockAwsS3.headObject).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'The default fallback image validated.',
+ Data: head
+ }
+ });
+ });
+
+ it('Should return failed when fallbackImageS3Bucket is not provided', async () => {
+ (event.ResourceProperties as CheckFallbackImageRequestProperties).FallbackImageS3Bucket = '';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'S3BucketNotProvided',
+ Message: 'You need to provide the default fallback image bucket.'
+ }
+ }
+ });
+ });
+
+ it('Should return failed when fallbackImageS3Key is not provided', async () => {
+ const resourceProperties = event.ResourceProperties as CheckFallbackImageRequestProperties;
+ resourceProperties.FallbackImageS3Bucket = 'fallback-image-bucket';
+ resourceProperties.FallbackImageS3Key = '';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'S3KeyNotProvided',
+ Message: 'You need to provide the default fallback image object key.'
+ }
+ }
+ });
+ });
+
+ it('Should return failed when the default fallback image does not exist', async () => {
+ mockAwsS3.headObject.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError('NotFound', null));
+ }
+ }));
+ (event.ResourceProperties as CheckFallbackImageRequestProperties).FallbackImageS3Key = 'fallback-image.jpg';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(mockAwsS3.headObject).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'FallbackImageError',
+ Message: `Either the object does not exist or you don't have permission to access the object: fallback-image-bucket/fallback-image.jpg`
+ }
+ }
+ });
+ });
+
+ it('Should retry and return success when IAM policy is not ready so S3 API returns AccessDenied or Forbidden', async () => {
+ mockAwsS3.headObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(ErrorCodes.ACCESS_DENIED, null));
+ }
+ }));
+ mockAwsS3.headObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(ErrorCodes.FORBIDDEN, null));
+ }
+ }));
+ mockAwsS3.headObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve(head);
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(mockAwsS3.headObject).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'The default fallback image validated.',
+ Data: head
+ }
+ });
+ });
+});
diff --git a/source/custom-resource/test/check-secrets-manager.spec.ts b/source/custom-resource/test/check-secrets-manager.spec.ts
new file mode 100644
index 000000000..69ac14aa7
--- /dev/null
+++ b/source/custom-resource/test/check-secrets-manager.spec.ts
@@ -0,0 +1,143 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsSecretManager, mockContext } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CheckSecretManagerRequestProperties, CustomResourceRequest, CustomResourceError } from '../lib';
+import { handler } from '../index';
+
+describe('CHECK_SECRETS_MANAGER', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.CHECK_SECRETS_MANAGER,
+ SecretsManagerName: 'secrets-manager-name',
+ SecretsManagerKey: 'secrets-manager-key'
+ }
+ };
+ const secret = {
+ SecretString: '{"secrets-manager-key":"secret-ingredient"}',
+ ARN: 'arn:of:secrets:managers:secret'
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("Should return success when secrets manager secret and secret's key exists", async () => {
+ mockAwsSecretManager.getSecretValue.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve(secret);
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Secrets Manager validated.',
+ ARN: secret.ARN
+ }
+ });
+ });
+
+ it('Should return failed when secretName is not provided', async () => {
+ (event.ResourceProperties as CheckSecretManagerRequestProperties).SecretsManagerName = '';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'SecretNotProvided',
+ Message: 'You need to provide AWS Secrets Manager secret.'
+ }
+ }
+ });
+ });
+
+ it('Should return failed when secretKey is not provided', async () => {
+ const resourceProperties = event.ResourceProperties as CheckSecretManagerRequestProperties;
+ resourceProperties.SecretsManagerName = 'secrets-manager-name';
+ resourceProperties.SecretsManagerKey = '';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'SecretKeyNotProvided',
+ Message: 'You need to provide AWS Secrets Manager secret key.'
+ }
+ }
+ });
+ });
+
+ it('Should return failed when secret key does not exist', async () => {
+ mockAwsSecretManager.getSecretValue.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve(secret);
+ }
+ }));
+
+ const resourceProperties = event.ResourceProperties as CheckSecretManagerRequestProperties;
+ resourceProperties.SecretsManagerKey = 'none-existing-key';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'SecretKeyNotFound',
+ Message: `AWS Secrets Manager secret requires ${resourceProperties.SecretsManagerKey} key.`
+ }
+ }
+ });
+ });
+
+ it('Should return failed when GetSecretValue fails', async () => {
+ mockAwsSecretManager.getSecretValue.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError('InternalServerError', 'GetSecretValue failed.'));
+ }
+ }));
+ (event.ResourceProperties as CheckSecretManagerRequestProperties).SecretsManagerName = 'secrets-manager-key';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'InternalServerError',
+ Message: 'GetSecretValue failed.'
+ }
+ }
+ });
+ });
+});
diff --git a/source/custom-resource/test/check-source-buckets.spec.ts b/source/custom-resource/test/check-source-buckets.spec.ts
new file mode 100644
index 000000000..af9ad03c5
--- /dev/null
+++ b/source/custom-resource/test/check-source-buckets.spec.ts
@@ -0,0 +1,72 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { consoleErrorSpy, consoleInfoSpy, mockAwsS3, mockContext } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest, CustomResourceError } from '../lib';
+import { handler } from '../index';
+
+describe('CHECK_SOURCE_BUCKETS', () => {
+ // Mock event data
+ const buckets = 'bucket-a,bucket-b,bucket-c';
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.CHECK_SOURCE_BUCKETS,
+ SourceBuckets: buckets
+ }
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should return success to check source buckets', async () => {
+ mockAwsS3.headBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(`Attempting to check if the following buckets exist: ${buckets}`);
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: { Message: 'Buckets validated.' }
+ });
+ });
+
+ it('should return failed when any buckets do not exist', async () => {
+ mockAwsS3.headBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'HeadObject failed.'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Could not find bucket: bucket-a');
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'BucketNotFound',
+ Message: `Could not find the following source bucket(s) in your account: ${buckets}. Please specify at least one source bucket that exists within your account and try again. If specifying multiple source buckets, please ensure that they are comma-separated.`
+ }
+ }
+ });
+ });
+});
diff --git a/source/custom-resource/test/copy-s3-assets.spec.ts b/source/custom-resource/test/copy-s3-assets.spec.ts
new file mode 100644
index 000000000..ccc936ad2
--- /dev/null
+++ b/source/custom-resource/test/copy-s3-assets.spec.ts
@@ -0,0 +1,147 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { consoleErrorSpy, consoleInfoSpy, mockAwsS3, mockContext } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, ErrorCodes, CopyS3AssetsRequestProperties, CustomResourceRequest, CustomResourceError } from '../lib';
+import { handler } from '../index';
+
+describe('COPY_S3_ASSETS', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.COPY_S3_ASSETS,
+ ManifestKey: 'manifest.json',
+ SourceS3Bucket: 'source-bucket',
+ SourceS3key: 'source-key',
+ DestS3Bucket: 'destination-bucket'
+ }
+ };
+
+ const manifest = {
+ files: ['index.html', 'scripts.js', 'style.css', 'image.png', 'image.jpg', 'image.svg', 'text.txt']
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should return success to copy S3 assets', async () => {
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: JSON.stringify(manifest) });
+ }
+ }));
+ mockAwsS3.copyObject.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve({ CopyObjectResult: 'Success' });
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+ const resourceProperties = event.ResourceProperties as CopyS3AssetsRequestProperties;
+
+ expect.assertions(2);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(`Source bucket: ${resourceProperties.SourceS3Bucket}`);
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Copy assets completed.',
+ Manifest: { Files: [...manifest.files] }
+ }
+ });
+ });
+
+ it('Should return failed when getting manifest fails', async () => {
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'GetObject failed.'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error occurred while getting manifest file.');
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'GetManifestFailure',
+ Message: 'Copy of website assets failed.'
+ }
+ }
+ });
+ });
+
+ it('Should return failed when copying assets fails', async () => {
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: JSON.stringify(manifest) });
+ }
+ }));
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'CopyObject failed.'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error occurred while copying assets.');
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'CopyAssetsFailure',
+ Message: 'Copy of website assets failed.'
+ }
+ }
+ });
+ });
+
+ it('Should retry and return success IAM policy if not ready so S3 API returns AccessDenied', async () => {
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(ErrorCodes.ACCESS_DENIED, null));
+ }
+ }));
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: JSON.stringify(manifest) });
+ }
+ }));
+ mockAwsS3.copyObject.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve({ CopyObjectResult: 'Success' });
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Copy assets completed.',
+ Manifest: { Files: [...manifest.files] }
+ }
+ });
+ });
+});
diff --git a/source/custom-resource/test/create-logging-bucket.spec.ts b/source/custom-resource/test/create-logging-bucket.spec.ts
new file mode 100644
index 000000000..514296ab3
--- /dev/null
+++ b/source/custom-resource/test/create-logging-bucket.spec.ts
@@ -0,0 +1,160 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { consoleErrorSpy, consoleInfoSpy, mockAwsEc2, mockAwsS3, mockContext } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest, CustomResourceError } from '../lib';
+import { handler } from '../index';
+
+describe('CREATE_LOGGING_BUCKET', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.CREATE_LOGGING_BUCKET,
+ BucketSuffix: `test-stack-test-region-01234567898`
+ }
+ };
+
+ it('Should return success and bucket name', async () => {
+ mockAwsEc2.describeRegions.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Regions: [{ RegionName: 'mock-region-1' }] });
+ }
+ }));
+ mockAwsS3.createBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+ mockAwsS3.putBucketEncryption.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+ mockAwsS3.putBucketPolicy.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+
+ await handler(event, mockContext);
+
+ expect.assertions(4);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringContaining("The opt-in status of the 'mock-region-1' region is 'opted-in'"));
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/));
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully enabled encryption on bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully added policy added to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ });
+
+ it('Should return failure when there is an error getting opt-in regions', async () => {
+ mockAwsEc2.describeRegions.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new Error('describeRegions failed'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: { Error: { Code: 'CustomResourceError', Message: 'describeRegions failed' } }
+ });
+ });
+
+ it('Should return failure when there is an error creating the bucket', async () => {
+ mockAwsEc2.describeRegions.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Regions: [] });
+ }
+ }));
+ mockAwsS3.createBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'createBucket failed'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(2);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/^Could not create bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: { Error: { Code: 'CustomResourceError', Message: 'createBucket failed' } }
+ });
+ });
+
+ it('Should return failure when there is an error enabling encryption on the created bucket', async () => {
+ mockAwsEc2.describeRegions.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Regions: [] });
+ }
+ }));
+ mockAwsS3.createBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+ mockAwsS3.putBucketEncryption.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'putBucketEncryption failed'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(3);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/));
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/^Failed to add encryption to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: { Error: { Code: 'CustomResourceError', Message: 'putBucketEncryption failed' } }
+ });
+ });
+
+ it('Should return failure when there is an error applying a policy to the created bucket', async () => {
+ mockAwsEc2.describeRegions.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Regions: [] });
+ }
+ }));
+ mockAwsS3.createBucket.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+ mockAwsS3.putBucketEncryption.mockImplementation(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+ mockAwsS3.putBucketPolicy.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'putBucketPolicy failed'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(4);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/));
+ expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringMatching(/^Successfully enabled encryption on bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/^Failed to add policy to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/));
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: { Error: { Code: 'CustomResourceError', Message: 'putBucketPolicy failed' } }
+ });
+ });
+});
diff --git a/source/custom-resource/test/create-uuid.spec.ts b/source/custom-resource/test/create-uuid.spec.ts
new file mode 100644
index 000000000..e5fde9a0c
--- /dev/null
+++ b/source/custom-resource/test/create-uuid.spec.ts
@@ -0,0 +1,36 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockContext, mockAxios } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from '../lib';
+import { handler } from '../index';
+
+describe('CREATE_UUID', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.CREATE_UUID
+ }
+ };
+
+ it('Should create an UUID', async () => {
+ mockAxios.put.mockResolvedValue({ status: 200 });
+
+ const response = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(response).toEqual({
+ Status: 'SUCCESS',
+ Data: { UUID: 'mock-uuid' }
+ });
+ });
+});
diff --git a/source/custom-resource/test/default.spec.ts b/source/custom-resource/test/default.spec.ts
new file mode 100644
index 000000000..2592809eb
--- /dev/null
+++ b/source/custom-resource/test/default.spec.ts
@@ -0,0 +1,32 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockContext } from './mock';
+import { CustomResourceRequestTypes, CustomResourceRequest } from '../lib';
+import { handler } from '../index';
+
+describe('Default', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.UPDATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: null
+ }
+ };
+
+ it('Should return success for other default custom resource', async () => {
+ const result = await handler(event, mockContext);
+
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {}
+ });
+ });
+});
diff --git a/source/custom-resource/test/index.spec.js b/source/custom-resource/test/index.spec.js
deleted file mode 100644
index cf099e9ec..000000000
--- a/source/custom-resource/test/index.spec.js
+++ /dev/null
@@ -1,854 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-// Import packages
-const axios = require('axios');
-const MockAdapter = require('axios-mock-adapter');
-const axiosMock = new MockAdapter(axios);
-
-// System environment variables
-process.env.AWS_REGION = 'test-region';
-process.env.RETRY_SECONDS = 0.01;
-
-// Mock UUID
-jest.mock('uuid', () => {
- return {
- v4: jest.fn(() => 'mock-uuid')
- };
-});
-
-// Mock data
-const now = new Date();
-global.Date = jest.fn(() => now);
-global.Date.getTime = now.getTime();
-
-// Mock axios
-axiosMock.onPut('/cfn-response').reply(200);
-
-// Mock context
-const context = {
- logStreamName: 'log-stream'
-};
-
-// Mock AWS SDK
-const mockS3 = jest.fn();
-const mockSecretsManager = jest.fn();
-jest.mock('aws-sdk', () => {
- return {
- S3: jest.fn(() => ({
- getObject: mockS3,
- copyObject: mockS3,
- putObject: mockS3,
- headBucket: mockS3,
- headObject: mockS3,
- createBucket: mockS3,
- putBucketEncryption: mockS3,
- putBucketPolicy: mockS3,
- waitFor: mockS3
- })),
- SecretsManager: jest.fn(() => ({
- getSecretValue: mockSecretsManager
- }))
- };
-});
-
-const mockConfig = `'use strict';
-
-const appVariables = {
-someKey: 'someValue'
-};`;
-
-// Import index.js
-const index = require('../index.js');
-
-describe('index', function() {
- describe('sendMetric', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "sendMetric",
- "anonymousData": "Yes",
- "Region": "test-region",
- "solutionId": "solution-id",
- "UUID": "mock-uuid",
- "version": "test-version",
- "enableSignature": "Yes",
- "enableDefaultFallbackImage": "Yes"
- }
- };
-
- it('should return success when sending anonymous metric succeeds', async function() {
- // Mock axios
- axiosMock.onPost('https://metrics.awssolutionsbuilder.com/generic').reply(200);
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Anonymous data was sent successfully.',
- Data: {
- Solution: 'solution-id',
- TimeStamp: `${now.toISOString().replace(/T/, ' ')}`,
- UUID: 'mock-uuid',
- Version: 'test-version',
- Data: {
- Region: 'test-region',
- Type: 'Create',
- EnableSignature: 'Yes',
- EnableDefaultFallbackImage: 'Yes'
- }
- }
- }
- });
- });
- it('should return success when sending anonymous usage fails', async function() {
- // Mock axios
- axiosMock.onPost('https://metrics.awssolutionsbuilder.com/generic').reply(500);
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Anonymous data was sent failed.',
- Data: {
- Solution: 'solution-id',
- TimeStamp: `${now.toISOString().replace(/T/, ' ')}`,
- UUID: 'mock-uuid',
- Version: 'test-version',
- Data: {
- Region: 'test-region',
- Type: 'Create',
- EnableSignature: 'Yes',
- EnableDefaultFallbackImage: 'Yes'
- }
- }
- }
- });
- });
- it('should not send annonymous metric when anonymouseData is "No"', async function() {
- event.ResourceProperties.anonymousData = 'No';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {}
- });
- });
- });
-
- describe('putConfigFile', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "putConfigFile",
- "configItem": {
- "someKey": "someValue"
- },
- "destS3Bucket": "destination-bucket",
- "destS3key": "demo-ui-config.js"
- }
- };
-
- beforeEach(() => {
- mockS3.mockReset();
- });
-
- it('should return success to put config file', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:PutObject
- return Promise.resolve();
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({
- Bucket: event.ResourceProperties.destS3Bucket,
- Body: mockConfig,
- Key: event.ResourceProperties.destS3key,
- ContentType: 'application/javascript'
- });
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Config file uploaded.',
- Content: mockConfig
- }
- });
- });
- it('should return failed when PutObject fails', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:PutObject
- return Promise.reject({ message: 'PutObject failed.' });
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({
- Bucket: event.ResourceProperties.destS3Bucket,
- Body: mockConfig,
- Key: event.ResourceProperties.destS3key,
- ContentType: 'application/javascript'
- });
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'ConfigFileCreationFailure',
- message: `Saving config file to ${event.ResourceProperties.destS3Bucket}/${event.ResourceProperties.destS3key} failed.`
- }
- }
- });
- });
- it('should retry and return success when IAM policy is not so S3 API returns AccessDenied', async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- // s3:PutObject
- return Promise.reject({ code: 'AccessDenied' });
- }
- };
- }).mockImplementationOnce(() => {
- return {
- promise() {
- // s3:PutObject
- return Promise.resolve();
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({
- Bucket: event.ResourceProperties.destS3Bucket,
- Body: mockConfig,
- Key: event.ResourceProperties.destS3key,
- ContentType: 'application/javascript'
- });
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Config file uploaded.',
- Content: mockConfig
- }
- });
- });
- });
-
- describe('copyS3assets', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "copyS3assets",
- "manifestKey": "manifest.json",
- "sourceS3Bucket": "source-bucket",
- "sourceS3key": "source-key",
- "destS3Bucket": "destination-bucket"
- }
- };
- const manifest = {
- files: [
- 'index.html',
- 'scripts.js',
- 'style.css',
- 'image.png',
- 'image.jpg',
- 'image.svg',
- 'text.txt'
- ]
- };
-
- beforeEach(() => {
- mockS3.mockReset();
- });
-
- it('should return success to copy S3 assets', async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- // s3:GetObject
- return Promise.resolve(
- {
- Body: JSON.stringify(manifest)
- }
- );
- }
- };
- }).mockImplementation(() => {
- return {
- promise() {
- // s3:CopyObject
- return Promise.resolve({ CopyObjectResult: 'Success' });
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Copy assets completed.',
- Manifest: manifest
- }
- });
- });
- it('should return failed when getting manifest fails', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:GetObject
- return Promise.reject({ message: 'GetObject failed.' });
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'GetManifestFailure',
- message: 'Copy of website assets failed.'
- }
- }
- });
- });
- it('should return failed when copying assets fails', async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- // s3:GetObject
- return Promise.resolve({ Body: JSON.stringify(manifest) });
- }
- };
- }).mockImplementation(() => {
- return {
- promise() {
- // s3:CopyObject
- return Promise.reject({ message: 'CopyObject failed.' });
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'CopyAssetsFailure',
- message: 'Copy of website assets failed.'
- }
- }
- });
- });
- it('should retry and return success IAM policy is not ready so S3 API returns AccessDenied', async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- // s3:GetObject
- return Promise.reject({ code: 'AccessDenied' });
- }
- };
- }).mockImplementationOnce(() => {
- return {
- promise() {
- // s3:GetObject
- return Promise.resolve({ Body: JSON.stringify(manifest) });
- }
- };
- }).mockImplementation(() => {
- return {
- promise() {
- // s3:CopyObject
- return Promise.resolve({ CopyObjectResult: 'Success' });
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Copy assets completed.',
- Manifest: manifest
- }
- });
- });
- });
-
- describe('createUuid', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "createUuid"
- }
- };
-
- it('should return uuid', async function() {
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: { UUID: 'mock-uuid' }
- });
- });
- });
-
- describe('checkSourceBuckets', function() {
- const buckets = 'bucket-a, bucket-b, bucket-c'
-
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "checkSourceBuckets",
- "sourceBuckets": buckets
- }
- };
-
- beforeEach(() => {
- mockS3.mockReset();
- });
-
- it('should return success to check source buckets', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:HeadBucket
- return Promise.resolve();
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: { Message: 'Buckets validated.' }
- });
- });
- it('should return failed when any buckets do not exist', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:HeadBucket
- return Promise.reject({ message: 'HeadObject failed.' });
- }
- };
- });
-
- const errorBuckets = buckets.replace(/\s/g, '').split(',');
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'BucketNotFound',
- message: `Could not find the following source bucket(s) in your account: ${errorBuckets.join(',')}. Please specify at least one source bucket that exists within your account and try again. If specifying multiple source buckets, please ensure that they are comma-separated.`
- }
- }
- });
- });
- });
-
- describe('checkSecretsManager', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "checkSecretsManager",
- "secretsManagerName": "secrets-manager-name",
- "secretsManagerKey": "secrets-manager-key"
- }
- };
- const secret = {
- SecretString: '{"secrets-manager-key":"secret-ingredient"}',
- ARN: 'arn:of:secrets:managers:secret'
- };
-
- beforeEach(() => {
- mockSecretsManager.mockReset();
- });
-
- it('should return success when secrets manager secret and secret\'s key exists', async function() {
- mockSecretsManager.mockImplementation(() => {
- return {
- promise() {
- // secretsManager:GetSecretValue
- return Promise.resolve(secret);
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'Secrets Manager validated.',
- ARN: secret.ARN
- }
- });
- });
- it('should return failed when secretName is not provided', async function() {
- event.ResourceProperties.secretsManagerName = '';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'SecretNotProvided',
- message: 'You need to provide AWS Secrets Manager secert.'
- }
- }
- });
- });
- it('should return failed when secretKey is not provided', async function() {
- event.ResourceProperties.secretsManagerName = 'secrets-manager-name';
- event.ResourceProperties.secretsManagerKey = '';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'SecretKeyNotProvided',
- message: 'You need to provide AWS Secrets Manager secert key.'
- }
- }
- });
- });
- it('should return failed when secret key does not exist', async function() {
- mockSecretsManager.mockImplementation(() => {
- return {
- promise() {
- // secretsManager:GetSecretValue
- return Promise.resolve(secret);
- }
- };
- });
-
- event.ResourceProperties.secretsManagerKey = 'none-existing-key';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'SecretKeyNotFound',
- message: `AWS Secrets Manager secret requries ${event.ResourceProperties.secretsManagerKey} key.`
- }
- }
- });
- });
- it('should return failed when GetSecretValue fails', async function() {
- mockSecretsManager.mockImplementation(() => {
- return {
- promise() {
- // secretsManager:GetSecretValue
- return Promise.reject({ code: 'InternalServerError', message: 'GetSecretValue failed.' });
- }
- };
- });
-
- event.ResourceProperties.secretsManagerKey = 'secrets-manager-key';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'InternalServerError',
- message: 'GetSecretValue failed.'
- }
- }
- });
- });
- });
-
- describe('checkFallbackImage', function() {
- // Mock event data
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "checkFallbackImage",
- "fallbackImageS3Bucket": "fallback-image-bucket",
- "fallbackImageS3Key": "fallback-image.jpg"
- }
- };
- const head = {
- "AcceptRanges": "bytes",
- "LastModified": "2020-01-23T18:52:47.000Z",
- "ContentLength": 200237,
- "ContentType": "image/jpeg"
- };
-
- beforeEach(() => {
- mockS3.mockReset();
- });
-
- it('should return success when the default fallback image exists', async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:headObject
- return Promise.resolve(head);
- }
- }
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'The default fallback image validated.',
- Data: head
- }
- });
- });
- it('should return failed when fallbackImageS3Bucket is not provided', async function() {
- event.ResourceProperties.fallbackImageS3Bucket = '';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'S3BucketNotProvided',
- message: 'You need to provide the default fallback image bucket.'
- }
- }
- });
- });
- it('should return failed when fallbackImageS3Key is not provided', async function() {
- event.ResourceProperties.fallbackImageS3Bucket = 'fallback-image-bucket';
- event.ResourceProperties.fallbackImageS3Key = '';
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'S3KeyNotProvided',
- message: 'You need to provide the default fallback image object key.'
- }
- }
- });
- });
- it('should return failed when the default fallback image does not exist', async function() {
- event.ResourceProperties.fallbackImageS3Key = 'fallback-image.jpg';
-
- mockS3.mockImplementation(() => {
- return {
- promise() {
- // s3:headObject
- return Promise.reject({ code: 'NotFound' });
- }
- }
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: 'FallbackImageError',
- message: `Either the object does not exist or you don't have permission to access the object: fallback-image-bucket/fallback-image.jpg`
- }
- }
- });
- });
- it('should retry and return success when IAM policy is not ready so S3 API returns AccessDenied or Forbidden', async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- // s3:headObject
- return Promise.reject({ code: 'AccessDenied' });
- }
- }
- }).mockImplementationOnce(() => {
- return {
- promise() {
- // s3:headObject
- return Promise.reject({ code: 'Forbidden' });
- }
- }
- }).mockImplementationOnce(() => {
- return {
- promise() {
- // s3:headObject
- return Promise.resolve(head);
- }
- }
- });
-
- const result = await index.handler(event, context);
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'fallback-image-bucket', Key: 'fallback-image.jpg' });
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {
- Message: 'The default fallback image validated.',
- Data: head
- }
- });
- });
- });
-
- describe('Default', function() {
- // Mock event data
- const event = {
- "RequestType": "Update",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "ServiceToken": "LAMBDA_ARN"
- }
- };
-
- it('should return success for other default custom resource', async function() {
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'SUCCESS',
- data: {}
- });
- });
- });
-
- describe('CreateCustomLoggingBucket', function() {
- const event = {
- "RequestType": "Create",
- "ResponseURL": "/cfn-response",
- "ResourceProperties": {
- "customAction": "createCFLoggingBucket",
- "stackName": "teststack",
- "bucketSuffix": `teststacktest-region01234567898`,
- "policy": {
- "Condition": {
- "Bool": {
- "aws:SecureTransport": "false"
- }
- },
- "Action": "*",
- "Resource": "",
- "Effect": "Deny",
- "Principal": "*",
- "Sid": "HttpsOnly"
- }
- }
- };
-
- beforeEach(() => {
- mockS3.mockReset();
- });
-
- it("Should return success and bucket name", async function() {
-
- mockS3.mockImplementation(() => {
- return {
- promise() {
-
- return Promise.resolve();
- }
- };
- });
- const result = await index.handler(event, context);
- expect(result).toMatchObject({
- status: 'SUCCESS',
- data: {bucketName: expect.stringMatching(/^teststack-logs-[a-z0-9]{8}/)}
- });
- });
-
- it("Should return failure when there is an error creating the bucket", async function() {
- mockS3.mockImplementation(() => {
- return {
- promise() {
- return Promise.reject({message: "createBucket failed"});
- }
- };
- });
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: "CustomResourceError",
- message: "createBucket failed",
- }
- }
- });
- });
-
- it("Should return failure when there is an error enabling encryption on the created bucket", async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve();
- }
- };
- }).mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({message: "putBucketEncryption failed"});
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: "CustomResourceError",
- message: "putBucketEncryption failed",
- }
- }
- });
- });
- it("Should return failure when there is an error applying a policy to the created bucket", async function() {
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve();
- }
- };
- }).mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve();
- }
- };
- }).mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({message: "putBucketPolicy failed"});
- }
- };
- });
-
- const result = await index.handler(event, context);
- expect(result).toEqual({
- status: 'FAILED',
- data: {
- Error: {
- code: "CustomResourceError",
- message: "putBucketPolicy failed",
- }
- }
- });
- });
- });
-});
\ No newline at end of file
diff --git a/source/custom-resource/test/mock.ts b/source/custom-resource/test/mock.ts
new file mode 100644
index 000000000..6382ee308
--- /dev/null
+++ b/source/custom-resource/test/mock.ts
@@ -0,0 +1,60 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { LambdaContext } from '../lib';
+
+export const mockAwsEc2 = {
+ describeRegions: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/ec2', () => jest.fn(() => ({ ...mockAwsEc2 })));
+
+export const mockAwsS3 = {
+ headObject: jest.fn(),
+ copyObject: jest.fn(),
+ getObject: jest.fn(),
+ putObject: jest.fn(),
+ headBucket: jest.fn(),
+ createBucket: jest.fn(),
+ putBucketEncryption: jest.fn(),
+ putBucketPolicy: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/s3', () => jest.fn(() => ({ ...mockAwsS3 })));
+
+export const mockAwsSecretManager = {
+ getSecretValue: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/secretsmanager', () => jest.fn(() => ({ ...mockAwsSecretManager })));
+
+export const mockAxios = {
+ put: jest.fn(),
+ post: jest.fn()
+};
+
+jest.mock('axios', () => ({
+ put: mockAxios.put,
+ post: mockAxios.post
+}));
+
+jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
+
+const mockTimeStamp = new Date();
+export const mockISOTimeStamp = mockTimeStamp.toISOString();
+
+jest.mock('moment', () => {
+ const originalMoment = jest.requireActual('moment');
+ const mockMoment = (date: string | undefined) => originalMoment(mockTimeStamp);
+ mockMoment.utc = () => ({
+ format: () => mockISOTimeStamp
+ });
+ return mockMoment;
+});
+
+export const consoleInfoSpy = jest.spyOn(console, 'info');
+export const consoleErrorSpy = jest.spyOn(console, 'error');
+
+export const mockContext: LambdaContext = {
+ logStreamName: 'mock-stream'
+};
diff --git a/source/custom-resource/test/put-config-file.spec.ts b/source/custom-resource/test/put-config-file.spec.ts
new file mode 100644
index 000000000..064c5dc3f
--- /dev/null
+++ b/source/custom-resource/test/put-config-file.spec.ts
@@ -0,0 +1,134 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsS3, mockContext, consoleInfoSpy } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest, PutConfigRequestProperties, ErrorCodes, CustomResourceError } from '../lib';
+import { handler } from '../index';
+
+describe('PUT_CONFIG_FILE', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ CustomAction: CustomResourceActions.PUT_CONFIG_FILE,
+ ConfigItem: {
+ Key: 'Value'
+ },
+ DestS3Bucket: 'destination-bucket',
+ DestS3key: 'demo-ui-config.js'
+ }
+ };
+
+ const mockConfig = `'use strict';
+
+const appVariables = {
+Key: 'Value'
+};`;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should return success to put config file', async () => {
+ mockAwsS3.putObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({});
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+ const resourceProperties = event.ResourceProperties as PutConfigRequestProperties;
+
+ expect.assertions(2);
+
+ expect(mockAwsS3.putObject).toHaveBeenCalledWith({
+ Bucket: resourceProperties.DestS3Bucket,
+ Body: mockConfig,
+ Key: resourceProperties.DestS3key,
+ ContentType: 'application/javascript'
+ });
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Config file uploaded.',
+ Content: mockConfig
+ }
+ });
+ });
+
+ it('Should return failed when PutObject fails', async () => {
+ mockAwsS3.putObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(null, 'PutObject failed'));
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+ const resourceProperties = event.ResourceProperties as PutConfigRequestProperties;
+
+ expect.assertions(3);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(`Attempting to save content blob destination location: ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key}`);
+ expect(mockAwsS3.putObject).toHaveBeenCalledWith({
+ Bucket: resourceProperties.DestS3Bucket,
+ Body: mockConfig,
+ Key: resourceProperties.DestS3key,
+ ContentType: 'application/javascript'
+ });
+ expect(result).toEqual({
+ Status: 'FAILED',
+ Data: {
+ Error: {
+ Code: 'ConfigFileCreationFailure',
+ Message: `Saving config file to ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key} failed.`
+ }
+ }
+ });
+ });
+
+ it('Should retry and return success when IAM policy is not so S3 API returns AccessDenied', async () => {
+ mockAwsS3.putObject
+ .mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new CustomResourceError(ErrorCodes.ACCESS_DENIED, null));
+ }
+ }))
+ .mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve();
+ }
+ }));
+
+ const result = await handler(event, mockContext);
+ const resourceProperties = event.ResourceProperties as PutConfigRequestProperties;
+
+ expect.assertions(4);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith(`Attempting to save content blob destination location: ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key}`);
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Waiting for retry...');
+ expect(mockAwsS3.putObject).toHaveBeenCalledWith({
+ Bucket: resourceProperties.DestS3Bucket,
+ Body: mockConfig,
+ Key: resourceProperties.DestS3key,
+ ContentType: 'application/javascript'
+ });
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Config file uploaded.',
+ Content: mockConfig
+ }
+ });
+ });
+});
diff --git a/source/custom-resource/test/send-anonymous-metric.spec.ts b/source/custom-resource/test/send-anonymous-metric.spec.ts
new file mode 100644
index 000000000..d7a6de948
--- /dev/null
+++ b/source/custom-resource/test/send-anonymous-metric.spec.ts
@@ -0,0 +1,163 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAxios, mockContext, consoleInfoSpy, mockISOTimeStamp, consoleErrorSpy } from './mock';
+import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest, SendMetricsRequestProperties } from '../lib';
+import { handler } from '../index';
+
+describe('SEND_ANONYMOUS_METRIC', () => {
+ // Mock event data
+ const event: CustomResourceRequest = {
+ RequestType: CustomResourceRequestTypes.CREATE,
+ ResponseURL: '/cfn-response',
+ PhysicalResourceId: 'mock-physical-id',
+ StackId: 'mock-stack-id',
+ ServiceToken: 'mock-service-token',
+ RequestId: 'mock-request-id',
+ LogicalResourceId: 'mock-logical-resource-id',
+ ResourceType: 'mock-resource-type',
+ ResourceProperties: {
+ AnonymousData: 'Yes',
+ CustomAction: CustomResourceActions.SEND_ANONYMOUS_METRIC,
+ UUID: 'mock-uuid',
+ AutoWebP: 'Yes',
+ CorsEnabled: 'Yes',
+ DeployDemoUi: 'Yes',
+ EnableDefaultFallbackImage: 'Yes',
+ EnableSignature: 'Yes',
+ LogRetentionPeriod: 5,
+ SourceBuckets: 'bucket-1, bucket-2, bucket-3'
+ }
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should return success when sending anonymous metric succeeds', async () => {
+ mockAxios.post.mockResolvedValue({ status: 200, statusText: 'OK' });
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(5);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"Solution":"solution-id"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"UUID":"mock-uuid"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"TimeStamp":'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Anonymous metric response: OK (200)');
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Anonymous data was sent successfully.',
+ Data: {
+ Solution: 'solution-id',
+ TimeStamp: mockISOTimeStamp,
+ UUID: 'mock-uuid',
+ Version: 'solution-version',
+ Data: {
+ Region: 'mock-region-1',
+ Type: 'Create',
+ AutoWebP: 'Yes',
+ CorsEnabled: 'Yes',
+ DeployDemoUi: 'Yes',
+ EnableDefaultFallbackImage: 'Yes',
+ EnableSignature: 'Yes',
+ LogRetentionPeriod: 5,
+ NumberOfSourceBuckets: 3
+ }
+ }
+ }
+ });
+ });
+
+ it('Should return success when sending anonymous usage fails', async () => {
+ mockAxios.post.mockResolvedValue({ status: 500, statusText: 'FAILS' });
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(5);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"Solution":"solution-id"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"UUID":"mock-uuid"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"TimeStamp":'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Anonymous metric response: FAILS (500)');
+
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Anonymous data was sent successfully.',
+ Data: {
+ Solution: 'solution-id',
+ TimeStamp: mockISOTimeStamp,
+ UUID: 'mock-uuid',
+ Version: 'solution-version',
+ Data: {
+ Region: 'mock-region-1',
+ Type: 'Create',
+ AutoWebP: 'Yes',
+ CorsEnabled: 'Yes',
+ DeployDemoUi: 'Yes',
+ EnableDefaultFallbackImage: 'Yes',
+ EnableSignature: 'Yes',
+ LogRetentionPeriod: 5,
+ NumberOfSourceBuckets: 3
+ }
+ }
+ }
+ });
+ });
+
+ it('Should return success when unable to send anonymous usage', async () => {
+ mockAxios.post.mockRejectedValue({ status: 500, statusText: 'FAILS' });
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(5);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"Solution":"solution-id"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"UUID":"mock-uuid"'));
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Sending anonymous metric', expect.stringContaining('"TimeStamp":'));
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error sending anonymous metric');
+
+ expect(result).toMatchObject({
+ Status: 'SUCCESS',
+ Data: { Message: 'Anonymous data was sent failed.' }
+ });
+ });
+
+ it('Should return success when sending anonymous metric without source buckets', async () => {
+ mockAxios.post.mockResolvedValue({ status: 200, statusText: 'OK' });
+ const eventWithoutBuckets = { ...event };
+ (eventWithoutBuckets.ResourceProperties).SourceBuckets = null;
+
+ const result = await handler(eventWithoutBuckets, mockContext);
+
+ expect.assertions(2);
+
+ expect(consoleInfoSpy).toHaveBeenCalledWith('Anonymous metric response: OK (200)');
+ expect(result).toMatchObject({
+ Status: 'SUCCESS',
+ Data: {
+ Message: 'Anonymous data was sent successfully.',
+ Data: { Data: { NumberOfSourceBuckets: 0 } }
+ }
+ });
+ });
+
+ it('Should not send antonymous metric when anonymousData is "No"', async () => {
+ (event.ResourceProperties as SendMetricsRequestProperties).AnonymousData = 'No';
+
+ const result = await handler(event, mockContext);
+
+ expect.assertions(1);
+
+ expect(result).toEqual({
+ Status: 'SUCCESS',
+ Data: {}
+ });
+ });
+});
diff --git a/source/custom-resource/test/setJestEnvironmentVariables.ts b/source/custom-resource/test/setJestEnvironmentVariables.ts
new file mode 100644
index 000000000..9fab7daf8
--- /dev/null
+++ b/source/custom-resource/test/setJestEnvironmentVariables.ts
@@ -0,0 +1,6 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+process.env.SOLUTION_ID = 'solution-id';
+process.env.SOLUTION_VERSION = 'solution-version';
+process.env.AWS_REGION = 'mock-region-1';
diff --git a/source/custom-resource/tsconfig.json b/source/custom-resource/tsconfig.json
new file mode 100644
index 000000000..56ebe595b
--- /dev/null
+++ b/source/custom-resource/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es6",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "outDir": "dist",
+ "emitDecoratorMetadata": true,
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "sourceMap": true,
+ "types": ["node", "@types/jest"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["package", "dist", "**/*.map"]
+}
diff --git a/source/demo-ui/index.html b/source/demo-ui/index.html
index ef7fd4012..5691a472c 100644
--- a/source/demo-ui/index.html
+++ b/source/demo-ui/index.html
@@ -9,11 +9,11 @@
Serverless Image Handler
-
+
-
+
@@ -45,8 +45,8 @@ Image Source
Original Image
-
-
+
+
Having trouble?
@@ -93,7 +93,7 @@ Editor
-
+
Having trouble?
diff --git a/source/demo-ui/scripts.js b/source/demo-ui/scripts.js
index 00437a669..c7f51f1c4 100644
--- a/source/demo-ui/scripts.js
+++ b/source/demo-ui/scripts.js
@@ -55,7 +55,7 @@ function getPreviewImage() {
_edits.resize.fit = _resize;
}
if (_fillColor !== "") { _edits.resize.background = hexToRgbA(_fillColor, 1) }
- if (_backgroundColor !== "") { _edits.flatten = { background: hexToRgbA(_backgroundColor, undefined) }}
+ if (_backgroundColor !== "") { _edits.flatten = { background: hexToRgbA(_backgroundColor, undefined) } }
if (_grayscale) { _edits.grayscale = _grayscale }
if (_flip) { _edits.flip = _flip }
if (_flop) { _edits.flop = _flop }
@@ -98,19 +98,19 @@ function getPreviewImage() {
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]];
+ 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)};
+ 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);
+ document.getElementById('editor-resize-mode').selectedIndex = 0;
+ $(".form-check-input").prop('checked', false);
}
\ No newline at end of file
diff --git a/source/demo-ui/style.css b/source/demo-ui/style.css
index 6b993ace1..252b7f466 100644
--- a/source/demo-ui/style.css
+++ b/source/demo-ui/style.css
@@ -21,9 +21,6 @@
max-height: 100% !important;
max-width: 100% !important;
}
-
-
-
.gallery-item {
width: 30%;
margin: 1%;
diff --git a/source/image-handler/image-handler.js b/source/image-handler/image-handler.js
deleted file mode 100644
index 62e1f6b17..000000000
--- a/source/image-handler/image-handler.js
+++ /dev/null
@@ -1,346 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const sharp = require('sharp');
-
-class ImageHandler {
- constructor(s3, rekognition) {
- this.s3 = s3;
- this.rekognition = rekognition;
- }
-
- /**
- * Main method for processing image requests and outputting modified images.
- * @param {ImageRequest} request - An ImageRequest object.
- */
- async process(request) {
- let returnImage = '';
- const originalImage = request.originalImage;
- const edits = request.edits;
-
- if (edits !== undefined && Object.keys(edits).length > 0) {
- let image = null;
- const keys = Object.keys(edits);
-
- if (keys.includes('rotate') && edits.rotate === null) {
- image = sharp(originalImage, { failOnError: false });
- } else {
- const metadata = await sharp(originalImage, { failOnError: false }).metadata();
- if (metadata.orientation) {
- image = sharp(originalImage, { failOnError: false }).withMetadata({ orientation: metadata.orientation });
- } else {
- image = sharp(originalImage, { failOnError: false }).withMetadata();
- }
- }
-
- const modifiedImage = await this.applyEdits(image, edits);
- if (request.outputFormat !== undefined) {
- modifiedImage.toFormat(request.outputFormat);
- }
- const bufferImage = await modifiedImage.toBuffer();
- returnImage = bufferImage.toString('base64');
- } else {
- returnImage = originalImage.toString('base64');
- }
-
- // If the converted image is larger than Lambda's payload hard limit, throw an error.
- const lambdaPayloadLimit = 6 * 1024 * 1024;
- if (returnImage.length > lambdaPayloadLimit) {
- throw {
- status: '413',
- code: 'TooLargeImageException',
- message: 'The converted image is too large to return.'
- };
- }
-
- return returnImage;
- }
-
- /**
- * Applies image modifications to the original image based on edits
- * specified in the ImageRequest.
- * @param {Sharp} image - The original sharp image.
- * @param {object} edits - The edits to be made to the original image.
- */
- async applyEdits(image, edits) {
- if (edits.resize === undefined) {
- edits.resize = {};
- edits.resize.fit = 'inside';
- } else {
- if (edits.resize.width) edits.resize.width = Math.round(Number(edits.resize.width));
- if (edits.resize.height) edits.resize.height = Math.round(Number(edits.resize.height));
- }
-
- // Apply the image edits
- for (const editKey in edits) {
- const value = edits[editKey];
- if (editKey === 'overlayWith') {
- const metadata = await image.metadata();
- let imageMetadata = metadata;
- if (edits.resize) {
- let imageBuffer = await image.toBuffer();
- imageMetadata = await sharp(imageBuffer).resize({ edits: { resize: edits.resize }}).metadata();
- }
-
- const { bucket, key, wRatio, hRatio, alpha } = value;
- const overlay = await this.getOverlayImage(bucket, key, wRatio, hRatio, alpha, imageMetadata);
- const overlayMetadata = await sharp(overlay).metadata();
-
- let { options } = value;
- if (options) {
- if (options.left !== undefined) {
- let left = options.left;
- if (isNaN(left) && left.endsWith('p')) {
- left = parseInt(left.replace('p', ''));
- if (left < 0) {
- left = imageMetadata.width + (imageMetadata.width * left / 100) - overlayMetadata.width;
- } else {
- left = imageMetadata.width * left / 100;
- }
- } else {
- left = parseInt(left);
- if (left < 0) {
- left = imageMetadata.width + left - overlayMetadata.width;
- }
- }
- isNaN(left) ? delete options.left : options.left = left;
- }
- if (options.top !== undefined) {
- let top = options.top;
- if (isNaN(top) && top.endsWith('p')) {
- top = parseInt(top.replace('p', ''));
- if (top < 0) {
- top = imageMetadata.height + (imageMetadata.height * top / 100) - overlayMetadata.height;
- } else {
- top = imageMetadata.height * top / 100;
- }
- } else {
- top = parseInt(top);
- if (top < 0) {
- top = imageMetadata.height + top - overlayMetadata.height;
- }
- }
- isNaN(top) ? delete options.top : options.top = top;
- }
- }
-
- const params = [{ ...options, input: overlay }];
- image.composite(params);
- } else if (editKey === 'smartCrop') {
- const options = value;
- const imageBuffer = await image.toBuffer({resolveWithObject: true});
- const boundingBox = await this.getBoundingBox(imageBuffer.data, options.faceIndex);
- const cropArea = this.getCropArea(boundingBox, options, imageBuffer.info);
- 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 if (editKey === 'roundCrop') {
- const options = value;
- const imageBuffer = await image.toBuffer({resolveWithObject: true});
- let width = imageBuffer.info.width;
- let height = imageBuffer.info.height;
-
- //check for parameters, if not provided, set to defaults
- const radiusX = options.rx && options.rx >= 0? options.rx : Math.min(width, height) / 2;
- const radiusY = options.ry && options.ry >= 0? options.ry : Math.min(width, height) / 2;
- const topOffset = options.top && options.top >= 0 ? options.top : height / 2;
- const leftOffset = options.left && options.left >= 0 ? options.left : width / 2;
-
- if(options)
- {
- const ellipse = Buffer.from(``);
- const params = [{ input: ellipse, blend: 'dest-in' }];
- let data = await image.composite(params).toBuffer();
- image = sharp(data).withMetadata().trim();
- }
-
- } else if (editKey === 'contentModeration') {
- const options = value;
- const imageBuffer = await image.toBuffer({resolveWithObject: true});
- const inappropriateContent = await this.detectInappropriateContent(imageBuffer.data, options);
- const blur = options.hasOwnProperty('blur') ? Math.ceil(Number(options.blur)) : 50;
-
- if(options && (blur >= 0.3 && blur <= 1000)) {
- if(options.moderationLabels){
- for(let item of inappropriateContent.ModerationLabels) {
- if (options.moderationLabels.includes(item.Name)){
- image.blur(blur);
- break;
- }
- }
- } else if(inappropriateContent.ModerationLabels.length) {
- image.blur(blur);
- }
- }
-
- } else {
- image[editKey](value);
- }
- }
- // Return the modified image
- return image;
- }
-
- /**
- * Gets an image to be used as an overlay to the primary image from an
- * Amazon S3 bucket.
- * @param {string} bucket - The name of the bucket containing the overlay.
- * @param {string} key - The object keyname corresponding to the overlay.
- * @param {number} wRatio - The width rate of the overlay image.
- * @param {number} hRatio - The height rate of the overlay image.
- * @param {number} alpha - The transparency alpha to the overlay.
- * @param {object} sourceImageMetadata - The metadata of the source image.
- */
- async getOverlayImage(bucket, key, wRatio, hRatio, alpha, sourceImageMetadata) {
- const params = { Bucket: bucket, Key: key };
- try {
- const { width, height } = sourceImageMetadata;
- const overlayImage = await this.s3.getObject(params).promise();
- let resize = {
- fit: 'inside'
- }
-
- // Set width and height of the watermark image based on the ratio
- const zeroToHundred = /^(100|[1-9]?[0-9])$/;
- if (zeroToHundred.test(wRatio)) {
- resize['width'] = parseInt(width * wRatio / 100);
- }
- if (zeroToHundred.test(hRatio)) {
- resize['height'] = parseInt(height * hRatio / 100);
- }
-
- // If alpha is not within 0-100, the default alpha is 0 (fully opaque).
- if (zeroToHundred.test(alpha)) {
- alpha = parseInt(alpha);
- } else {
- alpha = 0;
- }
-
- const convertedImage = await sharp(overlayImage.Body)
- .resize(resize)
- .composite([{
- input: Buffer.from([255, 255, 255, 255 * (1 - alpha / 100)]),
- raw: {
- width: 1,
- height: 1,
- channels: 4
- },
- tile: true,
- blend: 'dest-in'
- }]).toBuffer();
- return convertedImage;
- } catch (err) {
- throw {
- status: err.statusCode ? err.statusCode : 500,
- code: err.code,
- message: err.message
- };
- }
- }
-
- /**
- * Calculates the crop area for a smart-cropped image based on the bounding
- * box data returned by Amazon Rekognition, as well as padding options and
- * the image metadata.
- * @param {Object} boundingBox - The boudning box of the detected face.
- * @param {Object} options - Set of options for smart cropping.
- * @param {Object} metadata - Sharp image metadata.
- */
- getCropArea(boundingBox, options, metadata) {
- const padding = (options.padding !== undefined) ? parseFloat(options.padding) : 0;
- // Calculate the smart crop area
- const cropArea = {
- left : parseInt((boundingBox.Left * metadata.width) - padding),
- top : parseInt((boundingBox.Top * metadata.height) - padding),
- width : parseInt((boundingBox.Width * metadata.width) + (padding * 2)),
- height : parseInt((boundingBox.Height * metadata.height) + (padding * 2)),
- }
- // Return the crop area
- return cropArea;
- }
-
- /**
- * Gets the bounding box of the specified face index within an image, if specified.
- * @param {Sharp} imageBuffer - The original image.
- * @param {Integer} faceIndex - The zero-based face index value, moving from 0 and up as
- * confidence decreases for detected faces within the image.
- */
- async getBoundingBox(imageBuffer, faceIndex) {
- const params = { Image: { Bytes: imageBuffer }};
- const faceIdx = (faceIndex !== undefined) ? faceIndex : 0;
- try {
- const response = await this.rekognition.detectFaces(params).promise();
- if(response.FaceDetails.length <= 0) {
- return {Height: 1, Left: 0, Top: 0, Width: 1};
- }
- let boundingBox = {};
-
- //handle bounds > 1 and < 0
- for (let bound in response.FaceDetails[faceIdx].BoundingBox)
- {
- if (response.FaceDetails[faceIdx].BoundingBox[bound] < 0 ) boundingBox[bound] = 0;
- else if (response.FaceDetails[faceIdx].BoundingBox[bound] > 1) boundingBox[bound] = 1;
- else boundingBox[bound] = response.FaceDetails[faceIdx].BoundingBox[bound];
- }
-
- //handle bounds greater than the size of the image
- if (boundingBox.Left + boundingBox.Width > 1) {
- boundingBox.Width = 1 - boundingBox.Left;
- }
- if (boundingBox.Top + boundingBox.Height > 1) {
- boundingBox.Height = 1 - boundingBox.Top;
- }
-
- return boundingBox;
- } catch (err) {
- console.error(err);
- if (err.message === "Cannot read property 'BoundingBox' of undefined") {
- throw {
- status: 400,
- code: 'SmartCrop::FaceIndexOutOfRange',
- message: 'You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range.'
- };
- } else {
- throw {
- status: err.statusCode ? err.statusCode : 500,
- code: err.code,
- message: err.message
- };
- }
- }
- }
-
- /**
- * Detects inappropriate content in an image.
- * @param {Sharp} imageBuffer - The original image.
- * @param {Object} options - The options to pass to the dectectModerationLables Rekognition function
- */
- async detectInappropriateContent(imageBuffer, options) {
-
- const params = {
- Image: {Bytes: imageBuffer},
- MinConfidence: options.minConfidence ? parseFloat(options.minConfidence) : 75
- }
-
- try {
- const response = await this.rekognition.detectModerationLabels(params).promise();
- return response;
- } catch(err) {
- console.error(err)
- throw {
- status: err.statusCode ? err.statusCode : 500,
- code: err.code,
- message: err.message
- }
- }
- }
-}
-
-// Exports
-module.exports = ImageHandler;
diff --git a/source/image-handler/image-handler.ts b/source/image-handler/image-handler.ts
new file mode 100644
index 000000000..a2e6bc6f7
--- /dev/null
+++ b/source/image-handler/image-handler.ts
@@ -0,0 +1,443 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import S3 from 'aws-sdk/clients/s3';
+import Rekognition from 'aws-sdk/clients/rekognition';
+import sharp, { FormatEnum, OverlayOptions, ResizeOptions } from 'sharp';
+
+import { BoundingBox, BoxSize, ImageEdits, ImageFitTypes, ImageFormatTypes, ImageHandlerError, ImageRequestInfo, RekognitionCompatibleImage, StatusCodes } from './lib';
+
+export class ImageHandler {
+ private readonly LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024;
+
+ constructor(private readonly s3Client: S3, private readonly rekognitionClient: Rekognition) {}
+
+ /**
+ * Main method for processing image requests and outputting modified images.
+ * @param imageRequestInfo An image request.
+ * @returns Processed and modified image encoded as base64 string.
+ */
+ async process(imageRequestInfo: ImageRequestInfo): Promise {
+ const { originalImage, edits } = imageRequestInfo;
+
+ let base64EncodedImage = '';
+
+ if (edits && Object.keys(edits).length) {
+ let image: sharp.Sharp = null;
+
+ if (edits.rotate !== undefined && edits.rotate === null) {
+ image = sharp(originalImage, { failOnError: false });
+ } else {
+ const metadata = await sharp(originalImage, { failOnError: false }).metadata();
+ image = metadata.orientation
+ ? sharp(originalImage, { failOnError: false }).withMetadata({ orientation: metadata.orientation })
+ : sharp(originalImage, { failOnError: false }).withMetadata();
+ }
+
+ const modifiedImage = await this.applyEdits(image, edits);
+ if (imageRequestInfo.outputFormat !== undefined) {
+ if (imageRequestInfo.outputFormat === ImageFormatTypes.WEBP && typeof imageRequestInfo.reductionEffort !== 'undefined') {
+ modifiedImage.webp({ reductionEffort: imageRequestInfo.reductionEffort });
+ } else {
+ modifiedImage.toFormat(ImageHandler.convertImageFormatType(imageRequestInfo.outputFormat));
+ }
+ }
+
+ const imageBuffer = await modifiedImage.toBuffer();
+ base64EncodedImage = imageBuffer.toString('base64');
+ } else {
+ // change output format if specified
+ if (imageRequestInfo.outputFormat !== undefined) {
+ const modifiedImage = sharp(originalImage, { failOnError: false });
+ modifiedImage.toFormat(ImageHandler.convertImageFormatType(imageRequestInfo.outputFormat));
+
+ const imageBuffer = await modifiedImage.toBuffer();
+ base64EncodedImage = imageBuffer.toString('base64');
+ } else {
+ base64EncodedImage = originalImage.toString('base64');
+ }
+ }
+
+ // binary data need to be base64 encoded to pass to the API Gateway proxy https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html.
+ // checks whether base64 encoded image fits in 6M limit, see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html.
+ if (base64EncodedImage.length > this.LAMBDA_PAYLOAD_LIMIT) {
+ throw new ImageHandlerError(StatusCodes.REQUEST_TOO_LONG, 'TooLargeImageException', 'The converted image is too large to return.');
+ }
+
+ return base64EncodedImage;
+ }
+
+ /**
+ * Applies image modifications to the original image based on edits.
+ * @param originalImage The original sharp image.
+ * @param edits The edits to be made to the original image.
+ * @returns A modifications to the original image.
+ */
+ public async applyEdits(originalImage: sharp.Sharp, edits: ImageEdits): Promise {
+ if (edits.resize === undefined) {
+ edits.resize = {};
+ edits.resize.fit = ImageFitTypes.INSIDE;
+ } else {
+ if (edits.resize.width) edits.resize.width = Math.round(Number(edits.resize.width));
+ if (edits.resize.height) edits.resize.height = Math.round(Number(edits.resize.height));
+ }
+
+ // Apply the image edits
+ for (const edit in edits) {
+ switch (edit) {
+ case 'overlayWith': {
+ let imageMetadata: sharp.Metadata = await originalImage.metadata();
+
+ if (edits.resize) {
+ const imageBuffer = await originalImage.toBuffer();
+ const resizeOptions: ResizeOptions = edits.resize;
+
+ imageMetadata = await sharp(imageBuffer).resize(resizeOptions).metadata();
+ }
+
+ const { bucket, key, wRatio, hRatio, alpha, options } = edits.overlayWith;
+ const overlay = await this.getOverlayImage(bucket, key, wRatio, hRatio, alpha, imageMetadata);
+ const overlayMetadata = await sharp(overlay).metadata();
+ const overlayOption: OverlayOptions = { ...options, input: overlay };
+
+ if (options) {
+ const { left: leftOption, top: topOption } = options;
+ const getSize = (editSize: string | undefined, imageSize: number, overlaySize: number): number => {
+ let resultSize = NaN;
+
+ if (editSize !== undefined) {
+ if (editSize.endsWith('p')) {
+ resultSize = parseInt(editSize.replace('p', ''));
+ resultSize = Math.floor(resultSize < 0 ? imageSize + (imageSize * resultSize) / 100 - overlaySize : (imageSize * resultSize) / 100);
+ } else {
+ resultSize = parseInt(editSize);
+
+ if (resultSize < 0) {
+ resultSize = imageSize + resultSize - overlaySize;
+ }
+ }
+ }
+
+ return resultSize;
+ };
+
+ const left = getSize(leftOption, imageMetadata.width, overlayMetadata.width);
+ if (!isNaN(left)) overlayOption.left = left;
+
+ const top = getSize(topOption, imageMetadata.height, overlayMetadata.height);
+ if (!isNaN(top)) overlayOption.top = top;
+ }
+
+ originalImage.composite([overlayOption]);
+ break;
+ }
+ case 'smartCrop': {
+ // smart crop can be boolean or object
+ if (edits.smartCrop === true || typeof edits.smartCrop === 'object') {
+ const { faceIndex, padding } =
+ typeof edits.smartCrop === 'object'
+ ? edits.smartCrop
+ : {
+ faceIndex: undefined,
+ padding: undefined
+ };
+ const { imageBuffer, format } = await this.getRekognitionCompatibleImage(originalImage);
+ const boundingBox = await this.getBoundingBox(imageBuffer.data, faceIndex ?? 0);
+ const cropArea = this.getCropArea(boundingBox, padding ?? 0, imageBuffer.info);
+ try {
+ originalImage.extract(cropArea);
+ // convert image back to previous format
+ if (format !== imageBuffer.info.format) {
+ originalImage.toFormat(format);
+ }
+ } catch (error) {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'SmartCrop::PaddingOutOfBounds',
+ '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.'
+ );
+ }
+ }
+ break;
+ }
+ case 'roundCrop': {
+ // round crop can be boolean or object
+ if (edits.roundCrop === true || typeof edits.roundCrop === 'object') {
+ const { top, left, rx, ry } =
+ typeof edits.roundCrop === 'object'
+ ? edits.roundCrop
+ : {
+ top: undefined,
+ left: undefined,
+ rx: undefined,
+ ry: undefined
+ };
+ const imageBuffer = await originalImage.toBuffer({ resolveWithObject: true });
+ const width = imageBuffer.info.width;
+ const height = imageBuffer.info.height;
+
+ // check for parameters, if not provided, set to defaults
+ const radiusX = rx && rx >= 0 ? rx : Math.min(width, height) / 2;
+ const radiusY = ry && ry >= 0 ? ry : Math.min(width, height) / 2;
+ const topOffset = top && top >= 0 ? top : height / 2;
+ const leftOffset = left && left >= 0 ? left : width / 2;
+
+ const ellipse = Buffer.from(``);
+ const overlayOptions: OverlayOptions[] = [{ input: ellipse, blend: 'dest-in' }];
+
+ const data = await originalImage.composite(overlayOptions).toBuffer();
+ originalImage = sharp(data).withMetadata().trim();
+ }
+ break;
+ }
+ case 'contentModeration': {
+ // content moderation can be boolean or object
+ if (edits.contentModeration === true || typeof edits.contentModeration === 'object') {
+ const { minConfidence, blur, moderationLabels } =
+ typeof edits.contentModeration === 'object'
+ ? edits.contentModeration
+ : {
+ minConfidence: undefined,
+ blur: undefined,
+ moderationLabels: undefined
+ };
+ const { imageBuffer, format } = await this.getRekognitionCompatibleImage(originalImage);
+ const inappropriateContent = await this.detectInappropriateContent(imageBuffer.data, minConfidence);
+ const blurValue = blur !== undefined ? Math.ceil(blur) : 50;
+
+ if (blurValue >= 0.3 && blurValue <= 1000) {
+ if (moderationLabels) {
+ for (const moderationLabel of inappropriateContent.ModerationLabels) {
+ if (moderationLabels.includes(moderationLabel.Name)) {
+ originalImage.blur(blur);
+ break;
+ }
+ }
+ } else if (inappropriateContent.ModerationLabels.length) {
+ originalImage.blur(blur);
+ }
+ }
+ // convert image back to previous format
+ if (format !== imageBuffer.info.format) {
+ originalImage.toFormat(format);
+ }
+ }
+ break;
+ }
+ case 'crop': {
+ try {
+ originalImage.extract(edits.crop);
+ } catch (error) {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'Crop::AreaOutOfBounds',
+ 'The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.'
+ );
+ }
+ break;
+ }
+ default: {
+ if (edit in originalImage) {
+ originalImage[edit](edits[edit]);
+ }
+ }
+ }
+ }
+ // Return the modified image
+ return originalImage;
+ }
+
+ /**
+ * Gets an image to be used as an overlay to the primary image from an Amazon S3 bucket.
+ * @param bucket The name of the bucket containing the overlay.
+ * @param key The object keyname corresponding to the overlay.
+ * @param wRatio The width rate of the overlay image.
+ * @param hRatio The height rate of the overlay image.
+ * @param alpha The transparency alpha to the overlay.
+ * @param sourceImageMetadata The metadata of the source image.
+ * @returns An image to bo ber used as an overlay.
+ */
+ public async getOverlayImage(bucket: string, key: string, wRatio: string, hRatio: string, alpha: string, sourceImageMetadata: sharp.Metadata): Promise {
+ const params = { Bucket: bucket, Key: key };
+ try {
+ const { width, height } = sourceImageMetadata;
+ const overlayImage: S3.GetObjectOutput = await this.s3Client.getObject(params).promise();
+ const resizeOptions: ResizeOptions = {
+ fit: ImageFitTypes.INSIDE
+ };
+
+ // Set width and height of the watermark image based on the ratio
+ const zeroToHundred = /^(100|[1-9]?[0-9])$/;
+ if (zeroToHundred.test(wRatio)) {
+ resizeOptions.width = Math.floor((width * parseInt(wRatio)) / 100);
+ }
+ if (zeroToHundred.test(hRatio)) {
+ resizeOptions.height = Math.floor((height * parseInt(hRatio)) / 100);
+ }
+
+ // If alpha is not within 0-100, the default alpha is 0 (fully opaque).
+ const alphaValue = zeroToHundred.test(alpha) ? parseInt(alpha) : 0;
+ const imageBuffer = Buffer.isBuffer(overlayImage.Body) ? overlayImage.Body : Buffer.from(overlayImage.Body as Uint8Array);
+ return await sharp(imageBuffer)
+ .resize(resizeOptions)
+ .composite([
+ {
+ input: Buffer.from([255, 255, 255, 255 * (1 - alphaValue / 100)]),
+ raw: {
+ width: 1,
+ height: 1,
+ channels: 4
+ },
+ tile: true,
+ blend: 'dest-in'
+ }
+ ])
+ .toBuffer();
+ } catch (error) {
+ throw new ImageHandlerError(error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, error.code, error.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 boundingBox The bounding box of the detected face.
+ * @param padding Set of options for smart cropping.
+ * @param boxSize Sharp image metadata.
+ * @returns Calculated crop area for a smart-cropped image.
+ */
+ public getCropArea(boundingBox: BoundingBox, padding: number, boxSize: BoxSize): BoundingBox {
+ // calculate needed options dimensions
+ let left = Math.floor(boundingBox.left * boxSize.width - padding);
+ let top = Math.floor(boundingBox.top * boxSize.height - padding);
+ let extractWidth = Math.floor(boundingBox.width * boxSize.width + padding * 2);
+ let extractHeight = Math.floor(boundingBox.height * boxSize.height + padding * 2);
+
+ // check if dimensions fit within image dimensions and re-adjust if necessary
+ left = left < 0 ? 0 : left;
+ top = top < 0 ? 0 : top;
+ const maxWidth = boxSize.width - left;
+ const maxHeight = boxSize.height - top;
+ extractWidth = extractWidth > maxWidth ? maxWidth : extractWidth;
+ extractHeight = extractHeight > maxHeight ? maxHeight : extractHeight;
+
+ // Calculate the smart crop area
+ return {
+ left: left,
+ top: top,
+ width: extractWidth,
+ height: extractHeight
+ };
+ }
+
+ /**
+ * Gets the bounding box of the specified face index within an image, if specified.
+ * @param imageBuffer The original image.
+ * @param faceIndex The zero-based face index value, moving from 0 and up as confidence decreases for detected faces within the image.
+ * @returns The bounding box of the specified face index within an image.
+ */
+ public async getBoundingBox(imageBuffer: Buffer, faceIndex: number): Promise {
+ const params = { Image: { Bytes: imageBuffer } };
+
+ try {
+ const response = await this.rekognitionClient.detectFaces(params).promise();
+ if (response.FaceDetails.length <= 0) {
+ return { height: 1, left: 0, top: 0, width: 1 };
+ }
+
+ const boundingBox: { Height?: number; Left?: number; Top?: number; Width?: number } = {};
+ // handle bounds > 1 and < 0
+ for (const bound in response.FaceDetails[faceIndex].BoundingBox) {
+ if (response.FaceDetails[faceIndex].BoundingBox[bound] < 0) boundingBox[bound] = 0;
+ else if (response.FaceDetails[faceIndex].BoundingBox[bound] > 1) boundingBox[bound] = 1;
+ else boundingBox[bound] = response.FaceDetails[faceIndex].BoundingBox[bound];
+ }
+
+ // handle bounds greater than the size of the image
+ if (boundingBox.Left + boundingBox.Width > 1) {
+ boundingBox.Width = 1 - boundingBox.Left;
+ }
+ if (boundingBox.Top + boundingBox.Height > 1) {
+ boundingBox.Height = 1 - boundingBox.Top;
+ }
+
+ return { height: boundingBox.Height, left: boundingBox.Left, top: boundingBox.Top, width: boundingBox.Width };
+ } catch (error) {
+ console.error(error);
+
+ if (error.message === "Cannot read property 'BoundingBox' of undefined" || error.message === "Cannot read properties of undefined (reading 'BoundingBox')") {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'SmartCrop::FaceIndexOutOfRange',
+ 'You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range.'
+ );
+ } else {
+ throw new ImageHandlerError(error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, error.code, error.message);
+ }
+ }
+ }
+
+ /**
+ * Detects inappropriate content in an image.
+ * @param imageBuffer The original image.
+ * @param minConfidence The options to pass to the detectModerationLabels Rekognition function.
+ * @returns Detected inappropriate content in an image.
+ */
+ private async detectInappropriateContent(imageBuffer: Buffer, minConfidence: number | undefined): Promise {
+ try {
+ const params = {
+ Image: { Bytes: imageBuffer },
+ MinConfidence: minConfidence ?? 75
+ };
+ return await this.rekognitionClient.detectModerationLabels(params).promise();
+ } catch (error) {
+ console.error(error);
+ throw new ImageHandlerError(error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, error.code, error.message);
+ }
+ }
+
+ /**
+ * Converts serverless image handler image format type to 'sharp' format.
+ * @param imageFormatType Result output file type.
+ * @returns Converted 'sharp' format.
+ */
+ private static convertImageFormatType(imageFormatType: ImageFormatTypes): keyof FormatEnum {
+ switch (imageFormatType) {
+ case ImageFormatTypes.JPG:
+ return 'jpg';
+ case ImageFormatTypes.JPEG:
+ return 'jpeg';
+ case ImageFormatTypes.PNG:
+ return 'png';
+ case ImageFormatTypes.WEBP:
+ return 'webp';
+ case ImageFormatTypes.TIFF:
+ return 'tiff';
+ case ImageFormatTypes.HEIF:
+ return 'heif';
+ case ImageFormatTypes.RAW:
+ return 'raw';
+ default:
+ throw new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'UnsupportedOutputImageFormatException', `Format to ${imageFormatType} not supported`);
+ }
+ }
+
+ /**
+ * Converts the image to a rekognition compatible format if current format is not compatible.
+ * @param image the image to be modified by rekognition.
+ * @returns object containing image buffer data and original image format.
+ */
+ private async getRekognitionCompatibleImage(image: sharp.Sharp): Promise {
+ const metadata = await image.metadata();
+ const format = metadata.format;
+ let imageBuffer: { data: Buffer; info: sharp.OutputInfo };
+
+ // convert image to png if not jpeg or png
+ if (!['jpeg', 'png'].includes(format)) {
+ imageBuffer = await image.png().toBuffer({ resolveWithObject: true });
+ } else {
+ imageBuffer = await image.toBuffer({ resolveWithObject: true });
+ }
+
+ return { imageBuffer: imageBuffer, format: format };
+ }
+}
diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js
deleted file mode 100644
index 37b318c16..000000000
--- a/source/image-handler/image-request.js
+++ /dev/null
@@ -1,420 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const ThumborMapping = require('./thumbor-mapping');
-
-class ImageRequest {
- constructor(s3, secretsManager) {
- this.s3 = s3;
- this.secretsManager = secretsManager;
- }
-
- /**
- * 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 {
- // Checks signature enabled
- if (process.env.ENABLE_SIGNATURE === 'Yes') {
- const crypto = require('crypto');
- const { path, queryStringParameters } = event;
- if (!queryStringParameters || !queryStringParameters.signature) {
- throw {
- status: 400,
- message: 'Query-string requires the signature parameter.',
- code: 'AuthorizationQueryParametersError'
- };
- }
-
- const { signature } = queryStringParameters;
- try {
- const response = await this.secretsManager.getSecretValue({ SecretId: process.env.SECRETS_MANAGER }).promise();
- const secretString = JSON.parse(response.SecretString);
- const hash = crypto.createHmac('sha256', secretString[process.env.SECRET_KEY]).update(path).digest('hex');
-
- // Signature should be made with the full path.
- if (signature !== hash) {
- throw {
- status: 403,
- message: 'Signature does not match.',
- code: 'SignatureDoesNotMatch'
- };
- }
- } catch (error) {
- if (error.code === 'SignatureDoesNotMatch') {
- throw error;
- }
-
- console.error('Error occurred while checking signature.', error);
- throw {
- status: 500,
- message: 'Signature validation failed.',
- code: 'SignatureValidationFailure'
- };
- }
- }
-
- 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);
- this.headers = this.parseImageHeaders(event, this.requestType);
-
- if (!this.headers) {
- delete this.headers;
- }
-
- // If the original image is SVG file and it has any edits but no output format, change the format to WebP.
- if (this.ContentType === 'image/svg+xml'
- && this.edits && Object.keys(this.edits).length > 0
- && !this.edits.toFormat) {
- this.outputFormat = 'png'
- }
-
- /* Decide the output format of the image.
- * 1) If the format is provided, the output format is the provided format.
- * 2) If headers contain "Accept: image/webp", the output format is webp.
- * 3) Use the default image format for the rest of cases.
- */
- if (this.ContentType !== 'image/svg+xml' || this.edits.toFormat || this.outputFormat) {
- 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];
- }
- }
- }
-
- delete this.s3;
- delete this.secretsManager;
-
- return this;
- } catch (err) {
- console.error(err);
- throw err;
- }
- }
-
- /**
- * Gets the original image from an Amazon S3 bucket.
- * @param {string} bucket - The name of the bucket containing the image.
- * @param {string} key - The key name corresponding to the image.
- * @return {Promise} - The original image or an error.
- */
- async getOriginalImage(bucket, key) {
- const imageLocation = { Bucket: bucket, Key: key };
- try {
- const originalImage = await this.s3.getObject(imageLocation).promise();
-
- if (originalImage.ContentType) {
- //If using default s3 ContentType infer from hex headers
- if(originalImage.ContentType === 'binary/octet-stream') {
- const imageBuffer = Buffer.from(originalImage.Body);
- this.ContentType = this.inferImageType(imageBuffer);
- } else {
- this.ContentType = originalImage.ContentType;
- }
- } else {
- this.ContentType = "image";
- }
-
- if (originalImage.Expires) {
- this.Expires = new Date(originalImage.Expires).toUTCString();
- }
-
- if (originalImage.LastModified) {
- this.LastModified = new Date(originalImage.LastModified).toUTCString();
- }
-
- if (originalImage.CacheControl) {
- this.CacheControl = originalImage.CacheControl;
- } else {
- this.CacheControl = "max-age=31536000,public";
- }
- return originalImage.Body;
- } catch(err) {
- throw {
- status: ('NoSuchKey' === err.code) ? 404 : 500,
- code: err.code,
- message: err.message
- };
- }
- }
-
- /**
- * Parses the name of the appropriate Amazon S3 bucket to source the
- * original image from.
- * @param {string} event - Lambda request body.
- * @param {string} requestType - Image handler request type.
- */
- parseImageBucket(event, requestType) {
- if (requestType === "Default") {
- // Decode the image request
- const decoded = this.decodeRequest(event);
- if (decoded.bucket !== undefined) {
- // Check the provided bucket against the allowed list
- 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") {
- let { path } = event;
-
- if (requestType === "Custom") {
- const matchPattern = process.env.REWRITE_MATCH_PATTERN;
- const substitution = process.env.REWRITE_SUBSTITUTION;
-
- if (typeof(matchPattern) === 'string') {
- const patternStrings = matchPattern.split('/');
- const flags = patternStrings.pop();
- const parsedPatternString = matchPattern.slice(1, matchPattern.length - 1 - flags.length);
- const regExp = new RegExp(parsedPatternString, flags);
- path = path.replace(regExp, substitution);
- } else {
- path = path.replace(matchPattern, substitution);
- }
- }
- return decodeURIComponent(path.replace(/\/(\d+x\d+)\/|filters:[^\)]+|\/fit-in+|^\/+/g, '').replace(/\)/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$|\.svg$))/i);
- const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg|svg)/i);
- const definedEnvironmentVariables = (
- (process.env.REWRITE_MATCH_PATTERN !== "") &&
- (process.env.REWRITE_SUBSTITUTION !== "") &&
- (process.env.REWRITE_MATCH_PATTERN !== undefined) &&
- (process.env.REWRITE_SUBSTITUTION !== undefined)
- );
-
- //Check if path is base 64 encoded
- let isBase64Encoded = true;
- try {
- this.decodeRequest(event);
- } catch(error) {
- console.error(error);
- isBase64Encoded = false;
- }
-
- if (matchDefault.test(path) && isBase64Encoded) { // 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, svg) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.'
- };
- }
- }
-
- /**
- * Parses the headers to be sent with the response.
- * @param {object} event - Lambda request body.
- * @param {string} requestType - Image handler request type.
- * @return {object} Custom headers
- */
- parseImageHeaders(event, requestType) {
- if (requestType === 'Default') {
- const decoded = this.decodeRequest(event);
- if (decoded.headers) {
- return decoded.headers;
- }
- }
-
- return undefined;
- }
-
- /**
- * Decodes the base64-encoded image request path associated with default
- * image requests. Provides error handling for invalid or undefined path values.
- * @param {object} event - The proxied request object.
- */
- decodeRequest(event) {
- const path = event["path"];
- if (path !== undefined) {
- const encoded = path.charAt(0) === '/' ? path.slice(1) : path;
- const toBuffer = Buffer.from(encoded, 'base64');
- try {
- // To support European characters, 'ascii' was removed.
- return JSON.parse(toBuffer.toString());
- } catch (e) {
- throw ({
- status: 400,
- code: 'DecodeRequest::CannotDecodeRequest',
- message: 'The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance.'
- });
- }
- } else {
- throw ({
- status: 400,
- code: 'DecodeRequest::CannotReadPath',
- message: 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.'
- });
- }
- }
-
- /**
- * Returns a formatted image source bucket whitelist as specified in the
- * SOURCE_BUCKETS environment variable of the image handler Lambda
- * function. Provides error handling for missing/invalid values.
- */
- getAllowedSourceBuckets() {
- const sourceBuckets = process.env.SOURCE_BUCKETS;
- if (sourceBuckets === undefined) {
- throw ({
- status: 400,
- code: 'GetAllowedSourceBuckets::NoSourceBuckets',
- message: 'The SOURCE_BUCKETS variable could not be read. Please check that it is not empty and contains at least one source bucket, or multiple buckets separated by commas. Spaces can be provided between commas and bucket names, these will be automatically parsed out when decoding.'
- });
- } else {
- const formatted = sourceBuckets.replace(/\s+/g, '');
- const buckets = formatted.split(',');
- return buckets;
- }
- }
-
- /**
- * Return the output format depending on the accepts headers and request type
- * @param {Object} event - The request body.
- */
- getOutputFormat(event) {
- const autoWebP = process.env.AUTO_WEBP;
- if (autoWebP === 'Yes' && event.headers.Accept && event.headers.Accept.includes('image/webp')) {
- return 'webp';
- } else if (this.requestType === 'Default') {
- const decoded = this.decodeRequest(event);
- return decoded.outputFormat;
- }
-
- return null;
- }
-
- /**
- * Return the output format depending on first four hex values of an image file.
- * @param {Buffer} imageBuffer - Image buffer.
- */
- inferImageType(imageBuffer) {
- switch(imageBuffer.toString('hex').substring(0,8).toUpperCase()) {
- case '89504E47': return 'image/png';
- case 'FFD8FFDB': return 'image/jpeg';
- case 'FFD8FFE0': return 'image/jpeg';
- case 'FFD8FFEE': return 'image/jpeg';
- case 'FFD8FFE1': return 'image/jpeg';
- case '52494646': return 'image/webp';
- case '49492A00': return 'image/tiff';
- case '4D4D002A': return 'image/tiff';
- default: throw {
- status: 500,
- code: 'RequestTypeError',
- message: 'The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests.'
- };
- }
-}
-}
-
-// Exports
-module.exports = ImageRequest;
\ No newline at end of file
diff --git a/source/image-handler/image-request.ts b/source/image-handler/image-request.ts
new file mode 100644
index 000000000..54b6fd901
--- /dev/null
+++ b/source/image-handler/image-request.ts
@@ -0,0 +1,446 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import S3 from 'aws-sdk/clients/s3';
+import { createHmac } from 'crypto';
+
+import { DefaultImageRequest, ImageEdits, ImageFormatTypes, ImageHandlerError, ImageHandlerEvent, ImageRequestInfo, Headers, RequestTypes, StatusCodes } from './lib';
+import { SecretProvider } from './secret-provider';
+import { ThumborMapper } from './thumbor-mapper';
+
+type OriginalImageInfo = Partial<{
+ contentType: string;
+ expires: string;
+ lastModified: string;
+ cacheControl: string;
+ originalImage: Buffer;
+}>;
+
+export class ImageRequest {
+ private static readonly DEFAULT_REDUCTION_EFFORT = 4;
+
+ constructor(private readonly s3Client: S3, private readonly secretProvider: SecretProvider) {}
+
+ /**
+ * Initializer function for creating a new image request, used by the image handler to perform image modifications.
+ * @param event Lambda request body.
+ * @returns Initialized image request information.
+ */
+ public async setup(event: ImageHandlerEvent): Promise {
+ try {
+ await this.validateRequestSignature(event);
+
+ let imageRequestInfo: ImageRequestInfo = {};
+
+ imageRequestInfo.requestType = this.parseRequestType(event);
+ imageRequestInfo.bucket = this.parseImageBucket(event, imageRequestInfo.requestType);
+ imageRequestInfo.key = this.parseImageKey(event, imageRequestInfo.requestType);
+ imageRequestInfo.edits = this.parseImageEdits(event, imageRequestInfo.requestType);
+
+ const originalImage = await this.getOriginalImage(imageRequestInfo.bucket, imageRequestInfo.key);
+ imageRequestInfo = { ...imageRequestInfo, ...originalImage };
+
+ imageRequestInfo.headers = this.parseImageHeaders(event, imageRequestInfo.requestType);
+
+ // If the original image is SVG file and it has any edits but no output format, change the format to WebP.
+ if (imageRequestInfo.contentType === 'image/svg+xml' && imageRequestInfo.edits && Object.keys(imageRequestInfo.edits).length > 0 && !imageRequestInfo.edits.toFormat) {
+ imageRequestInfo.outputFormat = ImageFormatTypes.PNG;
+ }
+
+ /* Decide the output format of the image.
+ * 1) If the format is provided, the output format is the provided format.
+ * 2) If headers contain "Accept: image/webp", the output format is webp.
+ * 3) Use the default image format for the rest of cases.
+ */
+ if (imageRequestInfo.contentType !== 'image/svg+xml' || imageRequestInfo.edits.toFormat || imageRequestInfo.outputFormat) {
+ const outputFormat = this.getOutputFormat(event, imageRequestInfo.requestType);
+ // if webp check reduction effort, if invalid value, use 4 (default in sharp)
+ if (outputFormat === ImageFormatTypes.WEBP && imageRequestInfo.requestType === RequestTypes.DEFAULT) {
+ const decoded = this.decodeRequest(event);
+ if (typeof decoded.reductionEffort !== 'undefined') {
+ const reductionEffort = Math.trunc(decoded.reductionEffort);
+ const isValid = !isNaN(reductionEffort) && reductionEffort >= 0 && reductionEffort <= 6;
+ imageRequestInfo.reductionEffort = isValid ? reductionEffort : ImageRequest.DEFAULT_REDUCTION_EFFORT;
+ }
+ }
+ if (imageRequestInfo.edits && imageRequestInfo.edits.toFormat) {
+ imageRequestInfo.outputFormat = imageRequestInfo.edits.toFormat;
+ } else if (outputFormat) {
+ imageRequestInfo.outputFormat = outputFormat;
+ }
+ }
+
+ // Fix quality for Thumbor and Custom request type if outputFormat is different from quality type.
+ if (imageRequestInfo.outputFormat) {
+ const requestType = [RequestTypes.CUSTOM, RequestTypes.THUMBOR];
+ const acceptedValues = [ImageFormatTypes.JPEG, ImageFormatTypes.PNG, ImageFormatTypes.WEBP, ImageFormatTypes.TIFF, ImageFormatTypes.HEIF];
+
+ imageRequestInfo.contentType = `image/${imageRequestInfo.outputFormat}`;
+ if (requestType.includes(imageRequestInfo.requestType) && acceptedValues.includes(imageRequestInfo.outputFormat)) {
+ const qualityKey = Object.keys(imageRequestInfo.edits).filter(key => acceptedValues.includes(key as ImageFormatTypes))[0];
+
+ if (qualityKey && qualityKey !== imageRequestInfo.outputFormat) {
+ imageRequestInfo.edits[imageRequestInfo.outputFormat] = imageRequestInfo.edits[qualityKey];
+ delete imageRequestInfo.edits[qualityKey];
+ }
+ }
+ }
+
+ return imageRequestInfo;
+ } catch (error) {
+ console.error(error);
+
+ throw error;
+ }
+ }
+
+ /**
+ * Gets the original image from an Amazon S3 bucket.
+ * @param bucket The name of the bucket containing the image.
+ * @param key The key name corresponding to the image.
+ * @returns The original image or an error.
+ */
+ public async getOriginalImage(bucket: string, key: string): Promise {
+ try {
+ const result: OriginalImageInfo = {};
+
+ const imageLocation = { Bucket: bucket, Key: key };
+ const originalImage = await this.s3Client.getObject(imageLocation).promise();
+ const imageBuffer = Buffer.from(originalImage.Body as Uint8Array);
+
+ if (originalImage.ContentType) {
+ // If using default S3 ContentType infer from hex headers
+ if (['binary/octet-stream', 'application/octet-stream'].includes(originalImage.ContentType)) {
+ result.contentType = this.inferImageType(imageBuffer);
+ } else {
+ result.contentType = originalImage.ContentType;
+ }
+ } else {
+ result.contentType = 'image';
+ }
+
+ if (originalImage.Expires) {
+ result.expires = new Date(originalImage.Expires).toUTCString();
+ }
+
+ if (originalImage.LastModified) {
+ result.lastModified = new Date(originalImage.LastModified).toUTCString();
+ }
+
+ result.cacheControl = originalImage.CacheControl ?? 'max-age=31536000,public';
+ result.originalImage = imageBuffer;
+
+ return result;
+ } catch (error) {
+ let status = StatusCodes.INTERNAL_SERVER_ERROR;
+ let message = error.message;
+ if (error.code === 'NoSuchKey') {
+ status = StatusCodes.NOT_FOUND;
+ message = `The image ${key} does not exist or the request may not be base64 encoded properly.`;
+ }
+ throw new ImageHandlerError(status, error.code, message);
+ }
+ }
+
+ /**
+ * Parses the name of the appropriate Amazon S3 bucket to source the original image from.
+ * @param event Lambda request body.
+ * @param requestType Image handler request type.
+ * @returns The name of the appropriate Amazon S3 bucket.
+ */
+ public parseImageBucket(event: ImageHandlerEvent, requestType: RequestTypes): string {
+ if (requestType === RequestTypes.DEFAULT) {
+ // Decode the image request
+ const request = this.decodeRequest(event);
+
+ if (request.bucket !== undefined) {
+ // Check the provided bucket against the allowed list
+ const sourceBuckets = this.getAllowedSourceBuckets();
+
+ if (sourceBuckets.includes(request.bucket) || request.bucket.match(new RegExp('^' + sourceBuckets[0] + '$'))) {
+ return request.bucket;
+ } else {
+ throw new ImageHandlerError(
+ StatusCodes.FORBIDDEN,
+ 'ImageBucket::CannotAccessBucket',
+ 'The bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.'
+ );
+ }
+ } else {
+ // Try to use the default image source bucket env var
+ const sourceBuckets = this.getAllowedSourceBuckets();
+ return sourceBuckets[0];
+ }
+ } else if (requestType === RequestTypes.THUMBOR || requestType === RequestTypes.CUSTOM) {
+ // Use the default image source bucket env var
+ const sourceBuckets = this.getAllowedSourceBuckets();
+ return sourceBuckets[0];
+ } else {
+ throw new ImageHandlerError(
+ StatusCodes.NOT_FOUND,
+ 'ImageBucket::CannotFindBucket',
+ 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.'
+ );
+ }
+ }
+
+ /**
+ * Parses the edits to be made to the original image.
+ * @param event Lambda request body.
+ * @param requestType Image handler request type.
+ * @returns The edits to be made to the original image.
+ */
+ public parseImageEdits(event: ImageHandlerEvent, requestType: RequestTypes): ImageEdits {
+ if (requestType === RequestTypes.DEFAULT) {
+ const decoded = this.decodeRequest(event);
+ return decoded.edits;
+ } else if (requestType === RequestTypes.THUMBOR) {
+ const thumborMapping = new ThumborMapper();
+ return thumborMapping.mapPathToEdits(event.path);
+ } else if (requestType === RequestTypes.CUSTOM) {
+ const thumborMapping = new ThumborMapper();
+ const parsedPath = thumborMapping.parseCustomPath(event.path);
+ return thumborMapping.mapPathToEdits(parsedPath);
+ } else {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'ImageEdits::CannotParseEdits',
+ 'The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance.'
+ );
+ }
+ }
+
+ /**
+ * Parses the name of the appropriate Amazon S3 key corresponding to the original image.
+ * @param event Lambda request body.
+ * @param requestType Type of the request.
+ * @returns The name of the appropriate Amazon S3 key.
+ */
+ public parseImageKey(event: ImageHandlerEvent, requestType: RequestTypes): string {
+ if (requestType === RequestTypes.DEFAULT) {
+ // Decode the image request and return the image key
+ const { key } = this.decodeRequest(event);
+ return key;
+ }
+
+ if (requestType === RequestTypes.THUMBOR || requestType === RequestTypes.CUSTOM) {
+ let { path } = event;
+
+ if (requestType === RequestTypes.CUSTOM) {
+ const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env;
+
+ if (typeof REWRITE_MATCH_PATTERN === 'string') {
+ const patternStrings = REWRITE_MATCH_PATTERN.split('/');
+ const flags = patternStrings.pop();
+ const parsedPatternString = REWRITE_MATCH_PATTERN.slice(1, REWRITE_MATCH_PATTERN.length - 1 - flags.length);
+ const regExp = new RegExp(parsedPatternString, flags);
+
+ path = path.replace(regExp, REWRITE_SUBSTITUTION);
+ } else {
+ path = path.replace(REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION);
+ }
+ }
+
+ return decodeURIComponent(path.replace(/\/\d+x\d+:\d+x\d+\/|(?<=\/)\d+x\d+\/|filters:[^/]+|\/fit-in(?=\/)|^\/+/g, '').replace(/^\/+/, ''));
+ }
+
+ // Return an error for all other conditions
+ throw new ImageHandlerError(
+ StatusCodes.NOT_FOUND,
+ 'ImageEdits::CannotFindImage',
+ 'The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists.'
+ );
+ }
+
+ /**
+ * Determines how to handle the request being made based on the URL path prefix to the image request.
+ * Categorizes a request as either "image" (uses the Sharp library), "thumbor" (uses Thumbor mapping), or "custom" (uses the rewrite function).
+ * @param event Lambda request body.
+ * @returns The request type.
+ */
+ public parseRequestType(event: ImageHandlerEvent): RequestTypes {
+ const { path } = event;
+ const matchDefault = /^(\/?)([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
+ const matchThumbor = /^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)(((.(?!(\.[^.\\/]+$)))*$)|.*(\.jpg$|.\.png$|\.webp$|\.tiff$|\.jpeg$|\.svg$))/i;
+ const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env;
+ const definedEnvironmentVariables = REWRITE_MATCH_PATTERN !== '' && REWRITE_SUBSTITUTION !== '' && REWRITE_MATCH_PATTERN !== undefined && REWRITE_SUBSTITUTION !== undefined;
+
+ // Check if path is base 64 encoded
+ let isBase64Encoded = true;
+ try {
+ this.decodeRequest(event);
+ } catch (error) {
+ console.error(error);
+ isBase64Encoded = false;
+ }
+
+ if (matchDefault.test(path) && isBase64Encoded) {
+ // use sharp
+ return RequestTypes.DEFAULT;
+ } else if (definedEnvironmentVariables) {
+ // use rewrite function then thumbor mappings
+ return RequestTypes.CUSTOM;
+ } else if (matchThumbor.test(path)) {
+ // use thumbor mappings
+ return RequestTypes.THUMBOR;
+ } else {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'RequestTypeError',
+ 'The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.'
+ );
+ }
+ }
+
+ /**
+ * Parses the headers to be sent with the response.
+ * @param event Lambda request body.
+ * @param requestType Image handler request type.
+ * @returns The headers to be sent with the response.
+ */
+ public parseImageHeaders(event: ImageHandlerEvent, requestType: RequestTypes): Headers {
+ if (requestType === RequestTypes.DEFAULT) {
+ const { headers } = this.decodeRequest(event);
+ if (headers) {
+ return headers;
+ }
+ }
+ }
+
+ /**
+ * Decodes the base64-encoded image request path associated with default image requests.
+ * Provides error handling for invalid or undefined path values.
+ * @param event Lambda request body.
+ * @returns The decoded from base-64 image request.
+ */
+ public decodeRequest(event: ImageHandlerEvent): DefaultImageRequest {
+ const { path } = event;
+
+ if (path) {
+ const encoded = path.charAt(0) === '/' ? path.slice(1) : path;
+ const toBuffer = Buffer.from(encoded, 'base64');
+ try {
+ // To support European characters, 'ascii' was removed.
+ return JSON.parse(toBuffer.toString());
+ } catch (error) {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'DecodeRequest::CannotDecodeRequest',
+ 'The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance.'
+ );
+ }
+ } else {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'DecodeRequest::CannotReadPath',
+ 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.'
+ );
+ }
+ }
+
+ /**
+ * Returns a formatted image source bucket allowed list as specified in the SOURCE_BUCKETS environment variable of the image handler Lambda function.
+ * Provides error handling for missing/invalid values.
+ * @returns A formatted image source bucket.
+ */
+ public getAllowedSourceBuckets(): string[] {
+ const { SOURCE_BUCKETS } = process.env;
+
+ if (SOURCE_BUCKETS === undefined) {
+ throw new ImageHandlerError(
+ StatusCodes.BAD_REQUEST,
+ 'GetAllowedSourceBuckets::NoSourceBuckets',
+ 'The SOURCE_BUCKETS variable could not be read. Please check that it is not empty and contains at least one source bucket, or multiple buckets separated by commas. Spaces can be provided between commas and bucket names, these will be automatically parsed out when decoding.'
+ );
+ } else {
+ return SOURCE_BUCKETS.replace(/\s+/g, '').split(',');
+ }
+ }
+
+ /**
+ * Return the output format depending on the accepts headers and request type.
+ * @param event Lambda request body.
+ * @param requestType The request type.
+ * @returns The output format.
+ */
+ public getOutputFormat(event: ImageHandlerEvent, requestType: RequestTypes = undefined): ImageFormatTypes {
+ const { AUTO_WEBP } = process.env;
+
+ if (AUTO_WEBP === 'Yes' && event.headers.Accept && event.headers.Accept.includes('image/webp')) {
+ return ImageFormatTypes.WEBP;
+ } else if (requestType === RequestTypes.DEFAULT) {
+ const decoded = this.decodeRequest(event);
+ return decoded.outputFormat;
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the output format depending on first four hex values of an image file.
+ * @param imageBuffer Image buffer.
+ * @returns The output format.
+ */
+ public inferImageType(imageBuffer: Buffer): string {
+ const imageSignature = imageBuffer.slice(0, 4).toString('hex').toUpperCase();
+ switch (imageSignature) {
+ case '89504E47':
+ return 'image/png';
+ case 'FFD8FFDB':
+ case 'FFD8FFE0':
+ case 'FFD8FFEE':
+ case 'FFD8FFE1':
+ return 'image/jpeg';
+ case '52494646':
+ return 'image/webp';
+ case '49492A00':
+ return 'image/tiff';
+ case '4D4D002A':
+ return 'image/tiff';
+ default:
+ throw new ImageHandlerError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ 'RequestTypeError',
+ 'The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests.'
+ );
+ }
+ }
+
+ /**
+ * Validates the request's signature.
+ * @param event Lambda request body.
+ * @returns A promise.
+ * @throws Throws the error if validation is enabled and the provided signature is invalid.
+ */
+ private async validateRequestSignature(event: ImageHandlerEvent): Promise {
+ const { ENABLE_SIGNATURE, SECRETS_MANAGER, SECRET_KEY } = process.env;
+
+ // Checks signature enabled
+ if (ENABLE_SIGNATURE === 'Yes') {
+ const { path, queryStringParameters } = event;
+
+ if (!queryStringParameters?.signature) {
+ throw new ImageHandlerError(StatusCodes.BAD_REQUEST, 'AuthorizationQueryParametersError', 'Query-string requires the signature parameter.');
+ }
+
+ try {
+ const { signature } = queryStringParameters;
+ const secret = JSON.parse(await this.secretProvider.getSecret(SECRETS_MANAGER));
+ const key = secret[SECRET_KEY];
+ const hash = createHmac('sha256', key).update(path).digest('hex');
+
+ // Signature should be made with the full path.
+ if (signature !== hash) {
+ throw new ImageHandlerError(StatusCodes.FORBIDDEN, 'SignatureDoesNotMatch', 'Signature does not match.');
+ }
+ } catch (error) {
+ if (error.code === 'SignatureDoesNotMatch') {
+ throw error;
+ }
+
+ console.error('Error occurred while checking signature.', error);
+ throw new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'SignatureValidationFailure', 'Signature validation failed.');
+ }
+ }
+ }
+}
diff --git a/source/image-handler/index.js b/source/image-handler/index.js
deleted file mode 100755
index f17b87809..000000000
--- a/source/image-handler/index.js
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const AWS = require('aws-sdk');
-const s3 = new AWS.S3();
-const rekognition = new AWS.Rekognition();
-const secretsManager = new AWS.SecretsManager();
-
-const ImageRequest = require('./image-request.js');
-const ImageHandler = require('./image-handler.js');
-
-exports.handler = async (event) => {
- console.log(event);
- const imageRequest = new ImageRequest(s3, secretsManager);
- const imageHandler = new ImageHandler(s3, rekognition);
- const isAlb = event.requestContext && event.requestContext.hasOwnProperty('elb');
-
- try {
- const request = await imageRequest.setup(event);
- console.log(request);
-
- const processedRequest = await imageHandler.process(request);
- const headers = getResponseHeaders(false, isAlb);
- headers["Content-Type"] = request.ContentType;
- headers["Expires"] = request.Expires;
- headers["Last-Modified"] = request.LastModified;
- headers["Cache-Control"] = request.CacheControl;
-
- if (request.headers) {
- // Apply the custom headers overwritting any that may need overwriting
- for (let key in request.headers) {
- headers[key] = request.headers[key];
- }
- }
-
- return {
- statusCode: 200,
- isBase64Encoded: true,
- headers : headers,
- body: processedRequest
- };
- } catch (err) {
- console.error(err);
-
- // Default fallback image
- if (process.env.ENABLE_DEFAULT_FALLBACK_IMAGE === 'Yes'
- && process.env.DEFAULT_FALLBACK_IMAGE_BUCKET
- && process.env.DEFAULT_FALLBACK_IMAGE_BUCKET.replace(/\s/, '') !== ''
- && process.env.DEFAULT_FALLBACK_IMAGE_KEY
- && process.env.DEFAULT_FALLBACK_IMAGE_KEY.replace(/\s/, '') !== '') {
- try {
- const bucket = process.env.DEFAULT_FALLBACK_IMAGE_BUCKET;
- const objectKey = process.env.DEFAULT_FALLBACK_IMAGE_KEY;
- const defaultFallbackImage = await s3.getObject({ Bucket: bucket, Key: objectKey }).promise();
- const headers = getResponseHeaders(false, isAlb);
- headers['Content-Type'] = defaultFallbackImage.ContentType;
- headers['Last-Modified'] = defaultFallbackImage.LastModified;
- headers['Cache-Control'] = 'max-age=31536000,public';
-
- return {
- statusCode: err.status ? err.status : 500,
- isBase64Encoded: true,
- headers: headers,
- body: defaultFallbackImage.Body.toString('base64')
- };
- } catch (error) {
- console.error('Error occurred while getting the default fallback image.', error);
- }
- }
-
- if (err.status) {
- return {
- statusCode: err.status,
- isBase64Encoded: false,
- headers : getResponseHeaders(true, isAlb),
- body: JSON.stringify(err)
- };
- } else {
- return {
- statusCode: 500,
- isBase64Encoded: false,
- headers : getResponseHeaders(true, isAlb),
- body: JSON.stringify({ message: 'Internal error. Please contact the system administrator.', code: 'InternalError', status: 500 })
- };
- }
- }
-}
-
-/**
- * Generates the appropriate set of response headers based on a success
- * or error condition.
- * @param {boolean} isErr - has an error been thrown?
- * @param {boolean} isAlb - is the request from ALB?
- * @return {object} - Headers object
- */
-const getResponseHeaders = (isErr = false, isAlb = false) => {
- const corsEnabled = (process.env.CORS_ENABLED === "Yes");
- const headers = {
- "Access-Control-Allow-Methods": "GET",
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
- }
- if (!isAlb) {
- headers["Access-Control-Allow-Credentials"] = true;
- }
- if (corsEnabled) {
- headers["Access-Control-Allow-Origin"] = process.env.CORS_ORIGIN;
- }
- if (isErr) {
- headers["Content-Type"] = "application/json"
- }
- return headers;
-}
\ No newline at end of file
diff --git a/source/image-handler/index.ts b/source/image-handler/index.ts
new file mode 100755
index 000000000..ac3ed7654
--- /dev/null
+++ b/source/image-handler/index.ts
@@ -0,0 +1,127 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import Rekognition from 'aws-sdk/clients/rekognition';
+import S3 from 'aws-sdk/clients/s3';
+import SecretsManager from 'aws-sdk/clients/secretsmanager';
+
+import { getOptions } from '../solution-utils/get-options';
+import { isNullOrWhiteSpace } from '../solution-utils/helpers';
+import { ImageHandler } from './image-handler';
+import { ImageRequest } from './image-request';
+import { Headers, ImageHandlerEvent, ImageHandlerExecutionResult, StatusCodes } from './lib';
+import { SecretProvider } from './secret-provider';
+
+const awsSdkOptions = getOptions();
+const s3Client = new S3(awsSdkOptions);
+const rekognitionClient = new Rekognition(awsSdkOptions);
+const secretsManagerClient = new SecretsManager(awsSdkOptions);
+const secretProvider = new SecretProvider(secretsManagerClient);
+
+/**
+ * Image handler Lambda handler.
+ * @param event The image handler request event.
+ * @returns Processed request response.
+ */
+export async function handler(event: ImageHandlerEvent): Promise {
+ console.info('Received event:', JSON.stringify(event, null, 2));
+
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const isAlb = event.requestContext && Object.prototype.hasOwnProperty.call(event.requestContext, 'elb');
+
+ try {
+ const imageRequestInfo = await imageRequest.setup(event);
+ console.info(imageRequestInfo);
+
+ const processedRequest = await imageHandler.process(imageRequestInfo);
+
+ let headers = getResponseHeaders(false, isAlb);
+ headers['Content-Type'] = imageRequestInfo.contentType;
+ // eslint-disable-next-line dot-notation
+ headers['Expires'] = imageRequestInfo.expires;
+ headers['Last-Modified'] = imageRequestInfo.lastModified;
+ headers['Cache-Control'] = imageRequestInfo.cacheControl;
+
+ // Apply the custom headers overwriting any that may need overwriting
+ if (imageRequestInfo.headers) {
+ headers = { ...headers, ...imageRequestInfo.headers };
+ }
+
+ return {
+ statusCode: StatusCodes.OK,
+ isBase64Encoded: true,
+ headers: headers,
+ body: processedRequest
+ };
+ } catch (error) {
+ console.error(error);
+
+ // Default fallback image
+ const { ENABLE_DEFAULT_FALLBACK_IMAGE, DEFAULT_FALLBACK_IMAGE_BUCKET, DEFAULT_FALLBACK_IMAGE_KEY } = process.env;
+ if (ENABLE_DEFAULT_FALLBACK_IMAGE === 'Yes' && !isNullOrWhiteSpace(DEFAULT_FALLBACK_IMAGE_BUCKET) && !isNullOrWhiteSpace(DEFAULT_FALLBACK_IMAGE_KEY)) {
+ try {
+ const defaultFallbackImage = await s3Client.getObject({ Bucket: DEFAULT_FALLBACK_IMAGE_BUCKET, Key: DEFAULT_FALLBACK_IMAGE_KEY }).promise();
+
+ const headers = getResponseHeaders(false, isAlb);
+ headers['Content-Type'] = defaultFallbackImage.ContentType;
+ headers['Last-Modified'] = defaultFallbackImage.LastModified;
+ headers['Cache-Control'] = 'max-age=31536000,public';
+
+ return {
+ statusCode: error.status ? error.status : StatusCodes.INTERNAL_SERVER_ERROR,
+ isBase64Encoded: true,
+ headers: headers,
+ body: defaultFallbackImage.Body.toString('base64')
+ };
+ } catch (error) {
+ console.error('Error occurred while getting the default fallback image.', error);
+ }
+ }
+
+ if (error.status) {
+ return {
+ statusCode: error.status,
+ isBase64Encoded: false,
+ headers: getResponseHeaders(true, isAlb),
+ body: JSON.stringify(error)
+ };
+ } else {
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ isBase64Encoded: false,
+ headers: getResponseHeaders(true, isAlb),
+ body: JSON.stringify({ message: 'Internal error. Please contact the system administrator.', code: 'InternalError', status: StatusCodes.INTERNAL_SERVER_ERROR })
+ };
+ }
+ }
+}
+
+/**
+ * Generates the appropriate set of response headers based on a success or error condition.
+ * @param isError Has an error been thrown.
+ * @param isAlb Is the request from ALB.
+ * @returns Headers.
+ */
+function getResponseHeaders(isError: boolean = false, isAlb: boolean = false): Headers {
+ const { CORS_ENABLED, CORS_ORIGIN } = process.env;
+ const corsEnabled = CORS_ENABLED === 'Yes';
+ const headers: Headers = {
+ 'Access-Control-Allow-Methods': 'GET',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+ };
+
+ if (!isAlb) {
+ headers['Access-Control-Allow-Credentials'] = true;
+ }
+
+ if (corsEnabled) {
+ headers['Access-Control-Allow-Origin'] = CORS_ORIGIN;
+ }
+
+ if (isError) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ return headers;
+}
diff --git a/source/image-handler/jest.config.js b/source/image-handler/jest.config.js
new file mode 100644
index 000000000..25bd4f520
--- /dev/null
+++ b/source/image-handler/jest.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ roots: ['/test'],
+ testMatch: ['**/*.spec.ts'],
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest'
+ },
+ coverageReporters: ['text', ['lcov', { projectRoot: '../' }]],
+ setupFiles: ['./test/setJestEnvironmentVariables.ts']
+};
diff --git a/source/image-handler/lib/enums.ts b/source/image-handler/lib/enums.ts
new file mode 100644
index 000000000..b3e82e6b8
--- /dev/null
+++ b/source/image-handler/lib/enums.ts
@@ -0,0 +1,36 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export enum StatusCodes {
+ OK = 200,
+ BAD_REQUEST = 400,
+ FORBIDDEN = 403,
+ NOT_FOUND = 404,
+ REQUEST_TOO_LONG = 413,
+ INTERNAL_SERVER_ERROR = 500
+}
+
+export enum RequestTypes {
+ DEFAULT = 'Default',
+ CUSTOM = 'Custom',
+ THUMBOR = 'Thumbor'
+}
+
+export enum ImageFormatTypes {
+ JPG = 'jpg',
+ JPEG = 'jpeg',
+ PNG = 'png',
+ WEBP = 'webp',
+ TIFF = 'tiff',
+ HEIF = 'heif',
+ HEIC = 'heic',
+ RAW = 'raw'
+}
+
+export enum ImageFitTypes {
+ COVER = 'cover',
+ CONTAIN = 'contain',
+ FILL = 'fill',
+ INSIDE = 'inside',
+ OUTSIDE = 'outside'
+}
diff --git a/source/image-handler/lib/index.ts b/source/image-handler/lib/index.ts
new file mode 100644
index 000000000..34f97188c
--- /dev/null
+++ b/source/image-handler/lib/index.ts
@@ -0,0 +1,6 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './types';
+export * from './enums';
+export * from './interfaces';
diff --git a/source/image-handler/lib/interfaces.ts b/source/image-handler/lib/interfaces.ts
new file mode 100644
index 000000000..1c96aeb56
--- /dev/null
+++ b/source/image-handler/lib/interfaces.ts
@@ -0,0 +1,69 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import sharp from 'sharp';
+
+import { ImageFormatTypes, RequestTypes, StatusCodes } from './enums';
+import { Headers, ImageEdits } from './types';
+
+export interface ImageHandlerEvent {
+ path?: string;
+ queryStringParameters?: {
+ signature: string;
+ };
+ requestContext?: {
+ elb?: unknown;
+ };
+ headers?: Headers;
+}
+
+export interface DefaultImageRequest {
+ bucket?: string;
+ key: string;
+ edits?: ImageEdits;
+ outputFormat?: ImageFormatTypes;
+ reductionEffort?: number;
+ headers?: Headers;
+}
+
+export interface BoundingBox {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+export interface BoxSize {
+ height: number;
+ width: number;
+}
+
+export interface ImageRequestInfo {
+ requestType: RequestTypes;
+ bucket: string;
+ key: string;
+ edits?: ImageEdits;
+ originalImage: Buffer;
+ headers?: Headers;
+ contentType?: string;
+ expires?: string;
+ lastModified?: string;
+ cacheControl?: string;
+ outputFormat?: ImageFormatTypes;
+ reductionEffort?: number;
+}
+
+export interface RekognitionCompatibleImage {
+ imageBuffer: {
+ data: Buffer;
+ info: sharp.OutputInfo;
+ };
+ format: keyof sharp.FormatEnum;
+}
+
+export interface ImageHandlerExecutionResult {
+ statusCode: StatusCodes;
+ isBase64Encoded: boolean;
+ headers: Headers;
+ body: string;
+}
diff --git a/source/image-handler/lib/types.ts b/source/image-handler/lib/types.ts
new file mode 100644
index 000000000..89f7f6260
--- /dev/null
+++ b/source/image-handler/lib/types.ts
@@ -0,0 +1,16 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { StatusCodes } from './enums';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Headers = Record;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type ImageEdits = Record;
+
+export class ImageHandlerError extends Error {
+ constructor(public readonly status: StatusCodes, public readonly code: string, public readonly message: string) {
+ super();
+ }
+}
diff --git a/source/image-handler/package.json b/source/image-handler/package.json
index 1b002e371..68ed6fcde 100644
--- a/source/image-handler/package.json
+++ b/source/image-handler/package.json
@@ -1,28 +1,38 @@
{
"name": "image-handler",
+ "version": "6.0.0",
+ "private": true,
"description": "A Lambda function for performing on-demand image edits and manipulations.",
+ "license": "Apache-2.0",
+ "author": "AWS Solutions",
"main": "index.js",
- "author": {
- "name": "aws-solutions-builder"
+ "scripts": {
+ "prebuild": "npm run clean && npm install --arch=x64 --platform=linux",
+ "build": "tsc --build tsconfig.json",
+ "clean": "rm -rf node_modules/ dist/ coverage/ package-lock.json",
+ "include-solution-utils": "npm run solution-utils:prep && npm run solution-utils:package",
+ "package": "npm run build && npm prune --production && rsync -avrq ./node_modules ./dist && npm run include-solution-utils && npm run package:zip",
+ "package:zip": "cd dist && zip -q -r9 ./package.zip * && cd ..",
+ "solution-utils:package": "cd ../solution-utils && npm run package && cd dist/ && rsync -avrq . ../../$npm_package_name/dist/solution-utils/ && cd ../../$npm_package_name",
+ "solution-utils:prep": "rm -rf dist/solution-utils && mkdir dist/solution-utils",
+ "pretest": "npm run clean && npm install",
+ "test": "jest --coverage --silent"
},
- "version": "5.2.0",
- "private": true,
"dependencies": {
"color": "3.1.3",
"color-name": "1.1.4",
"sharp": "^0.27.0"
},
"devDependencies": {
- "aws-sdk": "2.771.0",
- "jest": "^26.4.2"
- },
- "scripts": {
- "pretest": "npm run build:init && npm install",
- "test": "jest test/*.spec.js --coverage --silent",
- "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"
- },
- "license": "Apache-2.0"
+ "@types/color": "^3.0.2",
+ "@types/color-name": "^1.1.1",
+ "@types/jest": "^27.0.0",
+ "@types/node": "^16.10.3",
+ "@types/sharp": "^0.27.0",
+ "aws-sdk": "^2.1031.0",
+ "jest": "^27.0.0",
+ "ts-jest": "^27.0.0",
+ "ts-node": "^10.2.1",
+ "typescript": "^4.4.3"
+ }
}
diff --git a/source/image-handler/secret-provider.ts b/source/image-handler/secret-provider.ts
new file mode 100644
index 000000000..65276c57d
--- /dev/null
+++ b/source/image-handler/secret-provider.ts
@@ -0,0 +1,32 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import SecretsManager from 'aws-sdk/clients/secretsmanager';
+
+/**
+ * Class provides cached access to the Secret Manager.
+ */
+export class SecretProvider {
+ private readonly cache: { secretId: string; secret: string } = { secretId: null, secret: null };
+
+ constructor(private readonly secretsManager: SecretsManager) {}
+
+ /**
+ * Returns the secret associated with the secret ID.
+ * Note: method caches the secret associated with `secretId` and makes a call to SecretManager
+ * in case if the `secretId` changes, i.e. when SECRETS_MANAGER environment variable values changes.
+ * @param secretId The secret ID.
+ * @returns Secret associated with the secret ID.
+ */
+ async getSecret(secretId: string): Promise {
+ if (this.cache.secretId === secretId && this.cache.secret) {
+ return this.cache.secret;
+ } else {
+ const response = await this.secretsManager.getSecretValue({ SecretId: secretId }).promise();
+ this.cache.secretId = secretId;
+ this.cache.secret = response.SecretString;
+
+ return this.cache.secret;
+ }
+ }
+}
diff --git a/source/image-handler/test/image-handler.spec.js b/source/image-handler/test/image-handler.spec.js
deleted file mode 100644
index 45d73fbe4..000000000
--- a/source/image-handler/test/image-handler.spec.js
+++ /dev/null
@@ -1,1009 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('fs');
-
-const mockAws = {
- getObject: jest.fn(),
- detectFaces: jest.fn(),
- detectModerationLabels: jest.fn()
-};
-jest.mock('aws-sdk', () => {
- return {
- S3: jest.fn(() => ({
- getObject: mockAws.getObject
- })),
- Rekognition: jest.fn(() => ({
- detectFaces: mockAws.detectFaces,
- detectModerationLabels: mockAws.detectModerationLabels
- }))
- };
-});
-
-const AWS = require('aws-sdk');
-const s3 = new AWS.S3();
-const rekognition = new AWS.Rekognition();
-const ImageHandler = require('../image-handler');
-const sharp = require('sharp');
-
-// ----------------------------------------------------------------------------
-// [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 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(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- expect(result).not.toEqual(request.originalImage);
- });
- });
- describe('002/withToFormat', function() {
- it('Should pass if the output image is in a different format than the original image', async function() {
- // Arrange
- 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(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- expect(result).not.toEqual(request.originalImage);
- });
- });
- describe('003/noEditsSpecified', function() {
- it('Should pass if no edits are specified and the original image is returned', async function() {
- // Arrange
- const request = {
- requestType: "default",
- bucket: "sample-bucket",
- key: "sample-image-001.jpg",
- originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
- }
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- expect(result).toEqual(request.originalImage.toString('base64'));
- });
- });
- describe('004/ExceedsLambdaPayloadLimit', function() {
- it('Should fail the return payload is larger than 6MB', async function() {
- // Arrange
- const request = {
- requestType: "default",
- bucket: "sample-bucket",
- key: "sample-image-001.jpg",
- originalImage: Buffer.alloc(6 * 1024 * 1024)
- };
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- try {
- await imageHandler.process(request);
- } catch (error) {
- // Assert
- expect(error).toEqual({
- status: '413',
- code: 'TooLargeImageException',
- message: 'The converted image is too large to return.'
- });
- }
- });
- });
- describe('005/RotateNull', function() {
- it('Should pass if rotate is null and return image without EXIF and ICC', async function() {
- // Arrange
- const originalImage = fs.readFileSync('./test/image/test.jpg');
- const request = {
- requestType: "default",
- bucket: "sample-bucket",
- key: "test.jpg",
- edits: {
- rotate: null
- },
- originalImage: originalImage
- };
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
- expect(metadata).not.toHaveProperty('exif');
- expect(metadata).not.toHaveProperty('icc');
- expect(metadata).not.toHaveProperty('orientation');
- });
- });
- describe('006/ImageOrientation', function() {
- it('Should pass if the original image has orientation', async function() {
- // Arrange
- const originalImage = fs.readFileSync('./test/image/test.jpg');
- const request = {
- requestType: "default",
- bucket: "sample-bucket",
- key: "test.jpg",
- edits: {
- resize: {
- width: 100,
- height: 100
- }
- },
- originalImage: originalImage
- };
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
- expect(metadata).toHaveProperty('icc');
- expect(metadata).toHaveProperty('exif');
- expect(metadata.orientation).toEqual(3);
- });
- });
- describe('007/ImageWithoutOrientation', function() {
- it('Should pass if the original image does not have orientation', async function() {
- // Arrange
- const request = {
- requestType: "default",
- bucket: "sample-bucket",
- key: "test.jpg",
- edits: {
- resize: {
- width: 100,
- height: 100
- }
- },
- originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
- };
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.process(request);
- // Assert
- const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
- expect(metadata).not.toHaveProperty('orientation');
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// [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 image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- grayscale: true,
- flip: true
- }
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- const expectedResult1 = result.options.greyscale;
- const expectedResult2 = result.options.flip;
- const combinedResults = expectedResult1 && expectedResult2;
- expect(combinedResults).toEqual(true);
- });
- });
- describe('002/overlay', function() {
- it('Should pass if an edit with the overlayWith keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- overlayWith: {
- bucket: 'aaa',
- key: 'bbb'
- }
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input.buffer).toEqual(originalImage);
- });
- });
- describe('003/overlay/options/smallerThanZero', function() {
- it('Should pass if an edit with the overlayWith keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- overlayWith: {
- bucket: 'aaa',
- key: 'bbb',
- options: {
- left: '-1',
- top: '-1'
- }
- }
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input.buffer).toEqual(originalImage);
- });
- });
- describe('004/overlay/options/greaterThanZero', function() {
- it('Should pass if an edit with the overlayWith keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- overlayWith: {
- bucket: 'aaa',
- key: 'bbb',
- options: {
- left: '1',
- top: '1'
- }
- }
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input.buffer).toEqual(originalImage);
- });
- });
- describe('005/overlay/options/percentage/greaterThanZero', function() {
- it('Should pass if an edit with the overlayWith keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- overlayWith: {
- bucket: 'aaa',
- key: 'bbb',
- options: {
- left: '50p',
- top: '50p'
- }
- }
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input.buffer).toEqual(originalImage);
- });
- });
- describe('006/overlay/options/percentage/smallerThanZero', function() {
- it('Should pass if an edit with the overlayWith keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- overlayWith: {
- bucket: 'aaa',
- key: 'bbb',
- options: {
- left: '-50p',
- top: '-50p'
- }
- }
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input.buffer).toEqual(originalImage);
- });
- });
- describe('007/smartCrop', function() {
- it('Should pass if an edit with the smartCrop keyname is passed to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- smartCrop: {
- faceIndex: 0,
- padding: 0
- }
- }
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- }
- }]
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result.options.input).not.toEqual(originalImage);
- });
- });
- describe('008/smartCrop/paddingOutOfBoundsError', function() {
- it('Should pass if an excessive padding value is passed to the smartCrop filter', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- smartCrop: {
- faceIndex: 0,
- padding: 80
- }
- }
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- }
- }]
- });
- }
- };
- });
- // Act
- try {
- const imageHandler = new ImageHandler(s3, rekognition);
- await imageHandler.applyEdits(image, edits);
- } catch (error) {
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(error).toEqual({
- 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.'
- });
- }
- });
- });
- describe('009/smartCrop/boundingBoxError', function() {
- it('Should pass if an excessive faceIndex value is passed to the smartCrop filter', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- smartCrop: {
- faceIndex: 10,
- padding: 0
- }
- }
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- }
- }]
- });
- }
- };
- });
- // Act
- try {
- const imageHandler = new ImageHandler(s3, rekognition);
- await imageHandler.applyEdits(image, edits);
- } catch (error) {
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(error).toEqual({
- 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.'
- });
- }
- });
- });
- describe('010/smartCrop/faceIndexUndefined', function() {
- it('Should pass if a faceIndex value of undefined is passed to the smartCrop filter', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- smartCrop: true
- }
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- }
- }]
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result.options.input).not.toEqual(originalImage);
- });
- });
- describe('011/resizeStringTypeNumber', function() {
- it('Should pass if resize width and height are provided as string number to the function', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const edits = {
- resize: {
- width: '99.1',
- height: '99.9'
- }
- }
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- const resultBuffer = await result.toBuffer();
- const convertedImage = await sharp(originalImage, { failOnError: false }).withMetadata().resize({ width: 99, height: 100 }).toBuffer();
- expect(resultBuffer).toEqual(convertedImage);
- });
- });
- describe('012/roundCrop/noOptions', function() {
- it('Should pass if roundCrop keyName is passed with no additional options', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const metadata = image.metadata();
-
- const edits = {
- roundCrop: true,
-
- }
-
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
-
- // Assert
- const expectedResult = {width: metadata.width / 2, height: metadata.height / 2}
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input).not.toEqual(expectedResult);
- });
- });
- describe('013/roundCrop/withOptions', function() {
- it('Should pass if roundCrop keyName is passed with additional options', async function() {
- // Arrange
- const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const metadata = image.metadata();
-
- const edits = {
- roundCrop: {
- top: 100,
- left: 100,
- rx: 100,
- ry: 100,
- },
-
- }
-
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
-
- // Assert
- const expectedResult = {width: metadata.width / 2, height: metadata.height / 2}
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
- expect(result.options.input).not.toEqual(expectedResult);
- });
- });
- describe('014/contentModeration', function() {
- it('Should pass and blur image with minConfidence provided', async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- contentModeration: {
- minConfidence: 75
- }
- }
- // Mock
- mockAws.detectModerationLabels.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ModerationLabels: [
- {
- Confidence: 99.76720428466,
- Name: 'Smoking',
- ParentName: 'Tobacco'
- },
- { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
- ],
- ModerationModelVersion: '4.0'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- const expected = image.blur(50);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result.options.input).not.toEqual(originalImage);
- expect(result).toEqual(expected);
- });
- it("should pass and blur to specified amount if blur option is provided", async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- contentModeration: {
- minConfidence: 75,
- blur: 100
- }
- }
- // Mock
- mockAws.detectModerationLabels.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ModerationLabels: [
- {
- Confidence: 99.76720428466,
- Name: 'Smoking',
- ParentName: 'Tobacco'
- },
- { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
- ],
- ModerationModelVersion: '4.0'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- const expected = image.blur(100);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result.options.input).not.toEqual(originalImage);
- expect(result).toEqual(expected);
- });
- it("should pass and blur if content moderation label matches specied moderartion label", async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- contentModeration: {
- moderationLabels: ["Smoking"]
- }
- }
- // Mock
- mockAws.detectModerationLabels.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ModerationLabels: [
- {
- Confidence: 99.76720428466,
- Name: 'Smoking',
- ParentName: 'Tobacco'
- },
- { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
- ],
- ModerationModelVersion: '4.0'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- const expected = image.blur(50);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result.options.input).not.toEqual(originalImage);
- expect(result).toEqual(expected);
- });
- it("should not blur if provided moderationLabels not found", async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- contentModeration: {
- minConfidence: 75,
- blur: 100,
- moderationLabels: ['Alcohol']
- }
- }
- // Mock
- mockAws.detectModerationLabels.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ModerationLabels: [
- {
- Confidence: 99.76720428466,
- Name: 'Smoking',
- ParentName: 'Tobacco'
- },
- { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
- ],
- ModerationModelVersion: '4.0'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.applyEdits(image, edits);
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(result).toEqual(image);
- });
- it("should fail if rekognition returns an error", async function() {
- // Arrange
- const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
- const image = sharp(originalImage, { failOnError: false }).withMetadata();
- const buffer = await image.toBuffer();
- const edits = {
- contentModeration: {
- minConfidence: 75,
- blur: 100
- }
- }
- // Mock
- mockAws.detectModerationLabels.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- status: 500,
- code: 'InternalServerError',
- message: 'Amazon Rekognition experienced a service issue. Try your call again.'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- try {
- const result = await imageHandler.applyEdits(image, edits);
- } catch(error) {
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }});
- expect(error).toEqual({
- status: 500,
- code: 'InternalServerError',
- message: 'Amazon Rekognition experienced a service issue. Try your call again.'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// [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() {
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const metadata = await sharp(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')).metadata();
- const result = await imageHandler.getOverlayImage('validBucket', 'validKey', '100', '100', '20', metadata);
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(result).toEqual(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVQI12P4z8CQCgAEZgFlTg0nBwAAAABJRU5ErkJggg==', 'base64'));
- });
- });
- describe('002/imageDoesNotExist', function() {
- it('Should throw an error if an invalid bucket or key name is provided, simulating a non-existant overlay image', async function() {
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'InternalServerError',
- message: 'SimulatedInvalidParameterException'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const metadata = await sharp(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')).metadata();
- try {
- await imageHandler.getOverlayImage('invalidBucket', 'invalidKey', '100', '100', '20', metadata);
- } catch (error) {
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
- expect(error).toEqual({
- status: 500,
- code: 'InternalServerError',
- message: 'SimulatedInvalidParameterException'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// [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(s3, rekognition);
- const result = imageHandler.getCropArea(boundingBox, options, metadata);
- // Assert
- const expectedResult = {
- left: 90,
- top: 112,
- width: 86,
- height: 112
- };
- expect(result).toEqual(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 currentImage = Buffer.from('TestImageData');
- const faceIndex = 0;
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- }
- }]
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
- // Assert
- const expectedResult = {
- Height: 0.18,
- Left: 0.55,
- Top: 0.33,
- Width: 0.23
- };
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }});
- expect(result).toEqual(expectedResult);
- });
- });
- describe('002/errorHandling', function() {
- it('Should simulate an error condition returned by Rekognition', async function() {
- // Arrange
- const currentImage = Buffer.from('NotTestImageData');
- const faceIndex = 0;
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'InternalServerError',
- message: 'SimulatedError'
- });
- }
- };
- });
- // Act
- const imageHandler = new ImageHandler(s3, rekognition);
- try {
- await imageHandler.getBoundingBox(currentImage, faceIndex);
- } catch (error) {
- // Assert
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }});
- expect(error).toEqual({
- status: 500,
- code: 'InternalServerError',
- message: 'SimulatedError'
- });
- }
- });
- });
- describe('003/noDetectedFaces', function () {
- it('Should pass if no faces are detected', async function () {
- //Arrange
- const currentImage = Buffer.from('TestImageData');
- const faceIndex = 0;
-
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: []
- });
- }
- };
- });
-
- //Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
-
- // Assert
- const expectedResult = {
- Height: 1,
- Left: 0,
- Top: 0,
- Width: 1
- };
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }});
- expect(result).toEqual(expectedResult);
- });
- });
- describe('004/boundsGreaterThanImageDimensions', function () {
- it('Should pass if bounds detected go beyond the image dimensions', async function () {
- //Arrange
- const currentImage = Buffer.from('TestImageData');
- const faceIndex = 0;
-
- // Mock
- mockAws.detectFaces.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- FaceDetails: [{
- BoundingBox: {
- Height: 1,
- Left: 0.50,
- Top: 0.30,
- Width: 0.65
- }
- }]
- });
- }
- };
- });
-
- //Act
- const imageHandler = new ImageHandler(s3, rekognition);
- const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
-
- // Assert
- const expectedResult = {
- Height: 0.70,
- Left: 0.50,
- Top: 0.30,
- Width: 0.50
- };
- expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }});
- expect(result).toEqual(expectedResult);
- });
- });
-});
\ No newline at end of file
diff --git a/source/image-handler/test/image-handler.spec.ts b/source/image-handler/test/image-handler.spec.ts
new file mode 100644
index 000000000..d84cd85d3
--- /dev/null
+++ b/source/image-handler/test/image-handler.spec.ts
@@ -0,0 +1,1195 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsRekognition, mockAwsS3 } from './mock';
+
+import fs from 'fs';
+import sharp from 'sharp';
+import S3 from 'aws-sdk/clients/s3';
+import Rekognition from 'aws-sdk/clients/rekognition';
+
+import { ImageHandler } from '../image-handler';
+import { BoundingBox, BoxSize, ImageEdits, ImageFormatTypes, ImageHandlerError, ImageRequestInfo, RequestTypes, StatusCodes } from '../lib';
+
+const s3Client = new S3();
+const rekognitionClient = new Rekognition();
+
+describe('process()', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('001/default', () => {
+ it('Should pass if the output image is different from the input image with edits applied', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'sample-image-001.jpg',
+ edits: {
+ grayscale: true,
+ flip: true
+ },
+ originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ expect(result).not.toEqual(request.originalImage);
+ });
+ });
+
+ describe('002/withToFormat', () => {
+ it('Should pass if the output image is in a different format than the original image', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'sample-image-001.jpg',
+ outputFormat: ImageFormatTypes.PNG,
+ edits: {
+ grayscale: true,
+ flip: true
+ },
+ originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ expect(result).not.toEqual(request.originalImage);
+ });
+ it('Should pass if the output image is webp format and reductionEffort is provided', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'sample-image-001.jpg',
+ outputFormat: ImageFormatTypes.WEBP,
+ reductionEffort: 3,
+ originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
+ };
+ jest.spyOn(sharp(), 'webp');
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ expect(result).not.toEqual(request.originalImage);
+ });
+ });
+
+ describe('003/noEditsSpecified', () => {
+ it('Should pass if no edits are specified and the original image is returned', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'sample-image-001.jpg',
+ originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ expect(result).toEqual(request.originalImage.toString('base64'));
+ });
+ });
+
+ describe('004/ExceedsLambdaPayloadLimit', () => {
+ it('Should fail the return payload is larger than 6MB', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'sample-image-001.jpg',
+ originalImage: Buffer.alloc(6 * 1024 * 1024)
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ try {
+ await imageHandler.process(request);
+ } catch (error) {
+ // Assert
+ expect(error).toMatchObject({
+ status: StatusCodes.REQUEST_TOO_LONG,
+ code: 'TooLargeImageException',
+ message: 'The converted image is too large to return.'
+ });
+ }
+ });
+ });
+
+ describe('005/RotateNull', () => {
+ it('Should pass if rotate is null and return image without EXIF and ICC', async () => {
+ // Arrange
+ const originalImage = fs.readFileSync('./test/image/1x1.jpg');
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'test.jpg',
+ edits: {
+ rotate: null
+ },
+ originalImage: originalImage
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
+ expect(metadata).not.toHaveProperty('exif');
+ expect(metadata).not.toHaveProperty('icc');
+ expect(metadata).not.toHaveProperty('orientation');
+ });
+ });
+
+ describe('006/ImageOrientation', () => {
+ it('Should pass if the original image has orientation', async () => {
+ // Arrange
+ const originalImage = fs.readFileSync('./test/image/1x1.jpg');
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'test.jpg',
+ edits: {
+ resize: {
+ width: 100,
+ height: 100
+ }
+ },
+ originalImage: originalImage
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
+ expect(metadata).toHaveProperty('icc');
+ expect(metadata).toHaveProperty('exif');
+ expect(metadata.orientation).toEqual(3);
+ });
+ });
+
+ describe('007/ImageWithoutOrientation', () => {
+ it('Should pass if the original image does not have orientation', async () => {
+ // Arrange
+ const request: ImageRequestInfo = {
+ requestType: RequestTypes.DEFAULT,
+ bucket: 'sample-bucket',
+ key: 'test.jpg',
+ edits: {
+ resize: {
+ width: 100,
+ height: 100
+ }
+ },
+ originalImage: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.process(request);
+
+ // Assert
+ const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
+ expect(metadata).not.toHaveProperty('orientation');
+ });
+ });
+});
+
+describe('applyEdits()', () => {
+ describe('001/standardEdits', () => {
+ it('Should pass if a series of standard edits are provided to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ grayscale: true,
+ flip: true
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ /* eslint-disable dot-notation */
+ const expectedResult1 = result['options'].greyscale;
+ const expectedResult2 = result['options'].flip;
+ const combinedResults = expectedResult1 && expectedResult2;
+ expect(combinedResults).toEqual(true);
+ });
+ });
+
+ describe('002/overlay', () => {
+ it('Should pass if an edit with the overlayWith keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'aaa',
+ key: 'bbb'
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input.buffer).toEqual(originalImage);
+ });
+ });
+
+ describe('003/overlay/options/smallerThanZero', () => {
+ it('Should pass if an edit with the overlayWith keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'aaa',
+ key: 'bbb',
+ options: {
+ left: '-1',
+ top: '-1'
+ }
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input.buffer).toEqual(originalImage);
+ });
+ });
+
+ describe('004/overlay/options/greaterThanZero', () => {
+ it('Should pass if an edit with the overlayWith keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'aaa',
+ key: 'bbb',
+ options: {
+ left: '1',
+ top: '1'
+ }
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input.buffer).toEqual(originalImage);
+ });
+ });
+
+ describe('005/overlay/options/percentage/greaterThanZero', () => {
+ it('Should pass if an edit with the overlayWith keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'aaa',
+ key: 'bbb',
+ options: {
+ left: '50p',
+ top: '50p'
+ }
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input.buffer).toEqual(originalImage);
+ });
+
+ it('Should pass if an edit with the overlayWith keyname contains position which could produce float number', async () => {
+ // Arrange
+ const originalImage = fs.readFileSync('./test/image/25x15.png');
+ const overlayImage = fs.readFileSync('./test/image/1x1.jpg');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'bucket',
+ key: 'key',
+ options: {
+ left: '25.5p',
+ top: '25.5p'
+ }
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: overlayImage });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+ const metadata = await result.metadata();
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(metadata.width).toEqual(25);
+ expect(metadata.height).toEqual(15);
+ expect(result.toBuffer()).not.toEqual(originalImage);
+ });
+ });
+
+ describe('006/overlay/options/percentage/smallerThanZero', () => {
+ it('Should pass if an edit with the overlayWith keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ overlayWith: {
+ bucket: 'aaa',
+ key: 'bbb',
+ options: {
+ left: '-50p',
+ top: '-50p'
+ }
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input.buffer).toEqual(originalImage);
+ });
+ });
+
+ describe('007/smartCrop', () => {
+ it('Should pass if an edit with the smartCrop keyname is passed to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ smartCrop: {
+ faceIndex: 0,
+ padding: 0
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 0.18,
+ Left: 0.55,
+ Top: 0.33,
+ Width: 0.23
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result['options'].input).not.toEqual(originalImage);
+ });
+ });
+
+ describe('008/smartCrop/paddingOutOfBoundsError', () => {
+ it('Should pass if an excessive padding value is passed to the smartCrop filter', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ smartCrop: {
+ faceIndex: 0,
+ padding: 80
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 0.18,
+ Left: 0.55,
+ Top: 0.33,
+ Width: 0.23
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ try {
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ await imageHandler.applyEdits(image, edits);
+ } catch (error) {
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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.'
+ });
+ }
+ });
+ });
+
+ describe('009/smartCrop/boundingBoxError', () => {
+ it('Should pass if an excessive faceIndex value is passed to the smartCrop filter', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ smartCrop: {
+ faceIndex: 10,
+ padding: 0
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 0.18,
+ Left: 0.55,
+ Top: 0.33,
+ Width: 0.23
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ try {
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ await imageHandler.applyEdits(image, edits);
+ } catch (error) {
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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.'
+ });
+ }
+ });
+ });
+
+ describe('010/smartCrop/faceIndexUndefined', () => {
+ it('Should pass if a faceIndex value of undefined is passed to the smartCrop filter', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ smartCrop: true
+ };
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 0.18,
+ Left: 0.55,
+ Top: 0.33,
+ Width: 0.23
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result['options'].input).not.toEqual(originalImage);
+ });
+ });
+
+ describe('011/resizeStringTypeNumber', () => {
+ it('Should pass if resize width and height are provided as string number to the function', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ resize: {
+ width: '99.1',
+ height: '99.9'
+ }
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ const resultBuffer = await result.toBuffer();
+ const convertedImage = await sharp(originalImage, { failOnError: false }).withMetadata().resize({ width: 99, height: 100 }).toBuffer();
+ expect(resultBuffer).toEqual(convertedImage);
+ });
+ });
+
+ describe('012/roundCrop/noOptions', () => {
+ it('Should pass if roundCrop keyName is passed with no additional options', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const metadata = await image.metadata();
+
+ const edits: ImageEdits = {
+ roundCrop: true
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ const expectedResult: ImageEdits = { width: metadata.width / 2, height: metadata.height / 2 };
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input).not.toEqual(expectedResult);
+ });
+ });
+
+ describe('013/roundCrop/withOptions', () => {
+ it('Should pass if roundCrop keyName is passed with additional options', async () => {
+ // Arrange
+ const originalImage = Buffer.from(
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==',
+ 'base64'
+ );
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const metadata = await image.metadata();
+
+ const edits: ImageEdits = {
+ roundCrop: {
+ top: 100,
+ left: 100,
+ rx: 100,
+ ry: 100
+ }
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ const expectedResult: ImageEdits = { width: metadata.width / 2, height: metadata.height / 2 };
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' });
+ expect(result['options'].input).not.toEqual(expectedResult);
+ });
+ });
+
+ describe('014/contentModeration', () => {
+ it('Should pass and blur image with minConfidence provided', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ contentModeration: {
+ minConfidence: 75
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectModerationLabels.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ModerationLabels: [
+ {
+ Confidence: 99.76720428466,
+ Name: 'Smoking',
+ ParentName: 'Tobacco'
+ },
+ { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
+ ],
+ ModerationModelVersion: '4.0'
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+ const expected = image.blur(50);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result['options'].input).not.toEqual(originalImage);
+ expect(result).toEqual(expected);
+ });
+
+ it('should pass and blur to specified amount if blur option is provided', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ contentModeration: {
+ minConfidence: 75,
+ blur: 100
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectModerationLabels.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ModerationLabels: [
+ {
+ Confidence: 99.76720428466,
+ Name: 'Smoking',
+ ParentName: 'Tobacco'
+ },
+ { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
+ ],
+ ModerationModelVersion: '4.0'
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+ const expected = image.blur(100);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result['options'].input).not.toEqual(originalImage);
+ expect(result).toEqual(expected);
+ });
+
+ it('should pass and blur if content moderation label matches specified moderation label', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ contentModeration: {
+ moderationLabels: ['Smoking']
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectModerationLabels.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ModerationLabels: [
+ {
+ Confidence: 99.76720428466,
+ Name: 'Smoking',
+ ParentName: 'Tobacco'
+ },
+ { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
+ ],
+ ModerationModelVersion: '4.0'
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+ const expected = image.blur(50);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result['options'].input).not.toEqual(originalImage);
+ expect(result).toEqual(expected);
+ });
+
+ it('should not blur if provided moderationLabels not found', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ contentModeration: {
+ minConfidence: 75,
+ blur: 100,
+ moderationLabels: ['Alcohol']
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectModerationLabels.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ModerationLabels: [
+ {
+ Confidence: 99.76720428466,
+ Name: 'Smoking',
+ ParentName: 'Tobacco'
+ },
+ { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' }
+ ],
+ ModerationModelVersion: '4.0'
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.applyEdits(image, edits);
+
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(result).toEqual(image);
+ });
+
+ it('should fail if rekognition returns an error', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const buffer = await image.toBuffer();
+ const edits: ImageEdits = {
+ contentModeration: {
+ minConfidence: 75,
+ blur: 100
+ }
+ };
+
+ // Mock
+ mockAwsRekognition.detectModerationLabels.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(
+ new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'Amazon Rekognition experienced a service issue. Try your call again.')
+ );
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ try {
+ await imageHandler.applyEdits(image, edits);
+ } catch (error) {
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer } });
+ expect(error).toMatchObject({
+ status: StatusCodes.INTERNAL_SERVER_ERROR,
+ code: 'InternalServerError',
+ message: 'Amazon Rekognition experienced a service issue. Try your call again.'
+ });
+ }
+ });
+ });
+
+ describe('015/crop/areaOutOfBoundsError', () => {
+ it('Should pass if a cropping area value is out of bounds', async () => {
+ // Arrange
+ const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+ const image = sharp(originalImage, { failOnError: false }).withMetadata();
+ const edits: ImageEdits = {
+ crop: {
+ left: 0,
+ right: 0,
+ width: 100,
+ height: 100
+ }
+ };
+
+ // Act
+ try {
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ await imageHandler.applyEdits(image, edits);
+ } catch (error) {
+ // Assert
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ code: 'Crop::AreaOutOfBounds',
+ message: 'The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.'
+ });
+ }
+ });
+ });
+});
+
+describe('getOverlayImage()', () => {
+ describe('001/validParameters', () => {
+ it('Should pass if the proper bucket name and key are supplied, simulating an image file that can be retrieved', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const metadata = await sharp(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')).metadata();
+ const result = await imageHandler.getOverlayImage('validBucket', 'validKey', '100', '100', '20', metadata);
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(result).toEqual(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVQI12P4z8CQCgAEZgFlTg0nBwAAAABJRU5ErkJggg==', 'base64'));
+ });
+
+ it('Should pass and do not throw an exception that the overlay image dimensions are not integer numbers', async () => {
+ // Mock
+ const originalImage = fs.readFileSync('./test/image/25x15.png');
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: originalImage });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const originalImageMetadata = await sharp(originalImage).metadata();
+ const result = await imageHandler.getOverlayImage('bucket', 'key', '75', '75', '20', originalImageMetadata);
+ const overlayImageMetadata = await sharp(result).metadata();
+
+ // Assert
+ expect(overlayImageMetadata.width).toEqual(18);
+ expect(overlayImageMetadata.height).toEqual(11);
+ });
+ });
+
+ describe('002/imageDoesNotExist', () => {
+ it('Should throw an error if an invalid bucket or key name is provided, simulating a nonexistent overlay image', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'SimulatedInvalidParameterException'));
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const metadata = await sharp(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')).metadata();
+ try {
+ await imageHandler.getOverlayImage('invalidBucket', 'invalidKey', '100', '100', '20', metadata);
+ } catch (error) {
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
+ expect(error).toMatchObject({
+ status: StatusCodes.INTERNAL_SERVER_ERROR,
+ code: 'InternalServerError',
+ message: 'SimulatedInvalidParameterException'
+ });
+ }
+ });
+ });
+});
+
+describe('getCropArea()', () => {
+ describe('001/validParameters', () => {
+ it('Should pass if the crop area can be calculated using a series of valid inputs/parameters', () => {
+ // Arrange
+ const boundingBox: BoundingBox = {
+ height: 0.18,
+ left: 0.55,
+ top: 0.33,
+ width: 0.23
+ };
+ const metadata: BoxSize = {
+ width: 200,
+ height: 400
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = imageHandler.getCropArea(boundingBox, 20, metadata);
+
+ // Assert
+ const expectedResult: BoundingBox = {
+ left: 90,
+ top: 112,
+ width: 86,
+ height: 112
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/validParameters and out of range', () => {
+ it('Should pass if the crop area is beyond the range of the image after padding is applied', () => {
+ // Arrange
+ const boundingBox: BoundingBox = {
+ height: 0.18,
+ left: 0.55,
+ top: 0.33,
+ width: 0.23
+ };
+ const metadata: BoxSize = {
+ width: 200,
+ height: 400
+ };
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = imageHandler.getCropArea(boundingBox, 500, metadata);
+
+ // Assert
+ const expectedResult: BoundingBox = {
+ left: 0,
+ top: 0,
+ width: 200,
+ height: 400
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+});
+
+describe('getBoundingBox()', () => {
+ describe('001/validParameters', () => {
+ it('Should pass if the proper parameters are passed to the function', async () => {
+ // Arrange
+ const currentImage = Buffer.from('TestImageData');
+ const faceIndex = 0;
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 0.18,
+ Left: 0.55,
+ Top: 0.33,
+ Width: 0.23
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
+
+ // Assert
+ const expectedResult: BoundingBox = {
+ height: 0.18,
+ left: 0.55,
+ top: 0.33,
+ width: 0.23
+ };
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage } });
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/errorHandling', () => {
+ it('Should simulate an error condition returned by Rekognition', async () => {
+ // Arrange
+ const currentImage = Buffer.from('NotTestImageData');
+ const faceIndex = 0;
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'SimulatedError'));
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ try {
+ await imageHandler.getBoundingBox(currentImage, faceIndex);
+ } catch (error) {
+ // Assert
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage } });
+ expect(error).toMatchObject({
+ status: StatusCodes.INTERNAL_SERVER_ERROR,
+ code: 'InternalServerError',
+ message: 'SimulatedError'
+ });
+ }
+ });
+ });
+
+ describe('003/noDetectedFaces', () => {
+ it('Should pass if no faces are detected', async () => {
+ // Arrange
+ const currentImage = Buffer.from('TestImageData');
+ const faceIndex = 0;
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: []
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
+
+ // Assert
+ const expectedResult: BoundingBox = {
+ height: 1,
+ left: 0,
+ top: 0,
+ width: 1
+ };
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage } });
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/boundsGreaterThanImageDimensions', () => {
+ it('Should pass if bounds detected go beyond the image dimensions', async () => {
+ // Arrange
+ const currentImage = Buffer.from('TestImageData');
+ const faceIndex = 0;
+
+ // Mock
+ mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ FaceDetails: [
+ {
+ BoundingBox: {
+ Height: 1,
+ Left: 0.5,
+ Top: 0.3,
+ Width: 0.65
+ }
+ }
+ ]
+ });
+ }
+ }));
+
+ // Act
+ const imageHandler = new ImageHandler(s3Client, rekognitionClient);
+ const result = await imageHandler.getBoundingBox(currentImage, faceIndex);
+
+ // Assert
+ const expectedResult: BoundingBox = {
+ height: 0.7,
+ left: 0.5,
+ top: 0.3,
+ width: 0.5
+ };
+ expect(mockAwsRekognition.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage } });
+ expect(result).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/source/image-handler/test/image-request.spec.js b/source/image-handler/test/image-request.spec.js
deleted file mode 100644
index 2f706514c..000000000
--- a/source/image-handler/test/image-request.spec.js
+++ /dev/null
@@ -1,1251 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const mockAws = {
- getObject: jest.fn(),
- getSecretValue: jest.fn()
-};
-jest.mock('aws-sdk', () => {
- return {
- S3: jest.fn(() => ({
- getObject: mockAws.getObject
- })),
- SecretsManager: jest.fn(() => ({
- getSecretValue: mockAws.getSecretValue
- }))
- };
-});
-
-const AWS = require('aws-sdk');
-const s3 = new AWS.S3();
-const secretsManager = new AWS.SecretsManager();
-const ImageRequest = require('../image-request');
-
-// ----------------------------------------------------------------------------
-// [async] setup()
-// ----------------------------------------------------------------------------
-describe('setup()', function() {
- beforeEach(() => {
- mockAws.getObject.mockReset();
- });
-
- 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"
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- 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
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('002/defaultImageRequest/toFormat', function() {
- it('Should pass when a default image request is provided and populate the ImageRequest object with the proper values', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- }
- process.env = {
- SOURCE_BUCKETS : "validBucket, validBucket2"
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Default',
- bucket: 'validBucket',
- key: 'validKey',
- edits: { toFormat: 'png' },
- outputFormat: 'png',
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/png'
- }
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('003/thumborImageRequest', function() {
- it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values', async function() {
- // Arrange
- const event = {
- path : "/filters:grayscale()/test-image-001.jpg"
- }
- process.env = {
- SOURCE_BUCKETS : "allowedBucket001, allowedBucket002"
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- 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
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'test-image-001.jpg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('004/thumborImageRequest/quality', function() {
- it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values', async function() {
- // Arrange
- const event = {
- path : "/filters:format(png)/filters:quality(50)/test-image-001.jpg"
- }
- process.env = {
- SOURCE_BUCKETS : "allowedBucket001, allowedBucket002"
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Thumbor',
- bucket: 'allowedBucket001',
- key: 'test-image-001.jpg',
- edits: {
- toFormat: 'png',
- png: { quality: 50 }
- },
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- outputFormat: 'png',
- ContentType: 'image/png'
- }
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'test-image-001.jpg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('005/customImageRequest', function() {
- it('Should pass when a custom image request is provided and populate the ImageRequest object with the proper values', async function() {
- // Arrange
- const event = {
- path : '/filters-rotate(90)/filters-grayscale()/custom-image.jpg'
- }
- process.env = {
- SOURCE_BUCKETS : "allowedBucket001, allowedBucket002",
- REWRITE_MATCH_PATTERN: /(filters-)/gm,
- REWRITE_SUBSTITUTION: 'filters:'
- }
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- 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(s3, secretsManager);
- 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
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'custom-image.jpg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('006/errorCase', function() {
- it('Should pass when an error is caught', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfX0='
- }
- process.env = {
- SOURCE_BUCKETS : "allowedBucket001, allowedBucket002"
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- // Assert
- try {
- await imageRequest.setup(event);
- } catch (error) {
- expect(error.code).toEqual('ImageBucket::CannotAccessBucket');
- }
- });
- });
- describe('007/enableSignature', function() {
- beforeAll(() => {
- process.env.ENABLE_SIGNATURE = 'Yes';
- process.env.SECRETS_MANAGER = 'serverless-image-hander';
- process.env.SECRET_KEY = 'signatureKey';
- process.env.SOURCE_BUCKETS = 'validBucket';
- });
- it('Should pass when the image signature is correct', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- queryStringParameters: {
- signature: '4d41311006641a56de7bca8abdbda91af254506107a2c7b338a13ca2fa95eac3'
- }
- };
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- mockAws.getSecretValue.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- SecretString: JSON.stringify({
- [process.env.SECRET_KEY]: 'secret'
- })
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Default',
- bucket: 'validBucket',
- key: 'validKey',
- edits: { toFormat: 'png' },
- outputFormat: 'png',
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/png'
- }
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(mockAws.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
- expect(imageRequest).toEqual(expectedResult);
- });
- it('Should throw an error when queryStringParameters are missing', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- try {
- await imageRequest.setup(event);
- } catch (error) {
- // Assert
- expect(error).toEqual({
- status: 400,
- message: 'Query-string requires the signature parameter.',
- code: 'AuthorizationQueryParametersError'
- });
- }
- });
- it('Should throw an error when the image signature query parameter is missing', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- queryStringParameters: {
- sign: '4d41311006641a56de7bca8abdbda91af254506107a2c7b338a13ca2fa95eac3'
- }
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- try {
- await imageRequest.setup(event);
- } catch (error) {
- // Assert
- expect(error).toEqual({
- status: 400,
- message: 'Query-string requires the signature parameter.',
- code: 'AuthorizationQueryParametersError'
- });
- }
- });
- it('Should throw an error when signature does not match', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- queryStringParameters: {
- signature: 'invalid'
- }
- };
- // Mock
- mockAws.getSecretValue.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- SecretString: JSON.stringify({
- [process.env.SECRET_KEY]: 'secret'
- })
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- try {
- await imageRequest.setup(event);
- } catch (error) {
- // Assert
- expect(mockAws.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
- expect(error).toEqual({
- status: 403,
- message: 'Signature does not match.',
- code: 'SignatureDoesNotMatch'
- });
- }
- });
- it('Should throw an error when any other error occurs', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
- queryStringParameters: {
- signature: '4d41311006641a56de7bca8abdbda91af254506107a2c7b338a13ca2fa95eac3'
- }
- };
- // Mock
- mockAws.getSecretValue.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- message: 'SimulatedError',
- code: 'InternalServerError'
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- try {
- await imageRequest.setup(event);
- } catch (error) {
- // Assert
- expect(mockAws.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
- expect(error).toEqual({
- status: 500,
- message: 'Signature validation failed.',
- code: 'SignatureValidationFailure'
- });
- }
- });
- });
- describe('008/SVGSupport', function() {
- beforeAll(() => {
- process.env.ENABLE_SIGNATURE = 'No';
- process.env.SOURCE_BUCKETS = 'validBucket';
- });
- it('Should return SVG image when no edit is provided for the SVG image', async function() {
- // Arrange
- const event = {
- path : '/image.svg'
- };
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ContentType: 'image/svg+xml',
- Body: Buffer.from('SampleImageContent\n')
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Thumbor',
- bucket: 'validBucket',
- key: 'image.svg',
- edits: {},
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/svg+xml'
- };
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- it('Should return WebP image when there are any edits and no output is specified for the SVG image', async function() {
- // Arrange
- const event = {
- path : '/100x100/image.svg',
- };
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ContentType: 'image/svg+xml',
- Body: Buffer.from('SampleImageContent\n')
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Thumbor',
- bucket: 'validBucket',
- key: 'image.svg',
- edits: { resize: { width: 100, height: 100 } },
- outputFormat: 'png',
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/png'
- };
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- it('Should return JPG image when output is specified to JPG for the SVG image', async function() {
- // Arrange
- const event = {
- path : '/filters:format(jpg)/image.svg',
- };
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ContentType: 'image/svg+xml',
- Body: Buffer.from('SampleImageContent\n')
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Thumbor',
- bucket: 'validBucket',
- key: 'image.svg',
- edits: { toFormat: 'jpeg' },
- outputFormat: 'jpeg',
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/jpeg'
- };
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
- describe('009/customHeaders', function() {
- it('Should pass and return the customer headers if custom headers are provided', async function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMifSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9'
- }
- process.env.SOURCE_BUCKETS = 'validBucket, validBucket2';
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- await imageRequest.setup(event);
- const expectedResult = {
- requestType: 'Default',
- bucket: 'validBucket',
- key: 'validKey',
- headers: { 'Cache-Control': 'max-age=31536000,public' },
- outputFormat: 'jpeg',
- originalImage: Buffer.from('SampleImageContent\n'),
- CacheControl: 'max-age=31536000,public',
- ContentType: 'image/jpeg'
- };
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(imageRequest).toEqual(expectedResult);
- });
- });
-});
-// ----------------------------------------------------------------------------
-// getOriginalImage()
-// ----------------------------------------------------------------------------
-describe('getOriginalImage()', function() {
- beforeEach(() => {
- mockAws.getObject.mockReset();
- });
-
- 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() {
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(result).toEqual(Buffer.from('SampleImageContent\n'));
- });
- });
- describe('002/imageDoesNotExist', function() {
- it('Should throw an error if an invalid bucket or key name is provided, simulating a non-existant original image', async function() {
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- message: 'SimulatedException'
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- // Assert
- try {
- await imageRequest.getOriginalImage('invalidBucket', 'invalidKey');
- } catch (error) {
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
- expect(error.status).toEqual(404);
- }
- });
- });
- describe('003/unknownError', function() {
- it('Should throw an error if an unkown problem happens when getting an object', async function() {
- // Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'InternalServerError',
- message: 'SimulatedException'
- });
- }
- };
- });
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- // Assert
- try {
- await imageRequest.getOriginalImage('invalidBucket', 'invalidKey');
- } catch (error) {
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
- expect(error.status).toEqual(500);
- }
- });
- });
- describe('004/noExtension', function() {
- const testFiles = [[0x89,0x50,0x4E,0x47],[0xFF,0xD8,0xFF,0xDB],[0xFF,0xD8,0xFF,0xE0],[0xFF,0xD8,0xFF,0xEE],[0xFF,0xD8,0xFF,0xE1],[0x52,0x49,0x46,0x46],[0x49,0x49,0x2A,0x00],[0x4D,0x4D,0x00,0x2A]];
- const expectFileType = ["image/png", "image/jpeg", "image/jpeg", "image/jpeg", "image/jpeg", "image/webp", "image/tiff", "image/tiff"];
- testFiles.forEach(function (test, index) {it('Should pass and infer content type if there is no extension, had default s3 content type and it has a vlid key and a valid bucket', async function() {
- //Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ContentType: 'binary/octet-stream',
- Body: Buffer.from(new Uint8Array(test))
- });
- }
- };
- })
-
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
- // Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(result).toEqual(Buffer.from(new Uint8Array(test)));
- expect(imageRequest.ContentType).toEqual(expectFileType[index]);
- });})
- it('Should fail to infer content type if there is no extension and file header is not recognized', async function() {
- //Mock
- mockAws.getObject.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- ContentType: 'binary/octet-stream',
- Body: Buffer.from(new Uint8Array(test))
- });
- }
- };
- })
-
- //Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- try {
- const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
- } catch(error){
- //Assert
- expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
- expect(error.status).toEqual(500);
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// parseImageBucket()
-// ----------------------------------------------------------------------------
-describe('parseImageBucket()', function() {
- describe('001/defaultRequestType/bucketSpecifiedInRequest/allowed', function() {
- it('Should pass if the bucket name is provided in the image request and has been whitelisted in SOURCE_BUCKETS', function() {
- // Arrange
- const event = {
- path : '/eyJidWNrZXQiOiJhbGxvd2VkQnVja2V0MDAxIiwia2V5Ijoic2FtcGxlSW1hZ2VLZXkwMDEuanBnIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjoidHJ1ZSJ9fQ=='
- }
- process.env = {
- SOURCE_BUCKETS : "allowedBucket001, allowedBucket002"
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageBucket(event, 'Default');
- // Assert
- const expectedResult = 'allowedBucket001';
- expect(result).toEqual(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(s3, secretsManager);
- // Assert
- try {
- imageRequest.parseImageBucket(event, 'Default');
- } catch (error) {
- expect(error).toEqual({
- 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(s3, secretsManager);
- const result = imageRequest.parseImageBucket(event, 'Default');
- // Assert
- const expectedResult = 'allowedBucket001';
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseImageBucket(event, 'Thumbor');
- // Assert
- const expectedResult = 'allowedBucket001';
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseImageBucket(event, 'Custom');
- // Assert
- const expectedResult = 'allowedBucket001';
- expect(result).toEqual(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(s3, secretsManager);
- // Assert
- try {
- imageRequest.parseImageBucket(event, undefined);
- } catch (error) {
- expect(error).toEqual({
- status: 404,
- code: 'ImageBucket::CannotFindBucket',
- message: 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// parseImageEdits()
-// ----------------------------------------------------------------------------
-describe('parseImageEdits()', function() {
- describe('001/defaultRequestType', function() {
- it('Should pass if the proper result is returned for a sample base64-encoded image request', function() {
- // Arrange
- const event = {
- path : '/eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0='
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageEdits(event, 'Default');
- // Assert
- const expectedResult = {
- grayscale: 'true',
- rotate: 90,
- flip: 'true'
- }
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseImageEdits(event, 'Thumbor');
- // Assert
- const expectedResult = {
- rotate: 90,
- grayscale: true
- }
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseImageEdits(event, 'Custom');
- // Assert
- const expectedResult = {
- rotate: 90,
- grayscale: true
- }
- expect(result).toEqual(expectedResult);
- });
- });
- describe('004/customRequestType', function() {
- it('Should throw an error if a requestType is not specified and/or the image edits cannot be parsed', function() {
- // Arrange
- const event = {
- path : '/filters:rotate(90)/filters:grayscale()/other-image.jpg'
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- // Assert
- try {
- imageRequest.parseImageEdits(event, undefined);
- } catch (error) {
- expect(error).toEqual({
- 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(s3, secretsManager);
- const result = imageRequest.parseImageKey(event, 'Default');
- // Assert
- const expectedResult = 'sample-image-001.jpg';
- expect(result).toEqual(expectedResult);
- });
- });
- describe('002/defaultRequestType/withSlashRequest', function () {
- it('should read image requests with base64 encoding having slash', function () {
- const event = {
- path : '/eyJidWNrZXQiOiJlbGFzdGljYmVhbnN0YWxrLXVzLWVhc3QtMi0wNjY3ODQ4ODU1MTgiLCJrZXkiOiJlbnYtcHJvZC9nY2MvbGFuZGluZ3BhZ2UvMV81N19TbGltTl9MaWZ0LUNvcnNldC1Gb3ItTWVuLVNOQVAvYXR0YWNobWVudHMvZmZjMWYxNjAtYmQzOC00MWU4LThiYWQtZTNhMTljYzYxZGQzX1/Ys9mE2YrZhSDZhNmK2YHYqiAoMikuanBnIiwiZWRpdHMiOnsicmVzaXplIjp7IndpZHRoIjo0ODAsImZpdCI6ImNvdmVyIn19fQ=='
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageKey(event, 'Default');
- // Assert
- const expectedResult = 'env-prod/gcc/landingpage/1_57_SlimN_Lift-Corset-For-Men-SNAP/attachments/ffc1f160-bd38-41e8-8bad-e3a19cc61dd3__ุณููู
ูููุช (2).jpg';
- expect(result).toEqual(expectedResult);
-
- })
- });
- describe('003/thumborRequestType', function() {
- it('Should pass if an image key value is provided in the thumbor request format', function() {
- // Arrange
- const event = {
- path : '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg'
- }
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageKey(event, 'Thumbor');
- // Assert
- const expectedResult = 'thumbor-image.jpg';
- expect(result).toEqual(expectedResult);
- });
- });
- describe('004/customRequestType', function() {
- it('Should pass if an image key value is provided in the custom request format', function() {
- // Arrange
- const event = {
- path : '/filters-rotate(90)/filters-grayscale()/custom-image.jpg'
- };
- process.env.REWRITE_MATCH_PATTERN = /(filters-)/gm;
- process.env.REWRITE_SUBSTITUTION = 'filters:';
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageKey(event, 'Custom');
- // Assert
- const expectedResult = 'custom-image.jpg';
- expect(result).toEqual(expectedResult);
- });
- });
- describe('005/customRequestStringType', 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'
- };
- process.env.REWRITE_MATCH_PATTERN = '/(filters-)/gm';
- process.env.REWRITE_SUBSTITUTION = 'filters:';
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageKey(event, 'Custom');
- // Assert
- const expectedResult = 'custom-image.jpg';
- expect(result).toEqual(expectedResult);
- });
- });
- describe('006/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(s3, secretsManager);
- // Assert
- try {
- imageRequest.parseImageKey(event, undefined);
- } catch (error) {
- expect(error).toEqual({
- status: 404,
- code: 'ImageEdits::CannotFindImage',
- message: 'The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists.'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// parseRequestType()
-// ----------------------------------------------------------------------------
-describe('parseRequestType()', function() {
- describe('001/defaultRequestType', function() {
- it('Should pass if the method detects a default request', function() {
- // Arrange
- const event = {
- path: '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5IjoibXktc2FtcGxlLWtleSIsImVkaXRzIjp7ImdyYXlzY2FsZSI6dHJ1ZX19'
- }
- process.env = {};
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseRequestType(event);
- // Assert
- const expectedResult = 'Default';
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseRequestType(event);
- // Assert
- const expectedResult = 'Thumbor';
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.parseRequestType(event);
- // Assert
- const expectedResult = 'Custom';
- expect(result).toEqual(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.bmp'
- }
- process.env = {};
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- // Assert
- try {
- imageRequest.parseRequestType(event);
- } catch (error) {
- expect(error).toEqual({
- 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, svg) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// parseImageHaders()
-// ----------------------------------------------------------------------------
-describe('parseImageHaders()', function() {
- it('001/Should return headers if headers are provided for a sample base64-encoded image request', function() {
- // Arrange
- const event = {
- path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMifSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9'
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageHeaders(event, 'Default');
- // Assert
- const expectedResult = {
- 'Cache-Control': 'max-age=31536000,public'
- };
- expect(result).toEqual(expectedResult);
- });
- it('001/Should retrun undefined if headers are not provided for a base64-encoded image request', function() {
- // Arrange
- const event = {
- path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5In0='
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageHeaders(event, 'Default');
- // Assert
- expect(result).toEqual(undefined);
- });
- it('001/Should retrun undefined for Thumbor or Custom requests', function() {
- // Arrange
- const event = {
- path: '/test.jpg'
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.parseImageHeaders(event, 'Thumbor');
- // Assert
- expect(result).toEqual(undefined);
- });
-});
-
-// ----------------------------------------------------------------------------
-// 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(s3, secretsManager);
- const result = imageRequest.decodeRequest(event);
- // Assert
- const expectedResult = {
- bucket: 'bucket-name-here',
- key: 'key-name-here'
- };
- expect(result).toEqual(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(s3, secretsManager);
- // Assert
- try {
- imageRequest.decodeRequest(event);
- } catch (error) {
- expect(error).toEqual({
- 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(s3, secretsManager);
- // Assert
- try {
- imageRequest.decodeRequest(event);
- } catch (error) {
- expect(error).toEqual({
- 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(s3, secretsManager);
- const result = imageRequest.getAllowedSourceBuckets();
- // Assert
- const expectedResult = ['allowedBucket001', 'allowedBucket002'];
- expect(result).toEqual(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(s3, secretsManager);
- // Assert
- try {
- imageRequest.getAllowedSourceBuckets();
- } catch (error) {
- expect(error).toEqual({
- status: 400,
- code: 'GetAllowedSourceBuckets::NoSourceBuckets',
- message: 'The SOURCE_BUCKETS variable could not be read. Please check that it is not empty and contains at least one source bucket, or multiple buckets separated by commas. Spaces can be provided between commas and bucket names, these will be automatically parsed out when decoding.'
- });
- }
- });
- });
-});
-
-// ----------------------------------------------------------------------------
-// getOutputFormat()
-// ----------------------------------------------------------------------------
-describe('getOutputFormat()', function () {
- describe('001/AcceptsHeaderIncludesWebP', function () {
- it('Should pass if it returns "webp" for an accepts header which includes webp', function () {
- // Arrange
- process.env = {
- AUTO_WEBP: 'Yes'
- };
- const event = {
- headers: {
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
- }
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.getOutputFormat(event);
- // Assert
- expect(result).toEqual('webp');
- });
- });
- describe('002/AcceptsHeaderDoesNotIncludeWebP', function () {
- it('Should pass if it returns null for an accepts header which does not include webp', function () {
- // Arrange
- process.env = {
- AUTO_WEBP: 'Yes'
- };
- const event = {
- headers: {
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
- }
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.getOutputFormat(event);
- // Assert
- expect(result).toEqual(null);
- });
- });
- describe('003/AutoWebPDisabled', function () {
- it('Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp', function () {
- // Arrange
- process.env = {
- AUTO_WEBP: 'No'
- };
- const event = {
- headers: {
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
- }
- };
- // Act
- const imageRequest = new ImageRequest(s3, secretsManager);
- const result = imageRequest.getOutputFormat(event);
- // Assert
- expect(result).toEqual(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(s3, secretsManager);
- const result = imageRequest.getOutputFormat(event);
- // Assert
- expect(result).toEqual(null);
- });
- });
-});
diff --git a/source/image-handler/test/image-request.spec.ts b/source/image-handler/test/image-request.spec.ts
new file mode 100644
index 000000000..c7535c528
--- /dev/null
+++ b/source/image-handler/test/image-request.spec.ts
@@ -0,0 +1,1855 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsS3, mockAwsSecretManager } from './mock';
+
+import SecretsManager from 'aws-sdk/clients/secretsmanager';
+import S3 from 'aws-sdk/clients/s3';
+
+import { ImageRequest } from '../image-request';
+import { ImageHandlerError, RequestTypes, StatusCodes } from '../lib';
+import { SecretProvider } from '../secret-provider';
+
+describe('setup()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ let secretProvider = new SecretProvider(secretsManager);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ secretProvider = new SecretProvider(secretsManager); // need to re-create the provider to make sure the secret is not cached
+ });
+
+ describe('001/defaultImageRequest', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when a default image request is provided and populate the ImageRequest object with the proper values', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9'
+ };
+ process.env.SOURCE_BUCKETS = 'validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = 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
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/defaultImageRequest/toFormat', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when a default image request is provided and populate the ImageRequest object with the proper values', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0='
+ };
+ process.env.SOURCE_BUCKETS = 'validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'validBucket',
+ key: 'validKey',
+ edits: { toFormat: 'png' },
+ outputFormat: 'png',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/png'
+ };
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('003/thumborImageRequest', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values', async () => {
+ // Arrange
+ const event = { path: '/filters:grayscale()/test-image-001.jpg' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = 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
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'test-image-001.jpg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/thumborImageRequest/quality', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values', async () => {
+ // Arrange
+ const event = { path: '/filters:format(png)/filters:quality(50)/test-image-001.jpg' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Thumbor',
+ bucket: 'allowedBucket001',
+ key: 'test-image-001.jpg',
+ edits: {
+ toFormat: 'png',
+ png: { quality: 50 }
+ },
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ outputFormat: 'png',
+ contentType: 'image/png'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'test-image-001.jpg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('005/customImageRequest', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when a custom image request is provided and populate the ImageRequest object with the proper values', async () => {
+ // 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:'
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ 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(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: RequestTypes.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
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'custom-image.jpg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should pass when a custom image request is provided and populate the ImageRequest object with the proper values and no file extension', async () => {
+ // Arrange
+ const event = {
+ path: '/filters-rotate(90)/filters-grayscale()/custom-image'
+ };
+ process.env = {
+ SOURCE_BUCKETS: 'allowedBucket001, allowedBucket002',
+ REWRITE_MATCH_PATTERN: '/(filters-)/gm',
+ REWRITE_SUBSTITUTION: 'filters:'
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ 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(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: RequestTypes.CUSTOM,
+ bucket: 'allowedBucket001',
+ key: 'custom-image',
+ 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
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'allowedBucket001', Key: 'custom-image' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('006/errorCase', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when an error is caught', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfX0='
+ };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ await imageRequest.setup(event);
+ } catch (error) {
+ expect(error.code).toEqual('ImageBucket::CannotAccessBucket');
+ }
+ });
+ });
+
+ describe('007/enableSignature', () => {
+ const OLD_ENV = process.env;
+
+ beforeAll(() => {
+ process.env.ENABLE_SIGNATURE = 'Yes';
+ process.env.SECRETS_MANAGER = 'serverless-image-handler';
+ process.env.SECRET_KEY = 'signatureKey';
+ process.env.SOURCE_BUCKETS = 'validBucket';
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass when the image signature is correct', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
+ queryStringParameters: {
+ signature: '4d41311006641a56de7bca8abdbda91af254506107a2c7b338a13ca2fa95eac3'
+ }
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ SecretString: JSON.stringify({
+ [process.env.SECRET_KEY]: 'secret'
+ })
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'validBucket',
+ key: 'validKey',
+ edits: { toFormat: 'png' },
+ outputFormat: 'png',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/png'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should throw an error when queryStringParameters are missing', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0='
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ try {
+ await imageRequest.setup(event);
+ } catch (error) {
+ // Assert
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ code: 'AuthorizationQueryParametersError',
+ message: 'Query-string requires the signature parameter.'
+ });
+ }
+ });
+
+ it('Should throw an error when the image signature query parameter is missing', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
+ queryStringParameters: null
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ try {
+ await imageRequest.setup(event);
+ } catch (error) {
+ // Assert
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ message: 'Query-string requires the signature parameter.',
+ code: 'AuthorizationQueryParametersError'
+ });
+ }
+ });
+
+ it('Should throw an error when signature does not match', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
+ queryStringParameters: {
+ signature: 'invalid'
+ }
+ };
+
+ // Mock
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ SecretString: JSON.stringify({
+ [process.env.SECRET_KEY]: 'secret'
+ })
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ try {
+ await imageRequest.setup(event);
+ } catch (error) {
+ // Assert
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
+ expect(error).toMatchObject({
+ status: 403,
+ message: 'Signature does not match.',
+ code: 'SignatureDoesNotMatch'
+ });
+ }
+ });
+
+ it('Should throw an error when any other error occurs', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJwbmcifX0=',
+ queryStringParameters: {
+ signature: '4d41311006641a56de7bca8abdbda91af254506107a2c7b338a13ca2fa95eac3'
+ }
+ };
+
+ // Mock
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'SimulatedError'));
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ try {
+ await imageRequest.setup(event);
+ } catch (error) {
+ // Assert
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: process.env.SECRETS_MANAGER });
+ expect(error).toMatchObject({
+ status: StatusCodes.INTERNAL_SERVER_ERROR,
+ message: 'Signature validation failed.',
+ code: 'SignatureValidationFailure'
+ });
+ }
+ });
+ });
+
+ describe('008/SVGSupport', () => {
+ const OLD_ENV = process.env;
+
+ beforeAll(() => {
+ process.env.ENABLE_SIGNATURE = 'No';
+ process.env.SOURCE_BUCKETS = 'validBucket';
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should return SVG image when no edit is provided for the SVG image', async () => {
+ // Arrange
+ const event = {
+ path: '/image.svg'
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'image/svg+xml',
+ Body: Buffer.from('SampleImageContent\n')
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Thumbor',
+ bucket: 'validBucket',
+ key: 'image.svg',
+ edits: {},
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/svg+xml'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should return WebP image when there are any edits and no output is specified for the SVG image', async () => {
+ // Arrange
+ const event = {
+ path: '/100x100/image.svg'
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'image/svg+xml',
+ Body: Buffer.from('SampleImageContent\n')
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Thumbor',
+ bucket: 'validBucket',
+ key: 'image.svg',
+ edits: { resize: { width: 100, height: 100 } },
+ outputFormat: 'png',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/png'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should return JPG image when output is specified to JPG for the SVG image', async () => {
+ // Arrange
+ const event = {
+ path: '/filters:format(jpg)/image.svg'
+ };
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'image/svg+xml',
+ Body: Buffer.from('SampleImageContent\n')
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Thumbor',
+ bucket: 'validBucket',
+ key: 'image.svg',
+ edits: { toFormat: 'jpeg' },
+ outputFormat: 'jpeg',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/jpeg'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'image.svg' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('009/customHeaders', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('Should pass and return the customer headers if custom headers are provided', async () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMifSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9'
+ };
+ process.env.SOURCE_BUCKETS = 'validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'validBucket',
+ key: 'validKey',
+ headers: { 'Cache-Control': 'max-age=31536000,public' },
+ outputFormat: 'jpeg',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/jpeg'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+
+ describe('010/reductionEffort', () => {
+ it('Should pass when valid reduction effort is provided and output is webp', async () => {
+ const event = {
+ path: '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjozfQ=='
+ };
+ process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'test',
+ key: 'test.png',
+ edits: undefined,
+ headers: undefined,
+ outputFormat: 'webp',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/webp',
+ reductionEffort: 3
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'test', Key: 'test.png' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should pass and use default reduction effort if it is invalid type and output is webp', async () => {
+ const event = {
+ path: '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjoidGVzdCJ9'
+ };
+ process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'test',
+ key: 'test.png',
+ edits: undefined,
+ headers: undefined,
+ outputFormat: 'webp',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/webp',
+ reductionEffort: 4
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'test', Key: 'test.png' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should pass and use default reduction effort if it is out of range and output is webp', async () => {
+ const event = {
+ path: '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjoxMH0='
+ };
+ process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'test',
+ key: 'test.png',
+ edits: undefined,
+ headers: undefined,
+ outputFormat: 'webp',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/webp',
+ reductionEffort: 4
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'test', Key: 'test.png' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+
+ it('Should pass and not use reductionEffort if it is not provided and output is webp', async () => {
+ const event = {
+ path: '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIn0='
+ };
+ process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2';
+
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const imageRequestInfo = await imageRequest.setup(event);
+ const expectedResult = {
+ requestType: 'Default',
+ bucket: 'test',
+ key: 'test.png',
+ edits: undefined,
+ headers: undefined,
+ outputFormat: 'webp',
+ originalImage: Buffer.from('SampleImageContent\n'),
+ cacheControl: 'max-age=31536000,public',
+ contentType: 'image/webp'
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'test', Key: 'test.png' });
+ expect(imageRequestInfo).toEqual(expectedResult);
+ });
+ });
+});
+
+describe('getOriginalImage()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('001/imageExists', () => {
+ it('Should pass if the proper bucket name and key are supplied, simulating an image file that can be retrieved', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(result.originalImage).toEqual(Buffer.from('SampleImageContent\n'));
+ });
+ });
+
+ describe('002/imageDoesNotExist', () => {
+ it('Should throw an error if an invalid bucket or key name is provided, simulating a non-existent original image', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'SimulatedException'));
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ await imageRequest.getOriginalImage('invalidBucket', 'invalidKey');
+ } catch (error) {
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
+ expect(error.status).toEqual(StatusCodes.NOT_FOUND);
+ }
+ });
+ });
+
+ describe('003/unknownError', () => {
+ it('Should throw an error if an unknown problem happens when getting an object', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'SimulatedException'));
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ await imageRequest.getOriginalImage('invalidBucket', 'invalidKey');
+ } catch (error) {
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'invalidBucket', Key: 'invalidKey' });
+ expect(error.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR);
+ }
+ });
+ });
+
+ describe('004/noExtension', () => {
+ const testFiles = [
+ [0x89, 0x50, 0x4e, 0x47],
+ [0xff, 0xd8, 0xff, 0xdb],
+ [0xff, 0xd8, 0xff, 0xe0],
+ [0xff, 0xd8, 0xff, 0xee],
+ [0xff, 0xd8, 0xff, 0xe1],
+ [0x52, 0x49, 0x46, 0x46],
+ [0x49, 0x49, 0x2a, 0x00],
+ [0x4d, 0x4d, 0x00, 0x2a]
+ ];
+ const expectFileType = ['image/png', 'image/jpeg', 'image/jpeg', 'image/jpeg', 'image/jpeg', 'image/webp', 'image/tiff', 'image/tiff'];
+
+ testFiles.forEach((test, index) => {
+ it('Should pass and infer content type if there is no extension, had default s3 content type and it has a valid key and a valid bucket', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'binary/octet-stream',
+ Body: Buffer.from(new Uint8Array(test))
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(result.originalImage).toEqual(Buffer.from(new Uint8Array(test)));
+ expect(result.contentType).toEqual(expectFileType[index]);
+ });
+
+ it('Should pass and infer content type if there is no extension, had default s3 content type and it has a valid key and a valid bucket and content type is application/octet-stream', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'application/octet-stream',
+ Body: Buffer.from(new Uint8Array(test))
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = await imageRequest.getOriginalImage('validBucket', 'validKey');
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(result.originalImage).toEqual(Buffer.from(new Uint8Array(test)));
+ expect(result.contentType).toEqual(expectFileType[index]);
+ });
+
+ it('Should fail to infer content type if there is no extension and file header is not recognized', async () => {
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ ContentType: 'binary/octet-stream',
+ Body: Buffer.from(new Uint8Array(test))
+ });
+ }
+ }));
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ try {
+ await imageRequest.getOriginalImage('validBucket', 'validKey');
+ } catch (error) {
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' });
+ expect(error.status).toEqual(500);
+ }
+ });
+ });
+ });
+});
+
+describe('parseImageBucket()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/defaultRequestType/bucketSpecifiedInRequest/allowed', () => {
+ it('Should pass if the bucket name is provided in the image request and has been allowed in SOURCE_BUCKETS', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJhbGxvd2VkQnVja2V0MDAxIiwia2V5Ijoic2FtcGxlSW1hZ2VLZXkwMDEuanBnIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjoidHJ1ZSJ9fQ==' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageBucket(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = 'allowedBucket001';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/defaultRequestType/bucketSpecifiedInRequest/notAllowed', () => {
+ it('Should throw an error if the bucket name is provided in the image request but has not been allowed in SOURCE_BUCKETS', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJhbGxvd2VkQnVja2V0MDAxIiwia2V5Ijoic2FtcGxlSW1hZ2VLZXkwMDEuanBnIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjoidHJ1ZSJ9fQ==' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket003, allowedBucket004';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.parseImageBucket(event, RequestTypes.DEFAULT);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.FORBIDDEN,
+ 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', () => {
+ 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', () => {
+ // Arrange
+ const event = { path: '/eyJrZXkiOiJzYW1wbGVJbWFnZUtleTAwMS5qcGciLCJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIn19==' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageBucket(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = 'allowedBucket001';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/thumborRequestType', () => {
+ it('Should pass if there is at least one SOURCE_BUCKET specified that can be used as the default for Thumbor requests', () => {
+ // Arrange
+ const event = { path: '/filters:grayscale()/test-image-001.jpg' };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'allowedBucket001';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('005/customRequestType', () => {
+ it('Should pass if there is at least one SOURCE_BUCKET specified that can be used as the default for Custom requests', () => {
+ // Arrange
+ const event = { path: '/filters:grayscale()/test-image-001.jpg' };
+
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageBucket(event, RequestTypes.CUSTOM);
+
+ // Assert
+ const expectedResult = 'allowedBucket001';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('006/invalidRequestType', () => {
+ it('Should pass if there is at least one SOURCE_BUCKET specified that can be used as the default for Custom requests', () => {
+ // Arrange
+ const event = {
+ path: '/filters:grayscale()/test-image-001.jpg'
+ };
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.parseImageBucket(event, undefined);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.NOT_FOUND,
+ code: 'ImageBucket::CannotFindBucket',
+ message: 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.'
+ });
+ }
+ });
+ });
+});
+
+describe('parseImageEdits()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/defaultRequestType', () => {
+ it('Should pass if the proper result is returned for a sample base64-encoded image request', () => {
+ // Arrange
+ const event = { path: '/eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0=' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageEdits(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = {
+ grayscale: 'true',
+ rotate: 90,
+ flip: 'true'
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/thumborRequestType', () => {
+ it('Should pass if the proper result is returned for a sample thumbor-type image request', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageEdits(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = {
+ rotate: 90,
+ grayscale: true
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('003/customRequestType', () => {
+ it('Should pass if the proper result is returned for a sample custom-type image request', () => {
+ // Arrange
+ const event = { path: '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg' };
+
+ process.env = {
+ REWRITE_MATCH_PATTERN: '/(filters-)/gm',
+ REWRITE_SUBSTITUTION: 'filters:'
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageEdits(event, RequestTypes.CUSTOM);
+
+ // Assert
+ const expectedResult = {
+ rotate: 90,
+ grayscale: true
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/customRequestType', () => {
+ it('Should throw an error if a requestType is not specified and/or the image edits cannot be parsed', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/other-image.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.parseImageEdits(event, undefined);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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.'
+ });
+ }
+ });
+ });
+});
+
+describe('parseImageKey()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/defaultRequestType', () => {
+ it('Should pass if an image key value is provided in the default request format', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5Ijoic2FtcGxlLWltYWdlLTAwMS5qcGcifQ==' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = 'sample-image-001.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/defaultRequestType/withSlashRequest', () => {
+ it('should read image requests with base64 encoding having slash', () => {
+ const event = {
+ path: '/eyJidWNrZXQiOiJlbGFzdGljYmVhbnN0YWxrLXVzLWVhc3QtMi0wNjY3ODQ4ODU1MTgiLCJrZXkiOiJlbnYtcHJvZC9nY2MvbGFuZGluZ3BhZ2UvMV81N19TbGltTl9MaWZ0LUNvcnNldC1Gb3ItTWVuLVNOQVAvYXR0YWNobWVudHMvZmZjMWYxNjAtYmQzOC00MWU4LThiYWQtZTNhMTljYzYxZGQzX1/Ys9mE2YrZhSDZhNmK2YHYqiAoMikuanBnIiwiZWRpdHMiOnsicmVzaXplIjp7IndpZHRoIjo0ODAsImZpdCI6ImNvdmVyIn19fQ=='
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = 'env-prod/gcc/landingpage/1_57_SlimN_Lift-Corset-For-Men-SNAP/attachments/ffc1f160-bd38-41e8-8bad-e3a19cc61dd3__ุณููู
ูููุช (2).jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('003/thumborRequestType', () => {
+ it('Should pass if an image key value is provided in the thumbor request format', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/thumborRequestType/withParenthesesRequest', () => {
+ it('Should pass if an image key value is provided in the thumbor request format having open, close parentheses', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1).jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1).jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request format having open parentheses', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request format having close parentheses', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image 1).jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image 1).jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request format having close parentheses in the middle of the name', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1) suffix.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request and the path has crop filter', () => {
+ // Arrange
+ const event = { path: '/10x10:100x100/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1) suffix.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request and the path has resize filter', () => {
+ // Arrange
+ const event = { path: '/10x10/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1) suffix.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request and the path has crop and resize filters', () => {
+ // Arrange
+ const event = { path: '/10x20:100x200/10x10/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'thumbor-image (1) suffix.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if an image key value is provided in the thumbor request and the key string has substring "fit-in"', () => {
+ // Arrange
+ const event = { path: '/fit-in/400x0/filters:fill(ffffff)/fit-in-thumbor-image (1) suffix.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'fit-in-thumbor-image (1) suffix.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('Should pass if the image in the sub-directory', () => {
+ // Arrange
+ const event = { path: '/100x100/test-100x100/test/beach-100x100.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR);
+
+ // Assert
+ const expectedResult = 'test-100x100/test/beach-100x100.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('005/customRequestType', () => {
+ it('Should pass if an image key value is provided in the custom request format', () => {
+ // Arrange
+ const event = { path: '/filters-rotate(90)/filters-grayscale()/custom-image.jpg' };
+
+ process.env = {
+ REWRITE_MATCH_PATTERN: '/(filters-)/gm',
+ REWRITE_SUBSTITUTION: 'filters:'
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.CUSTOM);
+
+ // Assert
+ const expectedResult = 'custom-image.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('006/customRequestStringType', () => {
+ it('Should pass if an image key value is provided in the custom request format', () => {
+ // Arrange
+ const event = { path: '/filters-rotate(90)/filters-grayscale()/custom-image.jpg' };
+
+ process.env = {
+ REWRITE_MATCH_PATTERN: '/(filters-)/gm',
+ REWRITE_SUBSTITUTION: 'filters:'
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageKey(event, RequestTypes.CUSTOM);
+
+ // Assert
+ const expectedResult = 'custom-image.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('007/elseCondition', () => {
+ it('Should throw an error if an unrecognized requestType is passed into the function as a parameter', () => {
+ // Arrange
+ const event = { path: '/filters:rotate(90)/filters:grayscale()/other-image.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.parseImageKey(event, undefined);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.NOT_FOUND,
+ 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.'
+ });
+ }
+ });
+ });
+});
+
+describe('parseRequestType()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/defaultRequestType', () => {
+ it('Should pass if the method detects a default request', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5IjoibXktc2FtcGxlLWtleSIsImVkaXRzIjp7ImdyYXlzY2FsZSI6dHJ1ZX19' };
+ process.env = {};
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseRequestType(event);
+
+ // Assert
+ const expectedResult = RequestTypes.DEFAULT;
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/thumborRequestType', () => {
+ it('Should pass if the method detects a thumbor request', () => {
+ // 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(s3Client, secretProvider);
+ const result = imageRequest.parseRequestType(event);
+
+ // Assert
+ const expectedResult = RequestTypes.THUMBOR;
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('003/customRequestType', () => {
+ it('Should pass if the method detects a custom request', () => {
+ // Arrange
+ const event = { path: '/additionalImageRequestParameters/image.jpg' };
+ process.env = {
+ REWRITE_MATCH_PATTERN: 'matchPattern',
+ REWRITE_SUBSTITUTION: 'substitutionString'
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseRequestType(event);
+
+ // Assert
+ const expectedResult = RequestTypes.CUSTOM;
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/elseCondition', () => {
+ it('Should throw an error if the method cannot determine the request type based on the three groups given', () => {
+ // Arrange
+ const event = { path: '12x12e24d234r2ewxsad123d34r.bmp' };
+
+ process.env = {};
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.parseRequestType(event);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ code: 'RequestTypeError',
+ message:
+ 'The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.'
+ });
+ }
+ });
+ });
+});
+
+describe('parseImageHeaders()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+
+ it('001/Should return headers if headers are provided for a sample base64-encoded image request', () => {
+ // Arrange
+ const event = {
+ path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMifSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9'
+ };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageHeaders(event, RequestTypes.DEFAULT);
+
+ // Assert
+ const expectedResult = {
+ 'Cache-Control': 'max-age=31536000,public'
+ };
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('001/Should return undefined if headers are not provided for a base64-encoded image request', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5In0=' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageHeaders(event, RequestTypes.DEFAULT);
+
+ // Assert
+ expect(result).toEqual(undefined);
+ });
+
+ it('001/Should return undefined for Thumbor or Custom requests', () => {
+ // Arrange
+ const event = { path: '/test.jpg' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.parseImageHeaders(event, RequestTypes.THUMBOR);
+
+ // Assert
+ expect(result).toEqual(undefined);
+ });
+});
+
+describe('decodeRequest()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+
+ describe('001/validRequestPathSpecified', () => {
+ it('Should pass if a valid base64-encoded path has been specified', () => {
+ // Arrange
+ const event = { path: '/eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.decodeRequest(event);
+
+ // Assert
+ const expectedResult = {
+ bucket: 'bucket-name-here',
+ key: 'key-name-here'
+ };
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/invalidRequestPathSpecified', () => {
+ it('Should throw an error if a valid base64-encoded path has not been specified', () => {
+ // Arrange
+ const event = { path: '/someNonBase64EncodedContentHere' };
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.decodeRequest(event);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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', () => {
+ it('Should throw an error if no path is specified at all', () => {
+ // Arrange
+ const event = {};
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.decodeRequest(event);
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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.'
+ });
+ }
+ });
+ });
+});
+
+describe('getAllowedSourceBuckets()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+
+ describe('001/sourceBucketsSpecified', () => {
+ it('Should pass if the SOURCE_BUCKETS environment variable is not empty and contains valid inputs', () => {
+ // Arrange
+ process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.getAllowedSourceBuckets();
+
+ // Assert
+ const expectedResult = ['allowedBucket001', 'allowedBucket002'];
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/noSourceBucketsSpecified', () => {
+ it('Should throw an error if the SOURCE_BUCKETS environment variable is empty or does not contain valid values', () => {
+ // Arrange
+ process.env = {};
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+
+ // Assert
+ try {
+ imageRequest.getAllowedSourceBuckets();
+ } catch (error) {
+ expect(error).toMatchObject({
+ status: StatusCodes.BAD_REQUEST,
+ 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.'
+ });
+ }
+ });
+ });
+});
+
+describe('getOutputFormat()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/AcceptsHeaderIncludesWebP', () => {
+ it('Should pass if it returns "webp" for an accepts header which includes webp', () => {
+ // 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'
+ }
+ };
+ process.env.AUTO_WEBP = 'Yes';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.getOutputFormat(event);
+
+ // Assert
+ expect(result).toEqual('webp');
+ });
+ });
+
+ describe('002/AcceptsHeaderDoesNotIncludeWebP', () => {
+ it('Should pass if it returns null for an accepts header which does not include webp', () => {
+ // Arrange
+ const event = {
+ headers: {
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/apng,*/*;q=0.8,application/signed-exchange;v=b3'
+ }
+ };
+ process.env.AUTO_WEBP = 'Yes';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.getOutputFormat(event);
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('003/AutoWebPDisabled', () => {
+ it('Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp', () => {
+ // 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'
+ }
+ };
+ process.env.AUTO_WEBP = 'No';
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.getOutputFormat(event);
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('004/AutoWebPUnset', () => {
+ it('Should pass if it returns null when AUTO_WEBP is not set with accepts header including webp', () => {
+ // 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(s3Client, secretProvider);
+ const result = imageRequest.getOutputFormat(event);
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+});
+
+describe('inferImageType()', () => {
+ const s3Client = new S3();
+ const secretsManager = new SecretsManager();
+ const secretProvider = new SecretProvider(secretsManager);
+
+ describe('001/shouldInferImageType', () => {
+ it('Should pass if it returns "image/jpeg"', () => {
+ // Arrange
+ const imageBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xee, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
+
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ const result = imageRequest.inferImageType(imageBuffer);
+
+ // Assert
+ expect(result).toEqual('image/jpeg');
+ });
+ });
+
+ describe('002/shouldNotInferImageType', () => {
+ it('Should pass throw an exception', () => {
+ // Arrange
+ const imageBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
+
+ try {
+ // Act
+ const imageRequest = new ImageRequest(s3Client, secretProvider);
+ imageRequest.inferImageType(imageBuffer);
+ } catch (error) {
+ // Assert
+ expect(error.status).toEqual(500);
+ expect(error.code).toEqual('RequestTypeError');
+ expect(error.message).toEqual(
+ 'The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests.'
+ );
+ }
+ });
+ });
+});
diff --git a/source/image-handler/test/image/test.jpg b/source/image-handler/test/image/1x1.jpg
similarity index 100%
rename from source/image-handler/test/image/test.jpg
rename to source/image-handler/test/image/1x1.jpg
diff --git a/source/image-handler/test/image/25x15.png b/source/image-handler/test/image/25x15.png
new file mode 100644
index 000000000..306608f1d
Binary files /dev/null and b/source/image-handler/test/image/25x15.png differ
diff --git a/source/image-handler/test/index.spec.js b/source/image-handler/test/index.spec.ts
similarity index 54%
rename from source/image-handler/test/index.spec.js
rename to source/image-handler/test/index.spec.ts
index db0e19c3b..30746c741 100644
--- a/source/image-handler/test/index.spec.js
+++ b/source/image-handler/test/index.spec.ts
@@ -1,81 +1,71 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-const mockS3 = jest.fn();
-jest.mock('aws-sdk', () => {
- return {
- S3: jest.fn(() => ({
- getObject: mockS3
- })),
- Rekognition: jest.fn(),
- SecretsManager: jest.fn()
- };
-});
-
-// Import index.js
-const index = require('../index.js');
+import { mockAwsS3 } from './mock';
+import { handler } from '../index';
+import { ImageHandlerError, ImageHandlerEvent, StatusCodes } from '../lib';
-describe('index', function() {
+describe('index', () => {
// Arrange
process.env.SOURCE_BUCKETS = 'source-bucket';
const mockImage = Buffer.from('SampleImageContent\n');
const mockFallbackImage = Buffer.from('SampleFallbackImageContent\n');
- describe('TC: Success', function() {
+ describe('TC: Success', () => {
beforeEach(() => {
- // Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- Body: mockImage,
- ContentType: 'image/jpeg'
- });
- }
- };
- });
- })
+ // Mock
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ Body: mockImage,
+ ContentType: 'image/jpeg'
+ });
+ }
+ }));
+ });
- it('001/should return the image when there is no error', async function() {
+ it('001/should return the image when there is no error', async () => {
// Arrange
- const event = {
- path: '/test.jpg'
- };
+ const event: ImageHandlerEvent = { path: '/test.jpg' };
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 200,
+ statusCode: StatusCodes.OK,
isBase64Encoded: true,
headers: {
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': true,
'Content-Type': 'image/jpeg',
- 'Expires': undefined,
+ Expires: undefined,
'Cache-Control': 'max-age=31536000,public',
'Last-Modified': undefined
},
body: mockImage.toString('base64')
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
- it('002/should return the image with custom headers when custom headers are provided', async function() {
+
+ it('002/should return the image with custom headers when custom headers are provided', async () => {
// Arrange
- const event = {
+ const event: ImageHandlerEvent = {
path: '/eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidGVzdC5qcGciLCJoZWFkZXJzIjp7IkN1c3RvbS1IZWFkZXIiOiJDdXN0b21WYWx1ZSJ9fQ=='
};
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 200,
+ statusCode: StatusCodes.OK,
headers: {
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': true,
'Content-Type': 'image/jpeg',
- 'Expires': undefined,
+ Expires: undefined,
'Cache-Control': 'max-age=31536000,public',
'Last-Modified': undefined,
'Custom-Header': 'CustomValue'
@@ -83,61 +73,58 @@ describe('index', function() {
body: mockImage.toString('base64'),
isBase64Encoded: true
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
- it('003/should return the image when the request is from ALB', async function() {
+
+ it('003/should return the image when the request is from ALB', async () => {
// Arrange
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg',
requestContext: {
elb: {}
}
};
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 200,
+ statusCode: StatusCodes.OK,
isBase64Encoded: true,
headers: {
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Content-Type': 'image/jpeg',
- 'Expires': undefined,
+ Expires: undefined,
'Cache-Control': 'max-age=31536000,public',
'Last-Modified': undefined
},
body: mockImage.toString('base64')
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
});
- describe('TC: Error', function() {
- it('001/should return an error JSON when an error occurs', async function() {
+ describe('TC: Error', () => {
+ it('001/should return an error JSON when an error occurs', async () => {
// Arrange
- const event = {
- path: '/test.jpg'
- };
+ const event: ImageHandlerEvent = { path: '/test.jpg' };
// Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- status: 404,
- message: 'NoSuchKey error happened.'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.'));
+ }
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 404,
+ statusCode: StatusCodes.NOT_FOUND,
isBase64Encoded: false,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -146,35 +133,37 @@ describe('index', function() {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- status: 404,
+ status: StatusCodes.NOT_FOUND,
code: 'NoSuchKey',
- message: 'NoSuchKey error happened.'
+ message: `The image test.jpg does not exist or the request may not be base64 encoded properly.`
})
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
- it('002/should return 500 error when there is no error status in the error', async function() {
+
+ it('002/should return 500 error when there is no error status in the error', async () => {
// Arrange
- const event = {
+ const event: ImageHandlerEvent = {
path: 'eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidGVzdC5qcGciLCJlZGl0cyI6eyJ3cm9uZ0ZpbHRlciI6dHJ1ZX19'
};
+
// Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.resolve({
- Body: mockImage,
- ContentType: 'image/jpeg'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({
+ Body: mockImage,
+ ContentType: 'image/jpeg'
+ });
+ }
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 500,
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
isBase64Encoded: false,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -185,45 +174,47 @@ describe('index', function() {
body: JSON.stringify({
message: 'Internal error. Please contact the system administrator.',
code: 'InternalError',
- status: 500
+ status: StatusCodes.INTERNAL_SERVER_ERROR
})
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
- it('003/should return the default fallback image when an error occurs if the default fallback image is enabled', async function() {
+
+ it('003/should return the default fallback image when an error occurs if the default fallback image is enabled', async () => {
// Arrange
process.env.ENABLE_DEFAULT_FALLBACK_IMAGE = 'Yes';
process.env.DEFAULT_FALLBACK_IMAGE_BUCKET = 'fallback-image-bucket';
process.env.DEFAULT_FALLBACK_IMAGE_KEY = 'fallback-image.png';
process.env.CORS_ENABLED = 'Yes';
process.env.CORS_ORIGIN = '*';
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg'
};
+
// Mock
- mockS3.mockReset();
- mockS3.mockImplementationOnce(() => {
- return {
+ mockAwsS3.getObject.mockReset();
+ mockAwsS3.getObject
+ .mockImplementationOnce(() => ({
promise() {
- return Promise.reject('UnknownError');
+ return Promise.reject(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'UnknownError', null));
}
- };
- }).mockImplementationOnce(() => {
- return {
+ }))
+ .mockImplementationOnce(() => ({
promise() {
return Promise.resolve({
Body: mockFallbackImage,
ContentType: 'image/png'
});
}
- };
- });
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 500,
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
isBase64Encoded: true,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -236,33 +227,31 @@ describe('index', function() {
},
body: mockFallbackImage.toString('base64')
};
+
// Assert
- expect(mockS3).toHaveBeenNthCalledWith(1, { Bucket: 'source-bucket', Key: 'test.jpg' });
- expect(mockS3).toHaveBeenNthCalledWith(2, { Bucket: 'fallback-image-bucket', Key: 'fallback-image.png' });
+ expect(mockAwsS3.getObject).toHaveBeenNthCalledWith(1, { Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenNthCalledWith(2, { Bucket: 'fallback-image-bucket', Key: 'fallback-image.png' });
expect(result).toEqual(expectedResult);
});
- it('004/should return an error JSON when getting the default fallback image fails if the default fallback image is enabled', async function() {
+
+ it('004/should return an error JSON when getting the default fallback image fails if the default fallback image is enabled', async () => {
// Arrange
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg'
};
+
// Mock
- mockS3.mockReset();
- mockS3.mockImplementation(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- status: 404,
- message: 'NoSuchKey error happened.'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockReset();
+ mockAwsS3.getObject.mockImplementation(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.'));
+ }
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 404,
+ statusCode: StatusCodes.NOT_FOUND,
isBase64Encoded: false,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -272,38 +261,36 @@ describe('index', function() {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- status: 404,
+ status: StatusCodes.NOT_FOUND,
code: 'NoSuchKey',
- message: 'NoSuchKey error happened.'
+ message: `The image test.jpg does not exist or the request may not be base64 encoded properly.`
})
};
+
// Assert
- expect(mockS3).toHaveBeenNthCalledWith(1, { Bucket: 'source-bucket', Key: 'test.jpg' });
- expect(mockS3).toHaveBeenNthCalledWith(2, { Bucket: 'fallback-image-bucket', Key: 'fallback-image.png' });
+ expect(mockAwsS3.getObject).toHaveBeenNthCalledWith(1, { Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenNthCalledWith(2, { Bucket: 'fallback-image-bucket', Key: 'fallback-image.png' });
expect(result).toEqual(expectedResult);
});
- it('005/should return an error JSON when the default fallback image key is not provided if the default fallback image is enabled', async function() {
+
+ it('005/should return an error JSON when the default fallback image key is not provided if the default fallback image is enabled', async () => {
// Arrange
process.env.DEFAULT_FALLBACK_IMAGE_KEY = '';
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg'
};
+
// Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- status: 404,
- message: 'NoSuchKey error happened.'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.'));
+ }
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 404,
+ statusCode: StatusCodes.NOT_FOUND,
isBase64Encoded: false,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -313,37 +300,35 @@ describe('index', function() {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- status: 404,
+ status: StatusCodes.NOT_FOUND,
code: 'NoSuchKey',
- message: 'NoSuchKey error happened.'
+ message: `The image test.jpg does not exist or the request may not be base64 encoded properly.`
})
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
- it('006/should return an error JSON when the default fallback image bucket is not provided if the default fallback image is enabled', async function() {
+
+ it('006/should return an error JSON when the default fallback image bucket is not provided if the default fallback image is enabled', async () => {
// Arrange
process.env.DEFAULT_FALLBACK_IMAGE_BUCKET = '';
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg'
};
+
// Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- status: 404,
- message: 'NoSuchKey error happened.'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.'));
+ }
+ }));
+
// Act
- const result = await index.handler(event);
+ const result = await handler(event);
const expectedResult = {
- statusCode: 404,
+ statusCode: StatusCodes.NOT_FOUND,
isBase64Encoded: false,
headers: {
'Access-Control-Allow-Methods': 'GET',
@@ -353,55 +338,54 @@ describe('index', function() {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- status: 404,
+ status: StatusCodes.NOT_FOUND,
code: 'NoSuchKey',
- message: 'NoSuchKey error happened.'
+ message: `The image test.jpg does not exist or the request may not be base64 encoded properly.`
})
};
+
// Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
expect(result).toEqual(expectedResult);
});
});
- it('007/should return an error JSON when ALB request is failed', async function() {
+
+ it('007/should return an error JSON when ALB request is failed', async () => {
// Arrange
- const event = {
+ const event: ImageHandlerEvent = {
path: '/test.jpg',
requestContext: {
elb: {}
}
};
+
// Mock
- mockS3.mockImplementationOnce(() => {
- return {
- promise() {
- return Promise.reject({
- code: 'NoSuchKey',
- status: 404,
- message: 'NoSuchKey error happened.'
- });
- }
- };
- });
+ mockAwsS3.getObject.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.reject(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.'));
+ }
+ }));
+
// Act
- const result = await index.handler(event);
- const expectedResult = {
- statusCode: 404,
- isBase64Encoded: false,
- headers: {
- 'Access-Control-Allow-Methods': 'GET',
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
- 'Access-Control-Allow-Origin': '*',
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- status: 404,
- code: 'NoSuchKey',
- message: 'NoSuchKey error happened.'
- })
- };
- // Assert
- expect(mockS3).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
- expect(result).toEqual(expectedResult);
+ const result = await handler(event);
+ const expectedResult = {
+ statusCode: StatusCodes.NOT_FOUND,
+ isBase64Encoded: false,
+ headers: {
+ 'Access-Control-Allow-Methods': 'GET',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ 'Access-Control-Allow-Origin': '*',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ status: StatusCodes.NOT_FOUND,
+ code: 'NoSuchKey',
+ message: `The image test.jpg does not exist or the request may not be base64 encoded properly.`
+ })
+ };
+
+ // Assert
+ expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'source-bucket', Key: 'test.jpg' });
+ expect(result).toEqual(expectedResult);
});
-});
\ No newline at end of file
+});
diff --git a/source/image-handler/test/mock.ts b/source/image-handler/test/mock.ts
new file mode 100644
index 000000000..61eaeee21
--- /dev/null
+++ b/source/image-handler/test/mock.ts
@@ -0,0 +1,28 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export const mockAwsS3 = {
+ headObject: jest.fn(),
+ copyObject: jest.fn(),
+ getObject: jest.fn(),
+ putObject: jest.fn(),
+ headBucket: jest.fn(),
+ createBucket: jest.fn(),
+ putBucketEncryption: jest.fn(),
+ putBucketPolicy: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/s3', () => jest.fn(() => ({ ...mockAwsS3 })));
+
+export const mockAwsSecretManager = {
+ getSecretValue: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/secretsmanager', () => jest.fn(() => ({ ...mockAwsSecretManager })));
+
+export const mockAwsRekognition = {
+ detectFaces: jest.fn(),
+ detectModerationLabels: jest.fn()
+};
+
+jest.mock('aws-sdk/clients/rekognition', () => jest.fn(() => ({ ...mockAwsRekognition })));
diff --git a/source/image-handler/test/secret-provider.spec.ts b/source/image-handler/test/secret-provider.spec.ts
new file mode 100644
index 000000000..92403d38d
--- /dev/null
+++ b/source/image-handler/test/secret-provider.spec.ts
@@ -0,0 +1,56 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mockAwsSecretManager } from './mock';
+import SecretsManager from 'aws-sdk/clients/secretsmanager';
+import { SecretProvider } from '../secret-provider';
+
+describe('index', () => {
+ const secretsManager = new SecretsManager();
+
+ afterEach(() => {
+ mockAwsSecretManager.getSecretValue.mockReset();
+ });
+
+ it('Should get a secret from secret manager if the cache is empty', async () => {
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ SecretString: 'secret_value' });
+ }
+ }));
+
+ const secretProvider = new SecretProvider(secretsManager);
+ const secretKeyFistCall = await secretProvider.getSecret('secret_id');
+ const secretKeySecondCall = await secretProvider.getSecret('secret_id');
+
+ expect(secretKeyFistCall).toEqual('secret_value');
+ expect(secretKeySecondCall).toEqual('secret_value');
+ expect(mockAwsSecretManager.getSecretValue).toBeCalledTimes(1);
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: 'secret_id' });
+ });
+
+ it('Should get a secret from secret manager and invalidate the cache', async () => {
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ SecretString: 'secret_value_1' });
+ }
+ }));
+ mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({
+ promise() {
+ return Promise.resolve({ SecretString: 'secret_value_2' });
+ }
+ }));
+
+ const secretProvider = new SecretProvider(secretsManager);
+ const getSecretKeyFistCall = await secretProvider.getSecret('secret_id_1');
+ const getSecretKeySecondCall = await secretProvider.getSecret('secret_id_2');
+ const getSecretKeyThirdCall = await secretProvider.getSecret('secret_id_2');
+
+ expect(getSecretKeyFistCall).toEqual('secret_value_1');
+ expect(getSecretKeySecondCall).toEqual('secret_value_2');
+ expect(getSecretKeyThirdCall).toEqual('secret_value_2');
+ expect(mockAwsSecretManager.getSecretValue).toBeCalledTimes(2);
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: 'secret_id_1' });
+ expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ SecretId: 'secret_id_2' });
+ });
+});
diff --git a/source/image-handler/test/setJestEnvironmentVariables.ts b/source/image-handler/test/setJestEnvironmentVariables.ts
new file mode 100644
index 000000000..44c40ab35
--- /dev/null
+++ b/source/image-handler/test/setJestEnvironmentVariables.ts
@@ -0,0 +1,4 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// a placeholder for environment variables
diff --git a/source/image-handler/test/thumbor-mapper.spec.ts b/source/image-handler/test/thumbor-mapper.spec.ts
new file mode 100644
index 000000000..940f42d42
--- /dev/null
+++ b/source/image-handler/test/thumbor-mapper.spec.ts
@@ -0,0 +1,975 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { ImageEdits, ImageFormatTypes } from '../lib';
+import { ThumborMapper } from '../thumbor-mapper';
+
+describe('process()', () => {
+ describe('001/thumborRequest', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/fit-in/200x300/filters:grayscale()/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: 200,
+ height: 300,
+ fit: 'inside'
+ },
+ grayscale: true
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('002/resize/fit-in', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/fit-in/400x300/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: 400,
+ height: 300,
+ fit: 'inside'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('003/resize/fit-in/noResizeValues', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/fit-in/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: { fit: 'inside' }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('004/resize/not-fit-in', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/400x300/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: 400,
+ height: 300
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('005/resize/widthIsZero', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/0x300/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: null,
+ height: 300,
+ fit: 'inside'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('006/resize/heightIsZero', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/400x0/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: 400,
+ height: null,
+ fit: 'inside'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('007/resize/widthAndHeightAreZero', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ // Arrange
+ const path = '/0x0/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ resize: {
+ width: null,
+ height: null,
+ fit: 'inside'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('008/crop', () => {
+ it('Should pass if the proper crop is applied', () => {
+ // Arrange
+ const path = '/10x0:100x200/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ crop: {
+ left: 10,
+ top: 0,
+ width: 90,
+ height: 200
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+
+ it('Should ignore crop if invalid dimension values are provided', () => {
+ // Arrange
+ const path = '/abc:0:10x200/test-image-001.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = { edits: {} };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+
+ it('Should pass if the proper crop and resize are applied', () => {
+ // Arrange
+ const path = '/10x0:100x200/10x20/test-image-001.jpg';
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = {
+ edits: {
+ crop: {
+ left: 10,
+ top: 0,
+ width: 90,
+ height: 200
+ },
+ resize: {
+ width: 10,
+ height: 20
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult.edits);
+ });
+ });
+
+ describe('009/noFileExtension', () => {
+ it('Should pass when format and quality filters are passed and file does not have extension', () => {
+ // Arrange
+ const path = '/filters:format(jpeg)/filters:quality(50)/image_without_extension';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = { toFormat: 'jpeg', jpeg: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+
+ it('Should pass when quality and format filters are passed and file does not have extension', () => {
+ // Arrange
+ const path = '/filters:quality(50)/filters:format(jpeg)/image_without_extension';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = { toFormat: 'jpeg', jpeg: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+
+ it('Should pass when quality and format filters are passed and file has extension', () => {
+ // Arrange
+ const path = '/filters:quality(50)/filters:format(jpeg)/image_without_extension.png';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapPathToEdits(path);
+
+ // Assert
+ const expectedResult = { toFormat: 'jpeg', png: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+});
+
+describe('parseCustomPath()', () => {
+ const OLD_ENV = {
+ REWRITE_MATCH_PATTERN: '/(filters-)/gm',
+ REWRITE_SUBSTITUTION: 'filters:'
+ };
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ process.env = OLD_ENV;
+ });
+
+ describe('001/validPath', () => {
+ it('Should pass if the proper edit translations are applied and in the correct order', () => {
+ const path = '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg';
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const result = thumborMapper.parseCustomPath(path);
+
+ // Assert
+ const expectedResult = '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg';
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/undefinedPath', () => {
+ it('Should throw an error if the path is not defined', () => {
+ const path = undefined;
+ // Act
+ const thumborMapper = new ThumborMapper();
+
+ // Assert
+ expect(() => {
+ thumborMapper.parseCustomPath(path);
+ }).toThrowError(new Error('ThumborMapping::ParseCustomPath::PathUndefined'));
+ });
+ });
+
+ describe('003/REWRITE_MATCH_PATTERN', () => {
+ it('Should throw an error if the environment variables are left undefined', () => {
+ const path = '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg';
+
+ // Act
+ delete process.env.REWRITE_MATCH_PATTERN;
+ const thumborMapper = new ThumborMapper();
+
+ // Assert
+ expect(() => {
+ thumborMapper.parseCustomPath(path);
+ }).toThrowError(new Error('ThumborMapping::ParseCustomPath::RewriteMatchPatternUndefined'));
+ });
+ });
+
+ describe('004/REWRITE_SUBSTITUTION', () => {
+ it('Should throw an error if the path is not defined', () => {
+ const path = '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg';
+
+ // Act
+ delete process.env.REWRITE_SUBSTITUTION;
+
+ const thumborMapper = new ThumborMapper();
+ // Assert
+ expect(() => {
+ thumborMapper.parseCustomPath(path);
+ }).toThrowError(new Error('ThumborMapping::ParseCustomPath::RewriteSubstitutionUndefined'));
+ });
+ });
+});
+
+describe('mapFilter()', () => {
+ describe('001/autojpg', () => {
+ it('Should pass if the filter is successfully converted from Thumbor:autojpg()', () => {
+ // Arrange
+ const edit = 'filters:autojpg()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { toFormat: 'jpeg' };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('002/background_color', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:background_color()', () => {
+ // Arrange
+ const edit = 'filters:background_color(ffff)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ flatten: { background: { r: 255, g: 255, b: 255 } }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('003/blur/singleParameter', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:blur()', () => {
+ // Arrange
+ const edit = 'filters:blur(60)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { blur: 30 };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('004/blur/doubleParameter', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:blur()', () => {
+ // Arrange
+ const edit = 'filters:blur(60, 2)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { blur: 2 };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('005/convolution', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:convolution()', () => {
+ // Arrange
+ const edit = 'filters:convolution(1;2;1;2;4;2;1;2;1,3,true)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ convolve: {
+ width: 3,
+ height: 3,
+ kernel: [1, 2, 1, 2, 4, 2, 1, 2, 1]
+ }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('006/equalize', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:equalize()', () => {
+ // Arrange
+ const edit = 'filters:equalize()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { normalize: true };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('007/fill/resizeUndefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:fill()', () => {
+ // Arrange
+ const edit = 'filters:fill(fff)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('008/fill/resizeDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:fill()', () => {
+ // Arrange
+ const edit = 'filters:fill(fff)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: {} };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('009/format/supportedFileType', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:format()', () => {
+ // Arrange
+ const edit = 'filters:format(png)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { toFormat: 'png' };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('010/format/unsupportedFileType', () => {
+ it('Should return undefined if an accepted file format is not specified', () => {
+ // Arrange
+ const edit = 'filters:format(test)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {};
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('011/no_upscale/resizeUndefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:no_upscale()', () => {
+ // Arrange
+ const edit = 'filters:no_upscale()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { resize: { withoutEnlargement: true } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('012/no_upscale/resizeDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:no_upscale()', () => {
+ // Arrange
+ const edit = 'filters:no_upscale()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: { height: 400, width: 300 } };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { height: 400, width: 300, withoutEnlargement: true } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('013/proportion/resizeDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:proportion()', () => {
+ // Arrange
+ const edit = 'filters:proportion(0.3)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: { height: 200, width: 200 } };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { height: 60, width: 60 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('014/proportion/resizeUndefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:resize()', () => {
+ // Arrange
+ const edit = 'filters:proportion(0.3)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ expect(edits.resize).not.toBeUndefined();
+ });
+ });
+
+ describe('015/quality/jpg', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:quality()', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { jpeg: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('016/quality/png', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:quality()', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = ImageFormatTypes.PNG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { png: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('017/quality/webp', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:quality()', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = ImageFormatTypes.WEBP;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { webp: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('018/quality/tiff', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:quality()', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = ImageFormatTypes.TIFF;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { tiff: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('019/quality/heif', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:quality()', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = ImageFormatTypes.HEIF;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { heif: { quality: 50 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('020/quality/other', () => {
+ it('Should return undefined if an unsupported file type is provided', () => {
+ // Arrange
+ const edit = 'filters:quality(50)';
+ const filetype = 'xml' as ImageFormatTypes;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {};
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('021/rgb', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:rgb()', () => {
+ // Arrange
+ const edit = 'filters:rgb(10, 10, 10)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { tint: { r: 25.5, g: 25.5, b: 25.5 } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('022/rotate', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:rotate()', () => {
+ // Arrange
+ const edit = 'filters:rotate(75)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { rotate: 75 };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('023/sharpen', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:sharpen()', () => {
+ // Arrange
+ const edit = 'filters:sharpen(75, 5)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { sharpen: 3.5 };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('024/stretch/default', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:stretch()', () => {
+ // Arrange
+ const edit = 'filters:stretch()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { resize: { fit: 'fill' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('025/stretch/resizeDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:stretch()', () => {
+ // Arrange
+ const edit = 'filters:stretch()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: { width: 300, height: 400 } };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+ // Assert
+ const expectedResult = { resize: { width: 300, height: 400, fit: 'fill' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('026/stretch/fit-in', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:stretch()', () => {
+ // Arrange
+ const edit = 'filters:stretch()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: { fit: 'inside' } };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { fit: 'inside' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('027/stretch/fit-in/resizeDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:stretch()', () => {
+ // Arrange
+ const edit = 'filters:stretch()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: { width: 400, height: 300, fit: 'inside' } };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { width: 400, height: 300, fit: 'inside' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('028/strip_exif', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:strip_exif()', () => {
+ // Arrange
+ const edit = 'filters:strip_exif()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { rotate: null };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('029/strip_icc', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:strip_icc()', () => {
+ // Arrange
+ const edit = 'filters:strip_icc()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { rotate: null };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('030/upscale', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:upscale()', () => {
+ // Arrange
+ const edit = 'filters:upscale()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = { resize: { fit: 'inside' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('031/upscale/resizeNotUndefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:upscale()', () => {
+ // Arrange
+ const edit = 'filters:upscale()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ let edits: ImageEdits = { resize: {} };
+ edits = thumborMapper.mapFilter(edit, filetype, edits);
+
+ // Assert
+ const expectedResult = { resize: { fit: 'inside' } };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('032/watermark/positionDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:watermark()', () => {
+ // Arrange
+ const edit = 'filters:watermark(bucket,key,100,100,0)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ overlayWith: {
+ bucket: 'bucket',
+ key: 'key',
+ alpha: '0',
+ wRatio: undefined,
+ hRatio: undefined,
+ options: {
+ left: '100',
+ top: '100'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('033/watermark/positionDefinedByPercentile', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:watermark()', () => {
+ // Arrange
+ const edit = 'filters:watermark(bucket,key,50p,30p,0)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ overlayWith: {
+ bucket: 'bucket',
+ key: 'key',
+ alpha: '0',
+ wRatio: undefined,
+ hRatio: undefined,
+ options: {
+ left: '50p',
+ top: '30p'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('034/watermark/positionDefinedWrong', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:watermark()', () => {
+ // Arrange
+ const edit = 'filters:watermark(bucket,key,x,x,0)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ overlayWith: {
+ bucket: 'bucket',
+ key: 'key',
+ alpha: '0',
+ wRatio: undefined,
+ hRatio: undefined,
+ options: {}
+ }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('035/watermark/ratioDefined', () => {
+ it('Should pass if the filter is successfully translated from Thumbor:watermark()', () => {
+ // Arrange
+ const edit = 'filters:watermark(bucket,key,100,100,0,10,10)';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {
+ overlayWith: {
+ bucket: 'bucket',
+ key: 'key',
+ alpha: '0',
+ wRatio: '10',
+ hRatio: '10',
+ options: {
+ left: '100',
+ top: '100'
+ }
+ }
+ };
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+
+ describe('036/elseCondition', () => {
+ it('Should pass if undefined is returned for an unsupported filter', () => {
+ // Arrange
+ const edit = 'filters:notSupportedFilter()';
+ const filetype = ImageFormatTypes.JPG;
+
+ // Act
+ const thumborMapper = new ThumborMapper();
+ const edits = thumborMapper.mapFilter(edit, filetype);
+
+ // Assert
+ const expectedResult = {};
+ expect(edits).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/source/image-handler/test/thumbor-mapping.spec.js b/source/image-handler/test/thumbor-mapping.spec.js
deleted file mode 100644
index 264c7b048..000000000
--- a/source/image-handler/test/thumbor-mapping.spec.js
+++ /dev/null
@@ -1,913 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const ThumborMapping = require('../thumbor-mapping');
-
-// ----------------------------------------------------------------------------
-// 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
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('002/resize/fit-in', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/fit-in/400x300/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: 400,
- height: 300,
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('003/resize/fit-in/noResizeValues', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/fit-in/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: { fit: 'inside' }
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('004/resize/not-fit-in', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/400x300/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: 400,
- height: 300
- }
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('005/resize/widthIsZero', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/0x300/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: null,
- height: 300,
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('006/resize/heightIsZero', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/400x0/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: 400,
- height: null,
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping.edits).toEqual(expectedResult.edits);
- });
- });
- describe('007/resize/widthAndHeightAreZero', function() {
- it('Should pass if the proper edit translations are applied and in the correct order', function() {
- // Arrange
- const event = {
- path : "/0x0/test-image-001.jpg"
- }
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.process(event);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: null,
- height: null,
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping.edits).toEqual(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';
- expect(result.path).toEqual(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'
- }
- delete process.env.REWRITE_MATCH_PATTERN;
- delete process.env.REWRITE_SUBSTITUTION;
- // Act
- const thumborMapping = new ThumborMapping();
- // Assert
- expect(() => {
- thumborMapping.parseCustomPath(event.path);
- }).toThrowError(new 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
- expect(() => {
- thumborMapping.parseCustomPath(event.path);
- }).toThrowError(new Error('ThumborMapping::ParseCustomPath::ParsingError'));
- });
- });
- describe('004/undefinedAll', function() {
- it('Should throw an error if the path is not defined', function() {
- const event = {};
- delete process.env.REWRITE_MATCH_PATTERN;
- delete process.env.REWRITE_SUBSTITUTION;
- // Act
- const thumborMapping = new ThumborMapping();
- // Assert
- expect(() => {
- thumborMapping.parseCustomPath(event.path);
- }).toThrowError(new 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' }
- };
- expect(thumborMapping).toEqual(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}}}
- };
- expect(thumborMapping).toEqual(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.deepStrictEqual(thumborMapping, expectedResult);
- expect(thumborMapping).toEqual(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 }
- };
- expect(thumborMapping).toEqual(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]
- }}
- };
- expect(thumborMapping).toEqual(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' }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('007/fill/resizeUndefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:fill()', function() {
- // Arrange
- const edit = 'filters:fill(fff)';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' }}
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
-
- describe('008/fill/resizeDefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:fill()', function() {
- // Arrange
- const edit = 'filters:fill(fff)';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.edits.resize = {};
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: { resize: { background: { r: 255, g: 255, b: 255 }, fit: 'contain' }}
- };
- expect(thumborMapping).toEqual(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' }
- };
- expect(thumborMapping).toEqual(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: { }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('014/proportion/resizeUndefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:resize()', function() {
- // Arrange
- const edit = 'filters:proportion(0.3)';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const actualResult = thumborMapping.edits.resize !== undefined;
- const expectedResult = true;
- expect(actualResult).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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: { }
- };
- expect(thumborMapping).toEqual(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
- }
- }
- };
- expect(thumborMapping).toEqual(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
- }
- };
- expect(thumborMapping).toEqual(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
- }
- };
- expect(thumborMapping).toEqual(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' }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('025/stretch/resizeDefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:stretch()', function() {
- // Arrange
- const edit = 'filters:stretch()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.edits.resize = {
- width: 300,
- height: 400
- };
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: 300,
- height: 400,
- fit: 'fill'
- }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('026/stretch/fit-in', function() {
- it('Should pass if the filter is successfully translated from Thumbor:stretch()', function() {
- // Arrange
- const edit = 'filters:stretch()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.edits.resize = {
- fit: 'inside'
- };
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- resize: { fit: 'inside' }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('027/stretch/fit-in/resizeDefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:stretch()', function() {
- // Arrange
- const edit = 'filters:stretch()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.edits.resize = {
- width: 400,
- height: 300,
- fit: 'inside'
- };
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- width: 400,
- height: 300,
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('028/strip_exif', function() {
- it('Should pass if the filter is successfully translated from Thumbor:strip_exif()', function() {
- // Arrange
- const edit = 'filters:strip_exif()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- rotate: null
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('029/strip_icc', function() {
- it('Should pass if the filter is successfully translated from Thumbor:strip_icc()', function() {
- // Arrange
- const edit = 'filters:strip_icc()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- rotate: null
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('030/upscale', function() {
- it('Should pass if the filter is successfully translated from Thumbor:upscale()', function() {
- // Arrange
- const edit = 'filters:upscale()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
- describe('031/upscale/resizeNotUndefined', function() {
- it('Should pass if the filter is successfully translated from Thumbor:upscale()', function() {
- // Arrange
- const edit = 'filters:upscale()';
- const filetype = 'jpg';
- // Act
- const thumborMapping = new ThumborMapping();
- thumborMapping.edits.resize = {};
- thumborMapping.mapFilter(edit, filetype);
- // Assert
- const expectedResult = {
- edits: {
- resize: {
- fit: 'inside'
- }
- }
- };
- expect(thumborMapping).toEqual(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'
- }
- }
- }
- };
- expect(thumborMapping).toEqual(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'
- }
- }
- }
- };
- expect(thumborMapping).toEqual(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: {}
- }
- }
- };
- expect(thumborMapping).toEqual(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'
- }
- }
- }
- };
- expect(thumborMapping).toEqual(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: {} };
- expect(thumborMapping).toEqual(expectedResult);
- });
- });
-})
\ No newline at end of file
diff --git a/source/image-handler/thumbor-mapper.ts b/source/image-handler/thumbor-mapper.ts
new file mode 100644
index 000000000..6b23f4d0d
--- /dev/null
+++ b/source/image-handler/thumbor-mapper.ts
@@ -0,0 +1,369 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import Color from 'color';
+import ColorName from 'color-name';
+
+import { ImageEdits, ImageFitTypes, ImageFormatTypes } from './lib';
+
+export class ThumborMapper {
+ private static readonly EMPTY_IMAGE_EDITS: ImageEdits = {};
+
+ /**
+ * Initializer function for creating a new Thumbor mapping, used by the image
+ * handler to perform image modifications based on legacy URL path requests.
+ * @param path The request path.
+ * @returns Image edits based on the request path.
+ */
+ public mapPathToEdits(path: string): ImageEdits {
+ const fileFormat = path.substr(path.lastIndexOf('.') + 1) as ImageFormatTypes;
+
+ let edits: ImageEdits = this.mergeEdits(this.mapCrop(path), this.mapResize(path), this.mapFitIn(path));
+
+ // parse the image path. we have to sort here to make sure that when we have a file name without extension,
+ // and `format` and `quality` filters are passed, then the `format` filter will go first to be able
+ // to apply the `quality` filter to the target image format.
+ const filters =
+ path
+ .match(/filters:[^)]+/g)
+ ?.map(filter => `${filter})`)
+ .sort() ?? [];
+ for (const filter of filters) {
+ edits = this.mapFilter(filter, fileFormat, edits);
+ }
+
+ return edits;
+ }
+
+ /**
+ * Enables users to migrate their current image request model to the SIH solution,
+ * without changing their legacy application code to accommodate new image requests.
+ * @param path The URL path extracted from the web request.
+ * @returns The parsed path using the match pattern and the substitution.
+ */
+ public parseCustomPath(path: string): string {
+ // Perform the substitution and return
+ const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env;
+
+ if (path === undefined) {
+ throw new Error('ThumborMapping::ParseCustomPath::PathUndefined');
+ } else if (REWRITE_MATCH_PATTERN === undefined) {
+ throw new Error('ThumborMapping::ParseCustomPath::RewriteMatchPatternUndefined');
+ } else if (REWRITE_SUBSTITUTION === undefined) {
+ throw new Error('ThumborMapping::ParseCustomPath::RewriteSubstitutionUndefined');
+ } else {
+ let parsedPath = '';
+
+ if (typeof REWRITE_MATCH_PATTERN === 'string') {
+ const patternStrings = REWRITE_MATCH_PATTERN.split('/');
+ const flags = patternStrings.pop();
+ const parsedPatternString = REWRITE_MATCH_PATTERN.slice(1, REWRITE_MATCH_PATTERN.length - 1 - flags.length);
+ const regExp = new RegExp(parsedPatternString, flags);
+ parsedPath = path.replace(regExp, REWRITE_SUBSTITUTION);
+ } else {
+ parsedPath = path.replace(REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION);
+ }
+
+ return parsedPath;
+ }
+ }
+
+ /**
+ * Scanner function for matching supported Thumbor filters and converting their capabilities into sharp.js supported operations.
+ * @param filterExpression The URL path filter.
+ * @param fileFormat The file type of the original image.
+ * @param previousEdits Cumulative edit, to take into account the previous filters, i.g. `stretch` uses `resize.fit` to make a right update.
+ * @returns Cumulative edits based on the previous edits and the current filter.
+ */
+ public mapFilter(filterExpression: string, fileFormat: ImageFormatTypes, previousEdits: ImageEdits = {}): ImageEdits {
+ const matched = filterExpression.match(/:(.+)\((.*)\)/);
+ const [_, filterName, filterValue] = matched;
+ const currentEdits = { ...previousEdits };
+
+ // Find the proper filter
+ switch (filterName) {
+ case 'autojpg': {
+ currentEdits.toFormat = ImageFormatTypes.JPEG;
+ break;
+ }
+ case 'background_color': {
+ const color = !ColorName[filterValue] ? `#${filterValue}` : filterValue;
+
+ currentEdits.flatten = { background: Color(color).object() };
+ break;
+ }
+ case 'blur': {
+ const [radius, sigma] = filterValue.split(',').map(x => (x === '' ? NaN : Number(x)));
+ currentEdits.blur = !isNaN(sigma) ? sigma : radius / 2;
+ break;
+ }
+ case 'convolution': {
+ const values = filterValue.split(',');
+ const matrix = values[0].split(';').map(str => Number(str));
+ const matrixWidth = Number(values[1]);
+ let matrixHeight = 0;
+ let counter = 0;
+
+ for (let i = 0; i < matrix.length; i++) {
+ if (counter === matrixWidth - 1) {
+ matrixHeight++;
+ counter = 0;
+ } else {
+ counter++;
+ }
+ }
+
+ currentEdits.convolve = {
+ width: matrixWidth,
+ height: matrixHeight,
+ kernel: matrix
+ };
+ break;
+ }
+ case 'equalize': {
+ currentEdits.normalize = true;
+ break;
+ }
+ case 'fill': {
+ if (currentEdits.resize === undefined) {
+ currentEdits.resize = {};
+ }
+
+ let color = filterValue;
+ if (!ColorName[color]) {
+ color = `#${color}`;
+ }
+
+ currentEdits.resize.fit = ImageFitTypes.CONTAIN;
+ currentEdits.resize.background = Color(color).object();
+ break;
+ }
+ case 'format': {
+ const imageFormatType = filterValue.replace(/[^0-9a-z]/gi, '').replace(/jpg/i, 'jpeg') as ImageFormatTypes;
+ const acceptedValues = [
+ ImageFormatTypes.HEIC,
+ ImageFormatTypes.HEIF,
+ ImageFormatTypes.JPEG,
+ ImageFormatTypes.PNG,
+ ImageFormatTypes.RAW,
+ ImageFormatTypes.TIFF,
+ ImageFormatTypes.WEBP
+ ];
+
+ if (acceptedValues.includes(imageFormatType)) {
+ currentEdits.toFormat = imageFormatType;
+ }
+ break;
+ }
+ case 'grayscale': {
+ currentEdits.grayscale = true;
+ break;
+ }
+ case 'no_upscale': {
+ if (currentEdits.resize === undefined) {
+ currentEdits.resize = {};
+ }
+
+ currentEdits.resize.withoutEnlargement = true;
+ break;
+ }
+ case 'proportion': {
+ if (currentEdits.resize === undefined) {
+ currentEdits.resize = {};
+ }
+ const ratio = Number(filterValue);
+
+ currentEdits.resize.width = Number(currentEdits.resize.width * ratio);
+ currentEdits.resize.height = Number(currentEdits.resize.height * ratio);
+ break;
+ }
+ case 'quality': {
+ const toSupportedImageFormatType = (format: ImageFormatTypes): ImageFormatTypes =>
+ [ImageFormatTypes.JPG, ImageFormatTypes.JPEG].includes(format)
+ ? ImageFormatTypes.JPEG
+ : [ImageFormatTypes.PNG, ImageFormatTypes.WEBP, ImageFormatTypes.TIFF, ImageFormatTypes.HEIF].includes(format)
+ ? format
+ : null;
+
+ // trying to get a target image type base on `fileFormat` passed to the current method.
+ // if we cannot get the target format, then trying to get the target format from `format` filter.
+ const targetImageFileFormat = toSupportedImageFormatType(fileFormat) ?? toSupportedImageFormatType(currentEdits.toFormat);
+
+ if (targetImageFileFormat) {
+ currentEdits[targetImageFileFormat] = { quality: Number(filterValue) };
+ }
+ break;
+ }
+ case 'rgb': {
+ const percentages = filterValue.split(',');
+ const values = percentages.map(percentage => 255 * (Number(percentage) / 100));
+ const [r, g, b] = values;
+
+ currentEdits.tint = { r, g, b };
+ break;
+ }
+ case 'rotate': {
+ currentEdits.rotate = Number(filterValue);
+ break;
+ }
+ case 'sharpen': {
+ const values = filterValue.split(',');
+
+ currentEdits.sharpen = 1 + Number(values[1]) / 2;
+ break;
+ }
+ case 'stretch': {
+ if (currentEdits.resize === undefined) {
+ currentEdits.resize = {};
+ }
+
+ // If fit-in is not defined, fit parameter would be 'fill'.
+ if (currentEdits.resize.fit !== ImageFitTypes.INSIDE) {
+ currentEdits.resize.fit = ImageFitTypes.FILL;
+ }
+ break;
+ }
+ case 'strip_exif':
+ case 'strip_icc': {
+ currentEdits.rotate = null;
+ break;
+ }
+ case 'upscale': {
+ if (currentEdits.resize === undefined) {
+ currentEdits.resize = {};
+ }
+
+ currentEdits.resize.fit = ImageFitTypes.INSIDE;
+ break;
+ }
+ case 'watermark': {
+ const options = filterValue.replace(/\s+/g, '').split(',');
+ const [bucket, key, xPos, yPos, alpha, wRatio, hRatio] = options;
+
+ currentEdits.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(Number(xPos))) {
+ currentEdits.overlayWith.options.left = xPos;
+ }
+ if (allowedPosPattern.test(yPos) || !isNaN(Number(yPos))) {
+ currentEdits.overlayWith.options.top = yPos;
+ }
+ break;
+ }
+ }
+
+ return currentEdits;
+ }
+
+ /**
+ * Maps the image path to crop image edit.
+ * @param path an image path.
+ * @returns image edits associated with crop.
+ */
+ private mapCrop(path: string): ImageEdits {
+ const pathCropMatchResult = path.match(/\d+x\d+:\d+x\d+/g);
+
+ if (pathCropMatchResult) {
+ const [leftTopPoint, rightBottomPoint] = pathCropMatchResult[0].split(':');
+
+ const [leftTopX, leftTopY] = leftTopPoint.split('x').map(x => parseInt(x, 10));
+ const [rightBottomX, rightBottomY] = rightBottomPoint.split('x').map(x => parseInt(x, 10));
+
+ if (!isNaN(leftTopX) && !isNaN(leftTopY) && !isNaN(rightBottomX) && !isNaN(rightBottomY)) {
+ const cropEdit: ImageEdits = {
+ crop: {
+ left: leftTopX,
+ top: leftTopY,
+ width: rightBottomX - leftTopX,
+ height: rightBottomY - leftTopY
+ }
+ };
+
+ return cropEdit;
+ }
+ }
+
+ return ThumborMapper.EMPTY_IMAGE_EDITS;
+ }
+
+ /**
+ * Maps the image path to resize image edit.
+ * @param path An image path.
+ * @returns Image edits associated with resize.
+ */
+ private mapResize(path: string): ImageEdits {
+ // Process the dimensions
+ const dimensionsMatchResult = path.match(/\/((\d+x\d+)|(0x\d+))\//g);
+
+ if (dimensionsMatchResult) {
+ // Assign dimensions from the first match only to avoid parsing dimension from image file names
+ const [width, height] = dimensionsMatchResult[0]
+ .replace(/\//g, '')
+ .split('x')
+ .map(x => parseInt(x));
+
+ // Set only if the dimensions provided are valid
+ if (!isNaN(width) && !isNaN(height)) {
+ const resizeEdit: ImageEdits = { resize: {} };
+
+ // If width or height is 0, fit would be inside.
+ if (width === 0 || height === 0) {
+ resizeEdit.resize.fit = ImageFitTypes.INSIDE;
+ }
+ resizeEdit.resize.width = width === 0 ? null : width;
+ resizeEdit.resize.height = height === 0 ? null : height;
+
+ return resizeEdit;
+ }
+ }
+
+ return ThumborMapper.EMPTY_IMAGE_EDITS;
+ }
+
+ /**
+ * Maps the image path to fit image edit.
+ * @param path An image path.
+ * @returns Image edits associated with fit-in filter.
+ */
+ private mapFitIn(path: string): ImageEdits {
+ return path.includes('fit-in') ? { resize: { fit: ImageFitTypes.INSIDE } } : ThumborMapper.EMPTY_IMAGE_EDITS;
+ }
+
+ /**
+ * A helper method to merge edits.
+ * @param edits Edits to merge.
+ * @returns Merged edits.
+ */
+ private mergeEdits(...edits: ImageEdits[]) {
+ return edits.reduce((result, current) => {
+ Object.keys(current).forEach(key => {
+ if (Array.isArray(result[key]) && Array.isArray(current[key])) {
+ result[key] = Array.from(new Set(result[key].concat(current[key])));
+ } else if (this.isObject(result[key]) && this.isObject(current[key])) {
+ result[key] = this.mergeEdits(result[key], current[key]);
+ } else {
+ result[key] = current[key];
+ }
+ });
+
+ return result;
+ }, {}) as ImageEdits;
+ }
+
+ /**
+ * A helper method to check whether a passed argument is object or not.
+ * @param obj Object to check.
+ * @returns Whether or not a passed argument is object.
+ */
+ private isObject(obj: unknown): boolean {
+ return obj && typeof obj === 'object' && !Array.isArray(obj);
+ }
+}
diff --git a/source/image-handler/thumbor-mapping.js b/source/image-handler/thumbor-mapping.js
deleted file mode 100644
index eaba61ee8..000000000
--- a/source/image-handler/thumbor-mapping.js
+++ /dev/null
@@ -1,247 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-const Color = require('color');
-const ColorName = require('color-name');
-
-class ThumborMapping {
-
- // Constructor
- constructor() {
- this.edits = {};
- }
-
- /**
- * Initializer function for creating a new Thumbor mapping, used by the image
- * handler to perform image modifications based on legacy URL path requests.
- * @param {object} event - The request body.
- */
- process(event) {
- // Setup
- this.path = event.path;
- let edits = this.path.match(/filters:[^\)]+/g);
- if (!edits) {
- edits = [];
- }
- const filetype = (this.path.split('.'))[(this.path.split('.')).length - 1];
-
- // Process the Dimensions
- const dimPath = this.path.match(/\/((\d+x\d+)|(0x\d+))\//g);
- if (dimPath) {
- // Assign dimenions from the first match only to avoid parsing dimension from image file names
- const dims = dimPath[0].replace(/\//g, '').split('x');
- const width = Number(dims[0]);
- const height = Number(dims[1]);
-
- // Set only if the dimensions provided are valid
- if (!isNaN(width) && !isNaN(height)) {
- this.edits.resize = {};
-
- // If width or height is 0, fit would be inside.
- if (width === 0 || height === 0) {
- this.edits.resize.fit = 'inside';
- }
- this.edits.resize.width = width === 0 ? null : width;
- this.edits.resize.height = height === 0 ? null : height;
- }
- }
-
- // fit-in filter
- if (this.path.includes('fit-in')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
- this.edits.resize.fit = 'inside';
- }
-
- // Parse the image path
- for (let i = 0; i < edits.length; i++) {
- const edit = `${edits[i]})`;
- 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.
- * @return {object} - The parsed path using the match pattern and the substitution.
- */
- 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) {
- let parsedPath = '';
-
- if (typeof(matchPattern) === 'string') {
- const patternStrings = matchPattern.split('/');
- const flags = patternStrings.pop();
- const parsedPatternString = matchPattern.slice(1, matchPattern.length - 1 - flags.length);
- const regExp = new RegExp(parsedPatternString, flags);
- parsedPath = path.replace(regExp, substitution);
- } else {
- parsedPath = path.replace(matchPattern, substitution);
- }
-
- return { path: parsedPath };
- } else {
- throw new Error('ThumborMapping::ParseCustomPath::ParsingError');
- }
- }
-
- /**
- * Scanner function for matching supported Thumbor filters and converting their
- * capabilities into Sharp.js supported operations.
- * @param {string} edit - The URL path filter.
- * @param {string} filetype - The file type of the original image.
- */
- mapFilter(edit, filetype) {
- const matched = edit.match(/:(.+)\((.*)\)/);
- const editKey = matched[1];
- let value = matched[2];
- // Find the proper filter
- if (editKey === ('autojpg')) {
- this.edits.toFormat = 'jpeg';
- } else if (editKey === ('background_color')) {
- if (!ColorName[value]) {
- value = `#${value}`
- }
- this.edits.flatten = { background: Color(value).object() };
- } else if (editKey === ('blur')) {
- const val = value.split(',');
- this.edits.blur = (val.length > 1) ? Number(val[1]) : Number(val[0]) / 2;
- } else if (editKey === ('convolution')) {
- const arr = value.split(',');
- const strMatrix = (arr[0]).split(';');
- let matrix = [];
- strMatrix.forEach(function(str) {
- matrix.push(Number(str));
- });
- const matrixWidth = arr[1];
- let matrixHeight = 0;
- let counter = 0;
- for (let i = 0; i < matrix.length; i++) {
- if (counter === (matrixWidth - 1)) {
- matrixHeight++;
- counter = 0;
- } else {
- counter++;
- }
- }
- this.edits.convolve = {
- width: Number(matrixWidth),
- height: Number(matrixHeight),
- kernel: matrix
- }
- } else if (editKey === ('equalize')) {
- this.edits.normalize = "true";
- } else if (editKey === ('fill')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
- if (!ColorName[value]) {
- value = `#${value}`
- }
- this.edits.resize.fit = 'contain';
- this.edits.resize.background = Color(value).object();
- } else if (editKey === ('format')) {
- const formattedValue = value.replace(/[^0-9a-z]/gi, '').replace(/jpg/i, 'jpeg');
- const acceptedValues = ['heic', 'heif', 'jpeg', 'png', 'raw', 'tiff', 'webp'];
- if (acceptedValues.includes(formattedValue)) {
- this.edits.toFormat = formattedValue;
- }
- } else if (editKey === ('grayscale')) {
- this.edits.grayscale = true;
- } else if (editKey === ('no_upscale')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
- this.edits.resize.withoutEnlargement = true;
- } else if (editKey === ('proportion')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
- const prop = Number(value);
- this.edits.resize.width = Number(this.edits.resize.width * prop);
- this.edits.resize.height = Number(this.edits.resize.height * prop);
- } else if (editKey === ('quality')) {
- if (['jpg', 'jpeg'].includes(filetype)) {
- this.edits.jpeg = { quality: Number(value) }
- } else if (filetype === 'png') {
- this.edits.png = { quality: Number(value) }
- } else if (filetype === 'webp') {
- this.edits.webp = { quality: Number(value) }
- } else if (filetype === 'tiff') {
- this.edits.tiff = { quality: Number(value) }
- } else if (filetype === 'heif') {
- this.edits.heif = { quality: Number(value) }
- }
- } else if (editKey === ('rgb')) {
- const percentages = value.split(',');
- const values = [];
- percentages.forEach(function (percentage) {
- const parsedPercentage = Number(percentage);
- const val = 255 * (parsedPercentage / 100);
- values.push(val);
- })
- this.edits.tint = { r: values[0], g: values[1], b: values[2] };
- } else if (editKey === ('rotate')) {
- this.edits.rotate = Number(value);
- } else if (editKey === ('sharpen')) {
- const sh = value.split(',');
- const sigma = 1 + Number(sh[1]) / 2;
- this.edits.sharpen = sigma;
- } else if (editKey === ('stretch')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
-
- // If fit-in is not defined, fit parameter would be 'fill'.
- if (this.edits.resize.fit !== 'inside') {
- this.edits.resize.fit = 'fill';
- }
- } else if (editKey === ('strip_exif') || editKey === ('strip_icc')) {
- this.edits.rotate = null;
- } else if (editKey === ('upscale')) {
- if (this.edits.resize === undefined) {
- this.edits.resize = {};
- }
- this.edits.resize.fit = "inside"
- } else if (editKey === ('watermark')) {
- const options = value.replace(/\s+/g, '').split(',');
- const bucket = options[0];
- const key = options[1];
- const xPos = options[2];
- const yPos = options[3];
- const alpha = options[4];
- const wRatio = options[5];
- const hRatio = options[6];
-
- this.edits.overlayWith = {
- bucket,
- key,
- alpha,
- wRatio,
- hRatio,
- options: {}
- }
- const allowedPosPattern = /^(100|[1-9]?[0-9]|-(100|[1-9][0-9]?))p$/;
- if (allowedPosPattern.test(xPos) || !isNaN(xPos)) {
- this.edits.overlayWith.options['left'] = xPos;
- }
- if (allowedPosPattern.test(yPos) || !isNaN(yPos)) {
- this.edits.overlayWith.options['top'] = yPos;
- }
- } else {
- return undefined;
- }
- }
-}
-
-// Exports
-module.exports = ThumborMapping;
\ No newline at end of file
diff --git a/source/image-handler/tsconfig.json b/source/image-handler/tsconfig.json
new file mode 100644
index 000000000..56ebe595b
--- /dev/null
+++ b/source/image-handler/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es6",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "outDir": "dist",
+ "emitDecoratorMetadata": true,
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "sourceMap": true,
+ "types": ["node", "@types/jest"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["package", "dist", "**/*.map"]
+}
diff --git a/source/package.json b/source/package.json
new file mode 100644
index 000000000..a6b688b5c
--- /dev/null
+++ b/source/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "source",
+ "version": "6.0.0",
+ "private": true,
+ "description": "ESLint and prettier dependencies to be used within the solution",
+ "license": "Apache-2.0",
+ "author": "AWS Solutions",
+ "devDependencies": {
+ "@types/node": "^16.4.5",
+ "@typescript-eslint/eslint-plugin": "^5.3.0",
+ "@typescript-eslint/parser": "^5.3.0",
+ "eslint": "^7.32.0",
+ "eslint-config-prettier": "^8.3.0",
+ "eslint-config-standard": "^16.0.3",
+ "eslint-plugin-header": "^3.1.1",
+ "eslint-plugin-import": "^2.25.2",
+ "eslint-plugin-jsdoc": "^37.0.3",
+ "eslint-plugin-node": "^11.1.0",
+ "eslint-plugin-prettier": "^4.0.0"
+ }
+}
diff --git a/source/solution-utils/get-options.ts b/source/solution-utils/get-options.ts
new file mode 100644
index 000000000..7db8ced4c
--- /dev/null
+++ b/source/solution-utils/get-options.ts
@@ -0,0 +1,19 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * If the SOLUTION_ID and SOLUTION_VERSION environment variables are set, this will return
+ * an object with a custom user agent string. Otherwise, the object returned will be empty.
+ * @param options The current options.
+ * @returns Either object with `customUserAgent` string or an empty object.
+ */
+export function getOptions(options: Record = {}): Record {
+ const { SOLUTION_ID, SOLUTION_VERSION } = process.env;
+ if (SOLUTION_ID && SOLUTION_VERSION) {
+ if (SOLUTION_ID.trim() !== '' && SOLUTION_VERSION.trim() !== '') {
+ options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${SOLUTION_VERSION}`;
+ }
+ }
+
+ return options;
+}
diff --git a/source/solution-utils/helpers.ts b/source/solution-utils/helpers.ts
new file mode 100644
index 000000000..a91e3a1ee
--- /dev/null
+++ b/source/solution-utils/helpers.ts
@@ -0,0 +1,11 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Indicates whether a specified string is null, empty, or consists only of white-space characters.
+ * @param str String to test.
+ * @returns `true` if the `str` parameter is null or empty, or if value consists exclusively of white-space characters.
+ */
+export function isNullOrWhiteSpace(str: string): boolean {
+ return !str || str.replace(/\s/g, '') === '';
+}
diff --git a/source/solution-utils/jest.config.js b/source/solution-utils/jest.config.js
new file mode 100644
index 000000000..a4e4b8635
--- /dev/null
+++ b/source/solution-utils/jest.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ roots: ['/test'],
+ testMatch: ['**/*.spec.ts'],
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest'
+ },
+ coverageReporters: [
+ 'text',
+ ['lcov', { 'projectRoot': '../' }]
+ ],
+ setupFiles: ['./test/setJestEnvironmentVariables.ts']
+};
diff --git a/source/solution-utils/logger.ts b/source/solution-utils/logger.ts
new file mode 100644
index 000000000..784894f05
--- /dev/null
+++ b/source/solution-utils/logger.ts
@@ -0,0 +1,70 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * The supported logging level.
+ */
+export enum LoggingLevel {
+ ERROR = 1,
+ WARN = 2,
+ INFO = 3,
+ DEBUG = 4,
+ VERBOSE = 5
+}
+
+/**
+ * Logger class.
+ */
+export default class Logger {
+ private readonly name: string;
+ private readonly loggingLevel: LoggingLevel;
+
+ /**
+ * Sets up the default properties.
+ * @param name The logger name which will be shown in the log.
+ * @param loggingLevel The logging level to show the minimum logs.
+ */
+ constructor(name: string, loggingLevel: string | LoggingLevel) {
+ this.name = name;
+
+ if (typeof loggingLevel === 'string' || !loggingLevel) {
+ this.loggingLevel = LoggingLevel[loggingLevel] || LoggingLevel.ERROR;
+ } else {
+ this.loggingLevel = loggingLevel;
+ }
+ }
+
+ /**
+ * Logs when the logging level is lower than the default logging level.
+ * @param loggingLevel The logging level of the log.
+ * @param messages The log messages.
+ */
+ public log(loggingLevel: LoggingLevel, ...messages: unknown[]): void {
+ if (loggingLevel <= this.loggingLevel) {
+ this.logInternal(loggingLevel, ...messages);
+ }
+ }
+
+ /**
+ * Logs based on the logging level.
+ * @param loggingLevel The logging level of the log.
+ * @param messages The log messages.
+ */
+ private logInternal(loggingLevel: LoggingLevel, ...messages: unknown[]): void {
+ switch (loggingLevel) {
+ case LoggingLevel.VERBOSE:
+ case LoggingLevel.DEBUG:
+ console.debug(`[${this.name}]`, ...messages);
+ break;
+ case LoggingLevel.INFO:
+ console.info(`[${this.name}]`, ...messages);
+ break;
+ case LoggingLevel.WARN:
+ console.warn(`[${this.name}]`, ...messages);
+ break;
+ default:
+ console.error(`[${this.name}]`, ...messages);
+ break;
+ }
+ }
+}
diff --git a/source/solution-utils/package.json b/source/solution-utils/package.json
new file mode 100644
index 000000000..3827ce94f
--- /dev/null
+++ b/source/solution-utils/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "solution-utils",
+ "version": "6.0.0",
+ "private": true,
+ "description": "Utilities to be used within this solution",
+ "license": "Apache-2.0",
+ "author": "AWS Solutions",
+ "main": "get-options",
+ "typings": "index",
+ "files": [
+ "get-options.js",
+ "logger.js"
+ ],
+ "scripts": {
+ "build": "npm run clean && npm install && npm run build:tsc",
+ "build:tsc": "tsc --project tsconfig.json",
+ "clean": "rm -rf node_modules/ dist/ coverage/ package-lock.json",
+ "package": "npm run build && npm prune --production && rsync -avrq ./node_modules ./dist",
+ "pretest": "npm run clean && npm install",
+ "test": "jest --coverage --silent"
+ },
+ "devDependencies": {
+ "@types/jest": "^27.0.0",
+ "@types/node": "^16.4.5",
+ "jest": "^27.0.4",
+ "ts-jest": "^27.0.4",
+ "ts-node": "^10.1.0",
+ "typescript": "^4.4.3"
+ }
+}
diff --git a/source/solution-utils/test/get-options.test.ts b/source/solution-utils/test/get-options.test.ts
new file mode 100644
index 000000000..2620a9c0e
--- /dev/null
+++ b/source/solution-utils/test/get-options.test.ts
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Spy on the console messages
+const consoleLogSpy = jest.spyOn(console, 'log');
+const consoleErrorSpy = jest.spyOn(console, 'error');
+
+describe('getOptions', () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = { ...OLD_ENV };
+ jest.resetModules();
+ consoleLogSpy.mockClear();
+ consoleErrorSpy.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('will return an empty object when environment variables are missing', () => {
+ const { getOptions } = require('../get-options');
+ expect.assertions(4);
+
+ process.env.SOLUTION_ID = ' '; // whitespace
+ expect(getOptions()).toEqual({});
+
+ delete process.env.SOLUTION_ID;
+ expect(getOptions()).toEqual({});
+
+ process.env.SOLUTION_ID = 'foo';
+ process.env.SOLUTION_VERSION = ' '; // whitespace
+ expect(getOptions()).toEqual({});
+
+ delete process.env.SOLUTION_VERSION;
+ expect(getOptions()).toEqual({});
+ });
+
+ it('will return an object with the custom user agent string', () => {
+ const { getOptions } = require('../get-options');
+ expect.assertions(1);
+ expect(getOptions()).toEqual({ customUserAgent: `AwsSolution/solution-id/solution-version` });
+ });
+});
diff --git a/source/solution-utils/test/setJestEnvironmentVariables.ts b/source/solution-utils/test/setJestEnvironmentVariables.ts
new file mode 100644
index 000000000..f47baed8e
--- /dev/null
+++ b/source/solution-utils/test/setJestEnvironmentVariables.ts
@@ -0,0 +1,6 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+process.env.SOLUTION_ID = 'solution-id';
+process.env.SOLUTION_VERSION = 'solution-version';
+process.env.LOGGING_LEVEL = 'VERBOSE';
diff --git a/source/solution-utils/tsconfig.json b/source/solution-utils/tsconfig.json
new file mode 100644
index 000000000..56ebe595b
--- /dev/null
+++ b/source/solution-utils/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es6",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "outDir": "dist",
+ "emitDecoratorMetadata": true,
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "sourceMap": true,
+ "types": ["node", "@types/jest"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["package", "dist", "**/*.map"]
+}