diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e9d21fd10 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*] +indent_size = 2 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index ffc769f27..000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,14 +0,0 @@ -**Issue #, if available:** - - - - -**Description of changes:** - - - -**Checklist** -- [ ] :wave: I have run the unit tests, and all unit tests have passed. -- [ ] :warning: This pull request might incur a breaking change. - -By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..4620e35af --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +version: 2 + +updates: + # Enable version updates for npm + - package-ecosystem: "npm" + # Look for `package.json` and `lock` files in this directory + directory: "/source/image-handler/" + schedule: + interval: "weekly" + reviewers: + - "stroeer/teams/buzz-end" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "stroeer/teams/buzz-end" + + - package-ecosystem: "terraform" + directory: "/source/image-handler/terraform" + schedule: + interval: "weekly" + reviewers: + - "stroeer/teams/buzz-end" + + - package-ecosystem: "terraform" + directory: "/source/thumbs/terraform" + schedule: + interval: "weekly" + reviewers: + - "stroeer/teams/buzz-end" + + # Enable version updates for Cargo + - package-ecosystem: "cargo" + # Look `Cargo.toml` in the repository root + directory: "/source/thumbs/" + # Check for updates every week + schedule: + interval: "weekly" + reviewers: + - "stroeer/teams/buzz-end" diff --git a/.github/terraform.yml b/.github/terraform.yml new file mode 100644 index 000000000..7dabdc8fb --- /dev/null +++ b/.github/terraform.yml @@ -0,0 +1,17 @@ +name: terraform + +on: + push: + branches: + - main + pull_request: + paths: + - "**.tf" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + static: + uses: stroeer/buzz-up/.github/workflows/tmpl_staticcheck.yml@main diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..bbeb16951 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,65 @@ +name: main + +on: + push: + branches: + - main + paths-ignore: + - "**.tf" + pull_request: + branches: + - main + paths-ignore: + - "**.tf" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# read+write needed for `aws-actions/configure-aws-credentials` to work +permissions: + id-token: write + contents: read + +jobs: + image-handler: + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: ubuntu-latest-arm64 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: install + run: make npm/install + - name: install + run: make npm/test + - name: build + run: make build + + - name: configure aws credentials + # 📌 Runs only if: + # 1. is a 'push to main' + # 2. did not run in a fork + if: ${{ github.ref == 'refs/heads/main' && github.repository_owner == 'stroeer' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::053041861227:role/github-s3-access-eu-west-1 + role-session-name: GitHubActions + aws-region: eu-west-1 + + - name: s3 upload artefact + # 📌 Runs only if: + # 1. is a 'push to main' + # 2. did not run in a fork + if: ${{ github.ref == 'refs/heads/main' && github.repository_owner == 'stroeer' }} + run: make deploy \ No newline at end of file diff --git a/.gitignore b/.gitignore index ff9a9a40b..9ea06d626 100755 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ demo-ui-config.js # System Files **/.DS_Store **/.vscode + +.terraform +**/*.iml +**/*.zip \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..32449bed2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/*.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..ee3d685d7 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..a55e7a179 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 000000000..62d8b5d9c --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..7073ecc55 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.86.0 + hooks: + - id: terraform_fmt + - id: terraform_validate + args: + - --init-args=-backend=false + - --init-args=-lockfile=readonly + - id: terraform_tflint + args: + - --args=--module + - id: terraform_trivy + args: + - --args=--tf-exclude-downloaded-modules + - --args=--skip-dirs "**/.terraform/**/*" + - --args=--severity=HIGH,CRITICAL + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..2f111c8a3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "printWidth": 120 +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 3be4c499d..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,103 +0,0 @@ -# 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). - -## [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) -- 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) - -### 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) - -### 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) - -### 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) -- Thumbor issues: - - `0` size support: [#183](https://github.com/awslabs/serverless-image-handler/issues/183) - - `convolution` filter does not work: [#187](https://github.com/awslabs/serverless-image-handler/issues/187) - - `fill` filter does not work: [#190](https://github.com/awslabs/serverless-image-handler/issues/190) -- __Note that__ duplicated features has been merged gracefully. - -### Removed -- AWS CloudFormation template: `serverless-image-handler.template` - -### Changed -- sharp base version (from 0.23.4 to 0.25.4) -- Remove `Promise` to return since `async` functions return promises: [#189](https://github.com/awslabs/serverless-image-handler/issues/189) -- Unit test statement coverage improvement: - - `image-handler.js`: `79.05%` to `100%` - - `image-request.js`: `93.58%` to `100%` - - `thumbor-mapping.js`: `99.29%` to `100%` - - `overall`: `91.55%` to `100%` - -## [4.2] - 2020-02-06 -### Added -- Honor outputFormat Parameter from the pull request [#117](https://github.com/awslabs/serverless-image-handler/pull/117) -- Support serving images under s3 subdirectories, Fix to make /fit-in/ work; Fix for VipsJpeg: Invalid SOS error plus several other critical fixes from the pull request [#130](https://github.com/awslabs/serverless-image-handler/pull/130) -- Allow regex in SOURCE_BUCKETS for environment variable from the pull request [#138](https://github.com/awslabs/serverless-image-handler/pull/138) -- Fix build script on other platforms from the pull request [#139](https://github.com/awslabs/serverless-image-handler/pull/139) -- Add Cache-Control response header from the pull request [#151](https://github.com/awslabs/serverless-image-handler/pull/151) -- Add AUTO_WEBP option to automatically serve WebP if the client supports it from the pull request [#152](https://github.com/awslabs/serverless-image-handler/pull/152) -- Use HTTP 404 & forward Cache-Control, Content-Type, Expires, and Last-Modified headers from S3 from the pull request [#158](https://github.com/awslabs/serverless-image-handler/pull/158) -- fix: DeprecationWarning: Buffer() is deprecated from the pull request [#174](https://github.com/awslabs/serverless-image-handler/pull/174) -- Add hex color support for Thumbor ```filters:background_color``` and ```filters:fill``` [#154](https://github.com/awslabs/serverless-image-handler/issues/154) -- Add format and watermark support for Thumbor [#109](https://github.com/awslabs/serverless-image-handler/issues/109), [#131](https://github.com/awslabs/serverless-image-handler/issues/131), [#109](https://github.com/awslabs/serverless-image-handler/issues/142) -- __Note that__ duplicated features has been merged gracefully. - -### Changed -- sharp base version (from 0.23.3 to 0.23.4) -- Image handler Amazon CloudFront distribution ```DefaultCacheBehavior.ForwaredValues.Header``` to ```["Origin", "Accept"]``` for webp -- Image resize process change for ```filters:no_upscale()``` handling by ```withoutEnlargement``` edit key [#144](https://github.com/awslabs/serverless-image-handler/issues/144) - -### Fixed -- Add and fix Cache-control, Content-Type, Expires, and Last-Modified headers to response: [#103](https://github.com/awslabs/serverless-image-handler/issues/103), [#107](https://github.com/awslabs/serverless-image-handler/issues/107), [#120](https://github.com/awslabs/serverless-image-handler/issues/120) -- Fix Amazon S3 bucket subfolder issue: [#106](https://github.com/awslabs/serverless-image-handler/issues/106), [#112](https://github.com/awslabs/serverless-image-handler/issues/112), [#119](https://github.com/awslabs/serverless-image-handler/issues/119), [#123](https://github.com/awslabs/serverless-image-handler/issues/123), [#167](https://github.com/awslabs/serverless-image-handler/issues/167), [#175](https://github.com/awslabs/serverless-image-handler/issues/175) -- Fix HTTP status code for missing images from 500 to 404: [#159](https://github.com/awslabs/serverless-image-handler/issues/159) -- Fix European character in filename issue: [#149](https://github.com/awslabs/serverless-image-handler/issues/149) -- Fix image scaling issue for filename containing 'x' character: [#163](https://github.com/awslabs/serverless-image-handler/issues/163), [#176](https://github.com/awslabs/serverless-image-handler/issues/176) -- Fix regular expression issue: [#114](https://github.com/awslabs/serverless-image-handler/issues/114), [#121](https://github.com/awslabs/serverless-image-handler/issues/121), [#125](https://github.com/awslabs/serverless-image-handler/issues/125) -- Fix not working quality parameter: [#129](https://github.com/awslabs/serverless-image-handler/issues/129) - -## [4.1] - 2019-12-31 -### Added -- CHANGELOG file -- 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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 3b6446687..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,4 +0,0 @@ -## 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 99f3ecffb..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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. - -Please read through this document before submitting any issues or pull requests to ensure we have all the necessary -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: - -* 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. -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. - -To send us a pull request, please: - -1. Fork the repository. -2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. -3. Ensure local tests pass. -4. Commit to your fork using clear commit messages. -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/). - -## 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. - -## 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. - -## 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. -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/Makefile b/Makefile new file mode 100644 index 000000000..4c402585a --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +REGION ?= eu-west-1 +APP_SUFFIX ?= +MODE ?= plan +DO_TF_UPGRADE ?= false + +ACCOUNT_ID = $(eval ACCOUNT_ID := $(shell aws --output text sts get-caller-identity --query "Account"))$(ACCOUNT_ID) +VERSION = $(eval VERSION := $$(shell git rev-parse --short HEAD))$(VERSION) + +TF_BACKEND_CFG = $(eval TF_BACKEND_CFG := -backend-config=bucket=terraform-state-$(ACCOUNT_ID)-$(REGION) \ + -backend-config=region=$(REGION) \ + -backend-config=key=regional/lambda/image-handler/terraform$(addprefix -,$(APP_SUFFIX)).tfstate)$(TF_BACKEND_CFG) + +TF_VARS = $(eval TF_VARS := -var="region=$(REGION)" -var="account_id=$(ACCOUNT_ID)" -var="app_suffix=$(APP_SUFFIX)")$(TF_VARS) +TF_FOLDERS := $(shell find . -not -path "*/\.*" -iname "*.tf" | sed -E "s|/[^/]+$$||" | sort --unique) + +WORK_DIR := source/image-handler + +all :: build tf + +.PHONY: clean +clean: + @echo "+ $@" + @cd $(WORK_DIR) && rm -rf ./dist/ ./node_modules/ + +.PHONY: npm/install +npm/install: + @echo "+ $@" + cd $(WORK_DIR) && npm install --cpu=arm64 --os=linux --libc=musl + +.PHONY: npm/test +npm/test: + @echo "+ $@" + cd $(WORK_DIR) && npm run test + +.PHONY: build +build: ## Builds the function + @echo "+ $@" + cd $(WORK_DIR) && npm run test && npm run build + +tf: ## Runs `terraform` + rm -f $(WORK_DIR)/terraform/.terraform/terraform.tfstate || true + terraform -chdir=$(WORK_DIR)/terraform init $(TF_VARS) -reconfigure -upgrade=$(DO_TF_UPGRADE) $(TF_BACKEND_CFG) + if [ "true" == "$(DO_TF_UPGRADE)" ]; then terraform -chdir=$(WORK_DIR)/terraform providers lock -platform=darwin_amd64 -platform=darwin_arm64 -platform=linux_amd64; fi + terraform -chdir=$(WORK_DIR)/terraform $(MODE) $(TF_VARS) + +.PHONY: deploy +deploy: build ## Uploads the artefact` to start CodePipeline deployment + @echo "+ $@" + aws s3 cp $(WORK_DIR)/dist/image-handler.zip s3://ci-$(ACCOUNT_ID)-$(REGION)/image-handler/image-handler$(addprefix -,$(APP_SUFFIX)).zip ; \ + +.PHONY: help +help: ## Display this help screen + @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: providers +providers: update ## Upgrades all providers and platform independent dependency locks (slow) + @echo "+ $@" + @for f in $(TF_FOLDERS) ; do \ + echo upgrading: $$f ;\ + terraform -chdir=$$f init -upgrade=true -backend=false;\ + terraform -chdir=$$f providers lock -platform=darwin_amd64 -platform=darwin_arm64 -platform=linux_amd64 ;\ + done + +.PHONY: update +update: ## Upgrades Terraform core, providers and modules constraints recursively using https://github.com/minamijoyo/tfupdate + @echo "+ $@" + @command -v tfupdate >/dev/null 2>&1 || { echo >&2 "Please install tfupdate: 'brew install minamijoyo/tfupdate/tfupdate'"; exit 1; } + @tfupdate terraform -v "~> 1" -r . + @tfupdate module -v "7.5.0" registry.terraform.io/moritzzimmer/lambda/aws -r . + @tfupdate module -v "7.5.0" registry.terraform.io/moritzzimmer/lambda/aws//modules/deployment -r . + @tfupdate provider aws -v "~> 5" -r . + @tfupdate provider opensearch -v "~> 2" -r . + diff --git a/README.md b/README.md index 30bd10043..99d6d8cdb 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,143 @@ -**_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 Readme + +The serverless-image-handler mono repository contains the source code and documentation for the [image-handler](#image-handler) and [image-thumbs](#image-thumbs) AWS Lambda functions. + +## Table of Contents + +- [Serverless Image Handler Readme](#serverless-image-handler-readme) +- [Table of Contents](#table-of-contents) +- [Architecture](#architecture) +- [Image Handler](#image-handler) + - [Overview](#overview) + - [Prerequisites](#prerequisites) + - [Usage](#usage) + - [Building](#building) + - [Testing](#testing) + - [Infrastructure deployment](#infrastructure-deployment) +- [Image Thumbs](#image-thumbs) + - [Overview](#overview-1) + - [Prerequisites](#prerequisites-1) + - [Building](#building-1) + - [Test / Invoke](#test--invoke) + - [Infrastructure deployment](#infrastructure-deployment-1) + +### Architecture -# 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/ +![Architecture](architecture.png) -_Note:_ it is recommended to build the application binary on Amazon Linux. +## Image Handler -## On This Page -- [Architecture Overview](#architecture-overview) -- [Creating a custom build](#creating-a-custom-build) -- [External Contributors](#external-contributors) -- [License](#license) +### Overview -## Architecture Overview -![Architecture](architecture.png) +The Image-handler is a solution Image Handler is a serverless image processing project built with Node.js 18 and the [Sharp](https://sharp.pixelplumbing.com/en/stable/) library. +It allows you to dynamically scale images on the fly and serves them through AWS Lambda, AWS CloudFront, and AWS S3. + +It aws originally forked from [aws-solutions/serverless-image-handler repository](https://github.com/aws-solutions/serverless-image-handler), but has been heavily modified to suit our needs. + +### Prerequisites + +- [Node.js](https://nodejs.org/en/) v20.x or later +- [Terraform](https://www.terraform.io/downloads.html) +- Make / npm +- AWS credentials with sufficient permissions to read images from S3 + +### Usage + +To scale images dynamically on the fly, you can make HTTP requests to the AWS CloudFront distribution URL. +The images are fetched from AWS S3, processed using Node.js 20 and Sharp / libvips, and then served through CloudFront. + +Example URL: + +```https://images.t-online.de/4k_hdr.jpg``` + +For more details see the [Usage](docs/Usage.md) documentation. + +### Environment variables + +The following environment variables are used by the image-handler: + +| Name | Description | +|---------------------------|-------------------------------------------------| +| `AUTO_WEBP` | Flag if the AUTO WEBP feature should be enabled | +| `CORS_ENABLED` | Flag if CORS should be enabled | +| `CORS_ORIGIN` | CORS origin. | +| `SOURCE_BUCKETS` | S3 Bucket with source images | + +### Building + +To build the package run: + +```make FUNC=image-handler build``` + +### Testing + +Run tests using the following Make command: + +```make npm/test``` + +### Infrastructure deployment + +Deploy the infrastructure using Terraform with the following Make command: + +```make FUNC=image-handler tf``` + +## Special interest section + +Useful links: + +* Image too large with default settings: `2023/02/JPCPt616git7/image.png` + +## Ströer specific adaptations + +### The file name does not matter + +The way we process our images does not depend on the file name. The part that copies the images from +the CMS into our delivery bucket will rename all images to `image.${extension}`. + +When rendering the image, the image-handler will always look for this filename, so it does not matter +what the original file name was. e.g. + +- `2023/02/JPCPt616git7/seo-title.png` will be served as `image.png` +- `2023/02/JPCPt616git7/other-usage-different-title.png` will be served as `image.png` + +resulting in the same response. + +### Cropping coordinates format + +- the original solution introduced thumbor cropping in [v6](https://github.com/aws-solutions/serverless-image-handler/commit/76558b787b9417450ee4a4f19dc9548be6dbada7) +- we have implemented this feature in advance, but with a slightly different syntax: + - The original solution uses `crop=left,top,right,bottom` + - We use `crop=left,top,width,height` to be more consistent with the CMS + +### Expired Content + +- The original solution uses the `Expires` header to cache images in the browser +- Our solution: + - reduces the `Cache-Control: max-age` according to the `Expires` header to handle expired content + - Also, once the Expires header is reached, the image-handler will return a `http/410 (GONE)` status code + +### Additional filter: thumbhash + +- `/filters:thumbhash()/` will trigger a hash-based thumbnail generation. See https://evanw.github.io/thumbhash/ +- The image can be cropped prior to this filter, but will ultimately be resized to `100x100` pixel to generate an efficient thumbnail +- The response will be `base64` encoded binary that can be converted via `Thumbhash.thumbHashToDataURL()` + +### Next data workaround + +Within the HTML markup resides the `next-data` [attribute](https://github.com/vercel/next.js/discussions/15117) + +Within this data structure there are some image URLs that are not directly intended to be rendered. Consider +them as templates. +Google (and other bots) will find and use this URL nonetheless. This image-handler will replace all calls that +contain template variables `/__WIDTH__x0/` with an actual size to mitigate this. + +### Removal of features not required + +If required once more, they need to be pulled from the original repository: -The AWS CloudFormation template deploys an Amazon CloudFront distribution, Amazon API Gateway REST API, and an AWS Lambda function. Amazon CloudFront provides a caching layer to reduce the cost of image processing and the latency of subsequent image delivery. The Amazon API Gateway provides endpoint resources and triggers the AWS Lambda function. The AWS Lambda function retrieves the image from the customer's Amazon Simple Storage Service (Amazon S3) bucket and uses Sharp to return a modified version of the image to the API Gateway. Additionally, the solution generates a CloudFront domain name that provides cached access to the image handler API. - -_**Note**:_ From v5.0, all AWS CloudFormation template resources are created be [AWS CDK](https://aws.amazon.com/cdk/) and [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/). Since the AWS CloudFormation template resources have the same logical ID comparing to v4.x, it makes the solution upgradable mostly from v4.x to v5. - -## Creating a custom build -The solution can be deployed through the CloudFormation template available on the solution home page. -To make changes to the solution, download or clone this repo, update the source code and then run the deployment/build-s3-dist.sh script to deploy the updated Lambda code to an Amazon S3 bucket in your account. - -### Prerequisites: -* [AWS Command Line Interface](https://aws.amazon.com/cli/) -* Node.js 12.x or later - -### 1. Clone the repository -```bash -git clone https://github.com/awslabs/serverless-image-handler.git -``` - -### 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 -``` - -### 3. Declare environment variables -```bash -export REGION=aws-region-code # the AWS region to launch the solution (e.g. us-east-1) -export DIST_OUTPUT_BUCKET=my-bucket-name # bucket where customized code will reside -export SOLUTION_NAME=my-solution-name # the solution name -export VERSION=my-version # version number for the customized code -``` - -### 4. Create an Amazon S3 Bucket -The CloudFormation template is configured to pull the Lambda deployment packages from Amazon S3 bucket in the region the template is being launched in. Create a bucket in the desired region with the region name appended to the name of the bucket. -```bash -aws s3 mb s3://$DIST_OUTPUT_BUCKET-$REGION --region $REGION -``` - -### 5. Create the deployment packages -Build the distributable: -```bash -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/ --recursive --acl bucket-owner-full-control -aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control -``` - -### 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) - -*** -## License -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0 +- Recognition and the corresponding code: + - "Smart Crop" (aka face recognition) + - Content Moderation (e.g. NSFW detection) +- Watermarking/Overlaying +- Secretsmanager and URL signing +- Dynamic S3 bucket selection \ No newline at end of file diff --git a/architecture.png b/architecture.png index 8d6ac401c..335070563 100644 Binary files a/architecture.png and b/architecture.png differ diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh deleted file mode 100755 index 844da2bb3..000000000 --- a/deployment/build-s3-dist.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/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 -# ./build-s3-dist.sh source-bucket-base-name trademarked-solution-name version-code -# -# Paramenters: -# - source-bucket-base-name: Name for the S3 bucket location where the template will source the Lambda -# code from. The template will append '-[region_name]' to this bucket name. -# For example: ./build-s3-dist.sh solutions my-solution v1.0.0 -# The template will then expect the source code to be located in the solutions-[region_name] bucket -# -# - trademarked-solution-name: name of the solution for consistency -# -# - version-code: version of the package - -# Check to see if input has been provided: -if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then - echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." - echo "For example: ./build-s3-dist.sh solutions trademarked-solution-name v1.0.0" - exit 1 -fi - -set -e - -# Get reference for all important folders -template_dir="$PWD" -template_dist_dir="$template_dir/global-s3-assets" -build_dist_dir="$template_dir/regional-s3-assets" -source_dir="$template_dir/../source" - -echo "------------------------------------------------------------------------------" -echo "Rebuild distribution" -echo "------------------------------------------------------------------------------" -rm -rf $template_dist_dir -mkdir -p $template_dist_dir -rm -rf $build_dist_dir -mkdir -p $build_dist_dir - -echo "------------------------------------------------------------------------------" -echo "CloudFormation template with CDK and Constructs" -echo "------------------------------------------------------------------------------" -export BUCKET_NAME=$1 -export SOLUTION_NAME=$2 -export VERSION=$3 - -cd $source_dir/constructs -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 - -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 - -echo "------------------------------------------------------------------------------" -echo "Package the demo-ui assets" -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 "------------------------------------------------------------------------------" -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 diff --git a/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh deleted file mode 100755 index 59e7933cb..000000000 --- a/deployment/run-unit-tests.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e - -current_dir=$PWD -source_dir=$current_dir/../source - -cd $source_dir/constructs -npm install -npm test - -cd $source_dir/image-handler -npm test - -cd $source_dir/custom-resource -npm test \ No newline at end of file diff --git a/docs/Usage.md b/docs/Usage.md new file mode 100644 index 000000000..c7c9c18f5 --- /dev/null +++ b/docs/Usage.md @@ -0,0 +1,179 @@ +# Usage + +It is recommended to have a look at the [output options](https://sharp.pixelplumbing.com/api-output), +[resize operations](https://sharp.pixelplumbing.com/api-resize) and [image operations](https://sharp.pixelplumbing.com/api-operation). + +### Feature: AUTO WEBP + +The CDN is configured to respect the value of the `Accept` HTTP header. Given +there are 2 different browsers, one supports `webp`, one does not: + +```shell +$ curl -v 'https://images.t-online.de/4k_hdr.jpg' \ + --header 'Accept: image/webp' +... +< HTTP/2 200 +< content-type: image/webp +... +``` + +```shell +$ curl -v 'https://images.t-online.de/4k_hdr.jpg' +... +< HTTP/2 200 +< content-type: image/jpeg +... +``` + +### (custom/experimental) Feature: Caching, Cache headers + +#### Cache-Control + +By default, every item has a `Cache-Control: public, max-age=31536000, immutable`. Images are immutable +within the CMS, so there is a `immutable` flag at the moment. +If the image has an expiry date in the CMS, there should be a `Expires` header present and the +`Cache-Control` will be reduced accordingly. + +After content has expired, a S3 lifecycle rule should automatically delete the master image from +the bucket. + +A sample response could look like this: + +```shell +$ curl -v https://images.t-online.de/GQSyGuVtRUsD/stimpson-stimpy-j-katzwinkel-ein-fetter-einfach-strukturierter-kater-aufnahme-aus-fruehester-kindheit.png +... +< HTTP/2 200 +< date: Fri, 15 Jan 2021 10:45:05 GMT +< content-type: image/png +< content-length: 2371 +< expires: Sat, 01 Jan 2022 01:00:00 GMT +< last-modified: Fri, 15 Jan 2021 10:44:25 GMT +< cache-control: max-age=30291336,public +... +``` + +#### ETag + +Each item has a strong `ETag` which should allow conditional requests. `ETag` are strong +validators: + +```shell +$ curl -v 'https://images.t-online.de/4k_hdr.jpg' \ + --header 'Accept: image/webp' \ + --header 'If-None-Match: "c84339d0817baaba0726aeb5b8532d55"' +... +< HTTP/2 304 +... +``` + +#### Date + +There is also a `Date` header with also allows conditional requests. `This is a weak validator. + +```shell +$ curl -v 'https://images.t-online.de/4k_hdr.jpg' \ + --header 'Accept: image/webp,*/*' \ + --header 'If-Modified-Since: Fri, 15 Jan 2021 09:55:58 GMT' +... +< HTTP/2 304 +... +``` + +## Filters + +Most of the filter are documented on the [AWS Solution page](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/appendix-d.html). + +Here is a tl;dr for the most important ones, demonstrated on an image from [the internet](https://wallpapersafari.com/w/pEwDaY): +### #nofilter + +will simply output the original image as is +![original](https://images.t-online.de/4k_hdr.jpg) +https://images.t-online.de/4k_hdr.jpg + +### Resize `/fit-in/${WIDTH}x${HEIGHT}/` + +this resizes the original image within the given rectangle without changing +the original image ratio. You can either set both `WIDTH` and `HEIGHT` or limit the image in either dimension + by setting the other dimension to `0`, e.g. only set the width to 666 px and scale the height accordingly + `/fit-in/666x0/` + +![resized](https://images.t-online.de/fit-in/666x0/4k_hdr.jpg) + +[`https://images.t-online.de/fit-in/666x0/4k_hdr.jpg`](https://images.t-online.de/fit-in/666x0/4k_hdr.jpg) + +### Cropping `/${X}x${Y}:${WIDTH}x${HEIGHT}/` + +starting from a `Point(x, y)` cut a rectangle sized `width x height`, without further resizing. + +![crop](https://images.t-online.de/1800x1450:888x500/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/4k_hdr.jpg) + +### Effects `/filters:blur(7)/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:blur(7)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:blur(7)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:blur(7)/4k_hdr.jpg) + +### Effects `/filters:grayscale()/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:grayscale()/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:grayscale()/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:grayscale()/4k_hdr.jpg) + +### Effects `/filters:quality(0-100)/` + +change the quality, [default is 80][output options] + +![crop](https://images.t-online.de/1800x1450:888x500/filters:quality(1)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:quality(1)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:quality(1)/4k_hdr.jpg) + +### Effects `/filters:rotate(0-360)/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:rotate(180)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:rotate(180)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:rotate(180)/4k_hdr.jpg) + +### Effects `/filters:roundCrop()/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/4k_hdr.jpg) + +Again, you can create custom ellipsis by specifying the coordinates like before: `/${X}x${Y}:${WIDTH}x${HEIGHT}/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:roundCrop(444x250:300x75)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:roundCrop(444x250:300x75)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:roundCrop(444x250:300x75)/4k_hdr.jpg) + +It is suggested to force _PNG_ as format, as _JPEG_ does not (?) support transparent backgrounds. +So in case a _JPEG_ is used (like here) and your Browser does not support _WEBP_, there will be a +black frame. + +#### PNG + +![crop](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(png)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(png)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(png)/4k_hdr.jpg) + +#### JPEG + +![crop](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(jpeg)/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(jpeg)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/filters:format(jpeg)/4k_hdr.jpg) + +### Effects `/filters:rotate(180)/` + +![crop](https://images.t-online.de/1800x1450:888x500/filters:roundCrop()/4k_hdr.jpg) + +[`https://images.t-online.de/1800x1450:888x500/filters:rotate(180)/4k_hdr.jpg`](https://images.t-online.de/1800x1450:888x500/filters:rotate(180)/4k_hdr.jpg) + +## Try it out + +Visit the [Demo UI](https://master-images-053041861227-eu-west-1.s3-eu-west-1.amazonaws.com/index.html), enter the following + +* `bucket name = master-images-053041861227-eu-west-1` +* `image key = oat.jpg` + +There are many other images already automatically imported into the image bucket, just check the API for more `keys`. \ No newline at end of file diff --git a/source/constructs/.gitignore b/source/constructs/.gitignore deleted file mode 100644 index ad34eb457..000000000 --- a/source/constructs/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -*.js -!jest.config.js -!issue-fixer/index.js -*.d.ts -node_modules - -# CDK asset staging directory -.cdk.staging -cdk.out - -# Parcel build directories -.cache -.build diff --git a/source/constructs/.npmignore b/source/constructs/.npmignore deleted file mode 100644 index c1d6d45dc..000000000 --- a/source/constructs/.npmignore +++ /dev/null @@ -1,6 +0,0 @@ -*.ts -!*.d.ts - -# CDK asset staging directory -.cdk.staging -cdk.out diff --git a/source/constructs/bin/constructs.ts b/source/constructs/bin/constructs.ts deleted file mode 100644 index b2a269b69..000000000 --- a/source/constructs/bin/constructs.ts +++ /dev/null @@ -1,8 +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 { ConstructsStack } from '../lib/constructs-stack'; - -const app = new cdk.App(); -new ConstructsStack(app, 'ConstructsStack'); \ No newline at end of file diff --git a/source/constructs/cdk.json b/source/constructs/cdk.json deleted file mode 100644 index ef09a7a9e..000000000 --- a/source/constructs/cdk.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "app": "npx ts-node bin/constructs.ts", - "context": { - "@aws-cdk/core:enableStackNameDuplicates": "false", - "aws-cdk:enableDiffNoFail": "true" - } -} diff --git a/source/constructs/jest.config.js b/source/constructs/jest.config.js deleted file mode 100644 index 772f97490..000000000 --- a/source/constructs/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - roots: ['/test'], - testMatch: ['**/*.test.ts'], - transform: { - '^.+\\.tsx?$': 'ts-jest' - } -}; diff --git a/source/constructs/lib/api.json b/source/constructs/lib/api.json 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/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/serverless-image-handler.ts b/source/constructs/lib/serverless-image-handler.ts deleted file mode 100644 index e48ace63f..000000000 --- a/source/constructs/lib/serverless-image-handler.ts +++ /dev/null @@ -1,650 +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'); - - // ImageHandlerFunctionRole - const imageHandlerFunctionRole = new cdkIam.Role(this, 'ImageHandlerFunctionRole', { - assumedBy: new cdkIam.ServicePrincipal('lambda.amazonaws.com'), - path: '/', - roleName: `${cdk.Aws.STACK_NAME}ImageHandlerFunctionRole-${cdk.Aws.REGION}` - }); - const cfnImageHandlerFunctionRole = imageHandlerFunctionRole.node.defaultChild as cdkIam.CfnRole; - this.addCfnNagSuppressRules(cfnImageHandlerFunctionRole, [ - { - id: 'W28', - reason: 'Resource name validated and found to pose no risk to updates that require replacement of this resource.' - } - ]); - cfnImageHandlerFunctionRole.overrideLogicalId('ImageHandlerFunctionRole'); - - // ImageHandlerPolicy - const imageHandlerPolicy = new cdkIam.Policy(this, 'ImageHandlerPolicy', { - policyName: `${cdk.Aws.STACK_NAME}ImageHandlerPolicy`, - statements: [ - new cdkIam.PolicyStatement({ - actions: [ - 'logs:CreateLogStream', - 'logs:CreateLogGroup', - 'logs:PutLogEvents' - ], - resources: [ - `arn:${cdk.Aws.PARTITION}:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/*` - ] - }), - new cdkIam.PolicyStatement({ - actions: [ - 's3:GetObject', - 's3:PutObject', - 's3:ListBucket' - ], - resources: [ - `arn:${cdk.Aws.PARTITION}:s3:::*` - ] - }), - new cdkIam.PolicyStatement({ - actions: [ - 'rekognition:DetectFaces' - ], - resources: [ - '*' - ] - }) - ] - }); - imageHandlerPolicy.attachToRole(imageHandlerFunctionRole); - const cfnImageHandlerPolicy = imageHandlerPolicy.node.defaultChild as cdkIam.CfnPolicy; - this.addCfnNagSuppressRules(cfnImageHandlerPolicy, [ - { - id: 'W12', - reason: 'rekognition:DetectFaces requires \'*\' resources.' - } - ]); - cfnImageHandlerPolicy.overrideLogicalId('ImageHandlerPolicy'); - - // ImageHandlerFunction - const imageHandlerFunction = new cdkLambda.Function(this, 'ImageHanlderFunction', { - description: 'Serverless Image Handler - Function for performing image edits and manipulations.', - code: new cdkLambda.S3Code( - cdkS3.Bucket.fromBucketArn(this, 'ImageHandlerLambdaSource', `arn:${cdk.Aws.PARTITION}:s3:::${BUCKET_NAME}-${cdk.Aws.REGION}`), - `${SOLUTION_NAME}/${VERSION}/image-handler.zip` - ), - handler: 'index.handler', - runtime: cdkLambda.Runtime.NODEJS_12_X, - timeout: cdk.Duration.seconds(30), - memorySize: 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; - cfnLambdaFunctionLogs.overrideLogicalId('ImageHandlerLogGroup'); - - // CloudFrontToApiGatewayToLambda pattern - const cloudFrontApiGatewayLambda = new CloudFrontToApiGatewayToLambda(this, 'CloudFrontApiGatewayLambda', { - existingLambdaObj: imageHandlerFunction, - insertHttpSecurityHeaders: false - }); - const { apiGatewayLogGroup, apiGateway, cloudFrontWebDistribution } = cloudFrontApiGatewayLambda; - - // ApiLogs - const cfnApiGatewayLogGroup = apiGatewayLogGroup.node.defaultChild as cdkLogs.CfnLogGroup; - cfnApiGatewayLogGroup.overrideLogicalId('ApiLogs'); - - // ImageHandlerApi - this.removeChildren(apiGateway, [ 'Endpoint', 'UsagePlan', 'Deployment', 'Default', 'DeploymentStage.prod' ]); - const cfnApiGateway = apiGateway.node.defaultChild as cdkApiGateway.CfnRestApi; - cfnApiGateway.name = 'ServerlessImageHandler'; - cfnApiGateway.body = apiBody; - cfnApiGateway.overrideLogicalId('ImageHandlerApi'); - - // ImageHandlerPermission - imageHandlerFunction.addPermission('ImageHandlerPermission', { - action: 'lambda:InvokeFunction', - sourceArn: `arn:${cdk.Aws.PARTITION}:execute-api:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${apiGateway.restApiId}/*/*/*`, - principal: new cdkIam.ServicePrincipal('apigateway.amazonaws.com') - }); - (imageHandlerFunction.node.findChild('ImageHandlerPermission') as cdkLambda.CfnPermission).overrideLogicalId('ImageHandlerPermission'); - - // ApiLoggingRole - const cfnApiGatewayLogRole = cloudFrontApiGatewayLambda.apiGatewayCloudWatchRole.node.defaultChild as cdkIam.CfnRole; - cfnApiGatewayLogRole.overrideLogicalId('ApiLoggingRole'); - - // ApiAccountConfig - const cfnApiGatewayAccount = cloudFrontApiGatewayLambda.node.findChild('LambdaRestApiAccount') as cdkApiGateway.CfnAccount; - cfnApiGatewayAccount.overrideLogicalId('ApiAccountConfig'); - - // ImageHandlerApiDeployment - const cfnApiGatewayDeployment = new cdkApiGateway.CfnDeployment(this, 'ImageHanlderApiDeployment', { - restApiId: apiGateway.restApiId, - stageName: 'image', - stageDescription: { - accessLogSetting: { - destinationArn: cfnApiGatewayLogGroup.attrArn, - format: '$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] "$context.httpMethod $context.resourcePath $context.protocol" $context.status $context.responseLength $context.requestId' - } - } - }); - this.addCfnNagSuppressRules(cfnApiGatewayDeployment, [ - { - id: 'W68', - reason: 'The solution does not require the usage plan.' - } - ]); - this.addDependencies(cfnApiGatewayDeployment, [ cfnApiGatewayAccount ]); - cfnApiGatewayDeployment.overrideLogicalId('ImageHandlerApiDeployment'); - - // Logs - const cloudFrontToApiGateway = cloudFrontApiGatewayLambda.node.findChild('CloudFrontToApiGateway'); - const accessLogBucket = cloudFrontToApiGateway.node.findChild('CloudfrontLoggingBucket') as cdkS3.Bucket; - const cfnAccessLogBucket = accessLogBucket.node.defaultChild as cdkS3.CfnBucket; - this.addCfnNagSuppressRules(cfnAccessLogBucket, [ - { - "id": "W35", - "reason": "Used to store access logs for other buckets" - } - ]); - cfnAccessLogBucket.overrideLogicalId('Logs'); - - // LogsBucketPolicy - const accessLogBucketPolicy = accessLogBucket.node.findChild('Policy') as cdkS3.BucketPolicy; - (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).overrideLogicalId('LogsBucketPolicy'); - - // ImageHandlerDistribution - const cfnCloudFrontDistribution = cloudFrontWebDistribution.node.defaultChild as cdkCloudFront.CfnDistribution; - cfnCloudFrontDistribution.distributionConfig = { - origins: [{ - domainName: `${apiGateway.restApiId}.execute-api.${cdk.Aws.REGION}.amazonaws.com`, - id: apiGateway.restApiId, - originPath: '/image', - customOriginConfig: { - httpsPort: 443, - originProtocolPolicy: 'https-only', - originSslProtocols: [ 'TLSv1.1', 'TLSv1.2' ] - } - }], - enabled: true, - httpVersion: 'http2', - comment: 'Image handler distribution', - defaultCacheBehavior: { - allowedMethods: [ 'GET', 'HEAD' ], - targetOriginId: apiGateway.restApiId, - forwardedValues: { - queryString: true, - queryStringCacheKeys: [ 'signature' ], - headers: [ 'Origin', 'Accept' ], - cookies: { forward: 'none' } - }, - viewerProtocolPolicy: 'https-only' - }, - customErrorResponses: [ - { errorCode: 500, errorCachingMinTtl: 10 }, - { errorCode: 501, errorCachingMinTtl: 10 }, - { errorCode: 502, errorCachingMinTtl: 10 }, - { errorCode: 503, errorCachingMinTtl: 10 }, - { errorCode: 504, errorCachingMinTtl: 10 } - ], - priceClass: 'PriceClass_All', - logging: { - includeCookies: false, - bucket: accessLogBucket.bucketRegionalDomainName, - prefix: 'image-handler-cf-logs/' - } - }; - cfnCloudFrontDistribution.overrideLogicalId('ImageHandlerDistribution'); - - // CloudFrontToS3 pattern - const cloudFrontToS3 = new CloudFrontToS3(this, 'CloudFrontToS3', { - bucketProps: { - versioned: false, - websiteIndexDocument: 'index.html', - websiteErrorDocument: 'index.html', - serverAccessLogsBucket: undefined, - accessControl: cdkS3.BucketAccessControl.PRIVATE - }, - insertHttpSecurityHeaders: false - }); - this.removeChildren(cloudFrontToS3, [ 'S3LoggingBucket', 'CloudfrontLoggingBucket' ]); - - // DemoBucket - const demoBucket = cloudFrontToS3.s3Bucket as cdkS3.Bucket; - const cfnDemoBucket = demoBucket.node.defaultChild as cdkS3.CfnBucket; - cfnDemoBucket.cfnOptions.condition = deployDemoUiCondition; - this.addCfnNagSuppressRules(cfnDemoBucket, [ - { - id: 'W35', - reason: 'This S3 bucket does not require access logging. API calls and image operations are logged to CloudWatch with custom reporting.' - } - ]) - cfnDemoBucket.overrideLogicalId('DemoBucket'); - - // DemoOriginAccessIdentity - const cfnDemoOriginAccessIdentity = cloudFrontToS3.node.findChild('CloudFrontOriginAccessIdentity') as cdkCloudFront.CfnCloudFrontOriginAccessIdentity; - cfnDemoOriginAccessIdentity.cloudFrontOriginAccessIdentityConfig = { - comment: `access-identity-${demoBucket.bucketName}` - }; - cfnDemoOriginAccessIdentity.cfnOptions.condition = deployDemoUiCondition; - cfnDemoOriginAccessIdentity.overrideLogicalId('DemoOriginAccessIdentity'); - - // DemoBucketPolicy - const demoBucketPolicy = demoBucket.node.findChild('Policy'); - const cfnDemoBucketPolicy = demoBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy; - cfnDemoBucketPolicy.policyDocument = { - Statement: [ - { - Action: [ 's3:GetObject' ], - Effect: 'Allow', - Resource: `${demoBucket.bucketArn}/*`, - Principal: { - CanonicalUser: cfnDemoOriginAccessIdentity.attrS3CanonicalUserId - } - } - ] - }; - cfnDemoBucketPolicy.cfnOptions.condition = deployDemoUiCondition; - cfnDemoBucketPolicy.cfnOptions.metadata = {}; - cfnDemoBucketPolicy.overrideLogicalId('DemoBucketPolicy'); - - // DemoDistribution - const demoDistribution = cloudFrontToS3.cloudFrontWebDistribution; - const cfnDemoDistribution = demoDistribution.node.defaultChild as cdkCloudFront.CfnDistribution; - cfnDemoDistribution.distributionConfig = { - comment: 'Website distribution for solution', - origins: [{ - id: 'S3-solution-website', - domainName: demoBucket.bucketRegionalDomainName, - s3OriginConfig: { - originAccessIdentity: `origin-access-identity/cloudfront/${cfnDemoOriginAccessIdentity.ref}` - } - }], - defaultCacheBehavior: { - targetOriginId: 'S3-solution-website', - allowedMethods: [ 'GET', 'HEAD' ], - cachedMethods: [ 'GET', 'HEAD' ], - forwardedValues: { - queryString: false - }, - viewerProtocolPolicy: 'redirect-to-https' - }, - ipv6Enabled: true, - viewerCertificate: { - cloudFrontDefaultCertificate: true - }, - enabled: true, - httpVersion: 'http2', - logging: { - includeCookies: false, - bucket: accessLogBucket.bucketRegionalDomainName, - prefix: 'demo-cf-logs/' - } - }; - cfnDemoDistribution.cfnOptions.condition = deployDemoUiCondition; - cfnDemoDistribution.overrideLogicalId('DemoDistribution'); - - // CustomResourceRole - const customResourceRole = new cdkIam.Role(this, 'CustomResourceRole', { - assumedBy: new cdkIam.ServicePrincipal('lambda.amazonaws.com'), - path: '/', - roleName: `${cdk.Aws.STACK_NAME}CustomResourceRole-${cdk.Aws.REGION}` - }); - const cfnCustomResourceRole = customResourceRole.node.defaultChild as cdkIam.CfnRole; - this.addCfnNagSuppressRules(cfnCustomResourceRole, [ - { - id: 'W28', - reason: 'Resource name validated and found to pose no risk to updates that require replacement of this resource.' - } - ]); - cfnCustomResourceRole.overrideLogicalId('CustomResourceRole'); - - // CustomResourcePolicy - const customResourcePolicy = new cdkIam.Policy(this, 'CustomResourcePolicy', { - policyName: `${cdk.Aws.STACK_NAME}CustomResourcePolicy`, - statements: [ - new cdkIam.PolicyStatement({ - actions: [ - 'logs:CreateLogStream', - 'logs:CreateLogGroup', - 'logs:PutLogEvents' - ], - resources: [ - `arn:${cdk.Aws.PARTITION}:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/*` - ] - }), - new cdkIam.PolicyStatement({ - actions: [ - 's3:GetObject', - 's3:PutObject', - 's3:ListBucket' - ], - resources: [ - `arn:${cdk.Aws.PARTITION}:s3:::*` - ] - }) - ] - }); - customResourcePolicy.attachToRole(customResourceRole); - const cfnCustomResourcePolicy = customResourcePolicy.node.defaultChild as cdkIam.CfnPolicy; - cfnCustomResourcePolicy.overrideLogicalId('CustomResourcePolicy'); - - // CustomResourceFunction - const customResourceFunction = new cdkLambda.Function(this, 'CustomResourceFunction', { - description: 'Serverless Image Handler - Custom resource', - code: new cdkLambda.S3Code( - cdkS3.Bucket.fromBucketArn(this, 'CustomResourceLambdaSource', `arn:${cdk.Aws.PARTITION}:s3:::${BUCKET_NAME}-${cdk.Aws.REGION}`), - `${SOLUTION_NAME}/${VERSION}/custom-resource.zip` - ), - handler: 'index.handler', - runtime: cdkLambda.Runtime.NODEJS_12_X, - timeout: cdk.Duration.seconds(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; - 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 ] - }); - } catch (error) { - console.error(error); - } - } - - /** - * Adds cfn-nag suppression rules to the AWS CloudFormation resource metadata. - * @param {cdk.CfnResource} resource Resource to add cfn-nag suppression rules - * @param {CfnNagSuppressRule[]} rules Rules to suppress - */ - addCfnNagSuppressRules(resource: cdk.CfnResource, rules: CfnNagSuppressRule[]) { - resource.addMetadata('cfn_nag', { - rules_to_suppress: rules - }); - } - - /** - * Adds dependencies to the AWS CloudFormation resource. - * @param {cdk.CfnResource} resource Resource to add AWS CloudFormation dependencies - * @param {cdk.CfnResource[]} dependencies Dependencies to be added to the AWS CloudFormation resource - */ - addDependencies(resource: cdk.CfnResource, dependencies: cdk.CfnResource[]) { - for (let dependency of dependencies) { - resource.addDependsOn(dependency); - } - } - - /** - * Removes AWS CDK created children from the AWS CloudFormation resource. - * @param {cdk.IConstruct} resource Resource to delete children - * @param {string[]} children The list of children to delete from the resource - */ - removeChildren(resource: cdk.IConstruct, children: string[]) { - for (let child of children) { - resource.node.tryRemoveChild(child); - } - } - - /** - * Removes all dependent children of the resource. - * @param {cdk.IConstruct} resource Resource to delete all dependent children - */ - removeAllChildren(resource: cdk.IConstruct) { - let children = resource.node.children; - for (let child of children) { - this.removeAllChildren(child); - resource.node.tryRemoveChild(child.node.id); - } - } - - /** - * Creates custom resource to the AWS CloudFormation template. - * @param {string} id Custom resource ID - * @param {cdkLambda.Function} customResourceFunction Custom resource Lambda function - * @param {CustomResourceConfig} config Custom resource configuration - * @return {cdk.CfnCustomResource} - */ - createCustomResource(id: string, customResourceFunction: cdkLambda.Function, config?: CustomResourceConfig): cdk.CfnCustomResource { - const customResource = new cdk.CfnCustomResource(this, id, { - serviceToken: customResourceFunction.functionArn - }); - customResource.addOverride('Type', 'Custom::CustomResource'); - customResource.overrideLogicalId(id); - - if (config) { - const { properties, condition, dependencies } = config; - - if (properties) { - for (let property of properties) { - customResource.addPropertyOverride(property.path, property.value); - } - } - - if (dependencies) { - this.addDependencies(customResource, dependencies); - } - - customResource.cfnOptions.condition = condition; - } - - return customResource; - } -} \ No newline at end of file diff --git a/source/constructs/package.json b/source/constructs/package.json deleted file mode 100644 index 70b5ec012..000000000 --- a/source/constructs/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "constructs", - "description": "Serverless Image Handler Constructs", - "version": "5.1.0", - "license": "Apache-2.0", - "bin": { - "constructs": "bin/constructs.js" - }, - "scripts": { - "build": "tsc", - "watch": "tsc -w", - "test": "export BUCKET_NAME=TEST && export SOLUTION_NAME=serverless-image-handler && export VERSION=TEST_VERSION && jest", - "cdk": "cdk" - }, - "devDependencies": { - "@aws-cdk/assert": "1.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" - } -} diff --git a/source/constructs/test/constructs.test.ts b/source/constructs/test/constructs.test.ts deleted file mode 100644 index c40e53c6f..000000000 --- a/source/constructs/test/constructs.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { expect as expectCDK, matchTemplate, MatchStyle } from '@aws-cdk/assert'; -import * as cdk from '@aws-cdk/core'; -import * as Constructs from '../lib/constructs-stack'; -import TestTemplate from './serverless-image-handler-test.json'; - -test('Serverless Image Handler Stack', () => { - const app = new cdk.App(); - // WHEN - const stack = new Constructs.ConstructsStack(app, 'MyTestStack'); - // THEN - expectCDK(stack).to(matchTemplate(TestTemplate, MatchStyle.EXACT)); -}); diff --git a/source/constructs/test/serverless-image-handler-test.json b/source/constructs/test/serverless-image-handler-test.json deleted file mode 100644 index 5bb6e86b2..000000000 --- a/source/constructs/test/serverless-image-handler-test.json +++ /dev/null @@ -1,1507 +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" - ] - } - }, - "Resources": { - "ImageHandlerFunctionRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - } - } - ], - "Version": "2012-10-17" - }, - "Path": "/", - "RoleName": { - "Fn::Join": [ - "", - [ - { - "Ref": "AWS::StackName" - }, - "ImageHandlerFunctionRole-", - { - "Ref": "AWS::Region" - } - ] - ] - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W28", - "reason": "Resource name validated and found to pose no risk to updates that require replacement of this resource." - } - ] - } - } - }, - "ImageHandlerPolicy": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyDocument": { - "Statement": [ - { - "Action": [ - "logs:CreateLogStream", - "logs:CreateLogGroup", - "logs:PutLogEvents" - ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":logs:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":log-group:/aws/lambda/*" - ] - ] - } - }, - { - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket" - ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":s3:::*" - ] - ] - } - }, - { - "Action": "rekognition:DetectFaces", - "Effect": "Allow", - "Resource": "*" - } - ], - "Version": "2012-10-17" - }, - "PolicyName": { - "Fn::Join": [ - "", - [ - { - "Ref": "AWS::StackName" - }, - "ImageHandlerPolicy" - ] - ] - }, - "Roles": [ - { - "Ref": "ImageHandlerFunctionRole" - } - ] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W12", - "reason": "rekognition:DetectFaces requires '*' resources." - } - ] - } - } - }, - "ImageHandlerFunction": { - "Type": "AWS::Lambda::Function", - "Properties": { - "Code": { - "S3Bucket": { - "Fn::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" - }, - "ApiLogs": { - "Type": "AWS::Logs::LogGroup", - "UpdateReplacePolicy": "Retain", - "DeletionPolicy": "Retain" - }, - "ImageHandlerApi": { - "Type": "AWS::ApiGateway::RestApi", - "Properties": { - "Body": { - "swagger": "2.0", - "info": { - "title": "ServerlessImageHandler" - }, - "basePath": "/image", - "schemes": [ - "https" - ], - "paths": { - "/{proxy+}": { - "x-amazon-apigateway-any-method": { - "produces": [ - "application/json" - ], - "parameters": [ - { - "name": "proxy", - "in": "path", - "required": true, - "type": "string" - }, - { - "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" - } - ] - } - } - }, - "LogsBucketPolicy": { - "Type": "AWS::S3::BucketPolicy", - "Properties": { - "Bucket": { - "Ref": "Logs" - }, - "PolicyDocument": { - "Statement": [ - { - "Action": "*", - "Condition": { - "Bool": { - "aws:SecureTransport": "false" - } - }, - "Effect": "Deny", - "Principal": "*", - "Resource": { - "Fn::Join": [ - "", - [ - { - "Fn::GetAtt": [ - "Logs", - "Arn" - ] - }, - "/*" - ] - ] - }, - "Sid": "HttpsOnly" - } - ], - "Version": "2012-10-17" - } - } - }, - "ImageHandlerDistribution": { - "Type": "AWS::CloudFront::Distribution", - "Properties": { - "DistributionConfig": { - "Comment": "Image handler distribution", - "CustomErrorResponses": [ - { - "ErrorCachingMinTTL": 10, - "ErrorCode": 500 - }, - { - "ErrorCachingMinTTL": 10, - "ErrorCode": 501 - }, - { - "ErrorCachingMinTTL": 10, - "ErrorCode": 502 - }, - { - "ErrorCachingMinTTL": 10, - "ErrorCode": 503 - }, - { - "ErrorCachingMinTTL": 10, - "ErrorCode": 504 - } - ], - "DefaultCacheBehavior": { - "AllowedMethods": [ - "GET", - "HEAD" - ], - "ForwardedValues": { - "Cookies": { - "Forward": "none" - }, - "Headers": [ - "Origin", - "Accept" - ], - "QueryString": true, - "QueryStringCacheKeys": [ - "signature" - ] - }, - "TargetOriginId": { - "Ref": "ImageHandlerApi" - }, - "ViewerProtocolPolicy": "https-only" - }, - "Enabled": true, - "HttpVersion": "http2", - "Logging": { - "Bucket": { - "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." - } - ] - } - } - }, - "DemoBucket": { - "Type": "AWS::S3::Bucket", - "Properties": { - "AccessControl": "Private", - "BucketEncryption": { - "ServerSideEncryptionConfiguration": [ - { - "ServerSideEncryptionByDefault": { - "SSEAlgorithm": "AES256" - } - } - ] - }, - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true - }, - "WebsiteConfiguration": { - "ErrorDocument": "index.html", - "IndexDocument": "index.html" - } - }, - "UpdateReplacePolicy": "Retain", - "DeletionPolicy": "Retain", - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W35", - "reason": "This S3 bucket does not require access logging. API calls and image operations are logged to CloudWatch with custom reporting." - } - ] - } - }, - "Condition": "DeployDemoUICondition" - }, - "DemoBucketPolicy": { - "Type": "AWS::S3::BucketPolicy", - "Properties": { - "Bucket": { - "Ref": "DemoBucket" - }, - "PolicyDocument": { - "Statement": [ - { - "Action": [ - "s3:GetObject" - ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - { - "Fn::GetAtt": [ - "DemoBucket", - "Arn" - ] - }, - "/*" - ] - ] - }, - "Principal": { - "CanonicalUser": { - "Fn::GetAtt": [ - "DemoOriginAccessIdentity", - "S3CanonicalUserId" - ] - } - } - } - ] - } - }, - "Condition": "DeployDemoUICondition" - }, - "DemoOriginAccessIdentity": { - "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity", - "Properties": { - "CloudFrontOriginAccessIdentityConfig": { - "Comment": { - "Fn::Join": [ - "", - [ - "access-identity-", - { - "Ref": "DemoBucket" - } - ] - ] - } - } - }, - "Condition": "DeployDemoUICondition" - }, - "DemoDistribution": { - "Type": "AWS::CloudFront::Distribution", - "Properties": { - "DistributionConfig": { - "Comment": "Website distribution for solution", - "DefaultCacheBehavior": { - "AllowedMethods": [ - "GET", - "HEAD" - ], - "CachedMethods": [ - "GET", - "HEAD" - ], - "ForwardedValues": { - "QueryString": false - }, - "TargetOriginId": "S3-solution-website", - "ViewerProtocolPolicy": "redirect-to-https" - }, - "Enabled": true, - "HttpVersion": "http2", - "IPV6Enabled": true, - "Logging": { - "Bucket": { - "Fn::GetAtt": [ - "Logs", - "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: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" - }, - "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" - } - }, - "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 deleted file mode 100644 index a09f01808..000000000 --- a/source/constructs/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2018", - "module": "commonjs", - "lib": ["es2018"], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, - "inlineSources": true, - "experimentalDecorators": true, - "strictPropertyInitialization": false, - "typeRoots": ["./node_modules/@types"], - "resolveJsonModule": true, - "esModuleInterop": true - }, - "exclude": ["cdk.out"] -} diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js deleted file mode 100644 index cd484b346..000000000 --- a/source/custom-resource/index.js +++ /dev/null @@ -1,458 +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 s3 = new AWS.S3(); -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; - 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)); -} \ No newline at end of file diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json deleted file mode 100644 index 7eba78e65..000000000 --- a/source/custom-resource/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "custom-resource", - "description": "Serverless Image Handler custom resource", - "main": "index.js", - "author": { - "name": "aws-solutions-builder" - }, - "version": "5.1.0", - "private": true, - "dependencies": { - "axios": "^0.21.1", - "uuid": "^8.3.0" - }, - "devDependencies": { - "aws-sdk": "2.712.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" -} diff --git a/source/custom-resource/test/index.spec.js b/source/custom-resource/test/index.spec.js deleted file mode 100644 index 6517c67cb..000000000 --- a/source/custom-resource/test/index.spec.js +++ /dev/null @@ -1,726 +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 - })), - 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: {} - }); - }); - }); -}); \ No newline at end of file diff --git a/source/demo-ui/index.html b/source/demo-ui/index.html deleted file mode 100644 index ef7fd4012..000000000 --- a/source/demo-ui/index.html +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - - - Serverless Image Handler - - - - - - - - - - - -
-
-
- Serverless Image Handler Demo -
-
-
-
-
-
-
Image Source
-
-
- Enter the name of an Amazon S3 bucket in your account that contains original image files. - - Enter the name of an original image file (with extension) stored in the above Amazon S3 bucket. - -
- -
-
-
-
-
-
Original Image
- - Original Image -
- Having trouble? -
-
-
-
-
-
-
Editor
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
-
-
-
-
- - [?] - -
-
-
-
- - [?] - -
-
-
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - [?] - -
-
-
-
-
-
-
- - - [?] -
-
- - [?] - -
-
-
-
- - [?] - -
-
-
-
- - -
-
-
-
-
-
-
-
Preview
-
- Preview Image -

-
- Request Body: - [?] -

-                        

-
-
- - [?] - -
-
- Having trouble? -
-
-
-
-
- - - - - - diff --git a/source/demo-ui/scripts.js b/source/demo-ui/scripts.js deleted file mode 100644 index 00437a669..000000000 --- a/source/demo-ui/scripts.js +++ /dev/null @@ -1,116 +0,0 @@ -/********************************************************************************************************************* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. * - * * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * - * with the License. A copy of the License is located at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * - * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * - * and limitations under the License. * - *********************************************************************************************************************/ - -function importOriginalImage() { - // Gather the bucket name and image key - const bucketName = $(`#txt-bucket-name`).first().val(); - const keyName = $(`#txt-key-name`).first().val(); - // Assemble the image request - const request = { - bucket: bucketName, - key: keyName - } - const strRequest = JSON.stringify(request); - const encRequest = btoa(strRequest); - // Import the image data into the element - $(`#img-original`) - .attr(`src`, `${appVariables.apiEndpoint}/${encRequest}`) - .attr(`data-bucket`, bucketName) - .attr(`data-key`, keyName); -} - -function getPreviewImage() { - // Gather the editor inputs - const _width = $(`#editor-width`).first().val(); - const _height = $(`#editor-height`).first().val(); - const _resize = $(`#editor-resize-mode`).first().val(); - const _fillColor = $(`#editor-fill-color`).first().val(); - const _backgroundColor = $(`#editor-background-color`).first().val(); - const _grayscale = $(`#editor-grayscale`).first().prop("checked"); - const _flip = $(`#editor-flip`).first().prop("checked"); - const _flop = $(`#editor-flop`).first().prop("checked"); - const _negative = $(`#editor-negative`).first().prop("checked"); - const _flatten = $(`#editor-flatten`).first().prop("checked"); - const _normalize = $(`#editor-normalize`).first().prop("checked"); - const _rgb = $(`#editor-rgb`).first().val(); - const _smartCrop = $(`#editor-smart-crop`).first().prop("checked"); - const _smartCropIndex = $(`#editor-smart-crop-index`).first().val(); - const _smartCropPadding = $(`#editor-smart-crop-padding`).first().val(); - // Setup the edits object - const _edits = {} - _edits.resize = {}; - if (_resize !== "Disabled") { - if (_width !== "") { _edits.resize.width = Number(_width) } - if (_height !== "") { _edits.resize.height = Number(_height) } - _edits.resize.fit = _resize; - } - if (_fillColor !== "") { _edits.resize.background = hexToRgbA(_fillColor, 1) } - if (_backgroundColor !== "") { _edits.flatten = { background: hexToRgbA(_backgroundColor, undefined) }} - if (_grayscale) { _edits.grayscale = _grayscale } - if (_flip) { _edits.flip = _flip } - if (_flop) { _edits.flop = _flop } - if (_negative) { _edits.negate = _negative } - if (_flatten) { _edits.flatten = _flatten } - if (_normalize) { _edits.normalise = _normalize } - if (_rgb !== "") { - const input = _rgb.replace(/\s+/g, ''); - const arr = input.split(','); - const rgb = { r: Number(arr[0]), g: Number(arr[1]), b: Number(arr[2]) }; - _edits.tint = rgb - } - if (_smartCrop) { - _edits.smartCrop = {}; - if (_smartCropIndex !== "") { _edits.smartCrop.faceIndex = Number(_smartCropIndex) } - if (_smartCropPadding !== "") { _edits.smartCrop.padding = Number(_smartCropPadding) } - } - if (Object.keys(_edits.resize).length === 0) { delete _edits.resize }; - // Gather the bucket and key names - const bucketName = $(`#img-original`).first().attr(`data-bucket`); - const keyName = $(`#img-original`).first().attr(`data-key`); - // Set up the request body - const request = { - bucket: bucketName, - key: keyName, - edits: _edits - } - if (Object.keys(request.edits).length === 0) { delete request.edits }; - console.log(request); - // Setup encoded request - const str = JSON.stringify(request); - const enc = btoa(str); - // Fill the preview image - $(`#img-preview`).attr(`src`, `${appVariables.apiEndpoint}/${enc}`); - // Fill the request body field - $(`#preview-request-body`).html(JSON.stringify(request, undefined, 2)); - // Fill the encoded URL field - $(`#preview-encoded-url`).val(`${appVariables.apiEndpoint}/${enc}`); -} - -function hexToRgbA(hex, _alpha) { - var c; - if(/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)){ - c= hex.substring(1).split(''); - if(c.length== 3){ - c= [c[0], c[0], c[1], c[1], c[2], c[2]]; - } - c= '0x'+c.join(''); - return { r: ((c>>16)&255), g: ((c>>8)&255), b: (c&255), alpha: Number(_alpha)}; - } - throw new Error('Bad Hex'); -} - -function resetEdits() { - $('.form-control').val(''); - document.getElementById('editor-resize-mode').selectedIndex = 0; - $(".form-check-input").prop('checked', false); -} \ No newline at end of file diff --git a/source/demo-ui/style.css b/source/demo-ui/style.css deleted file mode 100644 index 6b993ace1..000000000 --- a/source/demo-ui/style.css +++ /dev/null @@ -1,45 +0,0 @@ -.header { - background: #37474f !important; - color: #fff !important; - padding: 10px 10px 10px 20px !important; - font-size: 1.2em !important; -} -.header-italics { - color: #b0bec5 !important; -} -.content { - margin-top: 25px !important; -} -.card-original-image { - margin-top: 20px !important; -} -#img-original { - max-height: 100% !important; - max-width: 100% !important; -} -#img-preview { - max-height: 100% !important; - max-width: 100% !important; -} - - - -.gallery-item { - width: 30%; - margin: 1%; - max-height: auto; - border: 1px solid gray; - border-radius: 4px; - cursor: pointer; -} -.gallery-item-selected { - border: 4px solid #ffa726; -} -.preview-code-block { - background: #cfd8dc !important; - padding: 8px; - border-radius: 4px; -} -.preview-code-block code { - color: black !important; -} \ No newline at end of file diff --git a/source/image-handler/image-handler.js b/source/image-handler/image-handler.js deleted file mode 100644 index e71e2598e..000000000 --- a/source/image-handler/image-handler.js +++ /dev/null @@ -1,262 +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 = Number(edits.resize.width); - if (edits.resize.height) edits.resize.height = 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 metadata = await image.metadata(); - const imageBuffer = await image.toBuffer(); - const boundingBox = await this.getBoundingBox(imageBuffer, options.faceIndex); - const cropArea = this.getCropArea(boundingBox, options, metadata); - try { - image.extract(cropArea); - } catch (err) { - throw { - status: 400, - code: 'SmartCrop::PaddingOutOfBounds', - message: 'The padding value you provided exceeds the boundaries of the original image. Please try choosing a smaller value or applying padding via Sharp for greater specificity.' - }; - } - } else { - image[editKey](value); - } - } - // Return the modified image - return image; - } - - /** - * Gets an image to be used as an overlay to the primary image from an - * Amazon S3 bucket. - * @param {string} bucket - The name of the bucket containing the overlay. - * @param {string} key - The object keyname corresponding to the overlay. - * @param {number} wRatio - The width rate of the overlay image. - * @param {number} hRatio - The height rate of the overlay image. - * @param {number} alpha - The transparency alpha to the overlay. - * @param {object} sourceImageMetadata - The metadata of the source image. - */ - async getOverlayImage(bucket, key, wRatio, hRatio, alpha, sourceImageMetadata) { - const 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(); - return response.FaceDetails[faceIdx].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: 500, - code: err.code, - message: err.message - }; - } - } - } -} - -// Exports -module.exports = ImageHandler; diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js deleted file mode 100644 index 8b250fc52..000000000 --- a/source/image-handler/image-request.js +++ /dev/null @@ -1,382 +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. - */ - 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) { - 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) - ); - - if (matchDefault.test(path)) { // use sharp - return 'Default'; - } else if (matchCustom.test(path) && definedEnvironmentVariables) { // use rewrite function then thumbor mappings - return 'Custom'; - } else if (matchThumbor.test(path)) { // use thumbor mappings - return 'Thumbor'; - } else { - throw { - status: 400, - code: 'RequestTypeError', - message: 'The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, 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; - } -} - -// Exports -module.exports = ImageRequest; \ No newline at end of file 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/jest.config.js b/source/image-handler/jest.config.js new file mode 100644 index 000000000..d68066734 --- /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/package.json b/source/image-handler/package.json index 49ced547b..b03e67624 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -1,32 +1,40 @@ { "name": "image-handler", + "version": "6.2.6", + "private": true, "description": "A Lambda function for performing on-demand image edits and manipulations.", - "main": "index.js", + "license": "Apache-2.0", "author": { - "name": "aws-solutions-builder" + "name": "Amazon Web Services", + "url": "https://aws.amazon.com/solutions" }, - "version": "5.1.0", - "private": true, "dependencies": { - "sharp": "^0.26.1", - "color": "3.1.2", - "color-name": "1.1.4" + "@aws-sdk/client-s3": "3.614.0", + "sharp": "0.33.4" }, "devDependencies": { - "aws-sdk": "2.712.0", - "aws-sdk-mock": "^5.1.0", - "jest": "^26.4.2", - "mocha": "^8.1.3", - "sinon": "^9.0.3", - "nyc": "^15.1.0" + "@aws-lambda-powertools/logger": "2.4.0", + "@types/color": "^3.0.6", + "@types/color-name": "^1.1.4", + "@types/sharp": "^0.32.0", + "@types/aws-lambda": "8.10.141", + "aws-sdk-client-mock": "4.0.1", + "aws-sdk-client-mock-jest": "4.0.1", + "@aws-sdk/util-stream-node": "3.374.0", + "prettier": "3.3.2", + "tsup": "8.1.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.2.2", + "typescript": "^5.5.3" }, "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" + "pretest": "npm i --quiet", + "build:init": "rm -rf package-lock.json dist/ coverage/", + "build:zip": "cd dist && zip -r image-handler.zip *.js node_modules", + "build": "npm run build:init && npm install --cpu=arm64 --os=linux && tsup && npm run build:zip", + "test": "jest --coverage --silent", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"" }, - "license": "Apache-2.0" + "keywords": [] } diff --git a/source/image-handler/src/image-handler.ts b/source/image-handler/src/image-handler.ts new file mode 100644 index 000000000..8642d436f --- /dev/null +++ b/source/image-handler/src/image-handler.ts @@ -0,0 +1,345 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import sharp, { FormatEnum, OverlayOptions, SharpOptions } from 'sharp'; + +import { + ContentTypes, + ImageEdits, + ImageFitTypes, + ImageFormatTypes, + ImageHandlerError, + ImageRequestInfo, + StatusCodes, +} from './lib'; +import { S3 } from '@aws-sdk/client-s3'; +import { rgbaToThumbHash } from './lib/thumbhash'; + +export class ImageHandler { + private readonly LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024; + + constructor(private readonly s3Client: S3) {} + + /** + * Creates a Sharp object from Buffer + * @param originalImage An image buffer. + * @param edits The edits to be applied to an image + * @param options Additional sharp options to be applied + * @returns A Sharp image object + */ + // eslint-disable-next-line @typescript-eslint/ban-types + private async instantiateSharpImage( + originalImage: Buffer, + edits: ImageEdits, + options: SharpOptions, + ): Promise { + let image: sharp.Sharp; + + if (edits && edits.rotate !== undefined && edits.rotate === null) { + image = sharp(originalImage, options); + } else { + const metadata = await sharp(originalImage, options).metadata(); + image = metadata.orientation + ? sharp(originalImage, options).withMetadata({ orientation: metadata.orientation }) + : sharp(originalImage, options).withMetadata(); + } + + return image; + } + + /** + * Modify an image's output format if specified, also automatically optimize the image based on the output format or content type. + * @param modifiedImage the image object. + * @param imageRequestInfo the image request + * @returns A Sharp image object + */ + private modifyImageOutput(modifiedImage: sharp.Sharp, imageRequestInfo: ImageRequestInfo): sharp.Sharp { + const modifiedOutputImage = modifiedImage; + + if ( + ImageFormatTypes.WEBP === imageRequestInfo.outputFormat || + (undefined === imageRequestInfo.outputFormat && imageRequestInfo.contentType === ContentTypes.WEBP) + ) { + modifiedOutputImage.webp({ effort: imageRequestInfo.effort ?? 6 }); + } else if ( + ImageFormatTypes.PNG === imageRequestInfo.outputFormat || + (undefined === imageRequestInfo.outputFormat && imageRequestInfo.contentType === ContentTypes.PNG) + ) { + modifiedOutputImage.png({ palette: true, quality: 100, effort: 7, compressionLevel: 6 }); + } else if ( + ImageFormatTypes.JPEG === imageRequestInfo.outputFormat || + ImageFormatTypes.JPG === imageRequestInfo.outputFormat || + (undefined === imageRequestInfo.outputFormat && imageRequestInfo.contentType === ContentTypes.JPEG) + ) { + modifiedOutputImage.jpeg({ mozjpeg: true }); + } + + return modifiedOutputImage; + } + + /** + * 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; + const options: SharpOptions = { failOn: 'none', animated: imageRequestInfo.contentType === ContentTypes.GIF }; + let base64EncodedImage = ''; + + // Apply edits if specified + if (edits && Object.keys(edits).length) { + // convert image to Sharp object + options.animated = + typeof edits.animated !== 'undefined' ? edits.animated : imageRequestInfo.contentType === ContentTypes.GIF; + let image = await this.instantiateSharpImage(originalImage, edits, options); + + // default to non-animated if image does not have multiple pages + if (options.animated) { + const metadata = await image.metadata(); + if (!metadata.pages || metadata.pages <= 1) { + options.animated = false; + image = await this.instantiateSharpImage(originalImage, edits, options); + } + } + + // apply image edits + let modifiedImage = await this.applyEdits(image, edits, options.animated); + if ('thumbhash' in edits) { + base64EncodedImage = await this.thumbhash(modifiedImage, imageRequestInfo); + } else { + // modify image output if requested + modifiedImage = this.modifyImageOutput(modifiedImage, imageRequestInfo); + // convert to base64 encoded string + const imageBuffer = await modifiedImage.toBuffer(); + base64EncodedImage = imageBuffer.toString('base64'); + } + } else { + // convert image to Sharp and change output format if specified + let image = await this.instantiateSharpImage(originalImage, edits, options); + const modifiedImage = this.modifyImageOutput(image, imageRequestInfo); + // convert to base64 encoded string + const imageBuffer = await modifiedImage.toBuffer(); + base64EncodedImage = imageBuffer.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. + * @param isAnimation a flag whether the edit applies to animated files or not. + * @returns A modifications to the original image. + */ + public async applyEdits(originalImage: sharp.Sharp, edits: ImageEdits, isAnimation: boolean): Promise { + await this.applyResize(originalImage, edits); + + // Apply the image edits + for (const edit in edits) { + if (this.skipEdit(edit, isAnimation)) continue; + + switch (edit) { + case 'roundCrop': { + originalImage = await this.applyRoundCrop(originalImage, edits); + break; + } + case 'crop': { + this.applyCrop(originalImage, edits); + break; + } + case 'animated': { + break; + } + case 'thumbhash': { + originalImage.resize({ width: 100, height: 100, fit: ImageFitTypes.INSIDE }); + break; + } + default: { + if (edit in originalImage) { + originalImage[edit](edits[edit]); + } + } + } + } + // Return the modified image + return originalImage; + } + + /** + * Applies resize edit. + * @param originalImage The original sharp image. + * @param edits The edits to be made to the original image. + */ + private async applyResize(originalImage: sharp.Sharp, edits: ImageEdits): Promise { + if (edits.resize === undefined) { + return; + } + const resize = this.validateResizeInputs(edits.resize); + + if (resize.ratio) { + const ratio = resize.ratio; + + const { width, height } = resize.width && resize.height ? resize : await originalImage.metadata(); + + resize.width = Math.round(width * ratio); + resize.height = Math.round(height * ratio); + // Sharp doesn't have such parameter for resize(), we got it from Thumbor mapper. We don't need to keep this field in the `resize` object + delete resize.ratio; + + if (!resize.fit) resize.fit = ImageFitTypes.INSIDE; + } + } + + /** + * Validates resize edit parameters. + * @param resize The resize parameters. + */ + private validateResizeInputs(resize: any) { + if (resize.width) resize.width = Math.round(Number(resize.width)); + if (resize.height) resize.height = Math.round(Number(resize.height)); + + if ((resize.width != null && resize.width <= 0) || (resize.height != null && resize.height <= 0)) { + throw new ImageHandlerError(StatusCodes.BAD_REQUEST, 'InvalidResizeException', 'The image size is invalid.'); + } + return resize; + } + + /** + * Determines if the edits specified contain a valid roundCrop item + * @param edits The edits speficed + * @returns boolean + */ + private hasRoundCrop(edits: ImageEdits): boolean { + return edits.roundCrop === true || typeof edits.roundCrop === 'object'; + } + + /** + * @param param Value of corner to check + * @returns Boolean identifying whether roundCrop parameters are valid + */ + private validRoundCropParam(param: number) { + return param && param >= 0; + } + + /** + * Applies round crop edit. + * @param originalImage The original sharp image. + * @param edits The edits to be made to the original image. + */ + private async applyRoundCrop(originalImage: sharp.Sharp, edits: ImageEdits): Promise { + // round crop can be boolean or object + if (this.hasRoundCrop(edits)) { + 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 = this.validRoundCropParam(rx) ? rx : Math.min(width, height) / 2; + const radiusY = this.validRoundCropParam(ry) ? ry : Math.min(width, height) / 2; + const topOffset = this.validRoundCropParam(top) ? top : height / 2; + const leftOffset = this.validRoundCropParam(left) ? left : width / 2; + + const ellipse = Buffer.from( + ` `, + ); + const overlayOptions: OverlayOptions[] = [{ input: ellipse, blend: 'dest-in' }]; + + // Need to break out into another sharp pipeline to allow for resize after composite + const data = await originalImage + .composite(overlayOptions) + .png() // transparent background instead of black background + .toBuffer(); + return sharp(data).withMetadata().trim(); + } + + return originalImage; + } + + /** + * Applies crop edit. + * @param originalImage The original sharp image. + * @param edits The edits to be made to the original image. + */ + private applyCrop(originalImage: sharp.Sharp, edits: ImageEdits): void { + originalImage.extract(edits.crop); + } + + /** + * Checks whether an edit needs to be skipped or not. + * @param edit the current edit. + * @param isAnimation a flag whether the edit applies to `gif` file or not. + * @returns whether the edit needs to be skipped or not. + */ + private skipEdit(edit: string, isAnimation: boolean): boolean { + return isAnimation && ['rotate', 'smartCrop', 'roundCrop', 'contentModeration'].includes(edit); + } + + /** + * 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'; + case ImageFormatTypes.GIF: + return 'gif'; + case ImageFormatTypes.AVIF: + return 'avif'; + default: + throw new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + 'UnsupportedOutputImageFormatException', + `Format to ${imageFormatType} not supported`, + ); + } + } + + private async thumbhash(image: sharp.Sharp, imageRequestInfo: ImageRequestInfo): Promise { + const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const binaryThumbHash = rgbaToThumbHash(info.width, info.height, data); + + imageRequestInfo.contentType = ContentTypes.JSON; + imageRequestInfo.cacheControl = 'max-age=3600'; + return Buffer.from( + JSON.stringify({ + base64: Buffer.from(binaryThumbHash).toString('base64'), + // base64_url: thumbHashToDataURL(binaryThumbHash), // debugging thedata:image/png;base64 if required, will be done in the frontend + }), + ).toString('base64'); + } +} diff --git a/source/image-handler/src/image-request.ts b/source/image-handler/src/image-request.ts new file mode 100644 index 000000000..96d86a74f --- /dev/null +++ b/source/image-handler/src/image-request.ts @@ -0,0 +1,422 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ContentTypes, + DefaultImageRequest, + Headers, + ImageEdits, + ImageFormatTypes, + ImageHandlerError, + ImageRequestInfo, + RequestTypes, + StatusCodes, +} from './lib'; +import { ThumborMapper } from './thumbor-mapper'; +import { GetObjectCommand, GetObjectCommandInput, GetObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; +import { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { logger } from './index'; + +type OriginalImageInfo = Partial<{ + contentType: string; + expires: Date; + lastModified: Date; + cacheControl: string; + originalImage: Buffer; +}>; + +export class ImageRequest { + constructor(private readonly s3Client: S3) {} + + /** + * Determines the output format of an image + * @param imageRequestInfo Initialized image request information + * @param event Lambda requrest body + */ + private determineOutputFormat(imageRequestInfo: ImageRequestInfo, event: APIGatewayProxyEventV2): void { + 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.effort !== 'undefined') { + const effort = Math.trunc(decoded.effort); + const isValid = !isNaN(effort) && effort >= 0 && effort <= 6; + imageRequestInfo.effort = isValid ? effort : 4; + } + } + if (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. + * @param imageRequestInfo Initialized image request information + */ + private fixQuality(imageRequestInfo: ImageRequestInfo): void { + if (imageRequestInfo.outputFormat) { + const requestType = [RequestTypes.CUSTOM, RequestTypes.THUMBOR]; + const acceptedValues = [ + ImageFormatTypes.JPEG, + ImageFormatTypes.PNG, + ImageFormatTypes.WEBP, + ImageFormatTypes.TIFF, + ImageFormatTypes.HEIF, + ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, + ]; + + 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]; + } + } + } + } + + /** + * 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: APIGatewayProxyEventV2): Promise { + try { + let imageRequestInfo: ImageRequestInfo = {}; + + imageRequestInfo.requestType = this.parseRequestType(event); + imageRequestInfo.bucket = (process.env.SOURCE_BUCKETS ?? '').split(',')[0]; + 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 PNG. + if ( + imageRequestInfo.contentType === ContentTypes.SVG && + 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 !== ContentTypes.SVG || + imageRequestInfo.edits.toFormat || + imageRequestInfo.outputFormat + ) { + this.determineOutputFormat(imageRequestInfo, event); + } + + // Fix quality for Thumbor and Custom request type if outputFormat is different from quality type. + this.fixQuality(imageRequestInfo); + + return imageRequestInfo; + } catch (error) { + if (error.code && error.code !== 'NoSuchKey') { + logger.warn('Error occurred while setting up the image request. 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 = {}; + + let originalImage: GetObjectCommandOutput; + try { + const getObjectCommand: GetObjectCommandInput = { Bucket: bucket, Key: key }; + logger.debug('Getting image from S3:', { getObjectCommand }); + originalImage = await this.s3Client.send(new GetObjectCommand(getObjectCommand)); + } catch (error) { + logger.info('Error occurred while getting the image from S3. Error: ', error); + throw new ImageHandlerError( + StatusCodes.NOT_FOUND, + 'NoSuchKey', + `The image ${key} does not exist or the request may not be base64 encoded properly.`, + ); + } + let bodyBytes = await originalImage.Body?.transformToByteArray(); + const imageBuffer = Buffer.from(bodyBytes); + + 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 = originalImage.Expires; + } + + if (originalImage.LastModified) { + result.lastModified = originalImage.LastModified; + } + + result.cacheControl = originalImage.CacheControl ?? 'max-age=31536000'; + 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.`; + } else { + logger.warn('Error occurred while getting the original image. Error: ', error); + } + throw new ImageHandlerError(status, error.code, message); + } + } + + /** + * 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: APIGatewayProxyEventV2, 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.rawPath); + } else if (requestType === RequestTypes.CUSTOM) { + const thumborMapping = new ThumborMapper(); + const parsedPath = thumborMapping.parseCustomPath(event.rawPath); + 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: APIGatewayProxyEventV2, 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 { rawPath } = event; + + let key = decodeURIComponent( + rawPath + .replace(/\/__WIDTH__x0\//, '/1200x0/') + .replace(/\/\d+x\d+:\d+x\d+(?=\/)/g, '') + .replace(/\/\d+x\d+(?=\/)/g, '') + .replace(/filters:watermark\(.*\)/u, '') + .replace(/filters:[^/]+/g, '') + .replace(/\/fit-in(?=\/)/g, '') + .replace(/^\/+/g, '') + .replace(/^\/+/, '') + .replace(/\/+/g, '/') + .replace(/^authors\//, ''), + ); + + // ströer specific: our default image path is /${YYYY}/${MM}/${media_id}/image.${EXT} + if (key.match(/^\d{4}\/\d{2}\/.*\/[\w-]+\.\w+$/)) { + key = key.replace(/(.*)\/[\w-]+(\.\w+)$/, '$1/image$2'); + } + + return key; + } + + // 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: APIGatewayProxyEventV2): RequestTypes { + const { rawPath } = event; + const matchDefault = /^(\/?)([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; + const matchThumbor1 = /^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)/i; + const matchThumbor2 = /^((.(?!(\.[^.\\/]+$)))*$)/i; // NOSONAR + const matchThumbor3 = /.*(\.jpg$|\.jpeg$|.\.png$|\.webp$|\.tiff$|\.tif$|\.svg$|\.gif$|\.avif$)/i; // NOSONAR + 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) { + isBase64Encoded = false; + } + + if (matchDefault.test(rawPath) && isBase64Encoded) { + // use sharp + return RequestTypes.DEFAULT; + } else if (definedEnvironmentVariables) { + // use rewrite function then thumbor mappings + return RequestTypes.CUSTOM; + } else if (matchThumbor1.test(rawPath) && (matchThumbor2.test(rawPath) || matchThumbor3.test(rawPath))) { + // 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/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.', + ); + } + } + + // eslint-disable-next-line jsdoc/require-returns-check + /** + * Parses the headers to be sent with the response. + * @param event Lambda request body. + * @param requestType Image handler request type. + * @returns (optional) The headers to be sent with the response. + */ + public parseImageHeaders(event: APIGatewayProxyEventV2, 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: APIGatewayProxyEventV2): DefaultImageRequest { + const { rawPath } = event; + + if (rawPath) { + const encoded = rawPath.startsWith('/') ? rawPath.slice(1) : rawPath; + 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.', + ); + } + } + + /** + * 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: APIGatewayProxyEventV2, requestType: RequestTypes = undefined): ImageFormatTypes { + const { AUTO_WEBP, AUTO_AVIF } = process.env; + const accept = event.headers?.accept; + + if (AUTO_AVIF === 'Yes' && accept && accept.includes(ContentTypes.AVIF)) { + return ImageFormatTypes.WEBP; + } else if (AUTO_WEBP === 'Yes' && accept && accept.includes(ContentTypes.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 imageSignatures: { [key: string]: string } = { + '89504E47': ContentTypes.PNG, + '52494646': ContentTypes.WEBP, + '49492A00': ContentTypes.TIFF, + '4D4D002A': ContentTypes.TIFF, + '47494638': ContentTypes.GIF, + }; + const imageSignature = imageBuffer.subarray(0, 4).toString('hex').toUpperCase(); + if (imageSignatures[imageSignature]) { + return imageSignatures[imageSignature]; + } + if (imageBuffer.subarray(0, 2).toString('hex').toUpperCase() === 'FFD8') { + return ContentTypes.JPEG; + } + if (imageBuffer.subarray(4, 12).toString('hex').toUpperCase() === '6674797061766966') { + // FTYPAVIF (File Type AVIF) + return ContentTypes.AVIF; + } + // SVG does not have an imageSignature we can use here, would require parsing the XML to some degree + 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/jpeg, png, tiff, webp, gif, avif). Inferring the image type from hex headers is not available for SVG images. Refer to the documentation for additional guidance on forming image requests.', + ); + } +} diff --git a/source/image-handler/src/index.ts b/source/image-handler/src/index.ts new file mode 100755 index 000000000..79749e488 --- /dev/null +++ b/source/image-handler/src/index.ts @@ -0,0 +1,199 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ImageHandler } from './image-handler'; +import { ImageRequest } from './image-request'; +import { Headers, ImageHandlerExecutionResult, StatusCodes } from './lib'; +import { S3 } from '@aws-sdk/client-s3'; +import { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { LogStashFormatter } from './lib/LogstashFormatter'; + +const s3Client = new S3(); + +export const logger = new Logger({ + serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME ?? '', + logFormatter: new LogStashFormatter(), +}); + +/** + * Image handler Lambda handler. + * @param event The image handler request event. + * @returns Processed request response. + */ +export async function handler(event: APIGatewayProxyEventV2): Promise { + logger.appendKeys({ ...event, originalImage: undefined }); + logger.info('Image manipulation request', { headers: event.headers }); + + const imageRequest = new ImageRequest(s3Client); + const imageHandler = new ImageHandler(s3Client); + + try { + const imageRequestInfo = await imageRequest.setup(event); + logger.info('image request', { imageRequestInfo }); + + if (imageRequestInfo.expires && imageRequestInfo.expires.getTime() < Date.now()) { + logger.warn('Expired content was requested: ' + imageRequestInfo.key); + return { + statusCode: StatusCodes.GONE, + isBase64Encoded: false, + headers: getResponseHeaders(true, StatusCodes.GONE), + body: JSON.stringify({ + message: 'HTTP/410. Content ' + imageRequestInfo.key + ' has expired.', + code: 'Gone', + status: StatusCodes.GONE, + }), + }; + } + + const processedRequest = await imageHandler.process(imageRequestInfo); + + let headers = getResponseHeaders(false); + headers['Content-Type'] = imageRequestInfo.contentType; + + if (imageRequestInfo.expires) { + // eslint-disable-next-line dot-notation + headers['Expires'] = new Date(imageRequestInfo.expires).toUTCString(); + let seconds_until_expiry = Math.min( + 31536000, + Math.floor((imageRequestInfo.expires.getTime() - Date.now()) / 1000), + ); + headers['Cache-Control'] = 'max-age=' + seconds_until_expiry + ', immutable'; + } else { + headers['Cache-Control'] = imageRequestInfo.cacheControl + ', immutable'; + } + if (imageRequestInfo.lastModified) { + headers['Last-Modified'] = new Date(imageRequestInfo.lastModified).toUTCString(); + } + + // Apply the custom headers overwriting any that may need overwriting + if (imageRequestInfo.headers) { + headers = { ...headers, ...imageRequestInfo.headers }; + } + + return { + statusCode: StatusCodes.OK, + isBase64Encoded: true, + headers, + body: processedRequest, + }; + } catch (error) { + if (error.code && error.code !== 'NoSuchKey') { + logger.warn('Error occurred during image processing', { error }); + } + + const { statusCode, headers, body } = getErrorResponse(error); + return { + statusCode, + isBase64Encoded: false, + headers, + body, + }; + } +} + +/** + * Generates the appropriate set of response headers based on a success or error condition. + * @param isError Has an error been thrown. + * @param statusCode Http-StatusCode of the Response. + * @returns Headers. + */ +function getResponseHeaders(isError: boolean = false, statusCode: number = StatusCodes.OK): 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 (corsEnabled) { + headers['Access-Control-Allow-Origin'] = CORS_ORIGIN; + } + + if (isError) { + headers['Content-Type'] = 'application/json'; + } + + switch (statusCode) { + // Cache short-term 4xx errors + case StatusCodes.BAD_REQUEST: + case StatusCodes.FORBIDDEN: + case StatusCodes.NOT_FOUND: + headers['Cache-Control'] = 'max-age=3600, immutable'; + break; + // Cache long-term 4xx errors + case StatusCodes.GONE: + case StatusCodes.REQUEST_TOO_LONG: + headers['Cache-Control'] = 'max-age=31536000, immutable'; + break; + // No cache for 5xx errors + default: + break; + } + + return headers; +} + +/** + * Determines the appropriate error response values + * @param error The error object from a try/catch block + * @returns appropriate status code and body + */ +export function getErrorResponse(error) { + if (error?.status) { + return { + headers: getResponseHeaders(true, error.status), + statusCode: error.status, + body: JSON.stringify(error), + }; + } + + let statusCode = StatusCodes.INTERNAL_SERVER_ERROR; + switch (error?.message) { + /** + * if an image overlay is attempted and the overlaying image has greater dimensions + * that the base image, sharp will throw an exception and return this string + */ + case 'Image to composite must have same dimensions or smaller': + statusCode = StatusCodes.BAD_REQUEST; + return { + statusCode: statusCode, + headers: getResponseHeaders(true, statusCode), + body: JSON.stringify({ + /** + * return a message indicating overlay dimensions is the issue, the caller may not + * know that the sharp composite function was used + */ + message: 'Image to overlay must have same dimensions or smaller', + code: 'BadRequest', + status: statusCode, + }), + }; + /** + * if an image crop is attempted and the crop has greater dimensions + * than the base image, sharp will throw an exception and return this string + */ + case 'extract_area: bad extract area': + statusCode = StatusCodes.BAD_REQUEST; + return { + statusCode: statusCode, + headers: getResponseHeaders(true, statusCode), + body: JSON.stringify({ + code: 'Crop::AreaOutOfBounds', + message: + 'The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.', + status: statusCode, + }), + }; + default: + return { + statusCode: statusCode, + headers: getResponseHeaders(true, statusCode), + body: JSON.stringify({ + message: 'Internal error. Please contact the system administrator.', + code: 'InternalError', + status: statusCode, + }), + }; + } +} diff --git a/source/image-handler/src/lib/LogstashFormatter.ts b/source/image-handler/src/lib/LogstashFormatter.ts new file mode 100644 index 000000000..e62290de0 --- /dev/null +++ b/source/image-handler/src/lib/LogstashFormatter.ts @@ -0,0 +1,55 @@ +import { LogFormatter, LogItem } from '@aws-lambda-powertools/logger'; +import { LogAttributes, UnformattedAttributes } from '@aws-lambda-powertools/logger/types'; + +type LogStashLog = LogAttributes & { + '@timestamp': string; +}; + +const allowed_keys = ['rawPath', 'headers', 'http', 'error']; +const allowed_headers = ['accept', 'x-amz-cf-id', 'user-agent', 'host', 'origin', 'x-amzn-trace-id']; + +class LogStashFormatter extends LogFormatter { + public formatAttributes(attributes: UnformattedAttributes, additionalLogAttributes: LogAttributes): LogItem { + const baseAttributes: LogStashLog = { + '@timestamp': this.formatTimestamp(attributes.timestamp), + '@version': 1, + level: attributes.logLevel, + message: attributes.message, + // service: attributes.serviceName, + environment: attributes.environment, + awsRegion: attributes.awsRegion, + lambdaFunction: { + name: attributes.lambdaContext?.functionName, + arn: attributes.lambdaContext?.invokedFunctionArn, + memoryLimitInMB: attributes.lambdaContext?.memoryLimitInMB, + version: attributes.lambdaContext?.functionVersion, + coldStart: attributes.lambdaContext?.coldStart, + }, + }; + const logItem = new LogItem({ attributes: baseAttributes }); + if (additionalLogAttributes) { + if (additionalLogAttributes.hasOwnProperty('imageRequestInfo')) { + let additionalLogAttribute = additionalLogAttributes['imageRequestInfo']; + additionalLogAttribute['originalImage'] = undefined; + } + if (additionalLogAttributes.hasOwnProperty('headers')) { + let headers = additionalLogAttributes['headers']; + additionalLogAttributes['headers'] = allowed_headers.reduce((acc, key) => { + acc[key] = headers[key]; + return acc; + }, {}); + } + + additionalLogAttributes = allowed_keys.reduce((acc, key) => { + acc[key] = additionalLogAttributes[key]; + return acc; + }, {}); + + logItem.addAttributes(additionalLogAttributes); // add any attributes not explicitly defined + } + + return logItem; + } +} + +export { LogStashFormatter }; diff --git a/source/image-handler/src/lib/enums.ts b/source/image-handler/src/lib/enums.ts new file mode 100644 index 000000000..bef81ada5 --- /dev/null +++ b/source/image-handler/src/lib/enums.ts @@ -0,0 +1,50 @@ +// 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, + GONE = 410, + REQUEST_TOO_LONG = 413, + INTERNAL_SERVER_ERROR = 500, +} + +export enum RequestTypes { + CUSTOM = 'Custom', + THUMBOR = 'Thumbor', + DEFAULT = 'Default', +} + +export enum ImageFormatTypes { + JPG = 'jpg', + JPEG = 'jpeg', + PNG = 'png', + WEBP = 'webp', + TIFF = 'tiff', + HEIF = 'heif', + HEIC = 'heic', + RAW = 'raw', + GIF = 'gif', + AVIF = 'avif', +} + +export enum ImageFitTypes { + COVER = 'cover', + CONTAIN = 'contain', + FILL = 'fill', + INSIDE = 'inside', + OUTSIDE = 'outside', +} + +export enum ContentTypes { + PNG = 'image/png', + JPEG = 'image/jpeg', + WEBP = 'image/webp', + TIFF = 'image/tiff', + GIF = 'image/gif', + SVG = 'image/svg+xml', + AVIF = 'image/avif', + JSON = 'application/json', +} diff --git a/source/image-handler/src/lib/index.ts b/source/image-handler/src/lib/index.ts new file mode 100644 index 000000000..7e02a467d --- /dev/null +++ b/source/image-handler/src/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/src/lib/interfaces.ts b/source/image-handler/src/lib/interfaces.ts new file mode 100644 index 000000000..9942e8fde --- /dev/null +++ b/source/image-handler/src/lib/interfaces.ts @@ -0,0 +1,58 @@ +// 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 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?: Date; + lastModified?: Date; + cacheControl?: string; + outputFormat?: ImageFormatTypes; + effort?: 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; +} + +export interface DefaultImageRequest { + bucket?: string; + key: string; + edits?: ImageEdits; + outputFormat?: ImageFormatTypes; + effort?: number; + headers?: Headers; +} diff --git a/source/image-handler/src/lib/thumbhash.ts b/source/image-handler/src/lib/thumbhash.ts new file mode 100644 index 000000000..bbef2e698 --- /dev/null +++ b/source/image-handler/src/lib/thumbhash.ts @@ -0,0 +1,337 @@ +/** + * https://github.com/evanw/thumbhash/blob/main/js/thumbhash.js + * + * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns The ThumbHash as a Uint8Array. + */ +export function rgbaToThumbHash(w, h, rgba) { + // Encoding an image larger than 100x100 is slow with no benefit + if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`); + let { PI, round, max, cos, abs } = Math; + + // Determine the average color + let avg_r = 0, + avg_g = 0, + avg_b = 0, + avg_a = 0; + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255; + avg_r += (alpha / 255) * rgba[j]; + avg_g += (alpha / 255) * rgba[j + 1]; + avg_b += (alpha / 255) * rgba[j + 2]; + avg_a += alpha; + } + if (avg_a) { + avg_r /= avg_a; + avg_g /= avg_a; + avg_b /= avg_a; + } + + let hasAlpha: any = avg_a < w * h; + let l_limit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's alpha + let lx = max(1, round((l_limit * w) / max(w, h))); + let ly = max(1, round((l_limit * h) / max(w, h))); + let l = []; // luminance + let p = []; // yellow - blue + let q = []; // red - green + let a = []; // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color) + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255; + let r = avg_r * (1 - alpha) + (alpha / 255) * rgba[j]; + let g = avg_g * (1 - alpha) + (alpha / 255) * rgba[j + 1]; + let b = avg_b * (1 - alpha) + (alpha / 255) * rgba[j + 2]; + l[i] = (r + g + b) / 3; + p[i] = (r + g) / 2 - b; + q[i] = r - g; + a[i] = alpha; + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + let encodeChannel = (channel: any[], nx: number, ny: number): [number, any[], number] => { + let dc = 0, + ac = [], + scale = 0, + fx = []; + for (let cy = 0; cy < ny; cy++) { + for (let cx = 0; cx * ny < nx * (ny - cy); cx++) { + let f = 0; + for (let x = 0; x < w; x++) fx[x] = cos((PI / w) * cx * (x + 0.5)); + for (let y = 0; y < h; y++) + for (let x = 0, fy = cos((PI / h) * cy * (y + 0.5)); x < w; x++) f += channel[x + y * w] * fx[x] * fy; + f /= w * h; + if (cx || cy) { + ac.push(f); + scale = max(scale, abs(f)); + } else { + dc = f; + } + } + } + if (scale) for (let i = 0; i < ac.length; i++) ac[i] = 0.5 + (0.5 / scale) * ac[i]; + return [dc, ac, scale]; + }; + let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly)); + let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3); + let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3); + let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : []; + + // Write the constants + let isLandscape: any = w > h; + let header24 = + round(63 * l_dc) | + (round(31.5 + 31.5 * p_dc) << 6) | + (round(31.5 + 31.5 * q_dc) << 12) | + (round(31 * l_scale) << 18) | + (hasAlpha << 23); + let header16 = + (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15); + let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8]; + let ac_start = hasAlpha ? 6 : 5; + let ac_index = 0; + if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4)); + + // Write the varying factors + for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]) + for (let f of ac) hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2); + return new Uint8Array(hash); +} + +/** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The width, height, and pixels of the rendered placeholder image. + */ +export function thumbHashToRGBA(hash) { + let { PI, min, max, cos, round } = Math; + + // Read the constants + let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16); + let header16 = hash[3] | (hash[4] << 8); + let l_dc = (header24 & 63) / 63; + let p_dc = ((header24 >> 6) & 63) / 31.5 - 1; + let q_dc = ((header24 >> 12) & 63) / 31.5 - 1; + let l_scale = ((header24 >> 18) & 31) / 31; + let hasAlpha = header24 >> 23; + let p_scale = ((header16 >> 3) & 63) / 63; + let q_scale = ((header16 >> 9) & 63) / 63; + let isLandscape = header16 >> 15; + let lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7); + let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7); + let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1; + let a_scale = (hash[5] >> 4) / 15; + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5; + let ac_index = 0; + let decodeChannel = (nx, ny, scale) => { + let ac = []; + for (let cy = 0; cy < ny; cy++) + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) + ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale); + return ac; + }; + let l_ac = decodeChannel(lx, ly, l_scale); + let p_ac = decodeChannel(3, 3, p_scale * 1.25); + let q_ac = decodeChannel(3, 3, q_scale * 1.25); + let a_ac = hasAlpha && decodeChannel(5, 5, a_scale); + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash); + let w = round(ratio > 1 ? 32 : 32 * ratio); + let h = round(ratio > 1 ? 32 / ratio : 32); + let rgba = new Uint8Array(w * h * 4), + fx = [], + fy = []; + for (let y = 0, i = 0; y < h; y++) { + for (let x = 0; x < w; x++, i += 4) { + let l = l_dc, + p = p_dc, + q = q_dc, + a = a_dc; + + // Precompute the coefficients + for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) fx[cx] = cos((PI / w) * (x + 0.5) * cx); + for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) fy[cy] = cos((PI / h) * (y + 0.5) * cy); + + // Decode L + for (let cy = 0, j = 0; cy < ly; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) l += l_ac[j] * fx[cx] * fy2; + + // Decode P and Q + for (let cy = 0, j = 0; cy < 3; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { + let f = fx[cx] * fy2; + p += p_ac[j] * f; + q += q_ac[j] * f; + } + } + + // Decode A + if (hasAlpha) + for (let cy = 0, j = 0; cy < 5; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) a += a_ac[j] * fx[cx] * fy2; + + // Convert to RGB + let b = l - (2 / 3) * p; + let r = (3 * l - b + q) / 2; + let g = r - q; + rgba[i] = max(0, 255 * min(1, r)); + rgba[i + 1] = max(0, 255 * min(1, g)); + rgba[i + 2] = max(0, 255 * min(1, b)); + rgba[i + 3] = max(0, 255 * min(1, a)); + } + } + return { w, h, rgba }; +} + +/** + * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The RGBA values for the average color. Each value ranges from 0 to 1. + */ +export function thumbHashToAverageRGBA(hash) { + let { min, max } = Math; + let header = hash[0] | (hash[1] << 8) | (hash[2] << 16); + let l = (header & 63) / 63; + let p = ((header >> 6) & 63) / 31.5 - 1; + let q = ((header >> 12) & 63) / 31.5 - 1; + let hasAlpha = header >> 23; + let a = hasAlpha ? (hash[5] & 15) / 15 : 1; + let b = l - (2 / 3) * p; + let r = (3 * l - b + q) / 2; + let g = r - q; + return { + r: max(0, min(1, r)), + g: max(0, min(1, g)), + b: max(0, min(1, b)), + a, + }; +} + +/** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @returns The approximate aspect ratio (i.e. width / height). + */ +export function thumbHashToApproximateAspectRatio(hash) { + let header = hash[3]; + let hasAlpha = hash[2] & 0x80; + let isLandscape = hash[4] & 0x80; + let lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7; + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7; + return lx / ly; +} + +/** + * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by + * A. This is optimized for speed and simplicity and does not optimize for size + * at all. This doesn't do any compression (all values are stored uncompressed). + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns A data URL containing a PNG for the input image. + */ +export function rgbaToDataURL(w, h, rgba) { + let row = w * 4 + 1; + let idat = 6 + h * (5 + row); + let bytes = [ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + w >> 8, + w & 255, + 0, + 0, + h >> 8, + h & 255, + 8, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + idat >>> 24, + (idat >> 16) & 255, + (idat >> 8) & 255, + idat & 255, + 73, + 68, + 65, + 84, + 120, + 1, + ]; + let table = [ + 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948, -306674912, -267414716, + -690576408, -882789492, -1687895376, -2032938284, -1609899400, -1111625188, + ]; + let a = 1, + b = 0; + for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { + bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0); + for (b = (b + a) % 65521; i < end; i++) { + let u = rgba[i] & 255; + bytes.push(u); + a = (a + u) % 65521; + b = (b + a) % 65521; + } + } + bytes.push(b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130); + for (let [start, end] of [ + [12, 29], + [37, 41 + idat], + ]) { + let c = ~0; + for (let i = start; i < end; i++) { + c ^= bytes[i]; + c = (c >>> 4) ^ table[c & 15]; + c = (c >>> 4) ^ table[c & 15]; + } + c = ~c; + bytes[end++] = c >>> 24; + bytes[end++] = (c >> 16) & 255; + bytes[end++] = (c >> 8) & 255; + bytes[end++] = c & 255; + } + return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes)); +} + +/** + * Decodes a ThumbHash to a PNG data URL. This is a convenience function that + * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". + * + * @param hash The bytes of the ThumbHash. + * @returns A data URL containing a PNG for the rendered ThumbHash. + */ +export function thumbHashToDataURL(hash) { + let image = thumbHashToRGBA(hash); + return rgbaToDataURL(image.w, image.h, image.rgba); +} diff --git a/source/image-handler/src/lib/types.ts b/source/image-handler/src/lib/types.ts new file mode 100644 index 000000000..9d933c3b8 --- /dev/null +++ b/source/image-handler/src/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/src/thumbor-mapper.ts b/source/image-handler/src/thumbor-mapper.ts new file mode 100644 index 000000000..d6bfc477f --- /dev/null +++ b/source/image-handler/src/thumbor-mapper.ts @@ -0,0 +1,492 @@ +// 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.substring(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) + ?.flatMap(filter => filter.split(':').slice(1)) + ?.map(filter => `filters:${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; + } + } + + /** + * Maps background color the current edits object + * @param filterValue The specified color value + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapBGColor(filterValue: string, currentEdits: Record): void { + const color = !ColorName[filterValue] ? `#${filterValue}` : filterValue; + + currentEdits.flatten = { background: Color(color).object() }; + } + + /** + * Maps blur to current edits object + * @param filterValue The blur value provided + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapBlur(filterValue: string, currentEdits: Record): void { + const [radius, sigma] = filterValue.split(',').map(x => (x === '' ? NaN : Number(x))); + currentEdits.blur = !isNaN(sigma) ? sigma : radius / 2; + } + + /** + * Maps convolution to current edits object + * @param filterValue the convolution value provided + * @param currentEdits the edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapConvolution(filterValue: string, currentEdits: Record): void { + const values = filterValue.split(','); + const matrix = values[0].split(';').map(str => Number(str)); + const matrixWidth = Number(values[1]); + const matrixHeight = Math.ceil(matrix.length / matrixWidth); + + currentEdits.convolve = { + width: matrixWidth, + height: matrixHeight, + kernel: matrix, + }; + } + + /** + * Maps fill to the current edits object + * @param filterValue The fill value provided + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapFill(filterValue: string, currentEdits: Record): void { + 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(); + } + + /** + * Maps the output format to the current edits object + * @param filterValue The output format + * @param currentEdits The edits to be provided + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapFormat(filterValue: string, currentEdits: Record): void { + 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, + ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, + ]; + + if (acceptedValues.includes(imageFormatType)) { + currentEdits.toFormat = imageFormatType; + } + } + + /** + * Adds withoutEnlargement option to resize in currentEdits object + * @param currentEdits The edits to be perforemd + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapNoUpscale(currentEdits: Record): void { + if (currentEdits.resize === undefined) { + currentEdits.resize = {}; + } + + currentEdits.resize.withoutEnlargement = true; + } + + /** + * Maps resize ratios to the current edits object + * @param filterValue The ratio value + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapResizeRatio(filterValue: string, currentEdits: Record): void { + if (currentEdits.resize === undefined) { + currentEdits.resize = {}; + } + + const ratio = Number(filterValue); + if (currentEdits.resize.width && currentEdits.resize.height) { + currentEdits.resize.width = Number(currentEdits.resize.width * ratio); + currentEdits.resize.height = Number(currentEdits.resize.height * ratio); + } else { + currentEdits.resize.ratio = Number(filterValue); + } + } + + /** + * Maps the quality value of the output format to the current edits + * @param filterValue The quality value provided + * @param currentEdits The edits to be performed + * @param fileFormat The image format + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapQuality(filterValue: string, currentEdits: Record, fileFormat: ImageFormatTypes): void { + const toSupportedImageFormatType = (format: ImageFormatTypes): ImageFormatTypes => { + if ([ImageFormatTypes.JPG, ImageFormatTypes.JPEG].includes(format)) { + return ImageFormatTypes.JPEG; + } else if ( + [ + ImageFormatTypes.PNG, + ImageFormatTypes.WEBP, + ImageFormatTypes.TIFF, + ImageFormatTypes.HEIF, + ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, + ].includes(format) + ) { + return format; + } + }; + + // 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) }; + } + } + + /** + * Maps stretch fit to the current edits + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapStretch(currentEdits: Record): void { + 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; + } + } + + /** + * Maps upscale fit to the current edits + * @param currentEdits The edits to be performed + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapUpscale(currentEdits: Record): void { + if (currentEdits.resize === undefined) { + currentEdits.resize = {}; + } + + currentEdits.resize.fit = ImageFitTypes.INSIDE; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapWatermark(filterValue: string, currentEdits: Record): void { + 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]?\d|-(100|[1-9]\d?))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; + } + } + + /** + * 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(/:(.+)\((.*)\)/); // NOSONAR + const [_, filterName, filterValue] = matched; + const currentEdits = { ...previousEdits }; + + // Find the proper filter + switch (filterName) { + case 'autojpg': { + currentEdits.toFormat = ImageFormatTypes.JPEG; + break; + } + case 'background_color': { + this.mapBGColor(filterValue, currentEdits); + break; + } + case 'blur': { + this.mapBlur(filterValue, currentEdits); + break; + } + case 'convolution': { + this.mapConvolution(filterValue, currentEdits); + break; + } + case 'equalize': { + currentEdits.normalize = true; + break; + } + case 'fill': { + this.mapFill(filterValue, currentEdits); + break; + } + case 'format': { + this.mapFormat(filterValue, currentEdits); + break; + } + case 'grayscale': { + currentEdits.grayscale = true; + break; + } + case 'no_upscale': { + this.mapNoUpscale(currentEdits); + break; + } + case 'proportion': { + this.mapResizeRatio(filterValue, currentEdits); + break; + } + case 'quality': { + this.mapQuality(filterValue, currentEdits, fileFormat); + 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': { + this.mapStretch(currentEdits); + break; + } + case 'strip_exif': + case 'strip_icc': { + currentEdits.rotate = null; + break; + } + case 'upscale': { + this.mapUpscale(currentEdits); + break; + } + case 'watermark': { + this.mapWatermark(filterValue, currentEdits); + break; + } + case 'animated': { + currentEdits.animated = filterValue.toLowerCase() != 'false'; + break; + } + case 'roundCrop': { + currentEdits.roundCrop = true; + break; + } + case 'thumbhash': { + currentEdits.thumbhash = true; + 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{1,6}x\d{1,6}:\d{1,6}x\d{1,6}/g); + + if (pathCropMatchResult) { + const [leftTopPoint, rightBottomPoint] = pathCropMatchResult[0].split(':'); + + const [leftTopX, leftTopY] = leftTopPoint.split('x').map(x => parseInt(x, 10)); + const [width, height] = rightBottomPoint.split('x').map(x => parseInt(x, 10)); + + if (!isNaN(leftTopX) && !isNaN(leftTopY) && !isNaN(width) && !isNaN(height)) { + const cropEdit: ImageEdits = { + crop: { + left: leftTopX, + top: leftTopY, + width: width, + height: height, + }, + }; + + 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.replace(/\/__WIDTH__x0\//, '/1200x0/').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; + }, {}); + } + + /** + * 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/terraform/.terraform.lock.hcl b/source/image-handler/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..184073f86 --- /dev/null +++ b/source/image-handler/terraform/.terraform.lock.hcl @@ -0,0 +1,70 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.34.0" + constraints = ">= 5.0.0, ~> 5.0, >= 5.32.0" + hashes = [ + "h1:1Y1JgV1z99QqAK06+atyfNqreZxyGZKbm4mZO4VhhT8=", + "h1:CUCoX4ax5hrP6BH4973oP+hgz8VR2GuNPQil3FYwEqQ=", + "h1:Tbq6dKE+XyXmkup6+7eQj2vH+eCJipk8R3VXhebVYi4=", + "zh:01bb20ae12b8c66f0cacec4f417a5d6741f018009f3a66077008e67cce127aa4", + "zh:3b0c9bdbbf846beef2c9573fc27898ceb71b69cf9d2f4b1dd2d0c2b539eab114", + "zh:5226ecb9c21c2f6fbf1d662ac82459ffcd4ad058a9ea9c6200750a21a80ca009", + "zh:6021b905d9b3cd3d7892eb04d405c6fa20112718de1d6ef7b9f1db0b0c97721a", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9e61b8e0ccf923979cd2dc1f1140dbcb02f92248578e10c1996f560b6306317c", + "zh:ad6bf62cdcf531f2f92f6416822918b7ba2af298e4a0065c6baf44991fda982d", + "zh:b698b041ef38837753bbe5265dddbc70b76e8b8b34c5c10876e6aab0eb5eaf63", + "zh:bb799843c534f6a3f072a99d93a3b53ff97c58a96742be15518adf8127706784", + "zh:cebee0d942c37cd3b21e9050457cceb26d0a6ea886b855dab64bb67d78f863d1", + "zh:e061fdd1cb99e7c81fb4485b41ae000c6792d38f73f9f50aed0d3d5c2ce6dcfb", + "zh:eeb4943f82734946362696928336357cd1d36164907ae5905da0316a67e275e1", + "zh:ef09b6ad475efa9300327a30cbbe4373d817261c8e41e5b7391750b16ef4547d", + "zh:f01aab3881cd90b3f56da7c2a75f83da37fd03cc615fc5600a44056a7e0f9af7", + "zh:fcd0f724ebc4b56a499eb6c0fc602de609af18a0d578befa2f7a8df155c55550", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.2" + hashes = [ + "h1:IMVAUHKoydFrlPrl9OzasDnw/8ntZFerCC9iXw1rXQY=", + "zh:3248aae6a2198f3ec8394218d05bd5e42be59f43a3a7c0b71c66ec0df08b69e7", + "zh:32b1aaa1c3013d33c245493f4a65465eab9436b454d250102729321a44c8ab9a", + "zh:38eff7e470acb48f66380a73a5c7cdd76cc9b9c9ba9a7249c7991488abe22fe3", + "zh:4c2f1faee67af104f5f9e711c4574ff4d298afaa8a420680b0cb55d7bbc65606", + "zh:544b33b757c0b954dbb87db83a5ad921edd61f02f1dc86c6186a5ea86465b546", + "zh:696cf785090e1e8cf1587499516b0494f47413b43cb99877ad97f5d0de3dc539", + "zh:6e301f34757b5d265ae44467d95306d61bef5e41930be1365f5a8dcf80f59452", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:913a929070c819e59e94bb37a2a253c228f83921136ff4a7aa1a178c7cce5422", + "zh:aa9015926cd152425dbf86d1abdbc74bfe0e1ba3d26b3db35051d7b9ca9f72ae", + "zh:bb04798b016e1e1d49bcc76d62c53b56c88c63d6f2dfe38821afef17c416a0e1", + "zh:c23084e1b23577de22603cff752e59128d83cfecc2e6819edadd8cf7a10af11e", + ] +} + +provider "registry.terraform.io/opensearch-project/opensearch" { + version = "2.2.0" + constraints = "~> 2.0" + hashes = [ + "h1:ZLnqDpaoHsMSlxMflBTfmkSXmqnSnU8TebF+X1ai7Wk=", + "h1:gMKDYIo7XtPZrljW5AA4VkHQIo9vx8JaxDugwJNjFos=", + "h1:oJH4Uvmb0KPrKwTHmlMLUV06MmdAn0eC1koFTyK2TCw=", + "zh:057262ad89649f9ff90afb7f8c220f75c8ebbe9eaf6aa5bf6d9cf50b3f388956", + "zh:1e8fedfb1723444a3a76724dec700fec0927b5ada0e318a85c5b913793d5de4c", + "zh:1ff875a0ff225ff7fd98ccf9df5b9ad9d67bd113476d8eb0083aff4b78ef28c4", + "zh:238b01c89887d97bc185eefdff7635f017a95c3ac5186a5742ca9f2ef9a70c0e", + "zh:23c17d40cbeab654d8cd16b310fb721a4f2f170239e12c183c7b5e60d18d4d77", + "zh:2e53f28b88b83fa91ac30faee12661a2765b9b37f32fd23945a14bd7c6ca15b7", + "zh:413d87d11360d0cebaf77139e6d6dc11ae77d8b29662adac9c4ae25f7ad08c78", + "zh:4b2e8bb71c58ff392f4680fcc2e29ab36dd3a3715b306e56a049294a0c567dba", + "zh:619b9767f6e291545e5bc96963ed0e346e25798aba4e71527d8c65c029b88145", + "zh:86da4ac3bfc44b6a6da84176714ccfb8522a4a89e52e21a320d06fef9c8ee8d8", + "zh:91e1d003da32740d941c43b71fc32c2887bf75f3503e06ee0de540fd10198c96", + "zh:98ed421df075ef8bdd83cc84ea2e2d810772e07fc4179f6989782ff705b9d4d2", + "zh:e54b3bd1814d6e677dca275332b3ef2bdd4f696d50d0554df8cf8861119d24e7", + "zh:e734eb9e21d1e62e9b3aa2da53e5b59abbfce823ce1e113f36a576844bcb94d4", + ] +} diff --git a/source/image-handler/terraform/backend.tf b/source/image-handler/terraform/backend.tf new file mode 100644 index 000000000..a6bfaf16c --- /dev/null +++ b/source/image-handler/terraform/backend.tf @@ -0,0 +1,19 @@ +terraform { + backend "s3" { + encrypt = true + dynamodb_table = "terraform-lock" + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5" + } + opensearch = { + source = "opensearch-project/opensearch" + version = "~> 2" + } + } + + required_version = "~> 1" +} diff --git a/source/image-handler/terraform/cross_account_access.tf b/source/image-handler/terraform/cross_account_access.tf new file mode 100644 index 000000000..504abaa43 --- /dev/null +++ b/source/image-handler/terraform/cross_account_access.tf @@ -0,0 +1,59 @@ +locals { + # idea here is to provide each team with its own distinct role + # to allow them publishing static assets under their team directory, e.g. /s/dcp/ + teams = { + "newbiz-product-images" = { + account_id = 786771379108 + } + } +} + +resource "aws_iam_role" "s3_org_access" { + for_each = var.app_suffix == "" ? local.teams : { /*noop*/ } + + name = "s3-images-access-team-${each.key}-${var.region}" + path = "/cdn/" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = "sts:AssumeRole", + Principal = { + "AWS" : "arn:aws:iam::${each.value["account_id"]}:root" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "s3_org_access" { + for_each = var.app_suffix == "" ? local.teams : { /*noop*/ } + + role = aws_iam_role.s3_org_access[each.key].name + policy_arn = aws_iam_policy.s3_org_access[each.key].arn +} + +resource "aws_iam_policy" "s3_org_access" { + for_each = var.app_suffix == "" ? local.teams : { /*noop*/ } + + path = "/cdn/" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["s3:*Object"] # FIXME (MaNa, buzz-end): can we restrict this to concrete actions? https://aquasecurity.github.io/tfsec/v1.28.1/checks/aws/iam/no-policy-wildcards/ + Effect = "Allow" + Resource = "${aws_s3_bucket.images[0].arn}/${each.key}/*" + Sid : "ImageWriteAssetsAccessTeam${replace(title(each.key), "-", "")}" + }, + { + Action = ["kms:GenerateDataKey", "kms:Decrypt", "kms:Encrypt"] + Effect = "Allow" + Resource = aws_kms_key.images[0].arn + } + ] + }) +} \ No newline at end of file diff --git a/source/image-handler/terraform/data.tf b/source/image-handler/terraform/data.tf new file mode 100644 index 000000000..ef48ce708 --- /dev/null +++ b/source/image-handler/terraform/data.tf @@ -0,0 +1,49 @@ +data "aws_sns_topic" "notifications" { + name = "codestar-notifications" +} + +data "aws_s3_bucket" "pipeline_artifacts" { + bucket = "codepipeline-bucket-${var.account_id}-${var.region}" +} + +data "aws_s3_bucket" "ci" { + bucket = "ci-${var.account_id}-${var.region}" +} + +data "aws_vpc" "selected" { + tags = { + Name = "main" + } +} + +data "aws_subnets" "selected" { + filter { + name = "vpc-id" + values = [data.aws_vpc.selected.id] + } + + tags = { + Tier = "private" + } +} + + +data "aws_security_group" "vpc_endpoints" { + name = "vpc-endpoint-access" +} + +data "aws_security_group" "all_outbound" { + name = "allow-outbound-tcp" +} + +data "aws_security_group" "lambda" { + name = "lambda-default" +} + +data "aws_cloudfront_distribution" "images" { + id = "E3K0UX29CMXL6T" +} + +data "aws_ssm_parameter" "logging_layer" { + name = "/internal/lambda-logging-oss/arm64/layer_arn" +} \ No newline at end of file diff --git a/source/image-handler/terraform/iam.tf b/source/image-handler/terraform/iam.tf new file mode 100644 index 000000000..9f950c524 --- /dev/null +++ b/source/image-handler/terraform/iam.tf @@ -0,0 +1,21 @@ +data "aws_iam_policy_document" "lambda" { + statement { + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::master-images-${var.account_id}-${var.region}/*"] + } + statement { + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::master-images-${var.account_id}-${var.region}"] + } +} + +resource "aws_iam_policy" "lambda" { + description = "${local.function_name} Permissions" + name = "${module.lambda.function_name}-${var.region}" + policy = data.aws_iam_policy_document.lambda.json +} + +resource "aws_iam_role_policy_attachment" "lambda" { + role = module.lambda.role_name + policy_arn = aws_iam_policy.lambda.arn +} \ No newline at end of file diff --git a/source/image-handler/terraform/main.tf b/source/image-handler/terraform/main.tf new file mode 100644 index 000000000..5fab38502 --- /dev/null +++ b/source/image-handler/terraform/main.tf @@ -0,0 +1,156 @@ +locals { + function_name = join("-", compact(["image-handler", var.app_suffix])) + environment = "production" + zip_package = "../dist/image-handler.zip" + s3_key = "image-handler/${local.function_name}.zip" +} + +module "lambda" { + source = "registry.terraform.io/moritzzimmer/lambda/aws" + version = "7.5.0" + + architectures = ["arm64"] + layers = [ + "arn:aws:lambda:eu-west-1:053041861227:layer:CustomLoggingExtensionOpenSearch-Arm64:12", + aws_lambda_layer_version.sharp.arn + ] + cloudwatch_logs_enabled = false + description = "provider of cute kitty pics." + function_name = local.function_name + ignore_external_function_updates = true + memory_size = 2048 + publish = true + runtime = "nodejs20.x" + handler = "index.handler" + s3_bucket = aws_s3_object.this.bucket + s3_key = aws_s3_object.this.key + s3_object_version = aws_s3_object.this.version_id + timeout = 30 + + environment = { + variables = { + AUTO_WEBP = "Yes" + CORS_ENABLED = "Yes" + CORS_ORIGIN = "*" + SOURCE_BUCKETS = "master-images-${var.account_id}-${var.region}" + + LOG_EXT_OPEN_SEARCH_URL = "https://logs.stroeer.engineering" + } + } + + vpc_config = { + security_group_ids = [data.aws_security_group.vpc_endpoints.id, data.aws_security_group.all_outbound.id, data.aws_security_group.lambda.id] + subnet_ids = data.aws_subnets.selected.ids + } +} + +resource "aws_lambda_function_url" "production" { + authorization_type = "NONE" + function_name = aws_lambda_alias.this.function_name + qualifier = aws_lambda_alias.this.name +} + +resource "aws_lambda_permission" "function_url_allow_public_access" { + action = "lambda:InvokeFunctionUrl" + function_name = aws_lambda_alias.this.function_name + qualifier = aws_lambda_alias.this.name + principal = "*" + function_url_auth_type = "NONE" + statement_id = "FunctionURLAllowPublicAccess" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Deployment resources +# --------------------------------------------------------------------------------------------------------------------- + +// this resource is only used for the initial `terraform apply` all further +// deployments are running on CodePipeline +resource "aws_s3_object" "this" { + bucket = data.aws_s3_bucket.ci.bucket + key = local.s3_key + source = fileexists(local.zip_package) ? local.zip_package : null + etag = fileexists(local.zip_package) ? filemd5(local.zip_package) : null + + lifecycle { + ignore_changes = [etag, source, version_id, tags_all] + } +} + +resource "aws_lambda_alias" "this" { + description = "Alias for the active Lambda version" + function_name = module.lambda.function_name + function_version = module.lambda.version + name = local.environment + + lifecycle { + ignore_changes = [function_version] + } +} + +module "deployment" { + source = "registry.terraform.io/moritzzimmer/lambda/aws//modules/deployment" + version = "7.5.0" + + alias_name = aws_lambda_alias.this.name + codebuild_cloudwatch_logs_retention_in_days = 7 + codestar_notifications_target_arn = data.aws_sns_topic.notifications.arn + codepipeline_artifact_store_bucket = data.aws_s3_bucket.pipeline_artifacts.bucket + codepipeline_type = "V2" + s3_bucket = data.aws_s3_bucket.ci.bucket + s3_key = local.s3_key + function_name = local.function_name +} + +resource "opensearch_role" "logs_write_access" { + role_name = local.function_name + description = "Write access for ${local.function_name} lambda" + cluster_permissions = ["indices:data/write/bulk"] + + index_permissions { + index_patterns = ["${local.function_name}-lambda-*"] + allowed_actions = ["write", "create_index"] + } +} + +resource "opensearch_roles_mapping" "logs_write_access" { + role_name = opensearch_role.logs_write_access.role_name + backend_roles = [module.lambda.role_arn] +} + +resource "aws_lambda_layer_version" "sharp" { + layer_name = "sharp-image-library" + description = "Lambda layer with sharp image library" + + s3_bucket = aws_s3_bucket_object.sharp.bucket + s3_key = aws_s3_bucket_object.sharp.key + s3_object_version = aws_s3_bucket_object.sharp.version_id + skip_destroy = true + source_code_hash = filebase64sha256("${path.module}/lambda-layer.zip") + + compatible_runtimes = ["nodejs16.x", "nodejs18.x", "nodejs20.x"] + compatible_architectures = ["arm64"] + +} + +resource "aws_s3_bucket_object" "sharp" { + bucket = data.aws_s3_bucket.ci.bucket + key = "image-handler/sharp-lambda-layer.zip" + source = "${path.module}/lambda-layer.zip" + + depends_on = [ + null_resource.lambda_jar + ] +} + +locals { + sharp_version = "0.33.4" +} +resource "null_resource" "lambda_jar" { + triggers = { + on_version_change = local.sharp_version + } + + provisioner "local-exec" { + command = "curl -sL --ssl-no-revoke -o lambda-layer.zip https://github.com/pH200/sharp-layer/releases/download/${local.sharp_version}/release-arm64.zip" + } +} \ No newline at end of file diff --git a/source/image-handler/terraform/output.tf b/source/image-handler/terraform/output.tf new file mode 100644 index 000000000..88d1319ba --- /dev/null +++ b/source/image-handler/terraform/output.tf @@ -0,0 +1,3 @@ +output "function_url" { + value = aws_lambda_function_url.production.function_url +} \ No newline at end of file diff --git a/source/image-handler/terraform/provider.tf b/source/image-handler/terraform/provider.tf new file mode 100644 index 000000000..6c4663fe3 --- /dev/null +++ b/source/image-handler/terraform/provider.tf @@ -0,0 +1,20 @@ +provider "aws" { + region = var.region + + default_tags { + tags = { + managed_by = "terraform" + map-migrated = "d-server-00fvusu7ux3q9a" + service = local.function_name + source = "https://github.com/stroeer/serverless-image-handler" + App = "Images" + } + } + +} + +provider "opensearch" { + aws_region = var.region + healthcheck = true + url = "https://logs.stroeer.engineering" +} diff --git a/source/image-handler/terraform/s3.tf b/source/image-handler/terraform/s3.tf new file mode 100644 index 000000000..aec92f09a --- /dev/null +++ b/source/image-handler/terraform/s3.tf @@ -0,0 +1,187 @@ +resource "aws_s3_bucket" "images" { + count = var.app_suffix == "" ? 1 : 0 + bucket = "master-images-${var.account_id}-${var.region}" + force_destroy = false + tags = { + backup = "true" + } +} + +resource "aws_s3_bucket_versioning" "images" { + count = var.app_suffix == "" ? 1 : 0 + bucket = aws_s3_bucket.images[count.index].bucket + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "images" { + count = var.app_suffix == "" ? 1 : 0 + + bucket = aws_s3_bucket.images[count.index].bucket + rule { + id = "delete_old_versions" + status = "Enabled" + + expiration { + expired_object_delete_marker = true + } + noncurrent_version_expiration { + noncurrent_days = 14 + } + } +} + +resource "aws_kms_key" "images" { + count = var.app_suffix == "" ? 1 : 0 + description = "This key is used to encrypt bucket objects within the ${aws_s3_bucket.images[count.index].bucket} bucket." + deletion_window_in_days = 30 + enable_key_rotation = true +} + +resource "aws_kms_alias" "images" { + count = var.app_suffix == "" ? 1 : 0 + target_key_id = aws_kms_key.images[count.index].key_id + name = "alias/s3_image_bucket" +} + +resource "aws_kms_key_policy" "images" { + count = var.app_suffix == "" ? 1 : 0 + key_id = aws_kms_key.images[count.index].id + policy = jsonencode({ + Id = "User" + Statement = [ + { + "Sid" : "Allow direct access to key metadata to the account", + "Effect" : "Allow", + "Principal" : { + "AWS" : "arn:aws:iam::${var.account_id}:root" + }, + "Action" : ["kms:*"], + "Resource" : "*" + }, + { + "Sid" : "Allow CloudFront to use this key", + "Effect" : "Allow", + "Principal" : { + "Service" : ["cloudfront.amazonaws.com"] + }, + "Action" : [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey*" + ], + "Resource" : "*", + "Condition" : { + "StringEquals" : { + "aws:SourceArn" : data.aws_cloudfront_distribution.images.arn + } + } + }, + { + "Sid" : "Allow access through S3 for all principals in the account that are authorized to use S3", + "Effect" : "Allow", + "Principal" : { + "AWS" : "*" + }, + "Action" : [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + "Resource" : "*", + "Condition" : { + "StringEquals" : { + "kms:CallerAccount" : var.account_id + "kms:ViaService" : "s3.eu-west-1.amazonaws.com" + } + } + } + ] + Version = "2012-10-17" + }) +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "images" { + count = var.app_suffix == "" ? 1 : 0 + bucket = aws_s3_bucket.images[count.index].bucket + rule { + bucket_key_enabled = true + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.images[count.index].arn + } + } +} + +resource "aws_s3_bucket_public_access_block" "images" { + count = var.app_suffix == "" ? 1 : 0 + block_public_acls = true + block_public_policy = true + bucket = aws_s3_bucket.images[count.index].id + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "this" { + count = var.app_suffix == "" ? 1 : 0 + bucket = aws_s3_bucket.images[count.index].id + policy = data.aws_iam_policy_document.deny_insecure_transport[count.index].json +} + +data "aws_iam_policy_document" "deny_insecure_transport" { + count = var.app_suffix == "" ? 1 : 0 + statement { + sid = "denyInsecureTransport" + effect = "Deny" + + actions = [ + "s3:*", + ] + + resources = [aws_s3_bucket.images[count.index].arn, "${aws_s3_bucket.images[count.index].arn}/*"] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + statement { + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.images[count.index].arn}/*"] + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + condition { + variable = "AWS:SourceArn" + test = "StringEquals" + values = [data.aws_cloudfront_distribution.images.arn] + } + sid = "AllowCloudFrontServicePrincipalReadOnly" + } +} + +resource "aws_s3_object" "robots_txt" { + count = var.app_suffix == "" ? 1 : 0 + bucket = aws_s3_bucket.images[count.index].bucket + key = "robots.txt" + cache_control = "max-age=3600" + + content_type = "text/plain" + content = < { - return { - S3: jest.fn(() => ({ - getObject: mockAws.getObject - })), - Rekognition: jest.fn(() => ({ - detectFaces: mockAws.detectFaces - })) - }; -}); - -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: '100', - height: '100' - } - } - // 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: 100, height: 100 }).toBuffer(); - expect(resultBuffer).toEqual(convertedImage); - }); - }); -}); - -// ---------------------------------------------------------------------------- -// [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' - }); - } - }); - }); -}); \ No newline at end of file diff --git a/source/image-handler/test/image-handler/animated.spec.ts b/source/image-handler/test/image-handler/animated.spec.ts new file mode 100644 index 000000000..81fb90b79 --- /dev/null +++ b/source/image-handler/test/image-handler/animated.spec.ts @@ -0,0 +1,178 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; +import { S3 } from '@aws-sdk/client-s3'; +import { ContentTypes, ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { ImageHandler } from '../../src/image-handler'; + +const s3Client = new S3(); +const image = fs.readFileSync('./test/image/25x15.png'); +const gifImage = fs.readFileSync('./test/image/transparent-5x5-2page.gif'); + +describe('animated', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('Should create non animated image if the input image is a GIF but does not have multiple pages', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: 'sample-bucket', + key: 'sample-image-001.gif', + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(2); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: false, + }); + }); + + it('Should create animated image if the input image is GIF and has multiple pages', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: 'sample-bucket', + key: 'sample-image-001.gif', + edits: { grayscale: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: true, + }); + }); + + it('Should create non animated image if the input image is not a GIF', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.PNG, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: false, + }); + }); + + it('Should create non animated image if AutoWebP is enabled and the animated edit is not provided', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.WEBP, + bucket: 'sample-bucket', + key: 'sample-image-001.gif', + edits: { grayscale: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: false, + }); + }); + + it('Should create animated image if AutoWebP is enabled and the animated edit is true', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.WEBP, + bucket: 'sample-bucket', + key: 'sample-image-001.gif', + edits: { grayscale: true, animated: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: true, + }); + }); + + it('Should create non animated image if image is multipage gif, but animated edit is set to false', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: 'sample-bucket', + key: 'sample-image-001.gif', + edits: { grayscale: true, animated: false }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: false, + }); + }); + + it('Should attempt to create animated image if animated edit is set to true, regardless of original image and content type', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.PNG, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { grayscale: true, animated: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, 'instantiateSharpImage'); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(2); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOn: 'none', + animated: false, + }); + }); +}); diff --git a/source/image-handler/test/image-handler/crop.spec.ts b/source/image-handler/test/image-handler/crop.spec.ts new file mode 100644 index 000000000..8c37fb089 --- /dev/null +++ b/source/image-handler/test/image-handler/crop.spec.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import sharp, { SharpOptions } from 'sharp'; + +import { ImageHandler } from '../../src/image-handler'; +import { ImageEdits, StatusCodes } from '../../src/lib'; + +const s3Client = new S3(); + +// base64 encoded images +const image_png_white_5x5 = + 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFAQAAAAClFBtIAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAd2KE6QAAAAHdElNRQfnAxYODhUMhxdmAAAADElEQVQI12P4wQCFABhCBNn4i/hQAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIzLTAzLTIyVDE0OjE0OjIxKzAwOjAwtK8ALAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMy0wMy0yMlQxNDoxNDoyMSswMDowMMXyuJAAAAAASUVORK5CYII='; +const image_png_white_1x1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC'; + +describe('crop', () => { + it('Should fail if a cropping area value is out of bounds', async () => { + // Arrange + const originalImage = Buffer.from(image_png_white_1x1, 'base64'); + const image = sharp(originalImage, { failOn: 'none' }).withMetadata(); + const edits: ImageEdits = { + crop: { left: 0, top: 0, width: 100, height: 100 }, + }; + + // Act + try { + const imageHandler = new ImageHandler(s3Client); + await imageHandler.applyEdits(image, edits, false); + } 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.', + }); + } + }); + + // confirm that crops perform as expected + it('Should pass with a standard crop', async () => { + // 5x5 png + const originalImage = Buffer.from(image_png_white_5x5, 'base64'); + const image = sharp(originalImage, { failOnError: true }); + const edits: ImageEdits = { + crop: { left: 0, top: 0, width: 1, height: 1 }, + }; + + // crop an image and compare with the result expected + const imageHandler = new ImageHandler(s3Client); + const result = await imageHandler.applyEdits(image, edits, false); + const resultBuffer = await result.toBuffer(); + expect(resultBuffer).toEqual(Buffer.from(image_png_white_1x1, 'base64')); + }); + + // confirm that an invalid attribute sharp crop request containing *right* rather than *top* returns as a cropping error, + // note that this only confirms the behavior of the image-handler in this case, + // it is not an accurate description of the actual error + it('Should fail with an invalid crop request', async () => { + // 5x5 png + const originalImage = Buffer.from(image_png_white_5x5, 'base64'); + const options: SharpOptions = { failOn: 'none' }; + const image = sharp(originalImage, options).withMetadata(); + const edits: ImageEdits = { + crop: { top: 0, left: 0, width: 1, height: 1 }, + }; + + // crop an image and compare with the result expected + try { + const imageHandler = new ImageHandler(s3Client); + await imageHandler.applyEdits(image, edits, false); + } 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.', + }); + } + }); +}); diff --git a/source/image-handler/test/image-handler/error-response.spec.ts b/source/image-handler/test/image-handler/error-response.spec.ts new file mode 100644 index 000000000..a897da843 --- /dev/null +++ b/source/image-handler/test/image-handler/error-response.spec.ts @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import {getErrorResponse} from '../../src'; +import {StatusCodes} from '../../src/lib'; + +describe('getErrorResponse', () => { + it('should return an error response with the provided status code and error message', () => { + const error = { status: 404, message: 'Not Found' }; + const result = getErrorResponse(error); + + expect(result).toEqual({ + statusCode: 404, + headers: { + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET', + 'Cache-Control': 'max-age=3600, immutable', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(error), + }); + }); + + it('should handle "Image to composite must have same dimensions or smaller" error', () => { + const error = { message: 'Image to composite must have same dimensions or smaller' }; + const result = getErrorResponse(error); + + expect(result).toEqual({ + statusCode: StatusCodes.BAD_REQUEST, + headers: { + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET', + 'Cache-Control': 'max-age=3600, immutable', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Image to overlay must have same dimensions or smaller', + code: 'BadRequest', + status: StatusCodes.BAD_REQUEST, + }), + }); + }); + + it('should handle "extract_area: bad extract area" error', () => { + const error = { message: 'extract_area: bad extract area' }; + const result = getErrorResponse(error); + + expect(result).toEqual({ + statusCode: StatusCodes.BAD_REQUEST, + headers: { + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET', + 'Cache-Control': 'max-age=3600, immutable', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.', + code: 'Crop::AreaOutOfBounds', + status: StatusCodes.BAD_REQUEST, + }), + }); + }); + + it('should handle other errors and return INTERNAL_SERVER_ERROR', () => { + const error = { message: 'Some other error' }; + const result = getErrorResponse(error); + + expect(result).toEqual({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Internal error. Please contact the system administrator.', + code: 'InternalError', + status: StatusCodes.INTERNAL_SERVER_ERROR, + }), + }); + }); +}); diff --git a/source/image-handler/test/image-handler/format.spec.ts b/source/image-handler/test/image-handler/format.spec.ts new file mode 100644 index 000000000..e37d59d56 --- /dev/null +++ b/source/image-handler/test/image-handler/format.spec.ts @@ -0,0 +1,154 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import sharp from 'sharp'; +import fs from 'fs'; + +import { ImageHandler } from '../../src/image-handler'; +import { ImageFormatTypes, ImageRequestInfo, RequestTypes } from '../../src/lib'; + +const s3Client = new S3(); + +const image = fs.readFileSync('./test/image/25x15.png'); + +describe('format', () => { + 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); + 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, + effort: 3, + originalImage: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ), + }; + jest.spyOn(sharp(), 'webp'); + + // Act + const imageHandler = new ImageHandler(s3Client); + const result = await imageHandler.process(request); + + // Assert + expect(result).not.toEqual(request.originalImage); + }); + + 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); + const result = await imageHandler.process(request); + + // Assert + expect(result).not.toEqual(request.originalImage); + }); +}); + +describe('modifyImageOutput', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should return an image in the specified format when outputFormat is provided', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { grayscale: true, flip: true }, + outputFormat: ImageFormatTypes.JPEG, + originalImage: image, + }; + const imageHandler = new ImageHandler(s3Client); + const sharpImage = sharp(request.originalImage, { failOn: 'none' }).withMetadata(); + const toFormatSpy = jest.spyOn(sharp.prototype, 'jpeg'); + const result = await imageHandler['modifyImageOutput'](sharpImage, request).toBuffer(); + + // Act + const resultFormat = (await sharp(result).metadata()).format; + + // Assert + expect(toFormatSpy).toHaveBeenCalledWith({ mozjpeg: true }); + expect(resultFormat).toEqual(ImageFormatTypes.JPEG); + }); + + it('Should return an image in the same format when outputFormat is not provided', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { grayscale: true, flip: true }, + originalImage: image, + }; + const sharpImage = sharp(request.originalImage, { failOnError: false }).withMetadata(); + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = await imageHandler['modifyImageOutput'](sharpImage, request).toBuffer(); + const resultFormat = (await sharp(result).metadata()).format; + + // Assert + expect(resultFormat).toEqual(ImageFormatTypes.PNG); + }); + + it('Should return an image webp with reduction effort when outputFormat wepb and reduction effot provided', async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { grayscale: true, flip: true }, + outputFormat: ImageFormatTypes.WEBP, + effort: 3, + originalImage: image, + }; + const sharpImage = sharp(request.originalImage, { failOnError: false }).withMetadata(); + const imageHandler = new ImageHandler(s3Client); + const webpSpy = jest.spyOn(sharp.prototype, 'webp'); + + // Act + const result = await imageHandler['modifyImageOutput'](sharpImage, request).toBuffer(); + const resultFormat = (await sharp(result).metadata()).format; + + // Assert + expect(webpSpy).toHaveBeenCalledWith({ effort: request.effort }); + expect(resultFormat).toEqual(ImageFormatTypes.WEBP); + }); +}); diff --git a/source/image-handler/test/image-handler/resize.spec.ts b/source/image-handler/test/image-handler/resize.spec.ts new file mode 100644 index 000000000..d4efac946 --- /dev/null +++ b/source/image-handler/test/image-handler/resize.spec.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import sharp from 'sharp'; + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageEdits } from '../../src/lib'; +import { ImageHandler } from '../../src/image-handler'; + +const s3Client = new S3(); + +describe('resize', () => { + 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); + const result = await imageHandler.applyEdits(image, edits, false); + + // Assert + const resultBuffer = await result.toBuffer(); + const convertedImage = await sharp(originalImage, { failOnError: false }) + .withMetadata() + .resize({ width: 99, height: 100 }) + .toBuffer(); + expect(resultBuffer).toEqual(convertedImage); + }); +}); diff --git a/source/image-handler/test/image-handler/rotate.spec.ts b/source/image-handler/test/image-handler/rotate.spec.ts new file mode 100644 index 000000000..2e9108221 --- /dev/null +++ b/source/image-handler/test/image-handler/rotate.spec.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; +import sharp from 'sharp'; +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { ImageHandler } from '../../src/image-handler'; + +const s3Client = new S3(); + +describe('rotate', () => { + 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); + 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'); + }); + + 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: {}, + originalImage: originalImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + const result = await imageHandler.process(request); + + // Assert + const metadata = await sharp(Buffer.from(result, 'base64')).metadata(); + expect(metadata.orientation).toEqual(3); + }); + + 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: {}, + originalImage: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ), + }; + + // Act + const imageHandler = new ImageHandler(s3Client); + const result = await imageHandler.process(request); + + // Assert + const metadata = await sharp(Buffer.from(result, 'base64')).metadata(); + expect(metadata.orientation).toBe(1); + }); +}); diff --git a/source/image-handler/test/image-handler/round-crop.spec.ts b/source/image-handler/test/image-handler/round-crop.spec.ts new file mode 100644 index 000000000..5b10224b6 --- /dev/null +++ b/source/image-handler/test/image-handler/round-crop.spec.ts @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import sharp from 'sharp'; +import { S3 } from '@aws-sdk/client-s3'; +import { ImageHandler } from '../../src/image-handler'; +import { ImageEdits } from '../../src/lib'; + +const s3Client = new S3(); + +//jest spies +const hasRoundCropSpy = jest.spyOn(ImageHandler.prototype as any, 'hasRoundCrop'); +const validRoundCropParamSpy = jest.spyOn(ImageHandler.prototype as any, 'validRoundCropParam'); +const compositeSpy = jest.spyOn(sharp.prototype, 'composite'); + +describe('roundCrop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 }; + const radiusX = Math.min(metadata.height, metadata.width) / 2; + const radiusY = radiusX; + const height = metadata.height; + const width = metadata.width; + const leftOffset = metadata.width / 2; + const topOffset = metadata.height / 2; + const ellipse = Buffer.from( + ` `, + ); + // Act + const imageHandler = new ImageHandler(s3Client); + const result = await imageHandler.applyEdits(image, edits, false); + + // Assert + const expectedResult: ImageEdits = { width: metadata.width / 2, height: metadata.height / 2 }; + expect(result['options'].input).not.toEqual(expectedResult); + expect(hasRoundCropSpy).toHaveReturnedWith(true); + expect(validRoundCropParamSpy).toHaveBeenCalledTimes(4); + for (let i = 1; i <= 4; i++) { + expect(validRoundCropParamSpy).toHaveNthReturnedWith(i, undefined); + } + expect(compositeSpy).toHaveBeenCalledWith([{ input: ellipse, blend: 'dest-in' }]); + }); + + 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); + const result = await imageHandler.applyEdits(image, edits, false); + + // Assert + const expectedResult: ImageEdits = { width: metadata.width / 2, height: metadata.height / 2 }; + expect(result['options'].input).not.toEqual(expectedResult); + expect(hasRoundCropSpy).toHaveReturnedWith(true); + expect(validRoundCropParamSpy).toHaveReturnedWith(true); + expect(compositeSpy).toHaveBeenCalled(); + }); +}); + +describe('hasRoundCrop', () => { + it('Should return true when the edits object has roundCrop key', () => { + // Arrange + const edits: ImageEdits = { roundCrop: { top: 100, left: 100, rx: 100, ry: 100 } }; + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = imageHandler['hasRoundCrop'](edits); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when the edits object does not have roundCrop key', () => { + // Arrange + const edits: ImageEdits = { resize: { width: 50, height: 50 } }; + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = imageHandler['hasRoundCrop'](edits); + + // Assert + expect(result).toBe(false); + }); +}); + +describe('validRoundCropParam', () => { + it('Should return true when the input is a number greater than 0', () => { + // Arrange + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = imageHandler['validRoundCropParam'](2); + + // Assert + expect(result).toBe(true); + }); + + it('Should return false when the input is a number less than 0', () => { + // Arrange + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = imageHandler['validRoundCropParam'](-1); + + // Assert + expect(result).toBe(false); + }); + + it('Should return falsey value when the input is undefined', () => { + // Arrange + const imageHandler = new ImageHandler(s3Client); + + // Act + const result = imageHandler['validRoundCropParam'](undefined); + + // Assert + // Converted to bool to show falsey values would be false as the parameters is used in if expression. + expect(!!result).toBe(false); + }); +}); diff --git a/source/image-handler/test/image-handler/standard.spec.ts b/source/image-handler/test/image-handler/standard.spec.ts new file mode 100644 index 000000000..e010a98ad --- /dev/null +++ b/source/image-handler/test/image-handler/standard.spec.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; +import { S3 } from '@aws-sdk/client-s3'; +import sharp, { SharpOptions } from 'sharp'; +import { ImageEdits } from '../../src/lib'; +import { ImageHandler } from '../../src/image-handler'; + +const s3Client = new S3(); +const image = fs.readFileSync('./test/image/25x15.png'); +const withMetatdataSpy = jest.spyOn(sharp.prototype, 'withMetadata'); + +describe('standard', () => { + 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); + const result = await imageHandler.applyEdits(image, edits, false); + + // Assert + /* eslint-disable dot-notation */ + const expectedResult1 = result['options'].greyscale; + const expectedResult2 = result['options'].flip; + const combinedResults = expectedResult1 && expectedResult2; + expect(combinedResults).toEqual(true); + }); +}); + +describe('instantiateSharpImage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should not include metadata if the rotation is null', async () => { + // Arrange + const edits = { + rotate: null, + }; + const options: SharpOptions = { failOn: 'none' }; + const imageHandler = new ImageHandler(s3Client); + + // Act + await imageHandler['instantiateSharpImage'](image, edits, options); + + //Assert + expect(withMetatdataSpy).not.toHaveBeenCalled(); + }); + + it('Should include metadata and not define orientation if the rotation is not null and orientation is not defined', async () => { + // Arrange + const edits = { + rotate: undefined, + }; + const options: SharpOptions = { failOn: 'none' }; + const imageHandler = new ImageHandler(s3Client); + + // Act + await imageHandler['instantiateSharpImage'](image, edits, options); + + //Assert + expect(withMetatdataSpy).toHaveBeenCalled(); + expect(withMetatdataSpy).not.toHaveBeenCalledWith(expect.objectContaining({ orientation: expect.anything })); + }); + + it('Should include orientation metadata if the rotation is defined in the metadata', async () => { + // Arrange + const edits = { + rotate: undefined, + }; + const options: SharpOptions = { failOn: 'none' }; + const modifiedImage = await sharp(image).withMetadata({ orientation: 1 }).toBuffer(); + const imageHandler = new ImageHandler(s3Client); + + // Act + await imageHandler['instantiateSharpImage'](modifiedImage, edits, options); + + //Assert + expect(withMetatdataSpy).toHaveBeenCalledWith({ orientation: 1 }); + }); +}); diff --git a/source/image-handler/test/image-handler/thumbhash.spec.ts b/source/image-handler/test/image-handler/thumbhash.spec.ts new file mode 100644 index 000000000..fb9fa30c7 --- /dev/null +++ b/source/image-handler/test/image-handler/thumbhash.spec.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; +import { S3 } from '@aws-sdk/client-s3'; +import sharp from 'sharp'; +import { ContentTypes, ImageEdits, ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { ImageHandler } from '../../src/image-handler'; + +const s3Client = new S3(); +const image = fs.readFileSync('./test/image/25x15.png'); + +describe('standard', () => { + it('Should include orientation metadata if the rotation is defined in the metadata', async () => { + // Arrange + const edits = { + thumbhash: true, + }; + const imageHandler = new ImageHandler(s3Client); + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: 'sample-bucket', + key: 'sample-image-001.png', + edits: { thumbhash: true }, + originalImage: image, + }; + + // Act + const response = await imageHandler.process(request); + const json = JSON.parse(Buffer.from(response, 'base64').toString('utf-8')); + + //Assert + expect(json.base64).toBe('PwgCBICgtqh3eIh3d3gAAAAAAA=='); + expect(request.contentType).toBe(ContentTypes.JSON); + expect(request.cacheControl).toContain('max-age='); + }); +}); 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 77ea12e40..000000000 --- a/source/image-handler/test/image-request.spec.js +++ /dev/null @@ -1,1203 +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); - } - }); - }); -}); - -// ---------------------------------------------------------------------------- -// 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' - } - 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/decode-request.spec.ts b/source/image-handler/test/image-request/decode-request.spec.ts new file mode 100644 index 000000000..ec00b34ba --- /dev/null +++ b/source/image-handler/test/image-request/decode-request.spec.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { StatusCodes } from '../../src/lib'; +import { build_event } from '../helpers'; + +describe('decodeRequest', () => { + const s3Client = new S3(); + + it('Should pass if a valid base64-encoded path has been specified', () => { + // Arrange + const event = build_event({ + rawPath: '/eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.decodeRequest(event); + + // Assert + const expectedResult = { + bucket: 'bucket-name-here', + key: 'key-name-here', + }; + expect(result).toEqual(expectedResult); + }); + + it('Should throw an error if a valid base64-encoded path has not been specified', () => { + // Arrange + const event = build_event({ rawPath: '/someNonBase64EncodedContentHere' }); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // 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.', + }); + } + }); + + it('Should throw an error if no path is specified at all', () => { + // Arrange + const event = build_event({}); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // 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.', + }); + } + }); +}); diff --git a/source/image-handler/test/image-request/determine-output-format.spec.ts b/source/image-handler/test/image-request/determine-output-format.spec.ts new file mode 100644 index 000000000..d407f9321 --- /dev/null +++ b/source/image-handler/test/image-request/determine-output-format.spec.ts @@ -0,0 +1,174 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { APIGatewayProxyEventV2 } from 'aws-lambda'; +import * as Buffer from 'node:buffer'; +import { S3 } from '@aws-sdk/client-s3'; +import { ImageFormatTypes, ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { context } from '../helpers'; +import { ImageRequest } from '../../src/image-request'; +import { sdkStreamFromString } from '../mock'; + +const request: Record = { + bucket: 'bucket', + key: 'key', + edits: { + roundCrop: true, + resize: { + width: 100, + height: 100, + }, + }, +}; + +const createEvent = (request): APIGatewayProxyEventV2 => { + return { + headers: {}, + isBase64Encoded: false, + rawQueryString: '', + requestContext: context, + routeKey: '', + version: '', + rawPath: btoa(JSON.stringify(request)), + }; +}; + +describe('determineOutputFormat', () => { + const s3Client = new S3(); + + it('Should map edits.toFormat to outputFormat in image request', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.THUMBOR, + edits: { toFormat: ImageFormatTypes.PNG }, + originalImage: sdkStreamFromString('image'), + }; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.PNG); + }); + + it('Should map output format from edits to image request', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.PNG; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.PNG); + }); + + it('Should map reduction effort if included and output format is webp', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.WEBP; + request.effort = 3; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.WEBP); + expect(imageRequestInfo.effort).toEqual(3); + }); + + it('Should map default reduction effort if included but NaN and output format is webp', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.WEBP; + request.effort = 'invalid'; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.WEBP); + expect(imageRequestInfo.effort).toEqual(4); + }); + + it('Should map default reduction effort if included > 6 and output format is webp', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.WEBP; + request.effort = 7; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.WEBP); + expect(imageRequestInfo.effort).toEqual(4); + }); + + it('Should map default reduction effort if included but < 0 and output format is webp', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.WEBP; + request.effort = -1; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.WEBP); + expect(imageRequestInfo.effort).toEqual(4); + }); + + it('Should map truncated reduction effort if included but has a decimal and output format is webp', () => { + // Arrange + const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.DEFAULT, + originalImage: sdkStreamFromString('image'), + }; + request.outputFormat = ImageFormatTypes.WEBP; + request.effort = 2.378; + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['determineOutputFormat'](imageRequestInfo, createEvent(request)); + + // Assert + expect(imageRequestInfo.outputFormat).toEqual(ImageFormatTypes.WEBP); + expect(imageRequestInfo.effort).toEqual(2); + }); +}); diff --git a/source/image-handler/test/image-request/fix-quality.spec.ts b/source/image-handler/test/image-request/fix-quality.spec.ts new file mode 100644 index 000000000..759d209f0 --- /dev/null +++ b/source/image-handler/test/image-request/fix-quality.spec.ts @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageFormatTypes, ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { ImageRequest } from '../../src/image-request'; + +const imageRequestInfo: ImageRequestInfo = { + bucket: 'bucket', + key: 'key', + requestType: RequestTypes.THUMBOR, + edits: { png: { quality: 80 } }, + originalImage: Buffer.from('image'), + outputFormat: ImageFormatTypes.JPEG, +}; + +describe('fixQuality', () => { + const s3Client = new S3(); + + beforeEach(() => { + jest.clearAllMocks(); + imageRequestInfo.edits = { png: { quality: 80 } }; + }); + + it('Should map correct edits with quality key to edits if output in edits differs from output format in request ', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ jpeg: { quality: 80 } })); + expect(imageRequestInfo.edits.png).toBe(undefined); + }); + + it('should not map edits with quality key if not output format is not a supported type', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + imageRequestInfo.outputFormat = 'pdf' as ImageFormatTypes; + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); + }); + + it('should not map edits with quality key if not output format is the same as the quality key', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + imageRequestInfo.outputFormat = ImageFormatTypes.PNG; + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); + }); + + it('should not map edits with quality key if the request is of default type', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + imageRequestInfo.outputFormat = ImageFormatTypes.JPEG; + imageRequestInfo.requestType = RequestTypes.DEFAULT; + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); + }); + + it('should not map edits with quality key if the request is default type', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + imageRequestInfo.outputFormat = ImageFormatTypes.JPEG; + imageRequestInfo.requestType = RequestTypes.DEFAULT; + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); + }); + + it('should not map edits with quality key if the request if there is no output format', () => { + // Arrange + const imageRequest = new ImageRequest(s3Client); + delete imageRequestInfo.outputFormat; + + // Act + imageRequest['fixQuality'](imageRequestInfo); + + // Assert + expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); + }); +}); diff --git a/source/image-handler/test/image-request/get-original-image.spec.ts b/source/image-handler/test/image-request/get-original-image.spec.ts new file mode 100644 index 000000000..672fcfbc4 --- /dev/null +++ b/source/image-handler/test/image-request/get-original-image.spec.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GetObjectCommand, S3, S3Client } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { ImageHandlerError, StatusCodes } from '../../src/lib'; +import { sdkStreamFromString } from '../mock'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; + +describe('getOriginalImage', () => { + const s3Client = new S3(); + const mockS3Client = mockClient(S3Client); + beforeEach(() => { + mockS3Client.reset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should pass if the proper bucket name and key are supplied, simulating an image file that can be retrieved', async () => { + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = await imageRequest.getOriginalImage('validBucket', 'validKey'); + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'validBucket', + Key: 'validKey', + }); + expect(result.originalImage).toEqual(Buffer.from('SampleImageContent\n')); + }); + + it('Should throw an error if an invalid file signature is found, simulating an unsupported image type', async () => { + // Mock + mockS3Client + .on(GetObjectCommand) + .resolves({ Body: sdkStreamFromString('SampleImageContent\n'), ContentType: 'binary/octet-stream' }); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // Assert + try { + await imageRequest.getOriginalImage('validBucket', 'validKey'); + } catch (error) { + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'validBucket', + Key: 'validKey', + }); + expect(error.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR); + } + }); + + it('Should throw an error if an invalid bucket or key name is provided, simulating a non-existent original image', async () => { + // Mock + mockS3Client + .on(GetObjectCommand) + .rejects(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'SimulatedException')); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // Assert + try { + await imageRequest.getOriginalImage('invalidBucket', 'invalidKey'); + } catch (error) { + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'invalidBucket', + Key: 'invalidKey', + }); + expect(error.status).toEqual(StatusCodes.NOT_FOUND); + } + }); + + it('Should throw an error if an unknown problem happens when getting an object', async () => { + // Mock + mockS3Client + .on(GetObjectCommand) + .rejects(new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'InternalServerError', 'SimulatedException')); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // Assert + try { + await imageRequest.getOriginalImage('invalidBucket', 'invalidKey'); + } catch (error) { + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'invalidBucket', + Key: 'invalidKey', + }); + expect(error.status).toEqual(StatusCodes.NOT_FOUND); + } + }); + + ['binary/octet-stream', 'application/octet-stream'].forEach(contentType => { + test.each([ + { hex: [0x89, 0x50, 0x4e, 0x47], expected: 'image/png' }, + { hex: [0xff, 0xd8, 0xff, 0xdb], expected: 'image/jpeg' }, + { hex: [0xff, 0xd8, 0xff, 0xe0], expected: 'image/jpeg' }, + { hex: [0xff, 0xd8, 0xff, 0xee], expected: 'image/jpeg' }, + { hex: [0xff, 0xd8, 0xff, 0xe1], expected: 'image/jpeg' }, + { hex: [0x52, 0x49, 0x46, 0x46], expected: 'image/webp' }, + { hex: [0x49, 0x49, 0x2a, 0x00], expected: 'image/tiff' }, + { hex: [0x4d, 0x4d, 0x00, 0x2a], expected: 'image/tiff' }, + { hex: [0x47, 0x49, 0x46, 0x38], expected: 'image/gif' }, + { hex: [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], expected: 'image/avif' }, + ])('Should pass and infer $expected content type if there is no extension', async ({ hex, expected }) => { + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + ContentType: contentType, + Body: sdkStreamFromString(new Uint8Array(hex)), + }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const result = await imageRequest.getOriginalImage('validBucket', 'validKey'); + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'validBucket', + Key: 'validKey', + }); + expect(result.originalImage).toEqual(Buffer.from(new Uint8Array(hex))); + expect(result.contentType).toEqual(expected); + }); + }); +}); diff --git a/source/image-handler/test/image-request/get-output-format.spec.ts b/source/image-handler/test/image-request/get-output-format.spec.ts new file mode 100644 index 000000000..a109d75f2 --- /dev/null +++ b/source/image-handler/test/image-request/get-output-format.spec.ts @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { build_event } from '../helpers'; + +describe('getOutputFormat', () => { + const s3Client = new S3(); + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('Should pass if it returns "webp" for a lowercase accepts header which includes webp', () => { + // Arrange + const event = build_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); + const result = imageRequest.getOutputFormat(event); + + // Assert + expect(result).toEqual('webp'); + }); + + it('Should pass if it returns null for an accepts header which does not include webp', () => { + // Arrange + const event = build_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); + const result = imageRequest.getOutputFormat(event); + + // Assert + expect(result).toBeNull(); + }); + + it('Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp', () => { + // Arrange + const event = build_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); + const result = imageRequest.getOutputFormat(event); + + // Assert + expect(result).toBeNull(); + }); + + it('Should pass if it returns null when AUTO_WEBP is not set with accepts header including webp', () => { + // Arrange + const event = build_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); + const result = imageRequest.getOutputFormat(event); + + // Assert + expect(result).toBeNull(); + }); +}); diff --git a/source/image-handler/test/image-request/infer-image-type.spec.ts b/source/image-handler/test/image-request/infer-image-type.spec.ts new file mode 100644 index 000000000..4f74989fa --- /dev/null +++ b/source/image-handler/test/image-request/infer-image-type.spec.ts @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; + +describe('inferImageType', () => { + const s3Client = new S3(); + + test.each([ + { value: 'FFD8FFDB', type: 'image/jpeg' }, + { value: 'FFD8FFE0', type: 'image/jpeg' }, + { value: 'FFD8FFED', type: 'image/jpeg' }, + { value: 'FFD8FFEE', type: 'image/jpeg' }, + { value: 'FFD8FFE1', type: 'image/jpeg' }, + { value: 'FFD8FFE2', type: 'image/jpeg' }, + { value: 'FFD8XXXX', type: 'image/jpeg' }, + { value: '89504E47', type: 'image/png' }, + { value: '52494646', type: 'image/webp' }, + { value: '49492A00', type: 'image/tiff' }, + { value: '4D4D002A', type: 'image/tiff' }, + { value: '47494638', type: 'image/gif' }, + { value: '000000006674797061766966', type: 'image/avif' }, + ])('Should pass if it returns "$type" for a magic number of $value', ({ value, type }) => { + const byteValues = value.match(/.{1,2}/g).map(x => parseInt(x, 16)); + const imageBuffer = Buffer.from(byteValues.concat(new Array(8).fill(0x00))); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.inferImageType(imageBuffer); + + // Assert + expect(result).toEqual(type); + }); + + 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); + imageRequest.inferImageType(imageBuffer); + } catch (error) { + // Assert + expect(error.status).toEqual(500); + expect(error.code).toEqual('RequestTypeError'); + } + }); +}); diff --git a/source/image-handler/test/image-request/parse-image-edits.spec.ts b/source/image-handler/test/image-request/parse-image-edits.spec.ts new file mode 100644 index 000000000..a88c2a138 --- /dev/null +++ b/source/image-handler/test/image-request/parse-image-edits.spec.ts @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { RequestTypes, StatusCodes } from '../../src/lib'; +import { build_event } from '../helpers'; + +describe('parseImageEdits', () => { + const s3Client = new S3(); + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('Should pass if the proper result is returned for a sample base64-encoded image request', () => { + // Arrange + const event = build_event({ + rawPath: '/eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0=', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageEdits(event, RequestTypes.DEFAULT); + + // Assert + const expectedResult = { grayscale: 'true', rotate: 90, flip: 'true' }; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if the proper result is returned for a sample thumbor-type image request', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageEdits(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = { rotate: 90, grayscale: true }; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if the proper result is returned for a sample custom-type image request', () => { + // Arrange + const event = build_event({ + rawPath: '/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg', + }); + + process.env = { + REWRITE_MATCH_PATTERN: '/(filters-)/gm', + REWRITE_SUBSTITUTION: 'filters:', + }; + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageEdits(event, RequestTypes.CUSTOM); + + // Assert + const expectedResult = { rotate: 90, grayscale: true }; + expect(result).toEqual(expectedResult); + }); + + it('Should throw an error if a requestType is not specified and/or the image edits cannot be parsed', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/other-image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // 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.', + }); + } + }); +}); diff --git a/source/image-handler/test/image-request/parse-image-headers.spec.ts b/source/image-handler/test/image-request/parse-image-headers.spec.ts new file mode 100644 index 000000000..59078f68b --- /dev/null +++ b/source/image-handler/test/image-request/parse-image-headers.spec.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { RequestTypes } from '../../src/lib'; +import { build_event } from '../helpers'; + +describe('parseImageHeaders', () => { + const s3Client = new S3(); + + it('001/Should return undefined if headers are not provided for a base64-encoded image request', () => { + // Arrange + const event = build_event({ + rawPath: '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5In0=', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ rawPath: '/test.jpg' }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageHeaders(event, RequestTypes.THUMBOR); + + // Assert + expect(result).toEqual(undefined); + }); +}); diff --git a/source/image-handler/test/image-request/parse-image-key.spec.ts b/source/image-handler/test/image-request/parse-image-key.spec.ts new file mode 100644 index 000000000..32c36e8fd --- /dev/null +++ b/source/image-handler/test/image-request/parse-image-key.spec.ts @@ -0,0 +1,297 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { RequestTypes, StatusCodes } from '../../src/lib'; +import { build_event } from '../helpers'; + +describe('parseImageKey', () => { + const s3Client = new S3(); + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('Should pass if an image key value is provided in the default request format', () => { + // Arrange + const event = build_event({ + rawPath: '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5Ijoic2FtcGxlLWltYWdlLTAwMS5qcGcifQ==', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.DEFAULT); + + // Assert + const expectedResult = 'sample-image-001.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('should read image requests with base64 encoding having slash', () => { + const event = build_event({ + rawPath: + '/eyJidWNrZXQiOiJlbGFzdGljYmVhbnN0YWxrLXVzLWVhc3QtMi0wNjY3ODQ4ODU1MTgiLCJrZXkiOiJlbnYtcHJvZC9nY2MvbGFuZGluZ3BhZ2UvMV81N19TbGltTl9MaWZ0LUNvcnNldC1Gb3ItTWVuLVNOQVAvYXR0YWNobWVudHMvZmZjMWYxNjAtYmQzOC00MWU4LThiYWQtZTNhMTljYzYxZGQzX1/Ys9mE2YrZhSDZhNmK2YHYqiAoMikuanBnIiwiZWRpdHMiOnsicmVzaXplIjp7IndpZHRoIjo0ODAsImZpdCI6ImNvdmVyIn19fQ==', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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); + }); + + it('Should pass if an image key value is provided in the thumbor request format', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = 'thumbor-image.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if an image key value is provided in the thumbor request format having open, close parentheses', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1).jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image 1).jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/10x10:100x100/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/10x10/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/10x20:100x200/10x10/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ + rawPath: '/fit-in/400x0/filters:fill(ffffff)/fit-in-thumbor-image (1) suffix.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + 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 = build_event({ rawPath: '/100x100/test-100x100/test/beach-100x100.jpg' }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = 'test-100x100/test/beach-100x100.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if an image key value is provided in the custom request format', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/custom-image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.CUSTOM); + + // Assert + const expectedResult = 'custom-image.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('Should throw an error if an unrecognized requestType is passed into the function as a parameter', () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/other-image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + + // 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.', + }); + } + }); + + it('Should pass if an image key value is provided in the thumbor request format with a watermark containing a slash', () => { + // Arrange + const event = build_event({ + rawPath: '/fit-in/400x400/filters:watermark(bucket,folder/key.png,0,0)/image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = 'image.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if an image key value is provided in the thumbor request format with a watermark not containing a slash', () => { + // Arrange + const event = build_event({ + rawPath: '/fit-in/400x400/filters:watermark(bucket,key.png,0,0)/image.jpg', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = 'image.jpg'; + expect(result).toEqual(expectedResult); + }); + + it('Should replace stroeer request URLs with default file name.', () => { + // Arrange + const event = build_event({ + rawPath: + '/2024/07/abcdef/fit-in/400x400/filters:watermark(bucket,key.png,0,0)/seo-optimized-random-image-title.avif', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = '2024/07/abcdef/image.avif'; + expect(result).toEqual(expectedResult); + }); + + it('Should ignore next data template /__WIDTH__x0/.', () => { + // Arrange + const event = build_event({ + rawPath: '/2024/07/abcdef/fit-in/__WIDTH__x0/file.avif', + }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR); + + // Assert + const expectedResult = '2024/07/abcdef/image.avif'; + expect(result).toEqual(expectedResult); + }); +}); diff --git a/source/image-handler/test/image-request/parse-request-type.spec.ts b/source/image-handler/test/image-request/parse-request-type.spec.ts new file mode 100644 index 000000000..87ea6f171 --- /dev/null +++ b/source/image-handler/test/image-request/parse-request-type.spec.ts @@ -0,0 +1,168 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { S3 } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { RequestTypes, StatusCodes } from '../../src/lib'; +import { consoleInfoSpy } from '../mock'; +import { build_event } from '../helpers'; + +describe('parseRequestType', () => { + const s3Client = new S3(); + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('Should pass if the method detects a default request', () => { + // Arrange + const event = build_event({ + rawPath: + '/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5IjoibXktc2FtcGxlLWtleSIsImVkaXRzIjp7ImdyYXlzY2FsZSI6dHJ1ZX19', + }); + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseRequestType(event); + + // Assert + const expectedResult = RequestTypes.DEFAULT; + expect(result).toEqual(expectedResult); + }); + + it('Should pass if the method detects a thumbor request', () => { + // Arrange + const event = build_event({ + rawPath: + '/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); + const result = imageRequest.parseRequestType(event); + + // Assert + expect(result).toEqual(RequestTypes.THUMBOR); + }); + + it('Should pass for a thumbor request with no extension', () => { + // Arrange + const event = build_event({ + rawPath: '/unsafe/filters:brightness(10):contrast(30)/image', + }); + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseRequestType(event); + + // Assert + expect(result).toEqual(RequestTypes.THUMBOR); + }); + + test.each([ + { value: '.jpg' }, + { value: '.jpeg' }, + { value: '.png' }, + { value: '.webp' }, + { value: '.tiff' }, + { value: '.tif' }, + { value: '.svg' }, + { value: '.gif' }, + { value: '.avif' }, + ])('Should pass if get a request with supported image extension: $value', ({ value }) => { + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseRequestType(build_event({ rawPath: `image${value}` })); + + // Assert + expect(result).toEqual(RequestTypes.THUMBOR); + }); + + it('Should pass if the method detects a custom request', () => { + // Arrange + const event = build_event({ rawPath: '/additionalImageRequestParameters/image.jpg' }); + process.env = { + REWRITE_MATCH_PATTERN: 'matchPattern', + REWRITE_SUBSTITUTION: 'substitutionString', + }; + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseRequestType(event); + + // Assert + const expectedResult = RequestTypes.CUSTOM; + expect(result).toEqual(expectedResult); + }); + + it('Should throw an error if the method cannot determine the request type based on the three groups given', () => { + // Arrange + const event = build_event({ rawPath: '12x12e24d234r2ewxsad123d34r.bmp' }); + + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client); + + let parseError; + // Assert + try { + imageRequest.parseRequestType(event); + } catch (error) { + parseError = error; + } + expect(parseError).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/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.', + }); + }); + + it('Should throw an error for a thumbor request with invalid extension', () => { + // Arrange + const event = build_event({ + rawPath: '/testImage.abc', + }); + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client); + let parseError; + // Assert + try { + imageRequest.parseRequestType(event); + } catch (error) { + parseError = error; + } + expect(parseError).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/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.', + }); + }); + + it('Should pass if a path is provided without an extension', () => { + // Arrange + const event = build_event({ rawPath: '/image' }); + + // Act + const imageRequest = new ImageRequest(s3Client); + const result = imageRequest.parseRequestType(event); + + // Assert + const expectedResult = RequestTypes.THUMBOR; + expect(result).toEqual(expectedResult); + }); +}); diff --git a/source/image-handler/test/image-request/setup.spec.ts b/source/image-handler/test/image-request/setup.spec.ts new file mode 100644 index 000000000..2b33603cd --- /dev/null +++ b/source/image-handler/test/image-request/setup.spec.ts @@ -0,0 +1,485 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GetObjectCommand, S3, S3Client } from '@aws-sdk/client-s3'; +import { ImageRequest } from '../../src/image-request'; +import { ImageRequestInfo, RequestTypes } from '../../src/lib'; +import { sampleImageStream, sdkStreamFromString } from '../mock'; +import { build_event } from '../helpers'; +import 'aws-sdk-client-mock-jest'; +import { mockClient } from 'aws-sdk-client-mock'; +import fs, { createReadStream } from 'fs'; +import { sdkStreamMixin } from '@smithy/util-stream'; + +describe('setup', () => { + const OLD_ENV = process.env; + const mockS3Client = mockClient(S3Client); + + beforeEach(() => { + mockS3Client.reset(); + process.env = { ...OLD_ENV }; + }); + + afterEach(() => { + jest.clearAllMocks(); + process.env = OLD_ENV; + }); + + it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values 1', async () => { + // Arrange + const event = build_event({ rawPath: '/filters:grayscale()/test-image-001.jpg' }); + process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'allowedBucket001', + Key: 'test-image-001.jpg', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it('Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values 2', async () => { + // Arrange + const event = build_event({ + rawPath: '/filters:format(png)/filters:quality(50)/test-image-001.jpg', + }); + process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + outputFormat: 'png', + contentType: 'image/png', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'allowedBucket001', + Key: 'test-image-001.jpg', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it('Should pass when a custom image request is provided and populate the ImageRequest object with the proper values', async () => { + // Arrange + const event = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/custom-image.jpg', + }); + process.env = { + SOURCE_BUCKETS: 'allowedBucket001, allowedBucket002', + }; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + CacheControl: 'max-age=300', + ContentType: 'custom-type', + Expires: new Date('Tue, 24 Dec 2019 13:46:28 GMT'), + LastModified: new Date('Sat, 19 Dec 2009 16:30:47 GMT'), + Body: sdkStreamFromString('SampleImageContent\n'), + }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult: ImageRequestInfo = { + requestType: RequestTypes.THUMBOR, + bucket: 'allowedBucket001', + key: 'custom-image.jpg', + edits: { + grayscale: true, + rotate: 90, + }, + originalImage: Buffer.from('SampleImageContent\n'), + cacheControl: 'max-age=300', + contentType: 'custom-type', + expires: new Date('Tue, 24 Dec 2019 13:46:28 GMT'), + lastModified: new Date('Sat, 19 Dec 2009 16:30:47 GMT'), + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: '/filters:rotate(90)/filters:grayscale()/custom-image', + }); + process.env = { + SOURCE_BUCKETS: 'allowedBucket001, allowedBucket002', + }; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + CacheControl: 'max-age=300', + ContentType: 'custom-type', + Expires: new Date('Tue, 24 Dec 2019 13:46:28 GMT'), + LastModified: new Date('Sat, 19 Dec 2009 16:30:47 GMT'), + Body: sdkStreamFromString('SampleImageContent\n'), + }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: RequestTypes.THUMBOR, + bucket: 'allowedBucket001', + key: 'custom-image', + edits: { + grayscale: true, + rotate: 90, + }, + originalImage: Buffer.from('SampleImageContent\n'), + cacheControl: 'max-age=300', + contentType: 'custom-type', + expires: new Date('Tue, 24 Dec 2019 13:46:28 GMT'), + lastModified: new Date('Sat, 19 Dec 2009 16:30:47 GMT'), + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'allowedBucket001', + Key: 'custom-image', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + describe('SVGSupport', () => { + 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 () => { + // Arrange + const event = build_event({ + rawPath: '/image.svg', + }); + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + ContentType: 'image/svg+xml', + Body: sdkStreamFromString('SampleImageContent\n'), + }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image/svg+xml', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: '/100x100/image.svg', + }); + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + ContentType: 'image/svg+xml', + Body: sdkStreamFromString('SampleImageContent\n'), + }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image/png', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: '/filters:format(jpg)/image.svg', + }); + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ + ContentType: 'image/svg+xml', + Body: sdkStreamFromString('SampleImageContent\n'), + }); + + // Act + const imageRequest = new ImageRequest(new S3()); + 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', + contentType: 'image/jpeg', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'validBucket', + Key: 'image.svg', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + }); + + it('Should pass and return the customer headers if custom headers are provided', async () => { + // Arrange + const event = build_event({ + rawPath: + '/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCJ9LCJvdXRwdXRGb3JtYXQiOiJqcGVnIn0=', + }); + process.env.SOURCE_BUCKETS = 'validBucket, validBucket2'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: 'Default', + bucket: 'validBucket', + key: 'validKey', + headers: { 'Cache-Control': 'max-age=31536000' }, + outputFormat: 'jpeg', + originalImage: Buffer.from('SampleImageContent\n'), + cacheControl: 'max-age=31536000', + contentType: 'image/jpeg', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'validBucket', + Key: 'validKey', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it('Should pass when valid reduction effort is provided and output is webp', async () => { + const event = build_event({ + rawPath: + '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjozfQ==', + }); + process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sampleImageStream() }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: 'Default', + bucket: 'test', + key: 'test.png', + edits: undefined, + headers: undefined, + outputFormat: 'webp', + originalImage: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ), + cacheControl: 'max-age=31536000', + contentType: 'image/webp', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: + '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjoidGVzdCJ9', + }); + process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image/webp', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: + '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIiwicmVkdWN0aW9uRWZmb3J0IjoxMH0=', + }); + process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image/webp', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + 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 = build_event({ + rawPath: '/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIn0=', + }); + process.env.SOURCE_BUCKETS = 'test, validBucket, validBucket2'; + + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + 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', + contentType: 'image/webp', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'test', + Key: 'test.png', + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it('Should pass when a default image request is provided and populate the ImageRequest object with the proper values and a utf-8 key', async function () { + // Arrange + const event = build_event({ + rawPath: + 'eyJidWNrZXQiOiJ0ZXN0Iiwia2V5Ijoi5Lit5paHIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9', + }); + process.env = { + SOURCE_BUCKETS: 'test, test2', + }; + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') }); + + // Act + const imageRequest = new ImageRequest(new S3({})); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: 'Default', + bucket: 'test', + key: '中文', + edits: { grayscale: true }, + headers: undefined, + outputFormat: 'jpeg', + originalImage: Buffer.from('SampleImageContent\n'), + cacheControl: 'max-age=31536000', + contentType: 'image/jpeg', + }; + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'test', Key: '中文' }); + expect(imageRequestInfo).toEqual(expectedResult); + }); +}); 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/image/aws_logo.png b/source/image-handler/test/image/aws_logo.png new file mode 100644 index 000000000..35b3357f1 Binary files /dev/null and b/source/image-handler/test/image/aws_logo.png differ diff --git a/source/image-handler/test/image/transparent-10x10.jpeg b/source/image-handler/test/image/transparent-10x10.jpeg new file mode 100644 index 000000000..887d98614 Binary files /dev/null and b/source/image-handler/test/image/transparent-10x10.jpeg differ diff --git a/source/image-handler/test/image/transparent-10x10.png b/source/image-handler/test/image/transparent-10x10.png new file mode 100644 index 000000000..cb3dccd30 Binary files /dev/null and b/source/image-handler/test/image/transparent-10x10.png differ diff --git a/source/image-handler/test/image/transparent-10x5.jpeg b/source/image-handler/test/image/transparent-10x5.jpeg new file mode 100644 index 000000000..9c876ce9d Binary files /dev/null and b/source/image-handler/test/image/transparent-10x5.jpeg differ diff --git a/source/image-handler/test/image/transparent-10x5.png b/source/image-handler/test/image/transparent-10x5.png new file mode 100644 index 000000000..11da330c2 Binary files /dev/null and b/source/image-handler/test/image/transparent-10x5.png differ diff --git a/source/image-handler/test/image/transparent-5x10.jpeg b/source/image-handler/test/image/transparent-5x10.jpeg new file mode 100644 index 000000000..0cf7ce98a Binary files /dev/null and b/source/image-handler/test/image/transparent-5x10.jpeg differ diff --git a/source/image-handler/test/image/transparent-5x10.png b/source/image-handler/test/image/transparent-5x10.png new file mode 100644 index 000000000..4c886d35d Binary files /dev/null and b/source/image-handler/test/image/transparent-5x10.png differ diff --git a/source/image-handler/test/image/transparent-5x5-2page.gif b/source/image-handler/test/image/transparent-5x5-2page.gif new file mode 100644 index 000000000..465cb2ba2 Binary files /dev/null and b/source/image-handler/test/image/transparent-5x5-2page.gif differ diff --git a/source/image-handler/test/image/transparent-5x5.jpeg b/source/image-handler/test/image/transparent-5x5.jpeg new file mode 100644 index 000000000..43ff2df88 Binary files /dev/null and b/source/image-handler/test/image/transparent-5x5.jpeg differ diff --git a/source/image-handler/test/image/transparent-5x5.png b/source/image-handler/test/image/transparent-5x5.png new file mode 100644 index 000000000..daf6c4a84 Binary files /dev/null and b/source/image-handler/test/image/transparent-5x5.png differ diff --git a/source/image-handler/test/index.spec.js b/source/image-handler/test/index.spec.js deleted file mode 100644 index db0e19c3b..000000000 --- a/source/image-handler/test/index.spec.js +++ /dev/null @@ -1,407 +0,0 @@ -// 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'); - -describe('index', function() { - // Arrange - process.env.SOURCE_BUCKETS = 'source-bucket'; - const mockImage = Buffer.from('SampleImageContent\n'); - const mockFallbackImage = Buffer.from('SampleFallbackImageContent\n'); - - describe('TC: Success', function() { - beforeEach(() => { - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ - Body: mockImage, - ContentType: 'image/jpeg' - }); - } - }; - }); - }) - - it('001/should return the image when there is no error', async function() { - // Arrange - const event = { - path: '/test.jpg' - }; - // Act - const result = await index.handler(event); - const expectedResult = { - statusCode: 200, - 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, - 'Cache-Control': 'max-age=31536000,public', - 'Last-Modified': undefined - }, - body: mockImage.toString('base64') - }; - // Assert - expect(mockS3).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() { - // Arrange - const event = { - path: '/eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidGVzdC5qcGciLCJoZWFkZXJzIjp7IkN1c3RvbS1IZWFkZXIiOiJDdXN0b21WYWx1ZSJ9fQ==' - }; - // Act - const result = await index.handler(event); - const expectedResult = { - statusCode: 200, - headers: { - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Allow-Credentials': true, - 'Content-Type': 'image/jpeg', - 'Expires': undefined, - 'Cache-Control': 'max-age=31536000,public', - 'Last-Modified': undefined, - 'Custom-Header': 'CustomValue' - }, - body: mockImage.toString('base64'), - isBase64Encoded: true - }; - // Assert - expect(mockS3).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() { - // Arrange - const event = { - path: '/test.jpg', - requestContext: { - elb: {} - } - }; - // Act - const result = await index.handler(event); - const expectedResult = { - statusCode: 200, - isBase64Encoded: true, - headers: { - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Content-Type': 'image/jpeg', - '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(result).toEqual(expectedResult); - }); - }); - - describe('TC: Error', function() { - it('001/should return an error JSON when an error occurs', async function() { - // Arrange - const event = { - path: '/test.jpg' - }; - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: '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-Credentials': true, - '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); - }); - it('002/should return 500 error when there is no error status in the error', async function() { - // Arrange - const event = { - path: 'eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidGVzdC5qcGciLCJlZGl0cyI6eyJ3cm9uZ0ZpbHRlciI6dHJ1ZX19' - }; - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ - Body: mockImage, - ContentType: 'image/jpeg' - }); - } - }; - }); - // Act - const result = await index.handler(event); - const expectedResult = { - statusCode: 500, - isBase64Encoded: false, - headers: { - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Allow-Credentials': true, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - message: 'Internal error. Please contact the system administrator.', - code: 'InternalError', - status: 500 - }) - }; - // Assert - expect(mockS3).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() { - // 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 = { - path: '/test.jpg' - }; - // Mock - mockS3.mockReset(); - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject('UnknownError'); - } - }; - }).mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ - Body: mockFallbackImage, - ContentType: 'image/png' - }); - } - }; - }); - // Act - const result = await index.handler(event); - const expectedResult = { - statusCode: 500, - isBase64Encoded: true, - headers: { - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Allow-Credentials': true, - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'image/png', - 'Cache-Control': 'max-age=31536000,public', - 'Last-Modified': undefined - }, - 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(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() { - // Arrange - const event = { - path: '/test.jpg' - }; - // Mock - mockS3.mockReset(); - mockS3.mockImplementation(() => { - return { - promise() { - return Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: '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-Credentials': true, - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - status: 404, - code: 'NoSuchKey', - message: 'NoSuchKey error happened.' - }) - }; - // Assert - expect(mockS3).toHaveBeenNthCalledWith(1, { Bucket: 'source-bucket', Key: 'test.jpg' }); - expect(mockS3).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() { - // Arrange - process.env.DEFAULT_FALLBACK_IMAGE_KEY = ''; - const event = { - path: '/test.jpg' - }; - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: '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-Credentials': true, - '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); - }); - 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() { - // Arrange - process.env.DEFAULT_FALLBACK_IMAGE_BUCKET = ''; - const event = { - path: '/test.jpg' - }; - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: '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-Credentials': true, - '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); - }); - }); - it('007/should return an error JSON when ALB request is failed', async function() { - // Arrange - const event = { - path: '/test.jpg', - requestContext: { - elb: {} - } - }; - // Mock - mockS3.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject({ - code: 'NoSuchKey', - status: 404, - message: '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); - }); -}); \ No newline at end of file diff --git a/source/image-handler/test/index.spec.ts b/source/image-handler/test/index.spec.ts new file mode 100644 index 000000000..eb986119e --- /dev/null +++ b/source/image-handler/test/index.spec.ts @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { handler } from '../src'; +import { ImageHandlerError, StatusCodes } from '../src/lib'; +import { build_event } from './helpers'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { sampleImageStream } from './mock'; + +describe('index', () => { + // Arrange + process.env.SOURCE_BUCKETS = 'source-bucket'; + const mockS3Client = mockClient(S3Client); + + beforeEach(() => { + mockS3Client.reset(); + }); + + it('should return the image when there is no error', async () => { + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sampleImageStream(), ContentType: 'image/jpeg' }); + + // Arrange + const event = build_event({ rawPath: '/test.jpg' }); + + // Act + const result = await handler(event); + const expectedResult = { + statusCode: StatusCodes.OK, + isBase64Encoded: true, + headers: { + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'max-age=31536000, immutable', + Expires: undefined, + 'Last-Modified': undefined, + }, + body: '/9j/4QC8RXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAAABgAAkAcABAAAADAyMTABkQcABAAAAAECAwAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAAAEAAAADoAQAAQAAAAEAAAAAAAAA/+IB8ElDQ19QUk9GSUxFAAEBAAAB4GxjbXMEIAAAbW50clJHQiBYWVogB+IAAwAUAAkADgAdYWNzcE1TRlQAAAAAc2F3c2N0cmwAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1oYW5keem/Vlo+AbaDI4VVRvdPqgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKZGVzYwAAAPwAAAAkY3BydAAAASAAAAAid3RwdAAAAUQAAAAUY2hhZAAAAVgAAAAsclhZWgAAAYQAAAAUZ1hZWgAAAZgAAAAUYlhZWgAAAawAAAAUclRSQwAAAcAAAAAgZ1RSQwAAAcAAAAAgYlRSQwAAAcAAAAAgbWx1YwAAAAAAAAABAAAADGVuVVMAAAAIAAAAHABzAFIARwBCbWx1YwAAAAAAAAABAAAADGVuVVMAAAAGAAAAHABDAEMAMAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDD8AAAXd///zJgAAB5AAAP2S///7of///aIAAAPcAADAcVhZWiAAAAAAAABvoAAAOPIAAAOPWFlaIAAAAAAAAGKWAAC3iQAAGNpYWVogAAAAAAAAJKAAAA+FAAC2xHBhcmEAAAAAAAMAAAACZmkAAPKnAAANWQAAE9AAAApb/9sAQwAGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/9sAQwEGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/8IAEQgAAQABAwEiAAIRAQMRAf/EABUAAQEAAAAAAAAAAAAAAAAAAAAH/8QAFQEBAQAAAAAAAAAAAAAAAAAABQf/2gAMAwEAAhADEAAAAIOA6p//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/AH//2Q==', + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'source-bucket', + Key: 'test.jpg', + }); + expect(result).toEqual(expectedResult); + }); + + it('should return an error JSON when an error occurs', async () => { + // Arrange + const event = build_event({ rawPath: '/test.jpg' }); + // Mock + mockS3Client + .on(GetObjectCommand) + .rejects(new ImageHandlerError(StatusCodes.NOT_FOUND, 'NoSuchKey', 'NoSuchKey error happened.')); + + // Act + 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', + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=3600, immutable', + }, + 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(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'source-bucket', + Key: 'test.jpg', + }); + expect(result).toEqual(expectedResult); + }); + + it('should respond with http/410 GONE for expired content', async () => { + // Arrange + const event = build_event({ rawPath: '/test.webp' }); + // Mock + mockS3Client + .on(GetObjectCommand) + .resolves({ Expires: new Date('1970-01-01'), Body: sampleImageStream(), ContentType: 'image/webp' }); + + // Act + const result = await handler(event); + const expectedResult = { + statusCode: StatusCodes.GONE, + isBase64Encoded: false, + headers: { + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=31536000, immutable', + }, + body: JSON.stringify({ + message: `HTTP/410. Content test.webp has expired.`, + code: 'Gone', + status: StatusCodes.GONE, + }), + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'source-bucket', + Key: 'test.webp', + }); + expect(result).toEqual(expectedResult); + }); + + it('should respond with http/400 BAD REQUEST when out of bounds', async () => { + // Arrange + const event = build_event({ rawPath: '/0x0:9999x9999/test.webp' }); + // Mock + mockS3Client.on(GetObjectCommand).resolves({ Body: sampleImageStream(), ContentType: 'image/webp' }); + + // Act + const result = await handler(event); + const expectedResult = { + statusCode: StatusCodes.BAD_REQUEST, + isBase64Encoded: false, + headers: { + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=3600, immutable', + }, + body: JSON.stringify({ + code: 'Crop::AreaOutOfBounds', + message: `The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.`, + status: StatusCodes.BAD_REQUEST, + }), + }; + + // Assert + expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'source-bucket', + Key: 'test.webp', + }); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/source/image-handler/test/mock.ts b/source/image-handler/test/mock.ts new file mode 100644 index 000000000..0b18d91e6 --- /dev/null +++ b/source/image-handler/test/mock.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Readable } from 'stream'; +import { sdkStreamMixin } from '@smithy/util-stream'; +import { createReadStream } from 'fs'; + +export function sdkStreamFromString(body: any): any { + // create Stream from string + const stream = new Readable(); + stream.push(body); + stream.push(null); // end of stream + + // wrap the Stream with SDK mixin + return sdkStreamMixin(stream); +} + +function sdkStreamFromBase64String(data: string) { + let iterable = Buffer.from(data, 'base64'); + return sdkStreamFromString(iterable); +} +export const sample_image_base64: string = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +export function sampleImageStream(): any { + return sdkStreamFromBase64String(sample_image_base64); +} + +export const consoleInfoSpy = jest.spyOn(console, 'info'); diff --git a/source/image-handler/test/thumbor-mapper/crop.spec.ts b/source/image-handler/test/thumbor-mapper/crop.spec.ts new file mode 100644 index 000000000..45487e083 --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/crop.spec.ts @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ThumborMapper } from '../../src/thumbor-mapper'; + +describe('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: 100, 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: 100, height: 200 }, + resize: { width: 10, height: 20 }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); +}); diff --git a/source/image-handler/test/thumbor-mapper/edits.spec.ts b/source/image-handler/test/thumbor-mapper/edits.spec.ts new file mode 100644 index 000000000..c870ffbd3 --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/edits.spec.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ThumborMapper } from '../../src/thumbor-mapper'; + +describe('edits', () => { + it('Should pass if filters are chained', () => { + const path = '/filters:rotate(90):grayscale()/thumbor-image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass if filters are not chained', () => { + const path = '/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass if filters are both chained and individual', () => { + const path = '/filters:rotate(90):grayscale()/filters:blur(20)/thumbor-image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + blur: 10, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass even if there are slashes in the filter', () => { + const path = '/filters:watermark(bucket,folder/key.png,0,0)/image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + overlayWith: { + alpha: undefined, + bucket: 'bucket', + hRatio: undefined, + key: 'folder/key.png', + options: { + left: '0', + top: '0', + }, + wRatio: undefined, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); +}); diff --git a/source/image-handler/test/thumbor-mapper/filter.spec.ts b/source/image-handler/test/thumbor-mapper/filter.spec.ts new file mode 100644 index 000000000..71da7a219 --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/filter.spec.ts @@ -0,0 +1,770 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ImageEdits, ImageFormatTypes } from '../../src/lib'; +import { ThumborMapper } from '../../src/thumbor-mapper'; + +describe('filter', () => { + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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(); + expect(edits.resize.ratio).toEqual(0.3); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + it('Should pass if the filter is successfully translated from Thumbor:quality()', () => { + // Arrange + const edit = 'filters:quality(50)'; + const filetype = ImageFormatTypes.AVIF; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapFilter(edit, filetype); + + // Assert + const expectedResult = { avif: { quality: 50 } }; + expect(edits).toEqual(expectedResult); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + it('Should pass for fit-in combined with watermark in folder', () => { + // watermark params: bucket, key, xPos, yPos, alpha, wRatio, hRatio + const path = '/fit-in/400x400/filters:watermark(bucket,folder/key.png,0,0)/image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: 400, + fit: 'inside', + }, + overlayWith: { + bucket: 'bucket', + key: 'folder/key.png', + alpha: undefined, + wRatio: undefined, + hRatio: undefined, + options: { + left: '0', + top: '0', + }, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass for fit-in combined with watermark not in folder', () => { + // watermark params: bucket, key, xPos, yPos, alpha, wRatio, hRatio + const path = '/fit-in/400x400/filters:watermark(bucket,key.png,0,0)/image.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + resize: { + width: 400, + height: 400, + fit: 'inside', + }, + overlayWith: { + bucket: 'bucket', + key: 'key.png', + alpha: undefined, + wRatio: undefined, + hRatio: undefined, + options: { + left: '0', + top: '0', + }, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass if false is interpreted as non-animated', () => { + // Arrange + const path = '/filters:animated(fAlSe)/test-image-001.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: false, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass if empty value is interpreted as animated', () => { + // Arrange + const path = '/filters:animated()/test-image-001.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it('Should pass if non-false value is interpreted as animated', () => { + // Arrange + const path = '/filters:animated(ABCDEF)/test-image-001.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); +}); diff --git a/source/image-handler/test/thumbor-mapper/mappings.spec.ts b/source/image-handler/test/thumbor-mapper/mappings.spec.ts new file mode 100644 index 000000000..2c41b1ff6 --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/mappings.spec.ts @@ -0,0 +1,353 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ThumborMapper } from '../../src/thumbor-mapper'; +import { ImageFitTypes, ImageFormatTypes } from '../../src/lib'; + +describe('mapBGColor', () => { + it('Should map background rgb color with color object when color name string provided', () => { + // Arrange + const color = 'red'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapBGColor'](color, currentEdits); + + // Assert + expect(currentEdits.flatten).toEqual(expect.objectContaining({ background: { r: 255, g: 0, b: 0 } })); + }); + + it('Should map background rgb color with color object when color hex value', () => { + // Arrange + const color = 'FF0000'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapBGColor'](color, currentEdits); + + // Assert + expect(currentEdits.flatten).toEqual(expect.objectContaining({ background: { r: 255, g: 0, b: 0 } })); + }); +}); + +describe('mapBlur', () => { + it('Should map sigma value the blur value when sigma can be converted to number', () => { + // Arrange + const blurValue = '50,20'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapBlur'](blurValue, currentEdits); + + // Assert + expect(currentEdits.blur).toEqual(20); + }); + + it('Should map radius / 2 to the blur value when sigma can not be converted to number', () => { + // Arrange + const blurValue = '50'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapBlur'](blurValue, currentEdits); + + // Assert + expect(currentEdits.blur).toEqual(25); + }); +}); + +describe('mapConvolution', () => { + it('Should map the convolution matrix to the current edits', () => { + // Arrange + const convolutionValue = '1;2;3;4;5;6,2'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapConvolution'](convolutionValue, currentEdits); + + // Assert + expect(currentEdits.convolve).toEqual( + expect.objectContaining({ + height: 3, + width: 2, + kernel: [1, 2, 3, 4, 5, 6], + }), + ); + }); +}); + +describe('mapFill', () => { + it('Should map resize fit and fill background rgb color with color object when color name string provided', () => { + // Arrange + const color = 'red'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapFill'](color, currentEdits); + + // Assert + expect(currentEdits.resize).toEqual( + expect.objectContaining({ + fit: 'contain', + background: { r: 255, g: 0, b: 0 }, + }), + ); + }); + + it('Should map resize fit fill background rgb color with color object when color hex value', () => { + // Arrange + const color = 'FF0000'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapFill'](color, currentEdits); + + // Assert + expect(currentEdits.resize).toEqual( + expect.objectContaining({ + fit: 'contain', + background: { r: 255, g: 0, b: 0 }, + }), + ); + }); +}); + +describe('mapFormat', () => { + it('Should map the format value when it is an accepted format value', () => { + // Arrange + const formatType = 'png'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapFormat'](formatType, currentEdits); + + // Assert + expect(currentEdits.toFormat).toEqual(formatType); + }); + + it('Should map the format as jpeg when it is jpg', () => { + // Arrange + const formatType = 'jpg'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapFormat'](formatType, currentEdits); + + // Assert + expect(currentEdits.toFormat).toEqual('jpeg'); + }); + + it('Should not map the format as jpeg when it is not an accepted value', () => { + // Arrange + const formatType = 'pdf'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapFormat'](formatType, currentEdits); + + // Assert + expect(currentEdits.toFormat).toEqual(undefined); + }); +}); + +describe('mapNoUpscale', () => { + it('Should map resize without enlargement', () => { + // Arrange + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapNoUpscale'](currentEdits); + + // Assert + expect(currentEdits.resize.withoutEnlargement).toEqual(true); + }); +}); + +describe('mapResizeRatio', () => { + it('Should replace width and height proportionally if they already exist', () => { + // Arrange + const ratioValue = '0.5'; + const currentEdits: Record = { resize: { width: 10, height: 10 } }; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapResizeRatio'](ratioValue, currentEdits); + + // Assert + expect(currentEdits.resize).toEqual( + expect.objectContaining({ + width: 5, + height: 5, + }), + ); + }); + + it('Should map resize ratio if width and height are not defined', () => { + // Arrange + const ratioValue = '0.5'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapResizeRatio'](ratioValue, currentEdits); + + // Assert + expect(currentEdits.resize.ratio).toEqual(Number(ratioValue)); + }); +}); + +describe('mapQuality', () => { + it('Should map format and quality when it is a valid format', () => { + // Arrange + const qualityValue = '90'; + const formatType = ImageFormatTypes.JPG; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapQuality'](qualityValue, currentEdits, formatType); + + // Assert + expect(currentEdits.jpeg).toEqual({ quality: 90 }); + }); + + it('Should not map format and quality when it is not a valid format', () => { + // Arrange + const qualityValue = '90'; + const formatType = 'pdf'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapQuality'](qualityValue, currentEdits, formatType as ImageFormatTypes); + + // Assert + expect(currentEdits.jpg).toEqual(undefined); + }); +}); + +describe('mapStretch', () => { + it('Should map resize fit to fill if not already set to inside', () => { + // Arrange + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapStretch'](currentEdits); + + // Assert + expect(currentEdits.resize.fit).toEqual(ImageFitTypes.FILL); + }); + + it('Should not map resize fit to fill if it is already set to inside', () => { + // Arrange + const currentEdits: Record = { resize: { fit: ImageFitTypes.INSIDE } }; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapStretch'](currentEdits); + + // Assert + expect(currentEdits.resize.fit).toEqual(ImageFitTypes.INSIDE); + }); +}); + +describe('mapUpscale', () => { + it('Should map resize fit to inside', () => { + // Arrange + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapUpscale'](currentEdits); + + // Assert + expect(currentEdits.resize.fit).toEqual(ImageFitTypes.INSIDE); + }); +}); + +describe('mapWatermark', () => { + it('Should map overlayWith values with provided values if x and y pos are valid', () => { + // Arrange + const overlayValues = 'bucket, key, 10, -20, 50, 30, 40'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapWatermark'](overlayValues, currentEdits); + + // Assert + expect(currentEdits.overlayWith).toEqual( + expect.objectContaining({ + bucket: 'bucket', + key: 'key', + alpha: '50', + wRatio: '30', + hRatio: '40', + options: { + left: '10', + top: '-20', + }, + }), + ); + }); + + it('Should map overlayWith values without left option if x pos is invalid', () => { + // Arrange + const overlayValues = 'bucket, key, invalid, -20, 50, 30, 40'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapWatermark'](overlayValues, currentEdits); + + // Assert + expect(currentEdits.overlayWith).toEqual( + expect.objectContaining({ + bucket: 'bucket', + key: 'key', + alpha: '50', + wRatio: '30', + hRatio: '40', + options: { + top: '-20', + }, + }), + ); + }); + + it('Should map overlayWith values without top option if ypos is invalid', () => { + // Arrange + const overlayValues = 'bucket, key, 10, invalid, 50, 30, 40'; + const currentEdits: Record = {}; + const thumborMapper = new ThumborMapper(); + + // Act + thumborMapper['mapWatermark'](overlayValues, currentEdits); + + // Assert + expect(currentEdits.overlayWith).toEqual( + expect.objectContaining({ + bucket: 'bucket', + key: 'key', + alpha: '50', + wRatio: '30', + hRatio: '40', + options: { + left: '10', + }, + }), + ); + }); +}); diff --git a/source/image-handler/test/thumbor-mapper/parse.spec.ts b/source/image-handler/test/thumbor-mapper/parse.spec.ts new file mode 100644 index 000000000..b536d61a5 --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/parse.spec.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ThumborMapper } from '../../src/thumbor-mapper'; + +describe('parse', () => { + const OLD_ENV = { + REWRITE_MATCH_PATTERN: '/(filters-)/gm', + REWRITE_SUBSTITUTION: 'filters:', + }; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterEach(() => { + process.env = OLD_ENV; + }); + + 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); + }); + + 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')); + }); + + 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')); + }); + + 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')); + }); +}); diff --git a/source/image-handler/test/thumbor-mapper/resize.spec.ts b/source/image-handler/test/thumbor-mapper/resize.spec.ts new file mode 100644 index 000000000..9cc590aba --- /dev/null +++ b/source/image-handler/test/thumbor-mapper/resize.spec.ts @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ThumborMapper } from '../../src/thumbor-mapper'; + +describe('resize', () => { + it("Should replace next_data's template /__WIDTH__x0/ with an actual resolution of /1200x0/", () => { + // Arrange + const path = '/fit-in/__WIDTH__x0/test-image-001.jpg'; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { resize: { width: 1200, height: null, fit: 'inside' } }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); +}); 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-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..99e10715c --- /dev/null +++ b/source/image-handler/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 18", + + "_version": "18.2.0", + + "compilerOptions": { + "lib": ["es2023"], + "module": "commonjs", + "target": "es2022", + + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16", + "isolatedModules": true, + + "types": ["jest", "node"] + } +} \ No newline at end of file diff --git a/source/image-handler/tsup.config.ts b/source/image-handler/tsup.config.ts new file mode 100644 index 000000000..44e92f73b --- /dev/null +++ b/source/image-handler/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + // external: @aws-sdk/* is provided by AWS Lambda Node Runtime + external: ['@aws-sdk/client-s3'] +})