├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── general_question.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── SECURITY.md ├── VERSION.txt ├── architecture.png ├── default_architecture.png ├── deployment ├── build-s3-dist.sh ├── cdk-solution-helper │ ├── README.md │ ├── asset-packager │ │ ├── __tests__ │ │ │ ├── asset-packager.test.ts │ │ │ └── handler.test.ts │ │ ├── asset-packager.ts │ │ └── index.ts │ ├── jest.config.ts │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json └── run-unit-tests.sh ├── object_lambda_architecture.png └── source ├── .eslintignore ├── .eslintrc.json ├── .prettierrc.yml ├── constructs ├── .gitignore ├── .npmignore ├── bin │ └── constructs.ts ├── cdk.json ├── jest.config.js ├── lib │ ├── back-end │ │ ├── api-gateway-architecture.ts │ │ ├── back-end-construct.ts │ │ ├── s3-object-lambda-architecture.ts │ │ └── s3-object-lambda-origin.ts │ ├── common-resources │ │ ├── common-resources-construct.ts │ │ └── custom-resources │ │ │ └── custom-resource-construct.ts │ ├── dashboard │ │ ├── ops-insights-dashboard.ts │ │ ├── sih-metrics.ts │ │ └── widgets.ts │ ├── front-end │ │ └── front-end-construct.ts │ ├── serverless-image-stack.ts │ └── types.ts ├── package-lock.json ├── package.json ├── test │ ├── __snapshots__ │ │ └── constructs.test.ts.snap │ └── constructs.test.ts ├── tsconfig.json └── utils │ ├── aspects.ts │ └── utils.ts ├── custom-resource ├── index.ts ├── jest.config.js ├── lib │ ├── enums.ts │ ├── index.ts │ ├── interfaces.ts │ └── types.ts ├── package-lock.json ├── package.json ├── test │ ├── check-fallback-image.spec.ts │ ├── check-secrets-manager.spec.ts │ ├── check-source-buckets.spec.ts │ ├── create-logging-bucket.spec.ts │ ├── create-uuid.spec.ts │ ├── default.spec.ts │ ├── get-app-reg-application-name.spec.ts │ ├── mock.ts │ ├── put-config-file.spec.ts │ ├── send-anonymous-metric.spec.ts │ ├── setJestEnvironmentVariables.ts │ └── validate-existing-distribution.spec.ts └── tsconfig.json ├── demo-ui ├── demo-ui-manifest.json ├── index.html ├── package-lock.json ├── package.json ├── scripts.js └── style.css ├── image-handler ├── cloudfront-function-handlers │ ├── apig-request-modifier.js │ ├── ol-request-modifier.js │ └── ol-response-modifier.js ├── image-handler.ts ├── image-request.ts ├── index.ts ├── jest.config.js ├── lib │ ├── constants.ts │ ├── enums.ts │ ├── index.ts │ ├── interfaces.ts │ └── types.ts ├── package-lock.json ├── package.json ├── query-param-mapper.ts ├── secret-provider.ts ├── test │ ├── cloudfront-function-handlers │ │ ├── apig-request-modifier.spec.ts │ │ ├── ol-request-modifier.spec.ts │ │ └── ol-response-modifier.spec.ts │ ├── event-normalizer.spec.ts │ ├── image-handler │ │ ├── allowlist.spec.ts │ │ ├── animated.spec.ts │ │ ├── content-moderation.spec.ts │ │ ├── crop.spec.ts │ │ ├── error-response.spec.ts │ │ ├── format.spec.ts │ │ ├── limit-input-pixels.spec.ts │ │ ├── limits.spec.ts │ │ ├── overlay.spec.ts │ │ ├── query-param-mapper.spec.ts │ │ ├── resize.spec.ts │ │ ├── rotate.spec.ts │ │ ├── round-crop.spec.ts │ │ ├── smart-crop.spec.ts │ │ └── standard.spec.ts │ ├── image-request │ │ ├── decode-request.spec.ts │ │ ├── determine-output-format.spec.ts │ │ ├── fix-quality.spec.ts │ │ ├── get-allowed-source-buckets.spec.ts │ │ ├── get-original-image.spec.ts │ │ ├── get-output-format.spec.ts │ │ ├── infer-image-type.spec.ts │ │ ├── parse-image-bucket.spec.ts │ │ ├── parse-image-edits.spec.ts │ │ ├── parse-image-headers.spec.ts │ │ ├── parse-image-key.spec.ts │ │ ├── parse-query-param-edits.spec.ts │ │ ├── parse-request-type.spec.ts │ │ ├── recreate-query-string.spec.ts │ │ └── setup.spec.ts │ ├── image │ │ ├── 1x1.jpg │ │ ├── 25x15.png │ │ ├── aws_logo.png │ │ ├── transparent-10x10.jpeg │ │ ├── transparent-10x10.png │ │ ├── transparent-10x5.jpeg │ │ ├── transparent-10x5.png │ │ ├── transparent-5x10.jpeg │ │ ├── transparent-5x10.png │ │ ├── transparent-5x5-2page.gif │ │ ├── transparent-5x5.jpeg │ │ └── transparent-5x5.png │ ├── index.spec.ts │ ├── mock.ts │ ├── secret-provider.spec.ts │ ├── setJestEnvironmentVariables.ts │ └── thumbor-mapper │ │ ├── crop.spec.ts │ │ ├── edits.spec.ts │ │ ├── filter.spec.ts │ │ ├── mappings.spec.ts │ │ ├── parse.spec.ts │ │ └── resize.spec.ts ├── thumbor-mapper.ts └── tsconfig.json ├── metrics-utils ├── .npmignore ├── index.ts ├── jest.config.js ├── lambda │ ├── helpers │ │ ├── client-helper.ts │ │ ├── metrics-helper.ts │ │ └── types.ts │ └── index.ts ├── lib │ ├── query-builders.ts │ └── solutions-metrics.ts ├── package-lock.json ├── package.json ├── test │ ├── lambda │ │ ├── helpers │ │ │ ├── client-helper.spec.ts │ │ │ └── metrics-helper.spec.ts │ │ └── index.spec.ts │ └── lib │ │ └── solutions-metrics.spec.ts └── tsconfig.json ├── package-lock.json ├── package.json └── solution-utils ├── get-options.ts ├── helpers.ts ├── jest.config.js ├── logger.ts ├── package-lock.json ├── package.json ├── test ├── get-options.test.ts └── setJestEnvironmentVariables.ts └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/workflows/ @aws-solutions/sb-csne -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **Please complete the following information about the solution:** 20 | - [ ] Version: [e.g. v1.0.0] 21 | - [ ] Region: [e.g. us-east-1] 22 | - [ ] Was the solution modified from the version published on this repository? 23 | - [ ] If the answer to the previous question was yes, are the changes available on GitHub? 24 | - [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? 25 | - [ ] Were there any errors in the CloudWatch Logs? 26 | 27 | **Screenshots** 28 | 29 | 30 | **Additional context** 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this solution 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the feature you'd like** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General question 3 | about: Ask a general question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is your question?** 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Issue #, if available:** 2 | 3 | 4 | 5 | 6 | **Description of changes:** 7 | 8 | 9 | 10 | **Checklist** 11 | - [ ] :wave: I have added unit tests for all code changes. 12 | - [ ] :wave: I have run the unit tests, and all unit tests have passed. 13 | - [ ] :warning: This pull request might incur a breaking change. 14 | 15 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | **/dist 3 | **/global-s3-assets 4 | **/regional-s3-assets 5 | **/staging 6 | **/open-source 7 | **/.zip 8 | **/tmp 9 | **/out-tsc 10 | 11 | # dependencies 12 | **/node_modules 13 | **/modules 14 | 15 | # test assets 16 | **/coverage 17 | **/coverage-reports 18 | **/.nyc_output 19 | **/mock-* 20 | 21 | # misc 22 | **/npm-debug.log 23 | **/testem.log 24 | **/.vscode/settings.json 25 | demo-ui-config.js 26 | 27 | # System Files 28 | **/.DS_Store 29 | **/.vscode 30 | **/.idea 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | ## Reporting Bugs/Feature Requests 10 | 11 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 12 | 13 | When filing an issue, please check [existing open](https://github.com/aws-solutions/serverless-image-handler/issues), or [recently closed](https://github.com/aws-solutions/serverless-image-handler/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 14 | 15 | - A reproducible test case or series of steps 16 | - The version of our code being used 17 | - The region being used 18 | - Any modifications you've made relevant to the bug 19 | - Anything unusual about your environment or deployment 20 | 21 | ## Contributing via Pull Requests 22 | 23 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 24 | 25 | 1. You are working against the latest source on the _main_ branch. 26 | 1. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 27 | 1. You open an issue to discuss any significant work - we would hate for your time to be wasted. 28 | 29 | To send us a pull request, please: 30 | 31 | 1. Fork the repository. 32 | 1. Modify the source; please focus on the specific change you are contributing. 33 | 1. Add new unit tests for the new code. 34 | 1. Run _npx npm run prettier-format_ in _source_ to ensure that code format standards are maintained. 35 | 1. If your changes include new capabilities, include in the PR description text that can be folded into the solution documentation. 36 | 1. Commit to your fork using clear commit messages. 37 | 1. In your repository _Security_ section, ensure that security advisories are enabled and address any Dependabot issues that appear. 38 | 1. Send us a pull request, answering any default questions in the pull request interface. 39 | 1. If the changes are complex or may involve additional communication, we may create a feature branch specific to your PR and ask you to rebase using that branch. 40 | 41 | GitHub provides additional documentation 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/). 42 | 43 | ## Finding contributions to work on 44 | 45 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-solutions/serverless-image-handler/labels/help%20wanted) issues is a great place to start. 46 | 47 | ## Code of Conduct 48 | 49 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 50 | 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. 51 | 52 | ## Security issue notifications 53 | 54 | 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. 55 | 56 | ## Licensing 57 | 58 | See the [LICENSE](https://github.com/aws-solutions/serverless-image-handler/blob/main/LICENSE.txt) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 59 | 60 | We may ask you to sign a [Contributor License Agreement (CLA)](https://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 61 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | We take all security reports seriously. 4 | When we receive such reports, 5 | we will investigate and subsequently address 6 | any potential vulnerabilities as quickly as possible. 7 | If you discover a potential security issue in this project, 8 | please notify AWS/Amazon Security via our 9 | [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) 10 | or directly via email to [AWS Security](mailto:aws-security@amazon.com). 11 | Please do *not* create a public GitHub issue in this project. -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | 7.0.3 2 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/architecture.png -------------------------------------------------------------------------------- /default_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/default_architecture.png -------------------------------------------------------------------------------- /deployment/build-s3-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # The script is for aws-solutions internal purposes only 3 | 4 | [ "$DEBUG" == 'true' ] && set -x 5 | set -e 6 | 7 | # Check to see if input has been provided: 8 | if [ -z "$SOLUTION_NAME" ]; then 9 | echo "Please provide the trademark approved solution name through environment variables" 10 | exit 1 11 | fi 12 | 13 | function headline(){ 14 | echo "------------------------------------------------------------------------------" 15 | echo "$1" 16 | echo "------------------------------------------------------------------------------" 17 | } 18 | 19 | headline "[Init] Setting up paths and variables" 20 | deployment_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 21 | staging_dist_dir="$deployment_dir/staging" 22 | template_dist_dir="$deployment_dir/global-s3-assets" 23 | build_dist_dir="$deployment_dir/regional-s3-assets" 24 | source_dir="$deployment_dir/../source" 25 | cdk_source_dir="$source_dir/constructs" 26 | 27 | headline "[Init] Clean old folders" 28 | rm -rf "$staging_dist_dir" 29 | mkdir -p "$staging_dist_dir" 30 | rm -rf "$template_dist_dir" 31 | mkdir -p "$template_dist_dir" 32 | rm -rf "$build_dist_dir" 33 | mkdir -p "$build_dist_dir" 34 | 35 | headline "[Init] Ensure package versions are updated" 36 | npm --prefix "$source_dir" run bump-version 37 | 38 | headline "[Build] Synthesize cdk template and assets" 39 | cd "$cdk_source_dir" 40 | npm run clean:install 41 | overrideWarningsEnabled=false npx cdk synth --quiet --asset-metadata false --path-metadata --output="$staging_dist_dir" 42 | cd "$staging_dist_dir" 43 | rm tree.json manifest.json cdk.out ./*.assets.json 44 | cp "$staging_dist_dir"/*.template.json "$template_dist_dir"/"$SOLUTION_NAME".template 45 | rm ./*.template.json 46 | 47 | headline "[Package] Generate public assets for lambda and ui" 48 | cd "$deployment_dir"/cdk-solution-helper/asset-packager && npm ci 49 | npx ts-node ./index "$staging_dist_dir" "$build_dist_dir" 50 | rm -rf $staging_dist_dir -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/README.md: -------------------------------------------------------------------------------- 1 | ## CDK Solution Helper 2 | 3 | **For aws-solutions internal purposes only** 4 | 5 | `cdk-solution-helper` runs on solution internal pipeline and makes needed artifact modifications to support 1-click deployment using solution CloudFormation template. 6 | Additionally, it packages templates and lambda binaries and prepares them to be staged on the solution buckets. -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/__tests__/asset-packager.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | const readdirMock = jest.fn(); 7 | const addLocalFolderMock = jest.fn(); 8 | const renameMock = jest.fn(); 9 | const writeZipMock = jest.fn(); 10 | const lstatMock = jest.fn(); 11 | import path from "path"; 12 | import { CDKAssetPackager } from "../asset-packager"; 13 | jest 14 | .mock("node:fs/promises", () => { 15 | const originalModule = jest.requireActual("node:fs/promises"); 16 | return { 17 | ...originalModule, 18 | __esModule: true, 19 | readdir: readdirMock, 20 | lstat: lstatMock, 21 | rename: renameMock, 22 | }; 23 | }) 24 | .mock("adm-zip", () => { 25 | const originalModule = jest.requireActual("adm-zip"); 26 | return { 27 | ...originalModule, 28 | __esModule: true, 29 | default: jest.fn(() => ({ 30 | addLocalFolder: addLocalFolderMock, 31 | writeZip: writeZipMock, 32 | })), 33 | }; 34 | }); 35 | 36 | const __assetPath = "/myTestPath"; 37 | const __outputPath = "/outputPath"; 38 | const assetPackager = new CDKAssetPackager(__assetPath); 39 | const __asset1 = "asset.1"; 40 | const __asset2 = "asset.2.zip"; 41 | 42 | describe("CDKAssetPackager", () => { 43 | describe("getAssetPaths", function () { 44 | beforeEach(function () { 45 | readdirMock.mockClear(); 46 | }); 47 | 48 | it("should return empty array for invalid path", async function () { 49 | readdirMock.mockRejectedValue("invalid path"); 50 | expect(await assetPackager.getAssetPaths()).toEqual([]); 51 | }); 52 | 53 | it("should return empty array when no assets found", async function () { 54 | readdirMock.mockResolvedValue([]); 55 | expect(await assetPackager.getAssetPaths()).toEqual([]); 56 | }); 57 | 58 | it("should return array of paths when assets found", async function () { 59 | readdirMock.mockResolvedValue([__asset1, __asset2]); 60 | expect(await assetPackager.getAssetPaths()).toEqual([ 61 | path.join(__assetPath, __asset1), 62 | path.join(__assetPath, __asset2), 63 | ]); 64 | }); 65 | }); 66 | 67 | describe("createAssetZip", function () { 68 | beforeEach(function () { 69 | readdirMock.mockClear(); 70 | lstatMock.mockClear(); 71 | addLocalFolderMock.mockClear(); 72 | writeZipMock.mockClear(); 73 | }); 74 | 75 | it("should skip doing anything if path not a folder", async function () { 76 | // Arrange 77 | lstatMock.mockResolvedValue({ 78 | isDirectory: () => false, 79 | }); 80 | 81 | // Act, Assert 82 | await expect(assetPackager.createAssetZip(__assetPath)).resolves.toBeUndefined(); 83 | expect(addLocalFolderMock).toBeCalledTimes(0); 84 | }); 85 | 86 | it("should zip assets in the folder for valid path", async function () { 87 | // Arrange 88 | lstatMock.mockResolvedValue({ 89 | isDirectory: () => true, 90 | }); 91 | addLocalFolderMock.mockResolvedValue(undefined); 92 | writeZipMock.mockResolvedValue(undefined); 93 | 94 | // Act, Assert 95 | await expect(assetPackager.createAssetZip(__asset1)).resolves.toBeUndefined(); 96 | expect(addLocalFolderMock).toBeCalledTimes(1); 97 | expect(writeZipMock).toBeCalledWith(`${path.join(__assetPath, __asset1)}.zip`); 98 | }); 99 | 100 | it("should throw error if error encountered", async function () { 101 | lstatMock.mockRejectedValue(new Error("error encountered")); 102 | await expect(assetPackager.createAssetZip("")).rejects.toThrowError("error encountered"); 103 | }); 104 | }); 105 | 106 | describe("moveZips", function () { 107 | beforeEach(function () { 108 | renameMock.mockClear(); 109 | readdirMock.mockClear(); 110 | }); 111 | 112 | it("should move zips for valid paths", async function () { 113 | readdirMock.mockResolvedValue([__asset2]); 114 | await assetPackager.moveZips(__outputPath); 115 | expect(renameMock).toBeCalledWith( 116 | path.join(__assetPath, __asset2), 117 | path.join(__outputPath, __asset2.split("asset.").pop()!) 118 | ); 119 | }); 120 | 121 | it("should throw error if error encountered", async function () { 122 | readdirMock.mockRejectedValue(new Error("error encountered")); 123 | await expect(assetPackager.moveZips("")).rejects.toThrowError("error encountered"); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/__tests__/handler.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import path from "path"; 7 | import { existsSync } from "fs"; 8 | import { mkdir, writeFile, rm } from "node:fs/promises"; 9 | import { handler } from "../index"; 10 | 11 | const __assetDirectoryPath = path.join(__dirname, "mock-dir"); 12 | const __outputPath = path.join(__dirname, "mock-dir-output"); 13 | describe("Handler", () => { 14 | beforeAll(async function Arrange() { 15 | await rm(__assetDirectoryPath, { recursive: true, force: true }); 16 | await rm(__outputPath, { recursive: true, force: true }); 17 | await mkdir(__assetDirectoryPath); 18 | await mkdir(__outputPath); 19 | }); 20 | 21 | it("should fail in absence of path inputs ", async function () { 22 | expect.assertions(2); 23 | await expect(handler("", "")).rejects.toThrowError("undefined input path"); 24 | await expect(handler(undefined, undefined)).rejects.toThrowError("undefined input path"); 25 | }); 26 | 27 | it("should fail for invalid cdk asset path", async function () { 28 | expect.assertions(1); 29 | await expect(handler("invalidPath", __outputPath)).rejects.toThrowError(/(ENOENT).+(invalidPath)/g); 30 | }); 31 | 32 | it("should succeed if cdk assets not found", async function () { 33 | await expect(handler(__assetDirectoryPath, "invalidPath")).resolves.toBeUndefined(); 34 | }); 35 | 36 | it("should fail for invalid output path", async function () { 37 | // Arrange 38 | expect.assertions(1); 39 | const mockAssetPath = path.join(__assetDirectoryPath, "./asset.cdkAsset.zip"); 40 | await writeFile(mockAssetPath, "NoOp"); 41 | // Act, Assert 42 | await expect(handler(__assetDirectoryPath, "invalidPath")).rejects.toThrowError(/(ENOENT).+(invalidPath)/g); 43 | // Cleanup 44 | await rm(mockAssetPath); 45 | }); 46 | 47 | it("should successfully stage zip for valid paths", async function () { 48 | const zipName = "asset.cdkAsset.zip"; 49 | const mockAssetPath = path.join(__assetDirectoryPath, zipName); 50 | await writeFile(mockAssetPath, "NoOp"); 51 | await expect(handler(__assetDirectoryPath, __outputPath)).resolves.toBeUndefined(); 52 | expect(existsSync(path.join(__outputPath, zipName.split("asset.").pop()!))).toBe(true); 53 | }); 54 | 55 | afterAll(async function Cleanup() { 56 | await rm(__assetDirectoryPath, { recursive: true, force: true }); 57 | await rm(__outputPath, { recursive: true, force: true }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/asset-packager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { readdir, lstat, rename } from "node:fs/promises"; 7 | import path from "path"; 8 | import AdmZip from "adm-zip"; 9 | 10 | /** 11 | * @description Class to help with packaging and staging cdk assets 12 | * on solution internal pipelines 13 | */ 14 | export class CDKAssetPackager { 15 | constructor(private readonly assetFolderPath: string) { } 16 | 17 | /** 18 | * @description get cdk asset paths 19 | * All cdk generated assets are prepended with "asset" 20 | */ 21 | async getAssetPaths() { 22 | try { 23 | const allFiles = await readdir(this.assetFolderPath); 24 | const assetFilePaths = allFiles 25 | .filter((file) => file.includes("asset")) 26 | .map((file) => path.join(this.assetFolderPath, file)); 27 | return assetFilePaths; 28 | } catch (err) { 29 | console.error(err); 30 | return []; 31 | } 32 | } 33 | 34 | /** 35 | * @description creates zip from folder 36 | * @param folderPath 37 | */ 38 | async createAssetZip(folderPath: string) { 39 | const isDir = (await lstat(folderPath)).isDirectory(); 40 | if (isDir) { 41 | const zip = new AdmZip(); 42 | zip.addLocalFolder(path.join(folderPath, "./")); 43 | const zipName = `${folderPath.split("/").pop()}.zip`; 44 | zip.writeZip(path.join(this.assetFolderPath, zipName)); 45 | } 46 | } 47 | 48 | /** 49 | * @description moves zips to staging output directory in internal pipelines 50 | * @param outputPath 51 | */ 52 | async moveZips(outputPath: string) { 53 | const allFiles = await readdir(this.assetFolderPath); 54 | const allZipPaths = allFiles.filter((file) => path.extname(file) === ".zip"); 55 | for (const zipPath of allZipPaths) { 56 | await rename(path.join(this.assetFolderPath, zipPath), path.join(outputPath, zipPath.split("asset.").pop()!)); 57 | // remove cdk prepended string "asset.*" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { CDKAssetPackager } from "./asset-packager"; 7 | 8 | export async function handler(cdkAssetFolderPath: string | undefined, outputPath: string | undefined) { 9 | if (!cdkAssetFolderPath || !outputPath) throw new Error("undefined input path"); 10 | const assetPackager = new CDKAssetPackager(cdkAssetFolderPath); 11 | const assetPaths = await assetPackager.getAssetPaths(); 12 | for (const path of assetPaths) { 13 | await assetPackager.createAssetZip(path); 14 | } 15 | await assetPackager.moveZips(outputPath); 16 | } 17 | 18 | if (require.main === module) { 19 | // this module was run directly from the command line, getting command line arguments 20 | // e.g. npx ts-node index.ts cdkAssetPath outputPath 21 | const cdkAssetPath = process.argv[2]; 22 | const outputPath = process.argv[3]; 23 | handler(cdkAssetPath, outputPath) 24 | .then(() => console.log("all assets packaged")) 25 | .catch((err) => { 26 | console.error(err); 27 | throw err; 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import type { Config } from "jest"; 7 | 8 | const config: Config = { 9 | collectCoverage: true, 10 | coverageDirectory: "coverage", 11 | transform: { 12 | "^.+\\.(t)sx?$": "ts-jest", 13 | }, 14 | collectCoverageFrom: ["**/*.ts", "!**/*.test.ts", "!./jest.config.ts", "!./jest.setup.ts"], 15 | coverageReporters: [["lcov", { projectRoot: "../" }], "text"], 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-solution-helper", 3 | "version": "1.0.0", 4 | "description": "helper to update references in cdk generated cfn template and package lambda assets", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "author": { 10 | "name": "Amazon Web Services", 11 | "url": "https://aws.amazon.com/solutions" 12 | }, 13 | "license": "Apache-2.0", 14 | "devDependencies": { 15 | "@types/adm-zip": "^0.5.2", 16 | "@types/jest": "^29.5.6", 17 | "@types/node": "^20.10.4", 18 | "jest": "^29.7.0", 19 | "ts-jest": "^29.1.1", 20 | "ts-node": "^10.9.2", 21 | "typescript": "^5.3.3" 22 | }, 23 | "dependencies": { 24 | "adm-zip": "^0.5.10", 25 | "aws-cdk-lib": "^2.124.0" 26 | }, 27 | "overrides": { 28 | "semver": "7.5.4" 29 | }, 30 | "resolutions": { 31 | "semver": "7.5.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "ts-node/node16/tsconfig.json", 3 | } -------------------------------------------------------------------------------- /deployment/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script should be run from the repo's deployment directory 4 | # cd deployment 5 | # ./run-unit-tests.sh 6 | # 7 | 8 | [ "$DEBUG" == 'true' ] && set -x 9 | set -e 10 | 11 | function headline(){ 12 | echo "------------------------------------------------------------------------------" 13 | echo "$1" 14 | echo "------------------------------------------------------------------------------" 15 | } 16 | 17 | prepare_jest_coverage_report() { 18 | local component_name=$(basename "$1") 19 | 20 | if [ ! -d "coverage" ]; then 21 | echo "ValidationError: Missing required directory coverage after running unit tests" 22 | exit 129 23 | fi 24 | 25 | # prepare coverage reports 26 | rm -fr coverage/lcov-report 27 | mkdir -p $coverage_reports_top_path/jest 28 | coverage_report_path=$coverage_reports_top_path/jest/$component_name 29 | rm -fr $coverage_report_path 30 | mv coverage $coverage_report_path 31 | } 32 | 33 | headline "[Setup] Configure paths" 34 | template_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 35 | cdk_dir="$template_dir/../source/constructs" 36 | image_handler_dir="$template_dir/../source/image-handler" 37 | custom_resource_dir="$template_dir/../source/custom-resource" 38 | metrics_utils_dir="$template_dir/../source/metrics-utils" 39 | coverage_reports_top_path="$template_dir/../source/test/coverage-reports" 40 | 41 | headline "[Tests] Run unit tests" 42 | declare -a packages=( 43 | "$cdk_dir" 44 | "$image_handler_dir" 45 | "$custom_resource_dir" 46 | "$metrics_utils_dir" 47 | ) 48 | for package in "${packages[@]}"; do 49 | cd "$package" 50 | npm test 51 | prepare_jest_coverage_report "$package" 52 | done; -------------------------------------------------------------------------------- /object_lambda_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/object_lambda_architecture.png -------------------------------------------------------------------------------- /source/.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | *.d.ts 3 | node_modules 4 | coverage 5 | **/test/* 6 | -------------------------------------------------------------------------------- /source/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "jest": true, 5 | "node": true 6 | }, 7 | "plugins": ["@typescript-eslint", "import", "header"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module", 12 | "project": "**/tsconfig.json" 13 | }, 14 | "ignorePatterns": ["**/*.js", "**/node_modules/**"], 15 | "extends": [ 16 | "eslint:recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:import/recommended", 19 | "plugin:import/typescript", 20 | "standard", 21 | "plugin:jsdoc/recommended", 22 | "plugin:prettier/recommended" 23 | ], 24 | "rules": { 25 | "arrow-body-style": ["warn", "as-needed"], 26 | "prefer-arrow-callback": ["warn"], 27 | "no-inferrable-types": ["off", "ignore-params"], 28 | "no-unused-vars": ["off"], 29 | "no-useless-constructor": ["off"], 30 | "no-throw-literal": ["off"], 31 | 32 | "header/header": ["error", "line", [" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.", " SPDX-License-Identifier: Apache-2.0"], 2], 33 | 34 | "@typescript-eslint/no-inferrable-types": ["off", { "ignoreParameters": true, "ignoreProperties": true }], 35 | "@typescript-eslint/no-useless-constructor": ["off"], 36 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none", "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 37 | "@typescript-eslint/no-throw-literal": ["error"], 38 | 39 | "jsdoc/require-param-type": ["off"], 40 | "jsdoc/require-returns-type": ["off"], 41 | "jsdoc/newline-after-description": ["off"], 42 | 43 | "import/no-unresolved": 1, // warn only on Unable to resolve path import/no-unresolved 44 | "dot-notation": "off" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /source/.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # .prettierrc or .prettierrc.yaml 2 | arrowParens: "always" 3 | bracketSpacing: true 4 | endOfLine: "lf" 5 | htmlWhitespaceSensitivity: "css" 6 | proseWrap: "preserve" 7 | trailingComma: "es5" 8 | tabWidth: 2 9 | semi: true 10 | quoteProps: "as-needed" 11 | printWidth: 120 -------------------------------------------------------------------------------- /source/constructs/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /source/constructs/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /source/constructs/bin/constructs.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { App, DefaultStackSynthesizer } from "aws-cdk-lib"; 5 | import { ServerlessImageHandlerStack } from "../lib/serverless-image-stack"; 6 | 7 | // CDK and default deployment 8 | let synthesizer = new DefaultStackSynthesizer({ 9 | generateBootstrapVersionRule: false, 10 | }); 11 | 12 | // Solutions pipeline deployment 13 | const { DIST_OUTPUT_BUCKET, SOLUTION_NAME, VERSION } = process.env; 14 | if (DIST_OUTPUT_BUCKET && SOLUTION_NAME && VERSION) 15 | synthesizer = new DefaultStackSynthesizer({ 16 | generateBootstrapVersionRule: false, 17 | fileAssetsBucketName: `${DIST_OUTPUT_BUCKET}-\${AWS::Region}`, 18 | bucketPrefix: `${SOLUTION_NAME}/${VERSION}/`, 19 | }); 20 | 21 | const app = new App(); 22 | const solutionDisplayName = "Dynamic Image Transformation for Amazon CloudFront"; 23 | const solutionVersion = VERSION ?? app.node.tryGetContext("solutionVersion"); 24 | const description = `(${app.node.tryGetContext("solutionId")}) - ${solutionDisplayName}. Version ${solutionVersion}`; 25 | // eslint-disable-next-line no-new 26 | new ServerlessImageHandlerStack(app, "ServerlessImageHandlerStack", { 27 | synthesizer, 28 | description, 29 | solutionId: app.node.tryGetContext("solutionId"), 30 | solutionVersion, 31 | solutionName: app.node.tryGetContext("solutionName"), 32 | }); 33 | -------------------------------------------------------------------------------- /source/constructs/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/constructs.ts", 3 | "context": { 4 | "solutionId": "SO0023", 5 | "solutionVersion": "custom-v7.0.3", 6 | "solutionName": "dynamic-image-transformation-for-amazon-cloudfront" 7 | } 8 | } -------------------------------------------------------------------------------- /source/constructs/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.test.ts'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | }, 7 | coverageReporters: [ 8 | 'text', 9 | ['lcov', { 'projectRoot': '../' }] 10 | ] 11 | }; 12 | -------------------------------------------------------------------------------- /source/constructs/lib/back-end/s3-object-lambda-origin.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CfnDistribution, OriginBase, OriginProps } from "aws-cdk-lib/aws-cloudfront"; 5 | 6 | export class S3ObjectLambdaOrigin extends OriginBase { 7 | public constructor(domainName: string, props: OriginProps = {}) { 8 | super(domainName, props); 9 | } 10 | 11 | protected renderS3OriginConfig(): CfnDistribution.S3OriginConfigProperty { 12 | return {}; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /source/constructs/lib/dashboard/ops-insights-dashboard.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Aws, CfnCondition, Duration } from "aws-cdk-lib"; 5 | import { Dashboard, PeriodOverride, TextWidget } from "aws-cdk-lib/aws-cloudwatch"; 6 | import { Size, DefaultGraphWidget, DefaultSingleValueWidget } from "./widgets"; 7 | import { SIHMetrics, SUPPORTED_CLOUDFRONT_METRICS, SUPPORTED_LAMBDA_METRICS } from "./sih-metrics"; 8 | import { Construct } from "constructs"; 9 | 10 | export interface OperationalInsightsDashboardProps { 11 | readonly enabled: CfnCondition; 12 | readonly backendLambdaFunctionName: string; 13 | readonly cloudFrontDistributionId: string; 14 | readonly namespace: string; 15 | } 16 | export class OperationalInsightsDashboard extends Construct { 17 | public readonly dashboard: Dashboard; 18 | constructor(scope: Construct, id: string, props: OperationalInsightsDashboardProps) { 19 | super(scope, id); 20 | this.dashboard = new Dashboard(this, id, { 21 | dashboardName: `${Aws.STACK_NAME}-${props.namespace}-Operational-Insights-Dashboard`, 22 | defaultInterval: Duration.days(7), 23 | periodOverride: PeriodOverride.INHERIT, 24 | }); 25 | 26 | if (!props.backendLambdaFunctionName || !props.cloudFrontDistributionId) { 27 | throw new Error("backendLambdaFunctionName and cloudFrontDistributionId are required"); 28 | } 29 | 30 | const metrics = new SIHMetrics({ 31 | backendLambdaFunctionName: props.backendLambdaFunctionName, 32 | cloudFrontDistributionId: props.cloudFrontDistributionId, 33 | }); 34 | 35 | this.dashboard.addWidgets( 36 | new TextWidget({ 37 | markdown: "# Lambda", 38 | width: Size.FULL_WIDTH, 39 | height: 1, 40 | }) 41 | ); 42 | 43 | this.dashboard.addWidgets( 44 | new DefaultGraphWidget({ 45 | width: Size.THIRD_WIDTH, 46 | height: Size.THIRD_WIDTH, 47 | title: "Lambda Errors", 48 | metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.ERRORS), 49 | label: "Lambda Errors", 50 | unit: "Count", 51 | }), 52 | new DefaultGraphWidget({ 53 | width: Size.THIRD_WIDTH, 54 | height: Size.THIRD_WIDTH, 55 | title: "Lambda Duration", 56 | metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.DURATION), 57 | label: "Lambda Duration", 58 | unit: "Milliseconds", 59 | }), 60 | new DefaultGraphWidget({ 61 | width: Size.THIRD_WIDTH, 62 | height: Size.THIRD_WIDTH, 63 | title: "Lambda Invocations", 64 | metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), 65 | label: "Lambda Invocations", 66 | unit: "Count", 67 | }) 68 | ); 69 | 70 | this.dashboard.addWidgets( 71 | new TextWidget({ 72 | markdown: "# CloudFront", 73 | width: Size.FULL_WIDTH, 74 | height: 1, 75 | }) 76 | ); 77 | 78 | this.dashboard.addWidgets( 79 | new DefaultGraphWidget({ 80 | title: "CloudFront Requests", 81 | metric: metrics.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), 82 | label: "CloudFront Requests", 83 | unit: "Count", 84 | }), 85 | new DefaultGraphWidget({ 86 | title: "CloudFront Bytes Downloaded", 87 | metric: metrics.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), 88 | label: "CloudFront Bytes Downloaded", 89 | unit: "Bytes", 90 | }), 91 | new DefaultSingleValueWidget({ 92 | title: "Cache Hit Rate", 93 | metric: metrics.getCacheHitRate(), 94 | label: "Cache Hit Rate (%)", 95 | }), 96 | new DefaultSingleValueWidget({ 97 | title: "Average Image Size", 98 | metric: metrics.getAverageImageSize(), 99 | label: "Average Image Size (Bytes)", 100 | }) 101 | ); 102 | 103 | this.dashboard.addWidgets( 104 | new TextWidget({ 105 | markdown: "# Overall", 106 | width: Size.FULL_WIDTH, 107 | height: 1, 108 | }) 109 | ); 110 | 111 | this.dashboard.addWidgets( 112 | new DefaultSingleValueWidget({ 113 | title: "Estimated Cost", 114 | width: Size.FULL_WIDTH, 115 | metric: metrics.getEstimatedCost(), 116 | label: "Estimated Cost($)", 117 | fullPrecision: true, 118 | }) 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /source/constructs/lib/dashboard/sih-metrics.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { MathExpression, Metric } from "aws-cdk-lib/aws-cloudwatch"; 5 | 6 | /** 7 | Properties for configuring metrics 8 | */ 9 | export interface MetricProps { 10 | /** The name of the backend Lambda function to monitor */ 11 | readonly backendLambdaFunctionName: string; 12 | /** The CloudFront distribution ID to monitor */ 13 | readonly cloudFrontDistributionId: string; 14 | } 15 | 16 | export enum SUPPORTED_LAMBDA_METRICS { 17 | ERRORS = "Errors", 18 | INVOCATIONS = "Invocations", 19 | DURATION = "Duration", 20 | } 21 | 22 | export enum SUPPORTED_CLOUDFRONT_METRICS { 23 | REQUESTS = "Requests", 24 | BYTES_DOWNLOAD = "BytesDownloaded", 25 | } 26 | 27 | enum Namespace { 28 | LAMBDA = "AWS/Lambda", 29 | CLOUDFRONT = "AWS/CloudFront", 30 | } 31 | 32 | // Relevant AWS Pricing as of Dec 2024 for us-east-1 33 | const PRICING = { 34 | CLOUDFRONT_BYTES: 0.085 / 1024 / 1024 / 1024, 35 | CLOUDFRONT_REQUESTS: 0.0075 / 10000, 36 | LAMBDA_DURATION: 1.66667 / 100000 / 1000, 37 | LAMBDA_INVOCATIONS: 0.2 / 1000000, 38 | }; 39 | 40 | /** 41 | * Helper class for defining the underlying metrics available to the solution for ingestion into dashboard widgets 42 | */ 43 | export class SIHMetrics { 44 | private readonly props; 45 | 46 | constructor(props: MetricProps) { 47 | this.props = props; 48 | } 49 | 50 | /** 51 | * 52 | * @param metric Creates a MathExpression to represent the running sum of a given metric 53 | * @returns {MathExpression} The running sum of the provided metric 54 | */ 55 | runningSum(metric: Metric) { 56 | return new MathExpression({ 57 | expression: `RUNNING_SUM(metric)`, 58 | usingMetrics: { 59 | metric, 60 | }, 61 | }); 62 | } 63 | 64 | /** 65 | * Creates a Lambda metric with standard dimensions and statistics 66 | * @param metricName The name of the Lambda metric to create 67 | * @returns {Metric} Configured Lambda metric 68 | */ 69 | createLambdaMetric(metricName: SUPPORTED_LAMBDA_METRICS) { 70 | return new Metric({ 71 | namespace: Namespace.LAMBDA, 72 | metricName, 73 | dimensionsMap: { 74 | FunctionName: this.props.backendLambdaFunctionName, 75 | }, 76 | statistic: "SUM", 77 | }); 78 | } 79 | 80 | /** 81 | * Creates a CloudFront metric with standard dimensions and statistics 82 | * @param metricName The name of the CloudFront metric to create 83 | * @returns {Metric} Configured CloudFront metric 84 | */ 85 | createCloudFrontMetric(metricName: SUPPORTED_CLOUDFRONT_METRICS) { 86 | return new Metric({ 87 | namespace: Namespace.CLOUDFRONT, 88 | metricName, 89 | region: "us-east-1", 90 | dimensionsMap: { 91 | Region: "Global", 92 | DistributionId: this.props.cloudFrontDistributionId, 93 | }, 94 | statistic: "SUM", 95 | }); 96 | } 97 | 98 | /** 99 | * Calculates the cache hit rate for the Image Handler distribution. This is represented as the % of requests which were returned from the cache. 100 | * @returns {MathExpression} The cache hit rate as a percentage 101 | */ 102 | getCacheHitRate() { 103 | return new MathExpression({ 104 | expression: "100 * (cloudFrontRequests - lambdaInvocations) / (cloudFrontRequests)", 105 | usingMetrics: { 106 | cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), 107 | lambdaInvocations: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), 108 | }, 109 | }); 110 | } 111 | 112 | /** 113 | * Calculates estimated cost in USD based on AWS pricing as of Dec 2024 in us-east-1. 114 | * Note: This is an approximation for the us-east-1 region only and includes 115 | * CloudFront data transfer and requests, and Lambda duration and invocations. 116 | * Some additional charges may apply for other services or regions. 117 | * @returns {MathExpression} Estimated cost in USD 118 | */ 119 | getEstimatedCost() { 120 | return new MathExpression({ 121 | expression: `${PRICING.CLOUDFRONT_BYTES} * cloudFrontBytesDownloaded + ${PRICING.CLOUDFRONT_REQUESTS} * cloudFrontRequests + ${PRICING.LAMBDA_DURATION} * lambdaDuration + ${PRICING.LAMBDA_INVOCATIONS} * lambdaInvocations`, 122 | usingMetrics: { 123 | cloudFrontBytesDownloaded: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), 124 | cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), 125 | lambdaDuration: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.DURATION), 126 | lambdaInvocations: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), 127 | }, 128 | }); 129 | } 130 | 131 | /** 132 | * Calculates the average size of images served through CloudFront 133 | * @returns {MathExpression} Average image size in bytes per request 134 | */ 135 | getAverageImageSize() { 136 | return new MathExpression({ 137 | expression: "cloudFrontBytesDownloaded / cloudFrontRequests", 138 | usingMetrics: { 139 | cloudFrontBytesDownloaded: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), 140 | cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), 141 | }, 142 | }); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /source/constructs/lib/dashboard/widgets.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { 5 | GraphWidget, 6 | GraphWidgetProps, 7 | GraphWidgetView, 8 | LegendPosition, 9 | MathExpression, 10 | Metric, 11 | SingleValueWidget, 12 | SingleValueWidgetProps, 13 | Stats, 14 | } from "aws-cdk-lib/aws-cloudwatch"; 15 | import { Duration } from "aws-cdk-lib"; 16 | 17 | /** 18 | * Represents standard widget sizes for CloudWatch dashboards 19 | * Values indicate the number of grid units the widget will occupy 20 | */ 21 | export enum Size { 22 | FULL_WIDTH = 24, 23 | HALF_WIDTH = 12, 24 | THIRD_WIDTH = 8, 25 | QUARTER_WIDTH = 6, 26 | } 27 | 28 | export interface WidgetProps { 29 | width: number; 30 | height: number; 31 | } 32 | 33 | export interface DefaultGraphWidgetProps extends GraphWidgetProps { 34 | title: string; 35 | width?: number; 36 | height?: number; 37 | metric: Metric; 38 | label: string; 39 | unit: string; 40 | } 41 | 42 | export interface DefaultSingleValueWidgetProps extends Omit { 43 | title: string; 44 | width?: number; 45 | height?: number; 46 | metric: Metric | MathExpression; 47 | label: string; 48 | } 49 | 50 | /** 51 | * Creates a standardized graph widget which adds a RUNNING_SUM line to the metric being graphed 52 | * @augments GraphWidget 53 | */ 54 | export class RunningSumGraphWidget extends GraphWidget { 55 | constructor(props: GraphWidgetProps) { 56 | if (!props?.left?.length) { 57 | throw new Error("RunningSumGraphWidget requires at least one left metric to be defined"); 58 | } 59 | if (!props.leftYAxis || !props.leftYAxis.label) { 60 | throw new Error("Left Y axis and Left Y axis label are required"); 61 | } 62 | super({ ...props, rightYAxis: { ...props.leftYAxis, label: `Running-Total ${props.leftYAxis?.label}`, min: 0 } }); 63 | this.addRightMetric( 64 | new MathExpression({ 65 | expression: `RUNNING_SUM(metric)`, 66 | usingMetrics: { 67 | metric: props.left[0], 68 | }, 69 | }).with({ label: "Total" }) 70 | ); 71 | } 72 | } 73 | 74 | /** 75 | * Creates a standardized graph widget with running sum functionality 76 | * @augments RunningSumGraphWidget 77 | */ 78 | export class DefaultGraphWidget extends RunningSumGraphWidget { 79 | constructor(props: DefaultGraphWidgetProps) { 80 | super({ 81 | title: props.title, 82 | width: props.width || Size.HALF_WIDTH, 83 | height: props.height || Size.HALF_WIDTH, 84 | view: GraphWidgetView.TIME_SERIES, 85 | period: props.period || Duration.days(1), 86 | liveData: props.liveData ?? true, 87 | left: [ 88 | props.metric.with({ 89 | label: props.label, 90 | }), 91 | ], 92 | leftYAxis: { 93 | label: props.unit, 94 | showUnits: false, 95 | min: 0, 96 | }, 97 | legendPosition: LegendPosition.BOTTOM, 98 | statistic: Stats.SUM, 99 | }); 100 | } 101 | } 102 | 103 | /** 104 | * Creates a standardized single value widget which adds the provided label to the metric being graphed 105 | * and sets the period as time range by default. 106 | * @augments SingleValueWidget 107 | */ 108 | export class DefaultSingleValueWidget extends SingleValueWidget { 109 | constructor(props: DefaultSingleValueWidgetProps) { 110 | super({ 111 | title: props.title, 112 | width: props.width || Size.HALF_WIDTH, 113 | height: props.height || Size.QUARTER_WIDTH, 114 | metrics: [ 115 | props.metric.with({ 116 | label: props.label, 117 | }), 118 | ], 119 | period: props.period, 120 | setPeriodToTimeRange: props.sparkline ? false : props.setPeriodToTimeRange ?? true, 121 | fullPrecision: props.fullPrecision, 122 | sparkline: props.sparkline, 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /source/constructs/lib/front-end/front-end-construct.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Bucket, IBucket } from "aws-cdk-lib/aws-s3"; 5 | import { Aspects } from "aws-cdk-lib"; 6 | import { Construct } from "constructs"; 7 | import { CloudFrontToS3 } from "@aws-solutions-constructs/aws-cloudfront-s3"; 8 | 9 | import { ConditionAspect } from "../../utils/aspects"; 10 | import { addCfnSuppressRules } from "../../utils/utils"; 11 | import { Conditions } from "../common-resources/common-resources-construct"; 12 | 13 | export interface FrontEndProps { 14 | readonly logsBucket: IBucket; 15 | readonly conditions: Conditions; 16 | } 17 | 18 | /** 19 | * Construct that creates the front-end resources for the solution. A CloudFront Distribution, S3 bucket. 20 | */ 21 | export class FrontEndConstruct extends Construct { 22 | public readonly domainName: string; 23 | public readonly websiteHostingBucket: Bucket; 24 | 25 | constructor(scope: Construct, id: string, props: FrontEndProps) { 26 | super(scope, id); 27 | 28 | const cloudFrontToS3 = new CloudFrontToS3(this, "DistributionToS3", { 29 | bucketProps: { serverAccessLogsBucket: undefined }, 30 | cloudFrontDistributionProps: { 31 | comment: "Demo UI Distribution for Dynamic Image Transformation for Amazon CloudFront", 32 | enableLogging: true, 33 | logBucket: props.logsBucket, 34 | logFilePrefix: "ui-cloudfront/", 35 | errorResponses: [ 36 | { 37 | httpStatus: 403, 38 | responseHttpStatus: 200, 39 | responsePagePath: "/index.html", 40 | }, 41 | { 42 | httpStatus: 404, 43 | responseHttpStatus: 200, 44 | responsePagePath: "/index.html", 45 | }, 46 | ], 47 | }, 48 | insertHttpSecurityHeaders: false, 49 | }); 50 | 51 | // S3 bucket does not require access logging, calls are logged by CloudFront 52 | cloudFrontToS3.node.tryRemoveChild("S3LoggingBucket"); 53 | addCfnSuppressRules(cloudFrontToS3.s3Bucket, [ 54 | { id: "W35", reason: "This S3 bucket does not require access logging." }, 55 | ]); 56 | 57 | this.domainName = cloudFrontToS3.cloudFrontWebDistribution.domainName; 58 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 59 | this.websiteHostingBucket = cloudFrontToS3.s3Bucket!; 60 | 61 | Aspects.of(this).add(new ConditionAspect(props.conditions.deployUICondition)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /source/constructs/lib/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export type YesNo = "Yes" | "No"; 5 | 6 | export interface SolutionConstructProps { 7 | readonly corsEnabled: string; 8 | readonly corsOrigin: string; 9 | readonly sourceBuckets: string; 10 | readonly deployUI: YesNo; 11 | readonly logRetentionPeriod: string; 12 | readonly autoWebP: string; 13 | readonly enableSignature: YesNo; 14 | readonly originShieldRegion: string; 15 | readonly secretsManager: string; 16 | readonly secretsManagerKey: string; 17 | readonly enableDefaultFallbackImage: YesNo; 18 | readonly fallbackImageS3Bucket: string; 19 | readonly fallbackImageS3KeyBucket: string; 20 | readonly enableS3ObjectLambda: string; 21 | readonly useExistingCloudFrontDistribution: YesNo; 22 | readonly existingCloudFrontDistributionId: string; 23 | } 24 | -------------------------------------------------------------------------------- /source/constructs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constructs", 3 | "version": "7.0.3", 4 | "description": "Dynamic Image Transformation for Amazon CloudFront Constructs", 5 | "license": "Apache-2.0", 6 | "author": { 7 | "name": "Amazon Web Services", 8 | "url": "https://aws.amazon.com/solutions" 9 | }, 10 | "bin": { 11 | "constructs": "bin/constructs.js" 12 | }, 13 | "scripts": { 14 | "cdk": "cdk", 15 | "clean:install": "rm -rf node_modules/ cdk.out/ coverage/ && npm ci && cd ../ && npm run install:dependencies", 16 | "cdk:synth": "npm run clean:install && overrideWarningsEnabled=false npx cdk synth --asset-metadata false --path-metadata false --json false", 17 | "pretest": "npm run clean:install", 18 | "build": "tsc", 19 | "watch": "tsc -w", 20 | "test": "overrideWarningsEnabled=false jest --coverage", 21 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" 22 | }, 23 | "devDependencies": { 24 | "@aws-cdk/aws-servicecatalogappregistry-alpha": "v2.118.0-alpha.0", 25 | "@aws-solutions-constructs/aws-apigateway-lambda": "2.51.0", 26 | "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "2.51.0", 27 | "@aws-solutions-constructs/aws-cloudfront-s3": "2.51.0", 28 | "@aws-solutions-constructs/core": "2.51.0", 29 | "@types/jest": "^29.5.6", 30 | "@types/node": "^20.10.4", 31 | "aws-cdk": "^2.1003.0", 32 | "aws-cdk-lib": "^2.182.0", 33 | "constructs": "^10.3.0", 34 | "esbuild": "^0.25.0", 35 | "jest": "^29.7.0", 36 | "ts-jest": "^29.1.1", 37 | "ts-node": "^10.9.2", 38 | "typescript": "^5.3.3" 39 | }, 40 | "overrides": { 41 | "semver": "7.5.4" 42 | }, 43 | "resolutions": { 44 | "semver": "7.5.4" 45 | }, 46 | "dependencies": { 47 | "sharp": "^0.32.6", 48 | "metrics-utils": "file:../metrics-utils" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /source/constructs/test/constructs.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Template } from "aws-cdk-lib/assertions"; 5 | import { App } from "aws-cdk-lib"; 6 | 7 | import { ServerlessImageHandlerStack } from "../lib/serverless-image-stack"; 8 | 9 | test("Dynamic Image Transformation for Amazon CloudFront Stack Snapshot", () => { 10 | const app = new App({ 11 | context: { 12 | solutionId: "SO0023", 13 | solutionName: "dynamic-image-transformation-for-amazon-cloudfront", 14 | solutionVersion: "v7.0.1", 15 | }, 16 | }); 17 | 18 | const stack = new ServerlessImageHandlerStack(app, "TestStack", { 19 | solutionId: "S0ABC", 20 | solutionName: "dit", 21 | solutionVersion: "v7.0.1", 22 | }); 23 | 24 | const template = Template.fromStack(stack); 25 | 26 | const templateJson = template.toJSON(); 27 | 28 | /** 29 | * iterate templateJson and for any attribute called S3Key, replace the value for that attribute with "Omitted to remove snapshot dependency on hash", 30 | * this is so that the snapshot can be saved and will not change because the hash has been regenerated 31 | */ 32 | Object.keys(templateJson.Resources).forEach((key) => { 33 | if (templateJson.Resources[key].Properties?.Code?.S3Key) { 34 | templateJson.Resources[key].Properties.Code.S3Key = "Omitted to remove snapshot dependency on hash"; 35 | } 36 | if (templateJson.Resources[key].Properties?.Content?.S3Key) { 37 | templateJson.Resources[key].Properties.Content.S3Key = "Omitted to remove snapshot dependency on hash"; 38 | } 39 | if (templateJson.Resources[key].Properties?.SourceObjectKeys) { 40 | templateJson.Resources[key].Properties.SourceObjectKeys = [ 41 | "Omitted to remove snapshot dependency on demo ui module hash", 42 | ]; 43 | } 44 | if (templateJson.Resources[key].Properties?.Environment?.Variables?.SOLUTION_VERSION) { 45 | templateJson.Resources[key].Properties.Environment.Variables.SOLUTION_VERSION = 46 | "Omitted to remove snapshot dependency on solution version"; 47 | } 48 | }); 49 | 50 | expect.assertions(1); 51 | expect(templateJson).toMatchSnapshot(); 52 | }); 53 | -------------------------------------------------------------------------------- /source/constructs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "resolveJsonModule": true, 21 | "esModuleInterop": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | }, 24 | "exclude": ["cdk.out"] 25 | } 26 | -------------------------------------------------------------------------------- /source/constructs/utils/aspects.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CfnFunction } from "aws-cdk-lib/aws-lambda"; 5 | import { CfnCondition, CfnResource, IAspect } from "aws-cdk-lib"; 6 | import { IConstruct } from "constructs"; 7 | 8 | import { addCfnSuppressRules } from "./utils"; 9 | 10 | /** 11 | * CDK Aspect to add common CFN Nag rule suppressions to Lambda functions. 12 | */ 13 | export class SuppressLambdaFunctionCfnRulesAspect implements IAspect { 14 | /** 15 | * Implements IAspect.visit to suppress rules specific to a Lambda function. 16 | * @param node Construct node to visit 17 | */ 18 | visit(node: IConstruct): void { 19 | const resource = node as CfnResource; 20 | if (resource instanceof CfnFunction) { 21 | const rules = [ 22 | { 23 | id: "W58", 24 | reason: "The function does have permission to write CloudWatch Logs.", 25 | }, 26 | { 27 | id: "W89", 28 | reason: "The Lambda function does not require any VPC connection at all.", 29 | }, 30 | { 31 | id: "W92", 32 | reason: "The Lambda function does not require ReservedConcurrentExecutions.", 33 | }, 34 | ]; 35 | 36 | addCfnSuppressRules(resource, rules); 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * CDK Aspect implementation to set up conditions to the entire Construct resources. 43 | */ 44 | export class ConditionAspect implements IAspect { 45 | private readonly condition: CfnCondition; 46 | 47 | constructor(condition: CfnCondition) { 48 | this.condition = condition; 49 | } 50 | 51 | /** 52 | * Implements IAspect.visit to set the condition to whole resources in Construct. 53 | * @param node Construct node to visit 54 | */ 55 | visit(node: IConstruct): void { 56 | const resource = node as CfnResource; 57 | if (resource.cfnOptions) { 58 | resource.cfnOptions.condition = this.condition; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /source/constructs/utils/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CfnCondition, CfnResource, Resource } from "aws-cdk-lib"; 5 | 6 | interface CfnNagSuppressRule { 7 | id: string; 8 | reason: string; 9 | } 10 | 11 | /** 12 | * Adds CFN NAG suppress rules to the CDK resource. 13 | * @param resource The CDK resource. 14 | * @param rules The CFN NAG suppress rules. 15 | */ 16 | export function addCfnSuppressRules(resource: Resource | CfnResource | undefined, rules: CfnNagSuppressRule[]) { 17 | if (typeof resource === "undefined") return; 18 | 19 | if (resource instanceof Resource) { 20 | resource = resource.node.defaultChild as CfnResource; 21 | } 22 | 23 | if (resource.cfnOptions.metadata?.cfn_nag?.rules_to_suppress) { 24 | resource.cfnOptions.metadata.cfn_nag.rules_to_suppress.push(...rules); 25 | } else { 26 | resource.addMetadata("cfn_nag", { rules_to_suppress: rules }); 27 | } 28 | } 29 | 30 | /** 31 | * Adds CDK condition to the CDK resource. 32 | * @param resource The CDK resource. 33 | * @param condition The CDK condition. 34 | */ 35 | export function addCfnCondition(resource: Resource | CfnResource | undefined, condition: CfnCondition) { 36 | if (typeof resource === "undefined") return; 37 | 38 | if (resource instanceof Resource) { 39 | resource = resource.node.defaultChild as CfnResource; 40 | } 41 | 42 | resource.cfnOptions.condition = condition; 43 | } 44 | -------------------------------------------------------------------------------- /source/custom-resource/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.spec.ts'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | }, 7 | coverageReporters: [ 8 | 'text', 9 | ['lcov', { 'projectRoot': '../' }] 10 | ], 11 | setupFiles: ['./test/setJestEnvironmentVariables.ts'] 12 | }; 13 | -------------------------------------------------------------------------------- /source/custom-resource/lib/enums.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export enum CustomResourceActions { 5 | SEND_ANONYMOUS_METRIC = "sendMetric", 6 | PUT_CONFIG_FILE = "putConfigFile", 7 | CREATE_UUID = "createUuid", 8 | CHECK_SOURCE_BUCKETS = "checkSourceBuckets", 9 | CHECK_FIRST_BUCKET_REGION = "checkFirstBucketRegion", 10 | CHECK_SECRETS_MANAGER = "checkSecretsManager", 11 | CHECK_FALLBACK_IMAGE = "checkFallbackImage", 12 | CREATE_LOGGING_BUCKET = "createCloudFrontLoggingBucket", 13 | GET_APP_REG_APPLICATION_NAME = "getAppRegApplicationName", 14 | VALIDATE_EXISTING_DISTRIBUTION = "validateExistingDistribution", 15 | } 16 | 17 | export enum CustomResourceRequestTypes { 18 | CREATE = "Create", 19 | UPDATE = "Update", 20 | DELETE = "Delete", 21 | } 22 | 23 | export enum StatusTypes { 24 | SUCCESS = "SUCCESS", 25 | FAILED = "FAILED", 26 | } 27 | 28 | export enum ErrorCodes { 29 | ACCESS_DENIED = "AccessDenied", 30 | FORBIDDEN = "Forbidden", 31 | } 32 | -------------------------------------------------------------------------------- /source/custom-resource/lib/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export * from "./enums"; 5 | export * from "./interfaces"; 6 | export * from "./types"; 7 | -------------------------------------------------------------------------------- /source/custom-resource/lib/interfaces.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CustomResourceActions, CustomResourceRequestTypes, StatusTypes } from "./enums"; 5 | import { ResourcePropertyTypes } from "./types"; 6 | 7 | export interface CustomResourceRequestPropertiesBase { 8 | CustomAction: CustomResourceActions; 9 | } 10 | 11 | export interface SendMetricsRequestProperties extends CustomResourceRequestPropertiesBase { 12 | AnonymousData: "Yes" | "No"; 13 | UUID: string; 14 | CorsEnabled: string; 15 | SourceBuckets: string; 16 | DeployDemoUi: string; 17 | LogRetentionPeriod: number; 18 | AutoWebP: string; 19 | EnableSignature: string; 20 | EnableDefaultFallbackImage: string; 21 | EnableS3ObjectLambda: string; 22 | OriginShieldRegion: string; 23 | UseExistingCloudFrontDistribution: string; 24 | } 25 | 26 | export interface PutConfigRequestProperties extends CustomResourceRequestPropertiesBase { 27 | ConfigItem: unknown; 28 | DestS3Bucket: string; 29 | DestS3key: string; 30 | } 31 | 32 | export interface CheckSourceBucketsRequestProperties extends CustomResourceRequestPropertiesBase { 33 | SourceBuckets: string; 34 | } 35 | 36 | export interface CheckFirstBucketRegionRequestProperties extends CheckSourceBucketsRequestProperties { 37 | UUID: string; 38 | S3ObjectLambda: string; 39 | StackId: string; 40 | } 41 | 42 | export interface GetAppRegApplicationNameRequestProperties extends CustomResourceRequestPropertiesBase { 43 | StackId: string; 44 | DefaultName: string; 45 | } 46 | 47 | export interface ValidateExistingDistributionRequestProperties extends CustomResourceRequestPropertiesBase { 48 | ExistingDistributionID: string; 49 | } 50 | 51 | export interface CheckSecretManagerRequestProperties extends CustomResourceRequestPropertiesBase { 52 | SecretsManagerName: string; 53 | SecretsManagerKey: string; 54 | } 55 | 56 | export interface CheckFallbackImageRequestProperties extends CustomResourceRequestPropertiesBase { 57 | FallbackImageS3Bucket: string; 58 | FallbackImageS3Key: string; 59 | } 60 | 61 | export interface PolicyStatement { 62 | Action?: string; 63 | Resource?: string; 64 | Effect?: string; 65 | Principal?: string; 66 | Sid?: string; 67 | Condition?: Record; 68 | } 69 | 70 | export interface CreateLoggingBucketRequestProperties extends CustomResourceRequestPropertiesBase { 71 | BucketSuffix: string; 72 | StackId: string; 73 | } 74 | 75 | export interface CustomResourceRequest { 76 | RequestType: CustomResourceRequestTypes; 77 | PhysicalResourceId: string; 78 | StackId: string; 79 | ServiceToken: string; 80 | RequestId: string; 81 | LogicalResourceId: string; 82 | ResponseURL: string; 83 | ResourceType: string; 84 | ResourceProperties: ResourcePropertyTypes; 85 | } 86 | 87 | export interface CompletionStatus { 88 | Status: StatusTypes; 89 | Data: Record | { Error?: { Code: string; Message: string } }; 90 | } 91 | 92 | export interface LambdaContext { 93 | logStreamName: string; 94 | } 95 | 96 | export interface MetricsPayloadData { 97 | Region: string; 98 | Type: CustomResourceRequestTypes; 99 | CorsEnabled: string; 100 | NumberOfSourceBuckets: number; 101 | DeployDemoUi: string; 102 | LogRetentionPeriod: number; 103 | AutoWebP: string; 104 | EnableSignature: string; 105 | EnableDefaultFallbackImage: string; 106 | EnableS3ObjectLambda: string; 107 | OriginShieldRegion: string; 108 | UseExistingCloudFrontDistribution: string; 109 | } 110 | 111 | export interface MetricPayload { 112 | Solution: string; 113 | Version: string; 114 | UUID: string; 115 | TimeStamp: string; 116 | Data: MetricsPayloadData; 117 | } 118 | -------------------------------------------------------------------------------- /source/custom-resource/lib/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { 5 | CheckFallbackImageRequestProperties, 6 | CheckSecretManagerRequestProperties, 7 | CheckSourceBucketsRequestProperties, 8 | CreateLoggingBucketRequestProperties, 9 | CustomResourceRequestPropertiesBase, 10 | PutConfigRequestProperties, 11 | SendMetricsRequestProperties, 12 | CheckFirstBucketRegionRequestProperties, 13 | GetAppRegApplicationNameRequestProperties, 14 | ValidateExistingDistributionRequestProperties, 15 | } from "./interfaces"; 16 | 17 | export type ResourcePropertyTypes = 18 | | CustomResourceRequestPropertiesBase 19 | | SendMetricsRequestProperties 20 | | PutConfigRequestProperties 21 | | CheckSourceBucketsRequestProperties 22 | | CheckSecretManagerRequestProperties 23 | | CheckFallbackImageRequestProperties 24 | | CreateLoggingBucketRequestProperties 25 | | CheckFirstBucketRegionRequestProperties 26 | | GetAppRegApplicationNameRequestProperties 27 | | ValidateExistingDistributionRequestProperties; 28 | 29 | export class CustomResourceError extends Error { 30 | constructor(public readonly code: string, public readonly message: string) { 31 | super(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /source/custom-resource/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-resource", 3 | "version": "7.0.3", 4 | "private": true, 5 | "description": "Dynamic Image Transformation for Amazon CloudFront custom resource", 6 | "license": "Apache-2.0", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/solutions" 10 | }, 11 | "main": "index.js", 12 | "scripts": { 13 | "clean": "rm -rf node_modules/ dist/ coverage/", 14 | "pretest": "npm run clean && npm ci", 15 | "test": "jest --coverage --silent", 16 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" 17 | }, 18 | "dependencies": { 19 | "aws-sdk": "^2.1529.0", 20 | "axios": "^1.7.4", 21 | "moment": "^2.30.0", 22 | "uuid": "^9.0.1" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^29.5.6", 26 | "@types/node": "^20.10.4", 27 | "@types/uuid": "^9.0.6", 28 | "jest": "^29.7.0", 29 | "ts-jest": "^29.1.1", 30 | "ts-node": "^10.9.2", 31 | "typescript": "^5.3.3" 32 | }, 33 | "overrides": { 34 | "semver": "7.5.4" 35 | }, 36 | "resolutions": { 37 | "semver": "7.5.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /source/custom-resource/test/check-secrets-manager.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockAwsSecretManager, mockContext } from "./mock"; 5 | import { 6 | CustomResourceActions, 7 | CustomResourceRequestTypes, 8 | CheckSecretManagerRequestProperties, 9 | CustomResourceRequest, 10 | CustomResourceError, 11 | } from "../lib"; 12 | import { handler } from "../index"; 13 | 14 | describe("CHECK_SECRETS_MANAGER", () => { 15 | // Mock event data 16 | const event: CustomResourceRequest = { 17 | RequestType: CustomResourceRequestTypes.CREATE, 18 | ResponseURL: "/cfn-response", 19 | PhysicalResourceId: "mock-physical-id", 20 | StackId: "mock-stack-id", 21 | ServiceToken: "mock-service-token", 22 | RequestId: "mock-request-id", 23 | LogicalResourceId: "mock-logical-resource-id", 24 | ResourceType: "mock-resource-type", 25 | ResourceProperties: { 26 | CustomAction: CustomResourceActions.CHECK_SECRETS_MANAGER, 27 | SecretsManagerName: "secrets-manager-name", 28 | SecretsManagerKey: "secrets-manager-key", 29 | }, 30 | }; 31 | const secret = { 32 | SecretString: '{"secrets-manager-key":"secret-ingredient"}', 33 | ARN: "arn:of:secrets:managers:secret", 34 | }; 35 | 36 | beforeEach(() => { 37 | jest.resetAllMocks(); 38 | }); 39 | 40 | afterEach(() => { 41 | jest.clearAllMocks(); 42 | }); 43 | 44 | it("Should return success when secrets manager secret and secret's key exists", async () => { 45 | mockAwsSecretManager.getSecretValue.mockImplementation(() => ({ 46 | promise() { 47 | return Promise.resolve(secret); 48 | }, 49 | })); 50 | 51 | const result = await handler(event, mockContext); 52 | 53 | expect.assertions(1); 54 | 55 | expect(result).toEqual({ 56 | Status: "SUCCESS", 57 | Data: { 58 | Message: "Secrets Manager validated.", 59 | ARN: secret.ARN, 60 | }, 61 | }); 62 | }); 63 | 64 | it("Should return failed when secretName is not provided", async () => { 65 | (event.ResourceProperties as CheckSecretManagerRequestProperties).SecretsManagerName = ""; 66 | 67 | const result = await handler(event, mockContext); 68 | 69 | expect.assertions(1); 70 | 71 | expect(result).toEqual({ 72 | Status: "FAILED", 73 | Data: { 74 | Error: { 75 | Code: "SecretNotProvided", 76 | Message: "You need to provide AWS Secrets Manager secret.", 77 | }, 78 | }, 79 | }); 80 | }); 81 | 82 | it("Should return failed when secretKey is not provided", async () => { 83 | const resourceProperties = event.ResourceProperties as CheckSecretManagerRequestProperties; 84 | resourceProperties.SecretsManagerName = "secrets-manager-name"; 85 | resourceProperties.SecretsManagerKey = ""; 86 | 87 | const result = await handler(event, mockContext); 88 | 89 | expect.assertions(1); 90 | 91 | expect(result).toEqual({ 92 | Status: "FAILED", 93 | Data: { 94 | Error: { 95 | Code: "SecretKeyNotProvided", 96 | Message: "You need to provide AWS Secrets Manager secret key.", 97 | }, 98 | }, 99 | }); 100 | }); 101 | 102 | it("Should return failed when secret key does not exist", async () => { 103 | mockAwsSecretManager.getSecretValue.mockImplementation(() => ({ 104 | promise() { 105 | return Promise.resolve(secret); 106 | }, 107 | })); 108 | 109 | const resourceProperties = event.ResourceProperties as CheckSecretManagerRequestProperties; 110 | resourceProperties.SecretsManagerKey = "none-existing-key"; 111 | 112 | const result = await handler(event, mockContext); 113 | 114 | expect.assertions(1); 115 | 116 | expect(result).toEqual({ 117 | Status: "FAILED", 118 | Data: { 119 | Error: { 120 | Code: "SecretKeyNotFound", 121 | Message: `AWS Secrets Manager secret requires ${resourceProperties.SecretsManagerKey} key.`, 122 | }, 123 | }, 124 | }); 125 | }); 126 | 127 | it("Should return failed when GetSecretValue fails", async () => { 128 | mockAwsSecretManager.getSecretValue.mockImplementation(() => ({ 129 | promise() { 130 | return Promise.reject(new CustomResourceError("InternalServerError", "GetSecretValue failed.")); 131 | }, 132 | })); 133 | (event.ResourceProperties as CheckSecretManagerRequestProperties).SecretsManagerName = "secrets-manager-key"; 134 | 135 | const result = await handler(event, mockContext); 136 | 137 | expect.assertions(1); 138 | 139 | expect(result).toEqual({ 140 | Status: "FAILED", 141 | Data: { 142 | Error: { 143 | Code: "InternalServerError", 144 | Message: "GetSecretValue failed.", 145 | }, 146 | }, 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /source/custom-resource/test/check-source-buckets.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { consoleErrorSpy, consoleInfoSpy, mockAwsS3, mockContext } from "./mock"; 5 | import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest, CustomResourceError } from "../lib"; 6 | import { handler } from "../index"; 7 | 8 | describe("CHECK_SOURCE_BUCKETS", () => { 9 | // Mock event data 10 | const buckets = "bucket-a,bucket-b,bucket-c"; 11 | const event: CustomResourceRequest = { 12 | RequestType: CustomResourceRequestTypes.CREATE, 13 | ResponseURL: "/cfn-response", 14 | PhysicalResourceId: "mock-physical-id", 15 | StackId: "mock-stack-id", 16 | ServiceToken: "mock-service-token", 17 | RequestId: "mock-request-id", 18 | LogicalResourceId: "mock-logical-resource-id", 19 | ResourceType: "mock-resource-type", 20 | ResourceProperties: { 21 | CustomAction: CustomResourceActions.CHECK_SOURCE_BUCKETS, 22 | SourceBuckets: buckets, 23 | }, 24 | }; 25 | 26 | beforeEach(() => { 27 | jest.resetAllMocks(); 28 | }); 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | it("Should return success to check source buckets", async () => { 35 | mockAwsS3.headBucket.mockImplementation(() => ({ 36 | promise() { 37 | return Promise.resolve(); 38 | }, 39 | })); 40 | 41 | const result = await handler(event, mockContext); 42 | 43 | expect.assertions(2); 44 | 45 | expect(consoleInfoSpy).toHaveBeenCalledWith(`Attempting to check if the following buckets exist: ${buckets}`); 46 | expect(result).toEqual({ 47 | Status: "SUCCESS", 48 | Data: { Message: "Buckets validated." }, 49 | }); 50 | }); 51 | 52 | it("should return failed when any buckets do not exist", async () => { 53 | mockAwsS3.headBucket.mockImplementation(() => ({ 54 | promise() { 55 | return Promise.reject(new CustomResourceError(null, "HeadObject failed.")); 56 | }, 57 | })); 58 | 59 | const result = await handler(event, mockContext); 60 | 61 | expect(consoleErrorSpy).toHaveBeenCalledWith("Could not find bucket: bucket-a"); 62 | expect(result).toEqual({ 63 | Status: "FAILED", 64 | Data: { 65 | Error: { 66 | Code: "BucketNotFound", 67 | Message: `Could not find the following source bucket(s) in your account: ${buckets}. Please specify at least one source bucket that exists within your account and try again. If specifying multiple source buckets, please ensure that they are comma-separated.`, 68 | }, 69 | }, 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /source/custom-resource/test/create-uuid.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockContext, mockAxios } from "./mock"; 5 | import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; 6 | import { handler } from "../index"; 7 | 8 | describe("CREATE_UUID", () => { 9 | // Mock event data 10 | const event: CustomResourceRequest = { 11 | RequestType: CustomResourceRequestTypes.CREATE, 12 | ResponseURL: "/cfn-response", 13 | PhysicalResourceId: "mock-physical-id", 14 | StackId: "mock-stack-id", 15 | ServiceToken: "mock-service-token", 16 | RequestId: "mock-request-id", 17 | LogicalResourceId: "mock-logical-resource-id", 18 | ResourceType: "mock-resource-type", 19 | ResourceProperties: { 20 | CustomAction: CustomResourceActions.CREATE_UUID, 21 | }, 22 | }; 23 | 24 | it("Should create an UUID", async () => { 25 | mockAxios.put.mockResolvedValue({ status: 200 }); 26 | 27 | const response = await handler(event, mockContext); 28 | 29 | expect.assertions(1); 30 | 31 | expect(response).toEqual({ 32 | Status: "SUCCESS", 33 | Data: { UUID: "mock-uuid" }, 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /source/custom-resource/test/default.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockContext } from "./mock"; 5 | import { CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; 6 | import { handler } from "../index"; 7 | 8 | describe("Default", () => { 9 | // Mock event data 10 | const event: CustomResourceRequest = { 11 | RequestType: CustomResourceRequestTypes.UPDATE, 12 | ResponseURL: "/cfn-response", 13 | PhysicalResourceId: "mock-physical-id", 14 | StackId: "mock-stack-id", 15 | ServiceToken: "mock-service-token", 16 | RequestId: "mock-request-id", 17 | LogicalResourceId: "mock-logical-resource-id", 18 | ResourceType: "mock-resource-type", 19 | ResourceProperties: { 20 | CustomAction: null, 21 | }, 22 | }; 23 | 24 | it("Should return success for other default custom resource", async () => { 25 | const result = await handler(event, mockContext); 26 | 27 | expect(result).toEqual({ 28 | Status: "SUCCESS", 29 | Data: {}, 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /source/custom-resource/test/get-app-reg-application-name.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockCloudFormation, mockServiceCatalogAppRegistry, mockContext } from "./mock"; 5 | import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; 6 | import { handler } from "../index"; 7 | 8 | describe("GET_APP_REG_APPLICATION_NAME", () => { 9 | // Mock event data 10 | const defaultApplicationName = "ServerlessImageHandlerDefaultApplicationName"; 11 | const event: CustomResourceRequest = { 12 | RequestType: CustomResourceRequestTypes.CREATE, 13 | ResponseURL: "/cfn-response", 14 | PhysicalResourceId: "mock-physical-id", 15 | StackId: "mock-stack-id", 16 | ServiceToken: "mock-service-token", 17 | RequestId: "mock-request-id", 18 | LogicalResourceId: "mock-logical-resource-id", 19 | ResourceType: "mock-resource-type", 20 | ResourceProperties: { 21 | CustomAction: CustomResourceActions.GET_APP_REG_APPLICATION_NAME, 22 | DefaultName: defaultApplicationName, 23 | }, 24 | }; 25 | 26 | beforeEach(() => { 27 | jest.resetAllMocks(); 28 | }); 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | it("Should return default application name when application name could not be retrieved", async () => { 35 | mockCloudFormation.describeStackResources.mockImplementation(() => ({ 36 | promise() { 37 | return Promise.resolve({ 38 | StackResources: [ 39 | { 40 | LogicalResourceId: "SourceBucketA", 41 | PhysicalResourceId: "bucket-a", 42 | }, 43 | ], 44 | }); 45 | }, 46 | })); 47 | 48 | mockServiceCatalogAppRegistry.getApplication.mockImplementation(() => ({ 49 | promise() { 50 | return Promise.resolve({}); 51 | }, 52 | })); 53 | 54 | const result = await handler(event, mockContext); 55 | expect(result).toEqual({ 56 | Status: "SUCCESS", 57 | Data: { ApplicationName: defaultApplicationName }, 58 | }); 59 | }); 60 | 61 | it("Should return default application name when application does not yet exist in the stack", async () => { 62 | mockCloudFormation.describeStackResources.mockImplementation(() => ({ 63 | promise() { 64 | return Promise.resolve({ 65 | StackResources: [], 66 | }); 67 | }, 68 | })); 69 | 70 | mockServiceCatalogAppRegistry.getApplication.mockImplementation(() => ({ 71 | promise() { 72 | return Promise.resolve({}); 73 | }, 74 | })); 75 | 76 | const result = await handler(event, mockContext); 77 | expect(result).toEqual({ 78 | Status: "SUCCESS", 79 | Data: { ApplicationName: defaultApplicationName }, 80 | }); 81 | }); 82 | 83 | it("Should return application name when available", async () => { 84 | const applicationName = "SIHApplication"; 85 | mockCloudFormation.describeStackResources.mockImplementation(() => ({ 86 | promise() { 87 | return Promise.resolve({ 88 | StackResources: [ 89 | { 90 | LogicalResourceId: "SourceBucketA", 91 | PhysicalResourceId: "bucket-a", 92 | }, 93 | ], 94 | }); 95 | }, 96 | })); 97 | 98 | mockServiceCatalogAppRegistry.getApplication.mockImplementation(() => ({ 99 | promise() { 100 | return Promise.resolve({ 101 | name: applicationName, 102 | }); 103 | }, 104 | })); 105 | 106 | const result = await handler(event, mockContext); 107 | expect(result).toEqual({ 108 | Status: "SUCCESS", 109 | Data: { ApplicationName: applicationName }, 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /source/custom-resource/test/mock.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { LambdaContext } from "../lib"; 5 | 6 | export const mockAwsEc2 = { 7 | describeRegions: jest.fn(), 8 | }; 9 | 10 | jest.mock("aws-sdk/clients/ec2", () => jest.fn(() => ({ ...mockAwsEc2 }))); 11 | 12 | export const mockAwsS3 = { 13 | headObject: jest.fn(), 14 | copyObject: jest.fn(), 15 | getObject: jest.fn(), 16 | putObject: jest.fn(), 17 | headBucket: jest.fn(), 18 | createBucket: jest.fn(), 19 | putBucketEncryption: jest.fn(), 20 | putBucketPolicy: jest.fn(), 21 | putBucketTagging: jest.fn(), 22 | putBucketVersioning: jest.fn(), 23 | }; 24 | 25 | jest.mock("aws-sdk/clients/s3", () => jest.fn(() => ({ ...mockAwsS3 }))); 26 | 27 | export const mockAwsSecretManager = { 28 | getSecretValue: jest.fn(), 29 | }; 30 | 31 | jest.mock("aws-sdk/clients/secretsmanager", () => jest.fn(() => ({ ...mockAwsSecretManager }))); 32 | 33 | export const mockCloudFormation = { 34 | describeStackResources: jest.fn(), 35 | }; 36 | 37 | jest.mock("aws-sdk/clients/cloudformation", () => jest.fn(() => ({ ...mockCloudFormation }))); 38 | 39 | export const mockCloudFront = { 40 | getDistribution: jest.fn(), 41 | }; 42 | 43 | jest.mock("aws-sdk/clients/cloudfront", () => jest.fn(() => ({ ...mockCloudFront }))); 44 | 45 | export const mockServiceCatalogAppRegistry = { 46 | getApplication: jest.fn(), 47 | }; 48 | 49 | jest.mock("aws-sdk/clients/servicecatalogappregistry", () => jest.fn(() => ({ ...mockServiceCatalogAppRegistry }))); 50 | 51 | export const mockAxios = { 52 | put: jest.fn(), 53 | post: jest.fn(), 54 | }; 55 | 56 | jest.mock("axios", () => ({ 57 | put: mockAxios.put, 58 | post: mockAxios.post, 59 | })); 60 | 61 | jest.mock("uuid", () => ({ v4: jest.fn(() => "mock-uuid") })); 62 | 63 | const mockTimeStamp = new Date(); 64 | export const mockISOTimeStamp = mockTimeStamp.toISOString(); 65 | 66 | jest.mock("moment", () => { 67 | const originalMoment = jest.requireActual("moment"); 68 | const mockMoment = (date: string | undefined) => originalMoment(mockTimeStamp); 69 | mockMoment.utc = () => ({ 70 | format: () => mockISOTimeStamp, 71 | }); 72 | return mockMoment; 73 | }); 74 | 75 | export const consoleInfoSpy = jest.spyOn(console, "info"); 76 | export const consoleErrorSpy = jest.spyOn(console, "error"); 77 | 78 | export const mockContext: LambdaContext = { 79 | logStreamName: "mock-stream", 80 | }; 81 | -------------------------------------------------------------------------------- /source/custom-resource/test/put-config-file.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockAwsS3, mockContext, consoleInfoSpy } from "./mock"; 5 | import { 6 | CustomResourceActions, 7 | CustomResourceRequestTypes, 8 | CustomResourceRequest, 9 | PutConfigRequestProperties, 10 | ErrorCodes, 11 | CustomResourceError, 12 | } from "../lib"; 13 | import { handler } from "../index"; 14 | 15 | describe("PUT_CONFIG_FILE", () => { 16 | // Mock event data 17 | const event: CustomResourceRequest = { 18 | RequestType: CustomResourceRequestTypes.CREATE, 19 | ResponseURL: "/cfn-response", 20 | PhysicalResourceId: "mock-physical-id", 21 | StackId: "mock-stack-id", 22 | ServiceToken: "mock-service-token", 23 | RequestId: "mock-request-id", 24 | LogicalResourceId: "mock-logical-resource-id", 25 | ResourceType: "mock-resource-type", 26 | ResourceProperties: { 27 | CustomAction: CustomResourceActions.PUT_CONFIG_FILE, 28 | ConfigItem: { 29 | Key: "Value", 30 | }, 31 | DestS3Bucket: "destination-bucket", 32 | DestS3key: "demo-ui-config.js", 33 | }, 34 | }; 35 | 36 | const mockConfig = `'use strict'; 37 | 38 | const appVariables = { 39 | Key: 'Value' 40 | };`; 41 | 42 | beforeEach(() => { 43 | jest.resetAllMocks(); 44 | }); 45 | 46 | afterEach(() => { 47 | jest.clearAllMocks(); 48 | }); 49 | 50 | it("Should return success to put config file", async () => { 51 | mockAwsS3.putObject.mockImplementationOnce(() => ({ 52 | promise() { 53 | return Promise.resolve({}); 54 | }, 55 | })); 56 | 57 | const result = await handler(event, mockContext); 58 | const resourceProperties = event.ResourceProperties as PutConfigRequestProperties; 59 | 60 | expect.assertions(2); 61 | 62 | expect(mockAwsS3.putObject).toHaveBeenCalledWith({ 63 | Bucket: resourceProperties.DestS3Bucket, 64 | Body: mockConfig, 65 | Key: resourceProperties.DestS3key, 66 | ContentType: "application/javascript", 67 | }); 68 | expect(result).toEqual({ 69 | Status: "SUCCESS", 70 | Data: { 71 | Message: "Config file uploaded.", 72 | Content: mockConfig, 73 | }, 74 | }); 75 | }); 76 | 77 | it("Should return failed when PutObject fails", async () => { 78 | mockAwsS3.putObject.mockImplementationOnce(() => ({ 79 | promise() { 80 | return Promise.reject(new CustomResourceError(null, "PutObject failed")); 81 | }, 82 | })); 83 | 84 | const result = await handler(event, mockContext); 85 | const resourceProperties = event.ResourceProperties as PutConfigRequestProperties; 86 | 87 | expect.assertions(3); 88 | 89 | expect(consoleInfoSpy).toHaveBeenCalledWith( 90 | `Attempting to save content blob destination location: ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key}` 91 | ); 92 | expect(mockAwsS3.putObject).toHaveBeenCalledWith({ 93 | Bucket: resourceProperties.DestS3Bucket, 94 | Body: mockConfig, 95 | Key: resourceProperties.DestS3key, 96 | ContentType: "application/javascript", 97 | }); 98 | expect(result).toEqual({ 99 | Status: "FAILED", 100 | Data: { 101 | Error: { 102 | Code: "ConfigFileCreationFailure", 103 | Message: `Saving config file to ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key} failed.`, 104 | }, 105 | }, 106 | }); 107 | }); 108 | 109 | it("Should retry and return success when IAM policy is not so S3 API returns AccessDenied", async () => { 110 | mockAwsS3.putObject 111 | .mockImplementationOnce(() => ({ 112 | promise() { 113 | return Promise.reject(new CustomResourceError(ErrorCodes.ACCESS_DENIED, null)); 114 | }, 115 | })) 116 | .mockImplementationOnce(() => ({ 117 | promise() { 118 | return Promise.resolve(); 119 | }, 120 | })); 121 | 122 | const result = await handler(event, mockContext); 123 | const resourceProperties = event.ResourceProperties as PutConfigRequestProperties; 124 | 125 | expect.assertions(4); 126 | 127 | expect(consoleInfoSpy).toHaveBeenCalledWith( 128 | `Attempting to save content blob destination location: ${resourceProperties.DestS3Bucket}/${resourceProperties.DestS3key}` 129 | ); 130 | expect(consoleInfoSpy).toHaveBeenCalledWith("Waiting for retry..."); 131 | expect(mockAwsS3.putObject).toHaveBeenCalledWith({ 132 | Bucket: resourceProperties.DestS3Bucket, 133 | Body: mockConfig, 134 | Key: resourceProperties.DestS3key, 135 | ContentType: "application/javascript", 136 | }); 137 | expect(result).toEqual({ 138 | Status: "SUCCESS", 139 | Data: { 140 | Message: "Config file uploaded.", 141 | Content: mockConfig, 142 | }, 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /source/custom-resource/test/setJestEnvironmentVariables.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | process.env.SOLUTION_ID = "solution-id"; 5 | process.env.SOLUTION_VERSION = "solution-version"; 6 | process.env.AWS_REGION = "mock-region-1"; 7 | -------------------------------------------------------------------------------- /source/custom-resource/test/validate-existing-distribution.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockCloudFront, mockContext } from "./mock"; 5 | import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; 6 | import { handler } from "../index"; 7 | 8 | describe("VALIDATE_EXISTING_DISTRIBUTION", () => { 9 | // Mock event data 10 | const distributionId = "E1234ABCDEF"; 11 | const event: CustomResourceRequest = { 12 | RequestType: CustomResourceRequestTypes.CREATE, 13 | ResponseURL: "/cfn-response", 14 | PhysicalResourceId: "mock-physical-id", 15 | StackId: "mock-stack-id", 16 | ServiceToken: "mock-service-token", 17 | RequestId: "mock-request-id", 18 | LogicalResourceId: "mock-logical-resource-id", 19 | ResourceType: "mock-resource-type", 20 | ResourceProperties: { 21 | CustomAction: CustomResourceActions.VALIDATE_EXISTING_DISTRIBUTION, 22 | ExistingDistributionID: distributionId, 23 | }, 24 | }; 25 | 26 | beforeEach(() => { 27 | jest.resetAllMocks(); 28 | }); 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | it("Should return success when distribution exists and is valid", async () => { 35 | // Mock CloudFront getDistribution response 36 | mockCloudFront.getDistribution.mockImplementation(() => ({ 37 | promise() { 38 | return Promise.resolve({ 39 | Distribution: { 40 | DomainName: `${distributionId}.cloudfront.net`, 41 | Status: "Deployed", 42 | DistributionConfig: { 43 | Enabled: true, 44 | Origins: { 45 | Items: [ 46 | { 47 | DomainName: "example-bucket.s3.amazonaws.com", 48 | Id: "S3Origin", 49 | }, 50 | ], 51 | Quantity: 1, 52 | }, 53 | }, 54 | }, 55 | }); 56 | }, 57 | })); 58 | 59 | const result = await handler(event, mockContext); 60 | expect(result).toEqual({ 61 | Status: "SUCCESS", 62 | Data: { 63 | DistributionDomainName: `${distributionId}.cloudfront.net`, 64 | }, 65 | }); 66 | }); 67 | 68 | it("Should return failure when distribution does not exist", async () => { 69 | // Mock CloudFront getDistribution to throw error 70 | mockCloudFront.getDistribution.mockImplementation(() => ({ 71 | promise() { 72 | return Promise.reject(new Error("NoSuchDistribution")); 73 | }, 74 | })); 75 | 76 | const result = await handler(event, mockContext); 77 | expect(result).toEqual({ 78 | Status: "FAILED", 79 | Data: { 80 | Error: { 81 | Code: "CustomResourceError", 82 | Message: "NoSuchDistribution", 83 | }, 84 | }, 85 | }); 86 | }); 87 | 88 | it("Should return failure on unexpected error", async () => { 89 | mockCloudFront.getDistribution.mockImplementation(() => ({ 90 | promise() { 91 | return Promise.reject(new Error("Unexpected error")); 92 | }, 93 | })); 94 | 95 | const result = await handler(event, mockContext); 96 | expect(result).toEqual({ 97 | Status: "FAILED", 98 | Data: { 99 | Error: { 100 | Code: "CustomResourceError", 101 | Message: "Unexpected error", 102 | }, 103 | }, 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /source/custom-resource/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "types": ["node", "@types/jest"] 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["package", "dist", "**/*.map"] 15 | } 16 | -------------------------------------------------------------------------------- /source/demo-ui/demo-ui-manifest.json: -------------------------------------------------------------------------------- 1 | {"files":["index.html","scripts.js","style.css"]} 2 | -------------------------------------------------------------------------------- /source/demo-ui/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-ui", 3 | "version": "7.0.3", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "demo-ui", 9 | "version": "7.0.3", 10 | "hasInstallScript": true, 11 | "license": "Apache-2.0", 12 | "dependencies": { 13 | "@popperjs/core": "^2.11.8", 14 | "bootstrap": "^5.3.3", 15 | "jquery": "^3.7.1" 16 | } 17 | }, 18 | "node_modules/@popperjs/core": { 19 | "version": "2.11.8", 20 | "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 21 | "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 22 | "funding": { 23 | "type": "opencollective", 24 | "url": "https://opencollective.com/popperjs" 25 | } 26 | }, 27 | "node_modules/bootstrap": { 28 | "version": "5.3.3", 29 | "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", 30 | "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", 31 | "funding": [ 32 | { 33 | "type": "github", 34 | "url": "https://github.com/sponsors/twbs" 35 | }, 36 | { 37 | "type": "opencollective", 38 | "url": "https://opencollective.com/bootstrap" 39 | } 40 | ], 41 | "peerDependencies": { 42 | "@popperjs/core": "^2.11.8" 43 | } 44 | }, 45 | "node_modules/jquery": { 46 | "version": "3.7.1", 47 | "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", 48 | "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /source/demo-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-ui", 3 | "version": "7.0.3", 4 | "private": true, 5 | "description": "Dynamic Image Transformation for Amazon CloudFront demo ui", 6 | "license": "Apache-2.0", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/solutions" 10 | }, 11 | "main": "index.js", 12 | "scripts": { 13 | "clean": "rm -rf node_modules/ dist/ coverage/ modules/", 14 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version", 15 | "postinstall": "mkdir -p modules && cp -f node_modules/jquery/dist/jquery.slim.min.js node_modules/@popperjs/core/dist/umd/popper.min.js node_modules/bootstrap/dist/js/bootstrap.min.js node_modules/bootstrap/dist/css/bootstrap.min.css modules/" 16 | }, 17 | "dependencies": { 18 | "@popperjs/core": "^2.11.8", 19 | "bootstrap": "^5.3.3", 20 | "jquery": "^3.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /source/demo-ui/style.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background: #37474f !important; 3 | color: #fff !important; 4 | padding: 10px 10px 10px 20px !important; 5 | font-size: 1.2em !important; 6 | } 7 | .header-italics { 8 | color: #b0bec5 !important; 9 | } 10 | .content { 11 | margin-top: 25px !important; 12 | } 13 | .card-original-image { 14 | margin-top: 20px !important; 15 | } 16 | #img-original { 17 | max-height: 100% !important; 18 | max-width: 100% !important; 19 | } 20 | #img-preview { 21 | max-height: 100% !important; 22 | max-width: 100% !important; 23 | } 24 | .gallery-item { 25 | width: 30%; 26 | margin: 1%; 27 | max-height: auto; 28 | border: 1px solid gray; 29 | border-radius: 4px; 30 | cursor: pointer; 31 | } 32 | .gallery-item-selected { 33 | border: 4px solid #ffa726; 34 | } 35 | .preview-code-block { 36 | background: #cfd8dc !important; 37 | padding: 8px; 38 | border-radius: 4px; 39 | } 40 | .preview-code-block code { 41 | color: black !important; 42 | } -------------------------------------------------------------------------------- /source/image-handler/cloudfront-function-handlers/apig-request-modifier.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | 5 | function handler(event) { 6 | // Normalize accept header to only include values used on the backend 7 | if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { 8 | event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" 9 | } 10 | event.request.querystring = processQueryParams(event.request.querystring).join('&') 11 | return event.request; 12 | } 13 | 14 | function processQueryParams(querystring) { 15 | if (querystring == null) { 16 | return []; 17 | } 18 | 19 | const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; 20 | 21 | let qs = []; 22 | for (const key in querystring) { 23 | if (!ALLOWED_PARAMS.includes(key)) { 24 | continue; 25 | } 26 | const value = querystring[key]; 27 | qs.push( 28 | value.multiValue 29 | ? `${key}=${value.multiValue[value.multiValue.length - 1].value}` 30 | : `${key}=${value.value}` 31 | ) 32 | } 33 | 34 | return qs.sort(); 35 | } 36 | module.exports = { handler }; -------------------------------------------------------------------------------- /source/image-handler/cloudfront-function-handlers/ol-request-modifier.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | function handler(event) { 5 | // Normalize accept header to only include values used on the backend 6 | if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { 7 | event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" 8 | } 9 | event.request.querystring = processQueryParams(event.request.querystring).join('&') 10 | return event.request; 11 | } 12 | 13 | function processQueryParams(querystring) { 14 | if (querystring == null) { 15 | return []; 16 | } 17 | 18 | const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; 19 | const OL_PARAMS = {'signature': 'ol-signature', 'expires': 'ol-expires'}; 20 | 21 | let qs = []; 22 | for (const key in querystring) { 23 | if (!ALLOWED_PARAMS.includes(key)) { 24 | continue; 25 | } 26 | const value = querystring[key]; 27 | const mappedKey = OL_PARAMS[key] || key; 28 | qs.push( 29 | value.multiValue 30 | ? `${mappedKey}=${value.multiValue[value.multiValue.length - 1].value}` 31 | : `${mappedKey}=${value.value}` 32 | ) 33 | } 34 | 35 | return qs.sort(); 36 | } 37 | module.exports = { handler }; -------------------------------------------------------------------------------- /source/image-handler/cloudfront-function-handlers/ol-response-modifier.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | 5 | function handler(event) { 6 | const response = event.response; 7 | 8 | try { 9 | Object.keys(response.headers).forEach(key => { 10 | if (key.startsWith("x-amz-meta-") && key !== "x-amz-meta-statuscode") { 11 | const headerName = key.replace("x-amz-meta-", ""); 12 | response.headers[headerName] = response.headers[key]; 13 | delete response.headers[key]; 14 | } 15 | }); 16 | 17 | const statusCodeHeader = response.headers["x-amz-meta-statuscode"]; 18 | if (statusCodeHeader) { 19 | const status = parseInt(statusCodeHeader.value); 20 | if (status >= 400 && status <= 599) { 21 | response.statusCode = status; 22 | } 23 | 24 | delete response.headers["x-amz-meta-statuscode"]; 25 | } 26 | } catch (e) { 27 | console.log("Error: ", e); 28 | } 29 | return response; 30 | } 31 | 32 | module.exports = { handler }; -------------------------------------------------------------------------------- /source/image-handler/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.spec.ts'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | }, 7 | coverageReporters: ['text', ['lcov', { projectRoot: '../' }]], 8 | setupFiles: ['./test/setJestEnvironmentVariables.ts'] 9 | }; 10 | -------------------------------------------------------------------------------- /source/image-handler/lib/constants.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export const ALTERNATE_EDIT_ALLOWLIST_ARRAY = [ 5 | "overlayWith", 6 | "smartCrop", 7 | "roundCrop", 8 | "contentModeration", 9 | "crop", 10 | "animated", 11 | ] as const; 12 | 13 | const SHARP_IMAGE_OPERATIONS = { 14 | CHANNEL_FUNCTIONS: ["removeAlpha", "ensureAlpha", "extractChannel", "joinChannel", "bandbool"] as const, 15 | COLOR_FUNCTIONS: [ 16 | "tint", 17 | "greyscale", 18 | "grayscale", 19 | "pipelineColourspace", 20 | "pipelineColorspace", 21 | "toColourspace", 22 | "toColorspace", 23 | ] as const, 24 | OPERATION_FUNCTIONS: [ 25 | "rotate", 26 | "flip", 27 | "flop", 28 | "affine", 29 | "sharpen", 30 | "median", 31 | "blur", 32 | "flatten", 33 | "unflatten", 34 | "gamma", 35 | "negate", 36 | "normalise", 37 | "normalize", 38 | "clahe", 39 | "convolve", 40 | "threshold", 41 | "boolean", 42 | "linear", 43 | "recomb", 44 | "modulate", 45 | ] as const, 46 | FORMAT_OPERATIONS: ["jpeg", "png", "webp", "gif", "avif", "tiff", "heif", "toFormat"] as const, 47 | RESIZE_OPERATIONS: ["resize", "extend", "extract", "trim"] as const, 48 | } as const; 49 | 50 | export const SHARP_EDIT_ALLOWLIST_ARRAY: string[] = [ 51 | ...SHARP_IMAGE_OPERATIONS.CHANNEL_FUNCTIONS, 52 | ...SHARP_IMAGE_OPERATIONS.COLOR_FUNCTIONS, 53 | ...SHARP_IMAGE_OPERATIONS.OPERATION_FUNCTIONS, 54 | ...SHARP_IMAGE_OPERATIONS.FORMAT_OPERATIONS, 55 | ...SHARP_IMAGE_OPERATIONS.RESIZE_OPERATIONS, 56 | ] as const; 57 | 58 | export const HEADER_DENY_LIST = [ 59 | // Exact Matches 60 | /^authorization$/i, 61 | /^connection$/i, 62 | /^server$/i, 63 | /^transfer-encoding$/i, 64 | /^referrer-policy$/i, 65 | /^permissions-policy$/i, 66 | /^www-authenticate$/i, 67 | /^proxy-authenticate$/i, 68 | /^x-api-key$/i, 69 | /^set-cookie$/i, 70 | 71 | // Security Header Patterns 72 | /^x-frame-.*$/i, 73 | /^x-content-.*$/i, 74 | /^x-xss-.*$/i, 75 | /^strict-transport-.*$/i, 76 | /^permissions-.*$/i, 77 | 78 | // AWS Specific Patterns 79 | /^x-amz-.*$/i, 80 | /^x-amzn-.*$/i, 81 | 82 | // Access Control Patterns 83 | /^access-control-.*$/i, 84 | 85 | // Content and Transport Patterns 86 | /^strict-transport-.*$/i, 87 | /^cross-origin-.*$/i, 88 | /^content-.*$/i, 89 | ]; 90 | -------------------------------------------------------------------------------- /source/image-handler/lib/enums.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export enum StatusCodes { 5 | OK = 200, 6 | BAD_REQUEST = 400, 7 | FORBIDDEN = 403, 8 | NOT_FOUND = 404, 9 | REQUEST_TOO_LONG = 413, 10 | INTERNAL_SERVER_ERROR = 500, 11 | TIMEOUT = 503, 12 | } 13 | 14 | export enum RequestTypes { 15 | DEFAULT = "Default", 16 | CUSTOM = "Custom", 17 | THUMBOR = "Thumbor", 18 | } 19 | 20 | export enum ImageFormatTypes { 21 | JPG = "jpg", 22 | JPEG = "jpeg", 23 | PNG = "png", 24 | WEBP = "webp", 25 | TIFF = "tiff", 26 | HEIF = "heif", 27 | HEIC = "heic", 28 | RAW = "raw", 29 | GIF = "gif", 30 | AVIF = "avif", 31 | } 32 | 33 | export enum ImageFitTypes { 34 | COVER = "cover", 35 | CONTAIN = "contain", 36 | FILL = "fill", 37 | INSIDE = "inside", 38 | OUTSIDE = "outside", 39 | } 40 | 41 | export enum ContentTypes { 42 | PNG = "image/png", 43 | JPEG = "image/jpeg", 44 | WEBP = "image/webp", 45 | TIFF = "image/tiff", 46 | GIF = "image/gif", 47 | SVG = "image/svg+xml", 48 | AVIF = "image/avif", 49 | } 50 | -------------------------------------------------------------------------------- /source/image-handler/lib/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export * from "./types"; 5 | export * from "./enums"; 6 | export * from "./interfaces"; 7 | export * from "./constants"; 8 | -------------------------------------------------------------------------------- /source/image-handler/lib/interfaces.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import sharp from "sharp"; 5 | 6 | import { ImageFormatTypes, RequestTypes, StatusCodes } from "./enums"; 7 | import { Headers, ImageEdits } from "./types"; 8 | 9 | export interface QueryStringParameters { 10 | signature?: string; 11 | expires?: string; 12 | format?: string; 13 | fit?: string; 14 | width?: string; 15 | height?: string; 16 | rotate?: string; 17 | flip?: string; 18 | flop?: string; 19 | grayscale?: string; 20 | } 21 | 22 | export interface ImageHandlerEvent { 23 | path?: string; 24 | queryStringParameters?: QueryStringParameters; 25 | requestContext?: { 26 | elb?: unknown; 27 | }; 28 | headers?: Headers; 29 | } 30 | 31 | export interface S3UserRequest { 32 | url: string; 33 | headers: Headers; 34 | } 35 | 36 | export interface S3Event { 37 | userRequest: S3UserRequest; 38 | } 39 | 40 | export interface S3GetObjectEvent extends S3Event { 41 | getObjectContext: { 42 | outputRoute: string; 43 | outputToken: string; 44 | }; 45 | } 46 | 47 | export interface S3HeadObjectResult { 48 | statusCode: number; 49 | headers: Headers; 50 | } 51 | 52 | export interface DefaultImageRequest { 53 | bucket?: string; 54 | key: string; 55 | edits?: ImageEdits; 56 | outputFormat?: ImageFormatTypes; 57 | effort?: number; 58 | headers?: Headers; 59 | } 60 | 61 | export interface BoundingBox { 62 | height: number; 63 | left: number; 64 | top: number; 65 | width: number; 66 | } 67 | 68 | export interface BoxSize { 69 | height: number; 70 | width: number; 71 | } 72 | 73 | export interface ImageRequestInfo { 74 | requestType: RequestTypes; 75 | bucket: string; 76 | key: string; 77 | edits?: ImageEdits; 78 | originalImage: Buffer; 79 | headers?: Headers; 80 | contentType?: string; 81 | expires?: string; 82 | lastModified?: string; 83 | cacheControl?: string; 84 | outputFormat?: ImageFormatTypes; 85 | effort?: number; 86 | secondsToExpiry?: number; 87 | } 88 | 89 | export interface RekognitionCompatibleImage { 90 | imageBuffer: { 91 | data: Buffer; 92 | info: sharp.OutputInfo; 93 | }; 94 | format: keyof sharp.FormatEnum; 95 | } 96 | 97 | export interface ImageHandlerExecutionResult { 98 | statusCode: StatusCodes; 99 | isBase64Encoded: boolean; 100 | headers: Headers; 101 | body: Buffer | string; 102 | } 103 | -------------------------------------------------------------------------------- /source/image-handler/lib/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { StatusCodes } from "./enums"; 5 | import { SHARP_EDIT_ALLOWLIST_ARRAY, ALTERNATE_EDIT_ALLOWLIST_ARRAY } from "./constants"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | export type Headers = Record; 9 | 10 | type AllowlistedEdit = (typeof SHARP_EDIT_ALLOWLIST_ARRAY)[number] | (typeof ALTERNATE_EDIT_ALLOWLIST_ARRAY)[number]; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | export type ImageEdits = Partial>; 14 | 15 | export class ImageHandlerError extends Error { 16 | constructor(public readonly status: StatusCodes, public readonly code: string, public readonly message: string) { 17 | super(); 18 | } 19 | } 20 | 21 | export interface ErrorMapping { 22 | pattern: string; 23 | statusCode: number; 24 | errorType: string; 25 | message: string | ((err: Error) => string); 26 | } 27 | -------------------------------------------------------------------------------- /source/image-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-handler", 3 | "version": "7.0.3", 4 | "private": true, 5 | "description": "A Lambda function for performing on-demand image edits and manipulations.", 6 | "license": "Apache-2.0", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/solutions" 10 | }, 11 | "main": "index.js", 12 | "scripts": { 13 | "clean": "rm -rf node_modules/ dist/ coverage/", 14 | "pretest": "npm run clean && npm ci", 15 | "test": "jest --coverage --silent", 16 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" 17 | }, 18 | "dependencies": { 19 | "@types/aws-lambda": "^8.10.136", 20 | "aws-sdk": "^2.1529.0", 21 | "color": "4.2.3", 22 | "color-name": "1.1.4", 23 | "dayjs": "1.11.10", 24 | "sharp": "^0.32.6" 25 | }, 26 | "devDependencies": { 27 | "@types/color": "^3.0.5", 28 | "@types/color-name": "^1.1.2", 29 | "@types/jest": "^29.5.6", 30 | "@types/node": "^20.10.4", 31 | "jest": "^29.7.0", 32 | "ts-jest": "^29.1.1", 33 | "ts-node": "^10.9.2", 34 | "typescript": "^5.3.3" 35 | }, 36 | "overrides": { 37 | "semver": "7.5.4" 38 | }, 39 | "resolutions": { 40 | "semver": "7.5.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/image-handler/query-param-mapper.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ImageEdits, ImageHandlerError, QueryStringParameters, StatusCodes } from "./lib"; 5 | 6 | export class QueryParamMapper { 7 | mapToBoolean = (value: string): boolean => value === "true"; 8 | 9 | private static readonly QUERY_PARAM_MAPPING: Record< 10 | string, 11 | { path: string[]; key: string; transform?: (value: string) => string | number | boolean } 12 | > = { 13 | format: { path: [], key: "toFormat" }, 14 | fit: { path: ["resize"], key: "fit" }, 15 | width: { path: ["resize"], key: "width", transform: zeroStringToNullInt }, 16 | height: { path: ["resize"], key: "height", transform: zeroStringToNullInt }, 17 | rotate: { path: [], key: "rotate", transform: stringToNullInt }, 18 | flip: { path: [], key: "flip", transform: stringToBoolean }, 19 | flop: { path: [], key: "flop", transform: stringToBoolean }, 20 | grayscale: { path: [], key: "greyscale", transform: stringToBoolean }, 21 | greyscale: { path: [], key: "greyscale", transform: stringToBoolean }, 22 | }; 23 | 24 | public static readonly QUERY_PARAM_KEYS = Object.keys(this.QUERY_PARAM_MAPPING); 25 | 26 | /** 27 | * Initializer function for creating a new Thumbor mapping, used by the image 28 | * handler to perform image modifications based on legacy URL path requests. 29 | * @param queryParameters The query parameter provided alongside the request. 30 | * @returns Image edits included due to the provided query parameter. 31 | */ 32 | public mapQueryParamsToEdits(queryParameters: QueryStringParameters): ImageEdits { 33 | try { 34 | type Result = { 35 | [x: string]: string | number | boolean | Result; 36 | }; 37 | const result: Result = {}; 38 | 39 | Object.entries(queryParameters).forEach(([param, value]) => { 40 | if (value !== undefined && QueryParamMapper.QUERY_PARAM_MAPPING[param]) { 41 | const { path, key, transform } = QueryParamMapper.QUERY_PARAM_MAPPING[param]; 42 | 43 | // Traverse and create nested objects as needed 44 | let current: Result = result; 45 | for (const segment of path) { 46 | current[segment] = current[segment] || {}; 47 | current = current[segment] as Result; 48 | } 49 | 50 | if (transform) { 51 | value = transform(value); 52 | } 53 | // Assign the value at the final destination 54 | current[key] = value; 55 | } 56 | }); 57 | 58 | return result; 59 | } catch (error) { 60 | console.error(error); 61 | throw new ImageHandlerError( 62 | StatusCodes.BAD_REQUEST, 63 | "QueryParameterParsingError", 64 | "Query parameter parsing failed" 65 | ); 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Converts a string to a boolean value, using a list a defined falsy values 72 | * @param input The input string to be converted 73 | * @returns The boolean value of the input string 74 | */ 75 | function stringToBoolean(input: string): boolean { 76 | const falsyValues = ["0", "false", ""]; 77 | return !falsyValues.includes(input.toLowerCase()); 78 | } 79 | 80 | /** 81 | * Converts a string to an integer value, or null if the string is empty 82 | * @param input The input string to be converted 83 | * @returns The integer value of the input string, or null if the input is an empty string 84 | */ 85 | function stringToNullInt(input: string): number | null { 86 | return input === "" ? null : parseInt(input); 87 | } 88 | 89 | /** 90 | * Converts a string to an integer value, or null if the string is empty or "0" 91 | * @param input The input string to be converted 92 | * @returns The integer value of the input string, or null if the input is an empty string or "0" 93 | */ 94 | function zeroStringToNullInt(input: string): number | null { 95 | return input === "0" ? null : stringToNullInt(input); 96 | } 97 | -------------------------------------------------------------------------------- /source/image-handler/secret-provider.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 5 | 6 | /** 7 | * Class provides cached access to the Secret Manager. 8 | */ 9 | export class SecretProvider { 10 | private readonly cache: { secretId: string; secret: string } = { 11 | secretId: null, 12 | secret: null, 13 | }; 14 | 15 | constructor(private readonly secretsManager: SecretsManager) {} 16 | 17 | /** 18 | * Returns the secret associated with the secret ID. 19 | * Note: method caches the secret associated with `secretId` and makes a call to SecretManager 20 | * in case if the `secretId` changes, i.e. when SECRETS_MANAGER environment variable values changes. 21 | * @param secretId The secret ID. 22 | * @returns Secret associated with the secret ID. 23 | */ 24 | async getSecret(secretId: string): Promise { 25 | if (this.cache.secretId === secretId && this.cache.secret) { 26 | return this.cache.secret; 27 | } else { 28 | const response = await this.secretsManager.getSecretValue({ SecretId: secretId }).promise(); 29 | this.cache.secretId = secretId; 30 | this.cache.secret = response.SecretString; 31 | 32 | return this.cache.secret; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /source/image-handler/test/cloudfront-function-handlers/apig-request-modifier.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { handler } from "../../cloudfront-function-handlers/apig-request-modifier"; 5 | 6 | describe("index", () => { 7 | test("should sort values and filter invalid query params", () => { 8 | const testCases = [ 9 | { 10 | // Should sort query params 11 | input: { signature: { value: "value1" }, expires: { value: "value2" }, format: { value: "value3" } }, 12 | expected: "expires=value2&format=value3&signature=value1", 13 | }, 14 | { 15 | // Empty inputs are allowed 16 | input: {}, 17 | expected: "", 18 | }, 19 | { 20 | // Should filter invalid params 21 | input: { key2: { value: "value2" }, format: { value: "value3" } }, 22 | expected: "format=value3", 23 | }, 24 | { 25 | // Multi value keys use the last option 26 | input: { 27 | signature: { value: "value1" }, 28 | expires: { value: "value2", multiValue: [{ value: "value2" }, { value: "value4" }] }, 29 | format: { value: "value3" }, 30 | key2: { value: "value4" }, 31 | }, 32 | expected: "expires=value4&format=value3&signature=value1", 33 | }, 34 | ]; 35 | 36 | testCases.forEach(({ input, expected }) => { 37 | const event = { request: { querystring: input, uri: "test.com/" } }; 38 | const result = handler(event); 39 | expect(result.querystring).toEqual(expected); 40 | }); 41 | }); 42 | 43 | test("should normalize accept header allowing webp images to `image/webp`", () => { 44 | const event = { 45 | request: { 46 | headers: { 47 | accept: { 48 | value: "image/webp,other/test", 49 | }, 50 | }, 51 | statusCode: 200, 52 | }, 53 | }; 54 | 55 | // Call the handler 56 | const result = handler(event); 57 | 58 | // Ensure only image/webp is left 59 | expect(result.headers.accept.value).toBe("image/webp"); 60 | }); 61 | 62 | test("should not set request accept header if not present", () => { 63 | const event = { 64 | request: { 65 | headers: {}, 66 | statusCode: 200, 67 | }, 68 | }; 69 | 70 | // Call the handler 71 | const result = handler(event); 72 | 73 | expect(result.headers).toStrictEqual({}); 74 | }); 75 | 76 | test("should normalize accept header disallowing webp images to empty string", () => { 77 | const event = { 78 | request: { 79 | headers: { 80 | accept: { 81 | value: "image/jpeg,other/test", 82 | }, 83 | }, 84 | statusCode: 200, 85 | }, 86 | }; 87 | 88 | // Call the handler 89 | const result = handler(event); 90 | 91 | // Ensure an empty string is left 92 | expect(result.headers.accept.value).toBe(""); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /source/image-handler/test/cloudfront-function-handlers/ol-request-modifier.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { handler } from "../../cloudfront-function-handlers/ol-request-modifier"; 5 | 6 | describe("index", () => { 7 | test('should add "ol-" prefix to signature and expires querystring keys, sort by key, and filter invalid query params', () => { 8 | const testCases = [ 9 | { 10 | // Signature and Expires query strings are prefixed with -ol 11 | input: { signature: { value: "value1" }, expires: { value: "value2" }, format: { value: "value3" } }, 12 | expected: "format=value3&ol-expires=value2&ol-signature=value1", 13 | }, 14 | { 15 | // Empty inputs are allowed 16 | input: {}, 17 | expected: "", 18 | }, 19 | { 20 | // Should filter invalid params 21 | input: { key2: { value: "value2" }, format: { value: "value3" } }, 22 | expected: "format=value3", 23 | }, 24 | { 25 | // Keys are sorted 26 | input: { rotate: { value: "value3" }, format: { value: "value2" } }, 27 | expected: "format=value2&rotate=value3", 28 | }, 29 | { 30 | // Multi value keys use the last option 31 | input: { 32 | signature: { value: "value1" }, 33 | expires: { value: "value2", multiValue: [{ value: "value2" }, { value: "value4" }] }, 34 | format: { value: "value3" }, 35 | key2: { value: "value4" }, 36 | }, 37 | expected: "format=value3&ol-expires=value4&ol-signature=value1", 38 | }, 39 | // ol-signature is an invalid key, and is removed 40 | { 41 | input: { "ol-signature": { value: "some_value" } }, 42 | expected: "", 43 | }, 44 | ]; 45 | 46 | testCases.forEach(({ input, expected }) => { 47 | const event = { request: { querystring: input, uri: "test.com/" } }; 48 | const result = handler(event); 49 | expect(result.querystring).toEqual(expected); 50 | }); 51 | }); 52 | 53 | test("should normalize accept header allowing webp images to `image/webp`", () => { 54 | const event = { 55 | request: { 56 | headers: { 57 | accept: { 58 | value: "image/webp,other/test", 59 | }, 60 | }, 61 | statusCode: 200, 62 | }, 63 | }; 64 | 65 | // Call the handler 66 | const result = handler(event); 67 | 68 | // Ensure only image/webp is left 69 | expect(result.headers.accept.value).toBe("image/webp"); 70 | }); 71 | 72 | test("should not set request accept header if not present", () => { 73 | const event = { 74 | request: { 75 | headers: {}, 76 | statusCode: 200, 77 | }, 78 | }; 79 | 80 | // Call the handler 81 | const result = handler(event); 82 | 83 | expect(result.headers).toStrictEqual({}); 84 | }); 85 | 86 | test("should normalize accept header disallowing webp images to empty string", () => { 87 | const event = { 88 | request: { 89 | headers: { 90 | accept: { 91 | value: "image/jpeg,other/test", 92 | }, 93 | }, 94 | statusCode: 200, 95 | }, 96 | }; 97 | 98 | // Call the handler 99 | const result = handler(event); 100 | 101 | // Ensure an empty string is left 102 | expect(result.headers.accept.value).toBe(""); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /source/image-handler/test/cloudfront-function-handlers/ol-response-modifier.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { handler } from "../../cloudfront-function-handlers/ol-response-modifier"; 5 | 6 | describe("index", () => { 7 | test("should set response statusCode if x-amz-meta-statuscode header is present and status is between 400 and 599", () => { 8 | // Mock event with a response having x-amz-meta-statuscode header 9 | const event = { 10 | response: { 11 | headers: { 12 | "x-amz-meta-statuscode": { 13 | value: "500", 14 | }, 15 | }, 16 | statusCode: 200, 17 | }, 18 | }; 19 | 20 | // Call the handler 21 | const result = handler(event); 22 | 23 | // Check if statusCode is updated 24 | expect(result.statusCode).toBe(500); 25 | }); 26 | 27 | test("should not set response statusCode if x-amz-meta-statuscode header is not present", () => { 28 | // Mock event with a response without x-amz-meta-statuscode header 29 | const event = { 30 | response: { 31 | headers: {}, 32 | statusCode: 200, 33 | }, 34 | }; 35 | 36 | // Call the handler 37 | const result = handler(event); 38 | 39 | // Check if statusCode remains the same 40 | expect(result.statusCode).toBe(200); 41 | }); 42 | 43 | test("should not set response statusCode if x-amz-meta-statuscode header value is not a valid number", () => { 44 | // Mock event with a response having a non-numeric x-amz-meta-statuscode value 45 | const event = { 46 | response: { 47 | headers: { 48 | "x-amz-meta-statuscode": { 49 | value: "not-a-number", 50 | }, 51 | }, 52 | statusCode: 200, 53 | }, 54 | }; 55 | 56 | // Call the handler 57 | const result = handler(event); 58 | 59 | // Check if statusCode remains the same 60 | expect(result.statusCode).toBe(200); 61 | }); 62 | 63 | test("should not set response statusCode if x-amz-meta-statuscode header value is not an error", () => { 64 | // Mock event with a response having a successful statusCode 65 | const event = { 66 | response: { 67 | headers: { 68 | "x-amz-meta-statuscode": { 69 | value: "204", 70 | }, 71 | }, 72 | statusCode: 200, 73 | }, 74 | }; 75 | 76 | // Call the handler 77 | const result = handler(event); 78 | 79 | // Check if statusCode remains the same 80 | expect(result.statusCode).toBe(200); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /source/image-handler/test/event-normalizer.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { normalizeEvent } from ".."; 5 | import { ImageHandlerEvent, S3GetObjectEvent } from "../lib"; 6 | 7 | describe("normalizeEvent function", () => { 8 | const imageHandlerEvent: ImageHandlerEvent = { 9 | path: "/test.jpg", 10 | queryStringParameters: { 11 | signature: "testSignature", 12 | width: "100", 13 | }, 14 | requestContext: {}, 15 | headers: { Host: "example.com" }, 16 | }; 17 | 18 | const s3GetObjectEvent: S3GetObjectEvent = { 19 | userRequest: { 20 | url: "https://example.com/image/test.jpg?width=100&ol-signature=testSignature", 21 | headers: { 22 | Host: "example.com", 23 | }, 24 | }, 25 | getObjectContext: { 26 | outputRoute: "", 27 | outputToken: "", 28 | }, 29 | }; 30 | 31 | it('should return the event as is when s3_object_lambda_enabled is "No"', () => { 32 | const result = normalizeEvent(imageHandlerEvent, "No"); 33 | expect(result).toEqual(imageHandlerEvent); 34 | }); 35 | 36 | it('should normalize Object Lambda event when s3_object_lambda_enabled is "Yes"', () => { 37 | const result = normalizeEvent(s3GetObjectEvent, "Yes"); 38 | expect(result).toEqual(imageHandlerEvent); 39 | }); 40 | 41 | it('should handle Object Lambda event with empty queryStringParameters when s3_object_lambda_enabled is "Yes"', () => { 42 | const s3GetObjectEvent: S3GetObjectEvent = { 43 | userRequest: { 44 | url: "https://example.com/image/test.jpg", 45 | headers: { 46 | Host: "example.com", 47 | }, 48 | }, 49 | getObjectContext: { 50 | outputRoute: "", 51 | outputToken: "", 52 | }, 53 | }; 54 | const result = normalizeEvent(s3GetObjectEvent, "Yes"); 55 | expect(result.queryStringParameters).toEqual({ signature: undefined }); 56 | }); 57 | 58 | it("should handle Object Lambda event with s3KeyPath including /image", () => { 59 | const s3GetObjectEvent: S3GetObjectEvent = { 60 | userRequest: { 61 | url: "https://example.com/image/image/test.jpg", 62 | headers: { 63 | Host: "example.com", 64 | }, 65 | }, 66 | getObjectContext: { 67 | outputRoute: "", 68 | outputToken: "", 69 | }, 70 | }; 71 | const result = normalizeEvent(s3GetObjectEvent, "Yes"); 72 | expect(result.path).toEqual("/image/test.jpg"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/allowlist.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | import sharp from "sharp"; 7 | 8 | import { ImageHandler } from "../../image-handler"; 9 | import { ImageRequestInfo, RequestTypes } from "../../lib"; 10 | import fs from "fs"; 11 | 12 | const s3Client = new S3(); 13 | const rekognitionClient = new Rekognition(); 14 | 15 | describe("allowlist", () => { 16 | it("Non-allowlisted filters should not be called", async () => { 17 | // Arrange 18 | const originalImage = fs.readFileSync("./test/image/1x1.jpg"); 19 | const request: ImageRequestInfo = { 20 | requestType: RequestTypes.DEFAULT, 21 | bucket: "sample-bucket", 22 | key: "test.jpg", 23 | edits: { rotate: null, toFile: "test" } as unknown as any, // toFile is not allowlisted 24 | originalImage, 25 | }; 26 | 27 | const toFileSpy = jest.spyOn(sharp.prototype, "toFile"); 28 | const rotateSpy = jest.spyOn(sharp.prototype, "rotate"); 29 | 30 | // Act 31 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 32 | await imageHandler.process(request); 33 | 34 | // Assert 35 | expect(toFileSpy).toBeCalledTimes(0); 36 | expect(rotateSpy).toBeCalledTimes(1); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/crop.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | import sharp from "sharp"; 7 | 8 | import { ImageHandler } from "../../image-handler"; 9 | import { ImageEdits, StatusCodes } from "../../lib"; 10 | 11 | const s3Client = new S3(); 12 | const rekognitionClient = new Rekognition(); 13 | 14 | // base64 encoded images 15 | const imagePngWhite5x5 = 16 | "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFAQAAAAClFBtIAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAd2KE6QAAAAHdElNRQfnAxYODhUMhxdmAAAADElEQVQI12P4wQCFABhCBNn4i/hQAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIzLTAzLTIyVDE0OjE0OjIxKzAwOjAwtK8ALAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMy0wMy0yMlQxNDoxNDoyMSswMDowMMXyuJAAAAAASUVORK5CYII="; 17 | const imagePngWhite1x1 = 18 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"; 19 | 20 | describe("crop", () => { 21 | it("Should fail if a cropping area value is out of bounds", async () => { 22 | // Arrange 23 | const originalImage = Buffer.from(imagePngWhite1x1, "base64"); 24 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 25 | const edits: ImageEdits = { 26 | crop: { left: 0, top: 0, width: 100, height: 100 }, 27 | }; 28 | 29 | // Act 30 | try { 31 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 32 | await imageHandler.applyEdits(image, edits, false); 33 | } catch (error) { 34 | // Assert 35 | expect(error).toMatchObject({ 36 | status: StatusCodes.BAD_REQUEST, 37 | code: "Crop::AreaOutOfBounds", 38 | message: 39 | "The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.", 40 | }); 41 | } 42 | }); 43 | 44 | // confirm that crops perform as expected 45 | it("Should pass with a standard crop", async () => { 46 | // 5x5 png 47 | const originalImage = Buffer.from(imagePngWhite5x5, "base64"); 48 | const image = sharp(originalImage, { failOnError: true }); 49 | const edits: ImageEdits = { 50 | crop: { left: 0, top: 0, width: 1, height: 1 }, 51 | }; 52 | 53 | // crop an image and compare with the result expected 54 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 55 | const result = await imageHandler.applyEdits(image, edits, false); 56 | const resultBuffer = await result.toBuffer(); 57 | expect(resultBuffer).toEqual(Buffer.from(imagePngWhite1x1, "base64")); 58 | }); 59 | 60 | // confirm that an invalid attribute sharp crop request containing *right* rather than *top* returns as a cropping error, 61 | // note that this only confirms the behavior of the image-handler in this case, 62 | // it is not an accurate description of the actual error 63 | it("Should fail with an invalid crop request", async () => { 64 | // 5x5 png 65 | const originalImage = Buffer.from(imagePngWhite5x5, "base64"); 66 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 67 | const edits: ImageEdits = { 68 | crop: { left: 0, right: 0, width: 1, height: 1 }, 69 | }; 70 | 71 | // crop an image and compare with the result expected 72 | try { 73 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 74 | await imageHandler.applyEdits(image, edits, false); 75 | } catch (error) { 76 | // Assert 77 | expect(error).toMatchObject({ 78 | status: StatusCodes.BAD_REQUEST, 79 | code: "Crop::AreaOutOfBounds", 80 | message: 81 | "The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.", 82 | }); 83 | } 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/error-response.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { getErrorResponse } from "../../index"; 5 | import { StatusCodes } from "../../lib"; 6 | 7 | describe("getErrorResponse", () => { 8 | it("should return an error response with the provided status code and error message", () => { 9 | const error = { status: 404, message: "Not Found" }; 10 | const result = getErrorResponse(error); 11 | 12 | expect(result).toEqual({ 13 | statusCode: 404, 14 | body: JSON.stringify(error), 15 | }); 16 | }); 17 | 18 | it("should handle other errors and return INTERNAL_SERVER_ERROR", () => { 19 | const error = { message: "Some other error" }; 20 | const result = getErrorResponse(error); 21 | 22 | expect(result).toEqual({ 23 | statusCode: StatusCodes.INTERNAL_SERVER_ERROR, 24 | body: JSON.stringify({ 25 | message: "Internal error. Please contact the system administrator.", 26 | code: "InternalError", 27 | status: StatusCodes.INTERNAL_SERVER_ERROR, 28 | }), 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/limits.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | 7 | import { ImageHandler } from "../../image-handler"; 8 | import { ImageRequestInfo, RequestTypes, StatusCodes } from "../../lib"; 9 | 10 | const s3Client = new S3(); 11 | const rekognitionClient = new Rekognition(); 12 | 13 | describe("limits", () => { 14 | it("Should fail the return payload is larger than 6MB", async () => { 15 | // Arrange 16 | const request: ImageRequestInfo = { 17 | requestType: RequestTypes.DEFAULT, 18 | bucket: "sample-bucket", 19 | key: "sample-image-001.jpg", 20 | originalImage: Buffer.alloc(6 * 1024 * 1024), 21 | }; 22 | 23 | // Act 24 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 25 | try { 26 | await imageHandler.process(request); 27 | } catch (error) { 28 | // Assert 29 | expect(error).toMatchObject({ 30 | status: StatusCodes.REQUEST_TOO_LONG, 31 | code: "TooLargeImageException", 32 | message: "The converted image is too large to return.", 33 | }); 34 | } 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/query-param-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ImageHandlerError } from "../../lib"; 5 | import { QueryParamMapper } from "../../query-param-mapper"; 6 | 7 | describe("QueryParamMapper", () => { 8 | let mapper: QueryParamMapper; 9 | 10 | beforeEach(() => { 11 | mapper = new QueryParamMapper(); 12 | }); 13 | 14 | describe("mapQueryParamsToEdits", () => { 15 | it("should map format parameter correctly", () => { 16 | const result = mapper.mapQueryParamsToEdits({ format: "jpeg" }); 17 | expect(result).toEqual({ toFormat: "jpeg" }); 18 | }); 19 | 20 | it("should map resize parameters correctly", () => { 21 | const result = mapper.mapQueryParamsToEdits({ 22 | width: "100", 23 | height: "200", 24 | fit: "cover", 25 | }); 26 | expect(result).toEqual({ 27 | resize: { 28 | width: 100, 29 | height: 200, 30 | fit: "cover", 31 | }, 32 | }); 33 | }); 34 | 35 | it("should map zeroed width parameters to null", () => { 36 | const result = mapper.mapQueryParamsToEdits({ 37 | width: "0", 38 | height: "200", 39 | fit: "cover", 40 | }); 41 | expect(result).toEqual({ 42 | resize: { 43 | width: null, 44 | height: 200, 45 | fit: "cover", 46 | }, 47 | }); 48 | }); 49 | 50 | it("should transform boolean parameters correctly, should map grayscale to greyscale", () => { 51 | const result = mapper.mapQueryParamsToEdits({ 52 | flip: "true", 53 | flop: "false", 54 | grayscale: "true", 55 | }); 56 | expect(result).toEqual({ 57 | flip: true, 58 | flop: false, 59 | greyscale: true, 60 | }); 61 | }); 62 | 63 | it("should transform rotate parameter correctly", () => { 64 | const result = mapper.mapQueryParamsToEdits({ 65 | rotate: "90", 66 | }); 67 | expect(result).toEqual({ 68 | rotate: 90, 69 | }); 70 | }); 71 | 72 | it("should handle empty rotate value", () => { 73 | const result = mapper.mapQueryParamsToEdits({ 74 | rotate: "", 75 | }); 76 | expect(result).toEqual({ 77 | rotate: null, 78 | }); 79 | }); 80 | 81 | it("should ignore undefined values", () => { 82 | const result = mapper.mapQueryParamsToEdits({ 83 | format: undefined, 84 | width: "100", 85 | }); 86 | expect(result).toEqual({ 87 | resize: { 88 | width: 100, 89 | }, 90 | }); 91 | }); 92 | 93 | it("should ignore unknown parameters", () => { 94 | const result = mapper.mapQueryParamsToEdits({ 95 | // @ts-ignore 96 | unknown: "value", 97 | width: "100", 98 | }); 99 | expect(result).toEqual({ 100 | resize: { 101 | width: 100, 102 | }, 103 | }); 104 | }); 105 | 106 | it("should throw ImageHandlerError on parsing failure", () => { 107 | // Mock console.error to avoid logging during test 108 | console.error = jest.fn(); 109 | 110 | // Force an error by passing invalid input 111 | const invalidInput = null as any; 112 | 113 | expect(() => { 114 | mapper.mapQueryParamsToEdits(invalidInput); 115 | }).toThrow(ImageHandlerError); 116 | 117 | expect(() => { 118 | mapper.mapQueryParamsToEdits(invalidInput); 119 | }).toThrow("Query parameter parsing failed"); 120 | }); 121 | }); 122 | 123 | describe("stringToBoolean helper", () => { 124 | it('should return false for "false" and "0"', () => { 125 | const result1 = mapper.mapQueryParamsToEdits({ flip: "false" }); 126 | const result2 = mapper.mapQueryParamsToEdits({ flip: "0" }); 127 | 128 | expect(result1).toEqual({ flip: false }); 129 | expect(result2).toEqual({ flip: false }); 130 | }); 131 | 132 | it("should return true for other values", () => { 133 | const result1 = mapper.mapQueryParamsToEdits({ flip: "true" }); 134 | const result2 = mapper.mapQueryParamsToEdits({ flip: "1" }); 135 | 136 | expect(result1).toEqual({ flip: true }); 137 | expect(result2).toEqual({ flip: true }); 138 | }); 139 | }); 140 | 141 | describe("QUERY_PARAM_KEYS", () => { 142 | it("should contain all supported parameter keys", () => { 143 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("format"); 144 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("fit"); 145 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("width"); 146 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("height"); 147 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("rotate"); 148 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("flip"); 149 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("flop"); 150 | expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("grayscale"); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/resize.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | import sharp from "sharp"; 7 | 8 | import { ImageHandler } from "../../image-handler"; 9 | import { ImageEdits, ImageHandlerError } from "../../lib"; 10 | 11 | const s3Client = new S3(); 12 | const rekognitionClient = new Rekognition(); 13 | 14 | describe("resize", () => { 15 | it("Should pass if resize width and height are provided as string number to the function", async () => { 16 | // Arrange 17 | const originalImage = Buffer.from( 18 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 19 | "base64" 20 | ); 21 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 22 | const edits: ImageEdits = { resize: { width: "99.1", height: "99.9" } }; 23 | 24 | // Act 25 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 26 | const result = await imageHandler.applyEdits(image, edits, false); 27 | 28 | // Assert 29 | const resultBuffer = await result.toBuffer(); 30 | const convertedImage = await sharp(originalImage, { failOnError: false }) 31 | .withMetadata() 32 | .resize({ width: 99, height: 100 }) 33 | .toBuffer(); 34 | expect(resultBuffer).toEqual(convertedImage); 35 | }); 36 | 37 | it("Should throw an error if image edits dimensions are invalid", async () => { 38 | // Arrange 39 | const originalImage = Buffer.from( 40 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 41 | "base64" 42 | ); 43 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 44 | const edits: ImageEdits = { resize: { width: 0, height: 0 } }; 45 | 46 | // Act 47 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 48 | 49 | // Assert 50 | await expect(imageHandler.applyEdits(image, edits, false)).rejects.toThrow(ImageHandlerError); 51 | }); 52 | 53 | it("Should not throw an error if image edits dimensions contain null", async () => { 54 | // Arrange 55 | const originalImage = Buffer.from( 56 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 57 | "base64" 58 | ); 59 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 60 | const edits: ImageEdits = { resize: { width: 100, height: null } }; 61 | 62 | // Act 63 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 64 | 65 | // Assert 66 | const result = await imageHandler.applyEdits(image, edits, false); 67 | const resultBuffer = await result.toBuffer(); 68 | const convertedImage = await sharp(originalImage, { failOnError: false }) 69 | .withMetadata() 70 | .resize({ width: 100, height: null }) 71 | .toBuffer(); 72 | expect(resultBuffer).toEqual(convertedImage); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/rotate.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | import fs from "fs"; 7 | import sharp from "sharp"; 8 | 9 | import { ImageHandler } from "../../image-handler"; 10 | import { ImageRequestInfo, RequestTypes } from "../../lib"; 11 | 12 | const s3Client = new S3(); 13 | const rekognitionClient = new Rekognition(); 14 | 15 | describe("rotate", () => { 16 | it("Should pass if rotate is null and return image without EXIF and ICC", async () => { 17 | // Arrange 18 | const originalImage = fs.readFileSync("./test/image/1x1.jpg"); 19 | const request: ImageRequestInfo = { 20 | requestType: RequestTypes.DEFAULT, 21 | bucket: "sample-bucket", 22 | key: "test.jpg", 23 | edits: { rotate: null }, 24 | originalImage, 25 | }; 26 | 27 | // Act 28 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 29 | const result = await imageHandler.process(request); 30 | 31 | // Assert 32 | const metadata = await sharp(result).metadata(); 33 | expect(metadata).not.toHaveProperty("exif"); 34 | expect(metadata).not.toHaveProperty("icc"); 35 | expect(metadata).not.toHaveProperty("orientation"); 36 | }); 37 | 38 | it("Should pass if the original image has orientation", async () => { 39 | // Arrange 40 | const originalImage = fs.readFileSync("./test/image/1x1.jpg"); 41 | const request: ImageRequestInfo = { 42 | requestType: RequestTypes.DEFAULT, 43 | bucket: "sample-bucket", 44 | key: "test.jpg", 45 | edits: {}, 46 | originalImage, 47 | }; 48 | 49 | // Act 50 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 51 | const result = await imageHandler.process(request); 52 | 53 | // Assert 54 | const metadata = await sharp(result).metadata(); 55 | expect(metadata).toHaveProperty("icc"); 56 | expect(metadata).toHaveProperty("exif"); 57 | expect(metadata.orientation).toEqual(3); 58 | }); 59 | 60 | it("Should pass if the original image does not have orientation", async () => { 61 | // Arrange 62 | const request: ImageRequestInfo = { 63 | requestType: RequestTypes.DEFAULT, 64 | bucket: "sample-bucket", 65 | key: "test.jpg", 66 | edits: {}, 67 | originalImage: Buffer.from( 68 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 69 | "base64" 70 | ), 71 | }; 72 | 73 | // Act 74 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 75 | const result = await imageHandler.process(request); 76 | 77 | // Assert 78 | const metadata = await sharp(result).metadata(); 79 | expect(metadata).not.toHaveProperty("orientation"); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /source/image-handler/test/image-handler/standard.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import Rekognition from "aws-sdk/clients/rekognition"; 5 | import S3 from "aws-sdk/clients/s3"; 6 | import sharp from "sharp"; 7 | 8 | import { ImageHandler } from "../../image-handler"; 9 | import { ImageEdits, ImageRequestInfo, RequestTypes } from "../../lib"; 10 | import fs from "fs"; 11 | 12 | const s3Client = new S3(); 13 | const rekognitionClient = new Rekognition(); 14 | const image = fs.readFileSync("./test/image/25x15.png"); 15 | const withMetatdataSpy = jest.spyOn(sharp.prototype, "withMetadata"); 16 | 17 | describe("standard", () => { 18 | it("Should pass if a series of standard edits are provided to the function", async () => { 19 | // Arrange 20 | const originalImage = Buffer.from( 21 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 22 | "base64" 23 | ); 24 | const image = sharp(originalImage, { failOnError: false }).withMetadata(); 25 | const edits: ImageEdits = { grayscale: true, flip: true }; 26 | 27 | // Act 28 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 29 | const result = await imageHandler.applyEdits(image, edits, false); 30 | 31 | // Assert 32 | /* eslint-disable dot-notation */ 33 | const expectedResult1 = result["options"].greyscale; 34 | const expectedResult2 = result["options"].flip; 35 | const combinedResults = expectedResult1 && expectedResult2; 36 | expect(combinedResults).toEqual(true); 37 | }); 38 | 39 | it("Should pass if no edits are specified and the original image is returned", async () => { 40 | // Arrange 41 | const request: ImageRequestInfo = { 42 | requestType: RequestTypes.DEFAULT, 43 | bucket: "sample-bucket", 44 | key: "sample-image-001.jpg", 45 | originalImage: Buffer.from( 46 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", 47 | "base64" 48 | ), 49 | }; 50 | 51 | // Act 52 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 53 | const result = await imageHandler.process(request); 54 | 55 | // Assert 56 | expect(result).toEqual(request.originalImage); 57 | }); 58 | }); 59 | 60 | describe("instantiateSharpImage", () => { 61 | beforeEach(() => { 62 | jest.clearAllMocks(); 63 | }); 64 | 65 | it("Should not include metadata if the rotation is null", async () => { 66 | // Arrange 67 | const edits = { 68 | rotate: null, 69 | }; 70 | const options = { faiOnError: false }; 71 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 72 | 73 | // Act 74 | await imageHandler["instantiateSharpImage"](image, edits, options); 75 | 76 | // Assert 77 | expect(withMetatdataSpy).not.toHaveBeenCalled(); 78 | }); 79 | 80 | it("Should include metadata and not define orientation if the rotation is not null and orientation is not defined", async () => { 81 | // Arrange 82 | const edits = { 83 | rotate: undefined, 84 | }; 85 | const options = { faiOnError: false }; 86 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 87 | 88 | // Act 89 | await imageHandler["instantiateSharpImage"](image, edits, options); 90 | 91 | // Assert 92 | expect(withMetatdataSpy).toHaveBeenCalled(); 93 | expect(withMetatdataSpy).not.toHaveBeenCalledWith(expect.objectContaining({ orientation: expect.anything })); 94 | }); 95 | 96 | it("Should include orientation metadata if the rotation is defined in the metadata", async () => { 97 | // Arrange 98 | const edits = { 99 | rotate: undefined, 100 | }; 101 | const options = { faiOnError: false }; 102 | const modifiedImage = await sharp(image).withMetadata({ orientation: 1 }).toBuffer(); 103 | const imageHandler = new ImageHandler(s3Client, rekognitionClient); 104 | 105 | // Act 106 | await imageHandler["instantiateSharpImage"](modifiedImage, edits, options); 107 | 108 | // Assert 109 | expect(withMetatdataSpy).toHaveBeenCalledWith({ orientation: 1 }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/fix-quality.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { ImageFormatTypes, ImageRequestInfo, RequestTypes } from "../../lib"; 9 | import { SecretProvider } from "../../secret-provider"; 10 | 11 | const imageRequestInfo: ImageRequestInfo = { 12 | bucket: "bucket", 13 | key: "key", 14 | requestType: RequestTypes.THUMBOR, 15 | edits: { png: { quality: 80 } }, 16 | originalImage: Buffer.from("image"), 17 | outputFormat: ImageFormatTypes.JPEG, 18 | }; 19 | 20 | describe("fixQuality", () => { 21 | const s3Client = new S3(); 22 | const secretsManager = new SecretsManager(); 23 | const secretProvider = new SecretProvider(secretsManager); 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks(); 27 | imageRequestInfo.edits = { png: { quality: 80 } }; 28 | }); 29 | 30 | it("Should map correct edits with quality key to edits if output in edits differs from output format in request ", () => { 31 | // Arrange 32 | const imageRequest = new ImageRequest(s3Client, secretProvider); 33 | 34 | // Act 35 | imageRequest["fixQuality"](imageRequestInfo); 36 | 37 | // Assert 38 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ jpeg: { quality: 80 } })); 39 | expect(imageRequestInfo.edits.png).toBe(undefined); 40 | }); 41 | 42 | it("should not map edits with quality key if not output format is not a supported type", () => { 43 | // Arrange 44 | const imageRequest = new ImageRequest(s3Client, secretProvider); 45 | imageRequestInfo.outputFormat = "pdf" as ImageFormatTypes; 46 | 47 | // Act 48 | imageRequest["fixQuality"](imageRequestInfo); 49 | 50 | // Assert 51 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); 52 | }); 53 | 54 | it("should not map edits with quality key if not output format is the same as the quality key", () => { 55 | // Arrange 56 | const imageRequest = new ImageRequest(s3Client, secretProvider); 57 | imageRequestInfo.outputFormat = ImageFormatTypes.PNG; 58 | 59 | // Act 60 | imageRequest["fixQuality"](imageRequestInfo); 61 | 62 | // Assert 63 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); 64 | }); 65 | 66 | it("should not map edits with quality key if the request is of default type", () => { 67 | // Arrange 68 | const imageRequest = new ImageRequest(s3Client, secretProvider); 69 | imageRequestInfo.outputFormat = ImageFormatTypes.JPEG; 70 | imageRequestInfo.requestType = RequestTypes.DEFAULT; 71 | 72 | // Act 73 | imageRequest["fixQuality"](imageRequestInfo); 74 | 75 | // Assert 76 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); 77 | }); 78 | 79 | it("should not map edits with quality key if the request is default type", () => { 80 | // Arrange 81 | const imageRequest = new ImageRequest(s3Client, secretProvider); 82 | imageRequestInfo.outputFormat = ImageFormatTypes.JPEG; 83 | imageRequestInfo.requestType = RequestTypes.DEFAULT; 84 | 85 | // Act 86 | imageRequest["fixQuality"](imageRequestInfo); 87 | 88 | // Assert 89 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); 90 | }); 91 | 92 | it("should not map edits with quality key if the request if there is no output format", () => { 93 | // Arrange 94 | const imageRequest = new ImageRequest(s3Client, secretProvider); 95 | delete imageRequestInfo.outputFormat; 96 | 97 | // Act 98 | imageRequest["fixQuality"](imageRequestInfo); 99 | 100 | // Assert 101 | expect(imageRequestInfo.edits).toEqual(expect.objectContaining({ png: { quality: 80 } })); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/get-allowed-source-buckets.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { getAllowedSourceBuckets } from "../../image-request"; 5 | import { StatusCodes } from "../../lib"; 6 | 7 | describe("getAllowedSourceBuckets", () => { 8 | it("Should pass if the SOURCE_BUCKETS environment variable is not empty and contains valid inputs", () => { 9 | // Arrange 10 | process.env.SOURCE_BUCKETS = "allowedBucket001, allowedBucket002"; 11 | 12 | // Act 13 | const result = getAllowedSourceBuckets(); 14 | 15 | // Assert 16 | const expectedResult = ["allowedBucket001", "allowedBucket002"]; 17 | expect(result).toEqual(expectedResult); 18 | }); 19 | 20 | it("Should throw an error if the SOURCE_BUCKETS environment variable is empty or does not contain valid values", () => { 21 | // Arrange 22 | process.env = {}; 23 | 24 | // Act 25 | // Assert 26 | try { 27 | getAllowedSourceBuckets(); 28 | } catch (error) { 29 | expect(error).toMatchObject({ 30 | status: StatusCodes.BAD_REQUEST, 31 | code: "GetAllowedSourceBuckets::NoSourceBuckets", 32 | message: 33 | "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.", 34 | }); 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/get-output-format.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { SecretProvider } from "../../secret-provider"; 9 | 10 | describe("getOutputFormat", () => { 11 | const s3Client = new S3(); 12 | const secretsManager = new SecretsManager(); 13 | const secretProvider = new SecretProvider(secretsManager); 14 | const OLD_ENV = process.env; 15 | 16 | beforeEach(() => { 17 | process.env = { ...OLD_ENV }; 18 | }); 19 | 20 | afterAll(() => { 21 | process.env = OLD_ENV; 22 | }); 23 | 24 | it('Should pass if it returns "webp" for a capitalized accepts header which includes webp', () => { 25 | // Arrange 26 | const event = { 27 | headers: { 28 | Accept: 29 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", 30 | }, 31 | }; 32 | process.env.AUTO_WEBP = "Yes"; 33 | 34 | // Act 35 | const imageRequest = new ImageRequest(s3Client, secretProvider); 36 | const result = imageRequest.getOutputFormat(event); 37 | 38 | // Assert 39 | expect(result).toEqual("webp"); 40 | }); 41 | 42 | it('Should pass if it returns "webp" for a lowercase accepts header which includes webp', () => { 43 | // Arrange 44 | const event = { 45 | headers: { 46 | accept: 47 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", 48 | }, 49 | }; 50 | process.env.AUTO_WEBP = "Yes"; 51 | 52 | // Act 53 | const imageRequest = new ImageRequest(s3Client, secretProvider); 54 | const result = imageRequest.getOutputFormat(event); 55 | 56 | // Assert 57 | expect(result).toEqual("webp"); 58 | }); 59 | 60 | it("Should pass if it returns null for an accepts header which does not include webp", () => { 61 | // Arrange 62 | const event = { 63 | headers: { 64 | Accept: 65 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", 66 | }, 67 | }; 68 | process.env.AUTO_WEBP = "Yes"; 69 | 70 | // Act 71 | const imageRequest = new ImageRequest(s3Client, secretProvider); 72 | const result = imageRequest.getOutputFormat(event); 73 | 74 | // Assert 75 | expect(result).toBeNull(); 76 | }); 77 | 78 | it("Should pass if it returns null when AUTO_WEBP is disabled with accepts header including webp", () => { 79 | // Arrange 80 | const event = { 81 | headers: { 82 | Accept: 83 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", 84 | }, 85 | }; 86 | process.env.AUTO_WEBP = "No"; 87 | 88 | // Act 89 | const imageRequest = new ImageRequest(s3Client, secretProvider); 90 | const result = imageRequest.getOutputFormat(event); 91 | 92 | // Assert 93 | expect(result).toBeNull(); 94 | }); 95 | 96 | it("Should pass if it returns null when AUTO_WEBP is not set with accepts header including webp", () => { 97 | // Arrange 98 | const event = { 99 | headers: { 100 | Accept: 101 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", 102 | }, 103 | }; 104 | 105 | // Act 106 | const imageRequest = new ImageRequest(s3Client, secretProvider); 107 | const result = imageRequest.getOutputFormat(event); 108 | 109 | // Assert 110 | expect(result).toBeNull(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/infer-image-type.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { SecretProvider } from "../../secret-provider"; 9 | 10 | describe("inferImageType", () => { 11 | const s3Client = new S3(); 12 | const secretsManager = new SecretsManager(); 13 | const secretProvider = new SecretProvider(secretsManager); 14 | 15 | test.each([ 16 | { value: "FFD8FFDB", type: "image/jpeg" }, 17 | { value: "FFD8FFE0", type: "image/jpeg" }, 18 | { value: "FFD8FFED", type: "image/jpeg" }, 19 | { value: "FFD8FFEE", type: "image/jpeg" }, 20 | { value: "FFD8FFE1", type: "image/jpeg" }, 21 | { value: "FFD8FFE2", type: "image/jpeg" }, 22 | { value: "FFD8XXXX", type: "image/jpeg" }, 23 | { value: "89504E47", type: "image/png" }, 24 | { value: "52494646", type: "image/webp" }, 25 | { value: "49492A00", type: "image/tiff" }, 26 | { value: "4D4D002A", type: "image/tiff" }, 27 | { value: "47494638", type: "image/gif" }, 28 | { value: "000000006674797061766966", type: "image/avif" }, 29 | ])('Should pass if it returns "$type" for a magic number of $value', ({ value, type }) => { 30 | const byteValues = value.match(/.{1,2}/g).map((x) => parseInt(x, 16)); 31 | const imageBuffer = Buffer.from(byteValues.concat(new Array(8).fill(0x00))); 32 | 33 | // Act 34 | const imageRequest = new ImageRequest(s3Client, secretProvider); 35 | const result = imageRequest.inferImageType(imageBuffer); 36 | 37 | // Assert 38 | expect(result).toEqual(type); 39 | }); 40 | 41 | it("Should pass throw an exception", () => { 42 | // Arrange 43 | const imageBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); 44 | 45 | try { 46 | // Act 47 | const imageRequest = new ImageRequest(s3Client, secretProvider); 48 | imageRequest.inferImageType(imageBuffer); 49 | } catch (error) { 50 | // Assert 51 | expect(error.status).toEqual(500); 52 | expect(error.code).toEqual("RequestTypeError"); 53 | } 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/parse-image-edits.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { RequestTypes, StatusCodes } from "../../lib"; 9 | import { SecretProvider } from "../../secret-provider"; 10 | 11 | describe("parseImageEdits", () => { 12 | const s3Client = new S3(); 13 | const secretsManager = new SecretsManager(); 14 | const secretProvider = new SecretProvider(secretsManager); 15 | const OLD_ENV = process.env; 16 | 17 | beforeEach(() => { 18 | process.env = { ...OLD_ENV }; 19 | }); 20 | 21 | afterAll(() => { 22 | process.env = OLD_ENV; 23 | }); 24 | 25 | it("Should pass if the proper result is returned for a sample base64-encoded image request", () => { 26 | // Arrange 27 | const event = { 28 | path: "/eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0=", 29 | }; 30 | 31 | // Act 32 | const imageRequest = new ImageRequest(s3Client, secretProvider); 33 | const result = imageRequest.parseImageEdits(event, RequestTypes.DEFAULT); 34 | 35 | // Assert 36 | const expectedResult = { grayscale: "true", rotate: 90, flip: "true" }; 37 | expect(result).toEqual(expectedResult); 38 | }); 39 | 40 | it("Should pass if the proper result is returned for a sample thumbor-type image request", () => { 41 | // Arrange 42 | const event = { 43 | path: "/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg", 44 | }; 45 | 46 | // Act 47 | const imageRequest = new ImageRequest(s3Client, secretProvider); 48 | const result = imageRequest.parseImageEdits(event, RequestTypes.THUMBOR); 49 | 50 | // Assert 51 | const expectedResult = { rotate: 90, grayscale: true }; 52 | expect(result).toEqual(expectedResult); 53 | }); 54 | 55 | it("Should pass if the proper result is returned for a sample custom-type image request", () => { 56 | // Arrange 57 | const event = { 58 | path: "/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg", 59 | }; 60 | 61 | process.env = { 62 | REWRITE_MATCH_PATTERN: "/(filters-)/gm", 63 | REWRITE_SUBSTITUTION: "filters:", 64 | }; 65 | 66 | // Act 67 | const imageRequest = new ImageRequest(s3Client, secretProvider); 68 | const result = imageRequest.parseImageEdits(event, RequestTypes.CUSTOM); 69 | 70 | // Assert 71 | const expectedResult = { rotate: 90, grayscale: true }; 72 | expect(result).toEqual(expectedResult); 73 | }); 74 | 75 | it("Should throw an error if a requestType is not specified and/or the image edits cannot be parsed", () => { 76 | // Arrange 77 | const event = { 78 | path: "/filters:rotate(90)/filters:grayscale()/other-image.jpg", 79 | }; 80 | 81 | // Act 82 | const imageRequest = new ImageRequest(s3Client, secretProvider); 83 | 84 | // Assert 85 | try { 86 | imageRequest.parseImageEdits(event, undefined); 87 | } catch (error) { 88 | expect(error).toMatchObject({ 89 | status: StatusCodes.BAD_REQUEST, 90 | code: "ImageEdits::CannotParseEdits", 91 | message: 92 | "The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance.", 93 | }); 94 | } 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/parse-image-headers.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { RequestTypes } from "../../lib"; 9 | import { SecretProvider } from "../../secret-provider"; 10 | 11 | describe("parseImageHeaders", () => { 12 | const s3Client = new S3(); 13 | const secretsManager = new SecretsManager(); 14 | const secretProvider = new SecretProvider(secretsManager); 15 | 16 | it("001/Should return headers if headers are provided for a sample base64-encoded image request", () => { 17 | // Arrange 18 | const event = { 19 | path: "/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMifSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9", 20 | }; 21 | 22 | // Act 23 | const imageRequest = new ImageRequest(s3Client, secretProvider); 24 | const result = imageRequest.parseImageHeaders(event, RequestTypes.DEFAULT); 25 | 26 | // Assert 27 | const expectedResult = { 28 | "Cache-Control": "max-age=31536000,public", 29 | }; 30 | expect(result).toEqual(expectedResult); 31 | }); 32 | 33 | it("001/Should remove restricted headers if included with a base64-encoded image request", () => { 34 | //Arrange 35 | /** Includes restricted headers: 36 | * 'Transfer-encoding', 37 | * 'x-api-key', 38 | * 'x-amz-header', 39 | * 'content-type', 40 | * 'Access-Control-Allow-Origin' 41 | **/ 42 | const event = { 43 | path: "/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiaGVhZGVycyI6eyJDYWNoZS1Db250cm9sIjoibWF4LWFnZT0zMTUzNjAwMCxwdWJsaWMiLCAiVHJhbnNmZXItZW5jb2RpbmciOiAidmFsdWUiLCAieC1hcGkta2V5IjogInZhbHVlIiwgIngtYW16LWhlYWRlciI6ICJ2YWx1ZSIsICJjb250ZW50LXR5cGUiOiAiaHRtbCIsICJBY2Nlc3MtQ29udHJvbC1BbGxvdy1PcmlnaW4iOiAiKiJ9LCJvdXRwdXRGb3JtYXQiOiJqcGVnIn0=", 44 | }; 45 | // Act 46 | const imageRequest = new ImageRequest(s3Client, secretProvider); 47 | const result = imageRequest.parseImageHeaders(event, RequestTypes.DEFAULT); 48 | 49 | // Assert 50 | const expectedResult = { 51 | "Cache-Control": "max-age=31536000,public", 52 | }; 53 | expect(result).toEqual(expectedResult); 54 | }); 55 | 56 | it("001/Should return undefined if headers are not provided for a base64-encoded image request", () => { 57 | // Arrange 58 | const event = { 59 | path: "/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5In0=", 60 | }; 61 | 62 | // Act 63 | const imageRequest = new ImageRequest(s3Client, secretProvider); 64 | const result = imageRequest.parseImageHeaders(event, RequestTypes.DEFAULT); 65 | 66 | // Assert 67 | expect(result).toEqual(undefined); 68 | }); 69 | 70 | it("001/Should return undefined for Thumbor or Custom requests", () => { 71 | // Arrange 72 | const event = { path: "/test.jpg" }; 73 | 74 | // Act 75 | const imageRequest = new ImageRequest(s3Client, secretProvider); 76 | const result = imageRequest.parseImageHeaders(event, RequestTypes.THUMBOR); 77 | 78 | // Assert 79 | expect(result).toEqual(undefined); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/parse-query-param-edits.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ImageRequest } from "../../image-request"; 5 | import { ImageHandlerEvent } from "../../lib"; 6 | 7 | describe("parseImageEdits", () => { 8 | const OLD_ENV = process.env; 9 | 10 | beforeEach(() => { 11 | process.env = { ...OLD_ENV }; 12 | }); 13 | 14 | afterAll(() => { 15 | process.env = OLD_ENV; 16 | }); 17 | 18 | it("should parse rotate and width parameters from query string into image edits object", () => { 19 | // Arrange 20 | const event: ImageHandlerEvent = { 21 | queryStringParameters: { 22 | rotate: "90", 23 | width: "100", 24 | flip: "true", 25 | flop: "0", 26 | }, 27 | }; 28 | 29 | // Act 30 | const imageRequest = new ImageRequest(undefined, undefined); 31 | const result = imageRequest.parseQueryParamEdits(event, undefined); 32 | 33 | // Assert 34 | const expectedResult = { rotate: 90, resize: { width: 100 }, flip: true, flop: false }; 35 | expect(result).toEqual(expectedResult); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /source/image-handler/test/image-request/recreate-query-string.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import S3 from "aws-sdk/clients/s3"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | 7 | import { ImageRequest } from "../../image-request"; 8 | import { SecretProvider } from "../../secret-provider"; 9 | 10 | describe("recreateQueryString", () => { 11 | const s3Client = new S3(); 12 | const secretsManager = new SecretsManager(); 13 | const secretProvider = new SecretProvider(secretsManager); 14 | 15 | it("Should accurately recreate query strings", () => { 16 | const testCases = [ 17 | { 18 | // Signature should be removed 19 | queryParams: { signature: "test-signature", expires: "test-expires", format: "png" }, 20 | expected: "expires=test-expires&format=png", 21 | }, 22 | { 23 | queryParams: { grayscale: "true", expires: "test-expires", format: "png" }, 24 | expected: "expires=test-expires&format=png&grayscale=true", 25 | }, 26 | { 27 | queryParams: { 28 | signature: "test-signature", 29 | expires: "test-expires", 30 | format: "png", 31 | fit: "cover", 32 | width: "100", 33 | height: "100", 34 | rotate: "90", 35 | flip: "true", 36 | flop: "true", 37 | grayscale: "true", 38 | }, 39 | 40 | expected: 41 | "expires=test-expires&fit=cover&flip=true&flop=true&format=png&grayscale=true&height=100&rotate=90&width=100", 42 | }, 43 | ]; 44 | 45 | const imageRequest = new ImageRequest(s3Client, secretProvider); 46 | testCases.forEach(({ queryParams, expected }) => { 47 | // @ts-ignore 48 | const result = imageRequest.recreateQueryString(queryParams); 49 | expect(result).toEqual(expected); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /source/image-handler/test/image/1x1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/1x1.jpg -------------------------------------------------------------------------------- /source/image-handler/test/image/25x15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/25x15.png -------------------------------------------------------------------------------- /source/image-handler/test/image/aws_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/aws_logo.png -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-10x10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-10x10.jpeg -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-10x10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-10x10.png -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-10x5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-10x5.jpeg -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-10x5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-10x5.png -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-5x10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-5x10.jpeg -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-5x10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-5x10.png -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-5x5-2page.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-5x5-2page.gif -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-5x5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-5x5.jpeg -------------------------------------------------------------------------------- /source/image-handler/test/image/transparent-5x5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront/3294dd845e1c589b70625595180915e487bc3349/source/image-handler/test/image/transparent-5x5.png -------------------------------------------------------------------------------- /source/image-handler/test/mock.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export const mockAwsS3 = { 5 | headObject: jest.fn(), 6 | copyObject: jest.fn(), 7 | getObject: jest.fn(), 8 | putObject: jest.fn(), 9 | headBucket: jest.fn(), 10 | createBucket: jest.fn(), 11 | putBucketEncryption: jest.fn(), 12 | putBucketPolicy: jest.fn(), 13 | writeGetObjectResponse: jest.fn(), 14 | }; 15 | 16 | jest.mock("aws-sdk/clients/s3", () => jest.fn(() => ({ ...mockAwsS3 }))); 17 | 18 | export const mockAwsSecretManager = { 19 | getSecretValue: jest.fn(), 20 | }; 21 | 22 | jest.mock("aws-sdk/clients/secretsmanager", () => jest.fn(() => ({ ...mockAwsSecretManager }))); 23 | 24 | export const mockAwsRekognition = { 25 | detectFaces: jest.fn(), 26 | detectModerationLabels: jest.fn(), 27 | }; 28 | 29 | jest.mock("aws-sdk/clients/rekognition", () => jest.fn(() => ({ ...mockAwsRekognition }))); 30 | 31 | export const consoleInfoSpy = jest.spyOn(console, "info"); 32 | 33 | export const mockContext = { 34 | getRemainingTimeInMillis: jest.fn(), 35 | }; 36 | -------------------------------------------------------------------------------- /source/image-handler/test/secret-provider.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { mockAwsSecretManager } from "./mock"; 5 | import SecretsManager from "aws-sdk/clients/secretsmanager"; 6 | import { SecretProvider } from "../secret-provider"; 7 | 8 | describe("index", () => { 9 | const secretsManager = new SecretsManager(); 10 | 11 | afterEach(() => { 12 | mockAwsSecretManager.getSecretValue.mockReset(); 13 | }); 14 | 15 | it("Should get a secret from secret manager if the cache is empty", async () => { 16 | mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({ 17 | promise() { 18 | return Promise.resolve({ SecretString: "secret_value" }); 19 | }, 20 | })); 21 | 22 | const secretProvider = new SecretProvider(secretsManager); 23 | const secretKeyFistCall = await secretProvider.getSecret("secret_id"); 24 | const secretKeySecondCall = await secretProvider.getSecret("secret_id"); 25 | 26 | expect(secretKeyFistCall).toEqual("secret_value"); 27 | expect(secretKeySecondCall).toEqual("secret_value"); 28 | expect(mockAwsSecretManager.getSecretValue).toBeCalledTimes(1); 29 | expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ 30 | SecretId: "secret_id", 31 | }); 32 | }); 33 | 34 | it("Should get a secret from secret manager and invalidate the cache", async () => { 35 | mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({ 36 | promise() { 37 | return Promise.resolve({ SecretString: "secret_value_1" }); 38 | }, 39 | })); 40 | mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({ 41 | promise() { 42 | return Promise.resolve({ SecretString: "secret_value_2" }); 43 | }, 44 | })); 45 | 46 | const secretProvider = new SecretProvider(secretsManager); 47 | const getSecretKeyFistCall = await secretProvider.getSecret("secret_id_1"); 48 | const getSecretKeySecondCall = await secretProvider.getSecret("secret_id_2"); 49 | const getSecretKeyThirdCall = await secretProvider.getSecret("secret_id_2"); 50 | 51 | expect(getSecretKeyFistCall).toEqual("secret_value_1"); 52 | expect(getSecretKeySecondCall).toEqual("secret_value_2"); 53 | expect(getSecretKeyThirdCall).toEqual("secret_value_2"); 54 | expect(mockAwsSecretManager.getSecretValue).toBeCalledTimes(2); 55 | expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ 56 | SecretId: "secret_id_1", 57 | }); 58 | expect(mockAwsSecretManager.getSecretValue).toHaveBeenCalledWith({ 59 | SecretId: "secret_id_2", 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /source/image-handler/test/setJestEnvironmentVariables.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // a placeholder for environment variables 5 | -------------------------------------------------------------------------------- /source/image-handler/test/thumbor-mapper/crop.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ThumborMapper } from "../../thumbor-mapper"; 5 | 6 | describe("crop", () => { 7 | it("Should pass if the proper crop is applied", () => { 8 | // Arrange 9 | const path = "/10x0:100x200/test-image-001.jpg"; 10 | 11 | // Act 12 | const thumborMapper = new ThumborMapper(); 13 | const edits = thumborMapper.mapPathToEdits(path); 14 | 15 | // Assert 16 | const expectedResult = { 17 | edits: { 18 | crop: { left: 10, top: 0, width: 90, height: 200 }, 19 | }, 20 | }; 21 | expect(edits).toEqual(expectedResult.edits); 22 | }); 23 | 24 | it("Should ignore crop if invalid dimension values are provided", () => { 25 | // Arrange 26 | const path = "/abc:0:10x200/test-image-001.jpg"; 27 | 28 | // Act 29 | const thumborMapper = new ThumborMapper(); 30 | const edits = thumborMapper.mapPathToEdits(path); 31 | 32 | // Assert 33 | const expectedResult = { edits: {} }; 34 | expect(edits).toEqual(expectedResult.edits); 35 | }); 36 | 37 | it("Should pass if the proper crop and resize are applied", () => { 38 | // Arrange 39 | const path = "/10x0:100x200/10x20/test-image-001.jpg"; 40 | // Act 41 | const thumborMapper = new ThumborMapper(); 42 | const edits = thumborMapper.mapPathToEdits(path); 43 | 44 | // Assert 45 | const expectedResult = { 46 | edits: { 47 | crop: { left: 10, top: 0, width: 90, height: 200 }, 48 | resize: { width: 10, height: 20 }, 49 | }, 50 | }; 51 | expect(edits).toEqual(expectedResult.edits); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /source/image-handler/test/thumbor-mapper/edits.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ThumborMapper } from "../../thumbor-mapper"; 5 | 6 | describe("edits", () => { 7 | it("Should pass if filters are chained", () => { 8 | const path = "/filters:rotate(90):grayscale()/thumbor-image.jpg"; 9 | 10 | // Act 11 | const thumborMapper = new ThumborMapper(); 12 | const edits = thumborMapper.mapPathToEdits(path); 13 | 14 | // Assert 15 | const expectedResult = { 16 | edits: { 17 | rotate: 90, 18 | grayscale: true, 19 | }, 20 | }; 21 | expect(edits).toEqual(expectedResult.edits); 22 | }); 23 | 24 | it("Should pass if filters are not chained", () => { 25 | const path = "/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg"; 26 | 27 | // Act 28 | const thumborMapper = new ThumborMapper(); 29 | const edits = thumborMapper.mapPathToEdits(path); 30 | 31 | // Assert 32 | const expectedResult = { 33 | edits: { 34 | rotate: 90, 35 | grayscale: true, 36 | }, 37 | }; 38 | expect(edits).toEqual(expectedResult.edits); 39 | }); 40 | 41 | it("Should pass if filters are both chained and individual", () => { 42 | const path = "/filters:rotate(90):grayscale()/filters:blur(20)/thumbor-image.jpg"; 43 | 44 | // Act 45 | const thumborMapper = new ThumborMapper(); 46 | const edits = thumborMapper.mapPathToEdits(path); 47 | 48 | // Assert 49 | const expectedResult = { 50 | edits: { 51 | rotate: 90, 52 | grayscale: true, 53 | blur: 10, 54 | }, 55 | }; 56 | expect(edits).toEqual(expectedResult.edits); 57 | }); 58 | 59 | it("Should pass even if there are slashes in the filter", () => { 60 | const path = "/filters:watermark(bucket,folder/key.png,0,0)/image.jpg"; 61 | 62 | // Act 63 | const thumborMapper = new ThumborMapper(); 64 | const edits = thumborMapper.mapPathToEdits(path); 65 | 66 | // Assert 67 | const expectedResult = { 68 | edits: { 69 | overlayWith: { 70 | alpha: undefined, 71 | bucket: "bucket", 72 | hRatio: undefined, 73 | key: "folder/key.png", 74 | options: { 75 | left: "0", 76 | top: "0", 77 | }, 78 | wRatio: undefined, 79 | }, 80 | }, 81 | }; 82 | expect(edits).toEqual(expectedResult.edits); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /source/image-handler/test/thumbor-mapper/parse.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ThumborMapper } from "../../thumbor-mapper"; 5 | 6 | describe("parse", () => { 7 | const OLD_ENV = { 8 | REWRITE_MATCH_PATTERN: "/(filters-)/gm", 9 | REWRITE_SUBSTITUTION: "filters:", 10 | }; 11 | 12 | beforeEach(() => { 13 | process.env = { ...OLD_ENV }; 14 | }); 15 | 16 | afterEach(() => { 17 | process.env = OLD_ENV; 18 | }); 19 | 20 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 21 | const path = "/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg"; 22 | 23 | // Act 24 | const thumborMapper = new ThumborMapper(); 25 | const result = thumborMapper.parseCustomPath(path); 26 | 27 | // Assert 28 | const expectedResult = "/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg"; 29 | expect(result).toEqual(expectedResult); 30 | }); 31 | 32 | it("Should throw an error if the path is not defined", () => { 33 | const path = undefined; 34 | // Act 35 | const thumborMapper = new ThumborMapper(); 36 | 37 | // Assert 38 | expect(() => { 39 | thumborMapper.parseCustomPath(path); 40 | }).toThrowError(new Error("ThumborMapping::ParseCustomPath::PathUndefined")); 41 | }); 42 | 43 | it("Should throw an error if the environment variables are left undefined", () => { 44 | const path = "/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg"; 45 | 46 | // Act 47 | delete process.env.REWRITE_MATCH_PATTERN; 48 | const thumborMapper = new ThumborMapper(); 49 | 50 | // Assert 51 | expect(() => { 52 | thumborMapper.parseCustomPath(path); 53 | }).toThrowError(new Error("ThumborMapping::ParseCustomPath::RewriteMatchPatternUndefined")); 54 | }); 55 | 56 | it("Should throw an error if the path is not defined", () => { 57 | const path = "/filters-rotate(90)/filters-grayscale()/thumbor-image.jpg"; 58 | 59 | // Act 60 | delete process.env.REWRITE_SUBSTITUTION; 61 | 62 | const thumborMapper = new ThumborMapper(); 63 | // Assert 64 | expect(() => { 65 | thumborMapper.parseCustomPath(path); 66 | }).toThrowError(new Error("ThumborMapping::ParseCustomPath::RewriteSubstitutionUndefined")); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /source/image-handler/test/thumbor-mapper/resize.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { ThumborMapper } from "../../thumbor-mapper"; 5 | 6 | describe("resize", () => { 7 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 8 | // Arrange 9 | const path = "/fit-in/400x300/test-image-001.jpg"; 10 | 11 | // Act 12 | const thumborMapper = new ThumborMapper(); 13 | const edits = thumborMapper.mapPathToEdits(path); 14 | 15 | // Assert 16 | const expectedResult = { 17 | edits: { resize: { width: 400, height: 300, fit: "inside" } }, 18 | }; 19 | expect(edits).toEqual(expectedResult.edits); 20 | }); 21 | 22 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 23 | // Arrange 24 | const path = "/fit-in/test-image-001.jpg"; 25 | 26 | // Act 27 | const thumborMapper = new ThumborMapper(); 28 | const edits = thumborMapper.mapPathToEdits(path); 29 | 30 | // Assert 31 | const expectedResult = { edits: { resize: { fit: "inside" } } }; 32 | expect(edits).toEqual(expectedResult.edits); 33 | }); 34 | 35 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 36 | // Arrange 37 | const path = "/400x300/test-image-001.jpg"; 38 | 39 | // Act 40 | const thumborMapper = new ThumborMapper(); 41 | const edits = thumborMapper.mapPathToEdits(path); 42 | 43 | // Assert 44 | const expectedResult = { edits: { resize: { width: 400, height: 300 } } }; 45 | expect(edits).toEqual(expectedResult.edits); 46 | }); 47 | 48 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 49 | // Arrange 50 | const path = "/0x300/test-image-001.jpg"; 51 | 52 | // Act 53 | const thumborMapper = new ThumborMapper(); 54 | const edits = thumborMapper.mapPathToEdits(path); 55 | 56 | // Assert 57 | const expectedResult = { 58 | edits: { resize: { width: null, height: 300, fit: "inside" } }, 59 | }; 60 | expect(edits).toEqual(expectedResult.edits); 61 | }); 62 | 63 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 64 | // Arrange 65 | const path = "/400x0/test-image-001.jpg"; 66 | 67 | // Act 68 | const thumborMapper = new ThumborMapper(); 69 | const edits = thumborMapper.mapPathToEdits(path); 70 | 71 | // Assert 72 | const expectedResult = { 73 | edits: { resize: { width: 400, height: null, fit: "inside" } }, 74 | }; 75 | expect(edits).toEqual(expectedResult.edits); 76 | }); 77 | 78 | it("Should pass if the proper edit translations are applied and in the correct order", () => { 79 | // Arrange 80 | const path = "/0x0/test-image-001.jpg"; 81 | 82 | // Act 83 | const thumborMapper = new ThumborMapper(); 84 | const edits = thumborMapper.mapPathToEdits(path); 85 | 86 | // Assert 87 | const expectedResult = { 88 | edits: { resize: { width: null, height: null, fit: "inside" } }, 89 | }; 90 | expect(edits).toEqual(expectedResult.edits); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /source/image-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "types": ["node", "@types/jest"] 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["package", "dist", "**/*.map"] 15 | } 16 | -------------------------------------------------------------------------------- /source/metrics-utils/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /source/metrics-utils/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | export { SolutionsMetrics } from "./lib/solutions-metrics"; 5 | export * from "./lambda/helpers/types"; 6 | -------------------------------------------------------------------------------- /source/metrics-utils/jest.config.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | module.exports = { 5 | roots: ['/test'], 6 | testMatch: ['**/*.spec.ts'], 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest' 9 | }, 10 | coverageReporters: [ 11 | 'text', 12 | ['lcov', { 'projectRoot': '../' }] 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /source/metrics-utils/lambda/helpers/client-helper.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; 5 | import { SQSClient } from "@aws-sdk/client-sqs"; 6 | import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; 7 | 8 | export class ClientHelper { 9 | private sqsClient: SQSClient; 10 | private cwClients: { [key: string]: CloudWatchClient }; 11 | private cwLogsClient: CloudWatchLogsClient; 12 | 13 | constructor() { 14 | this.cwClients = {}; 15 | } 16 | 17 | getSqsClient(): SQSClient { 18 | if (!this.sqsClient) { 19 | this.sqsClient = new SQSClient(); 20 | } 21 | return this.sqsClient; 22 | } 23 | 24 | getCwClient(region: string = "default"): CloudWatchClient { 25 | if (region === "default") { 26 | region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "default"; 27 | } 28 | if (!(region in this.cwClients)) { 29 | this.cwClients[region] = region === "default" ? new CloudWatchClient({}) : new CloudWatchClient({ region }); 30 | } 31 | return this.cwClients[region]; 32 | } 33 | 34 | getCwLogsClient(): CloudWatchLogsClient { 35 | if (!this.cwLogsClient) { 36 | this.cwLogsClient = new CloudWatchLogsClient(); 37 | } 38 | return this.cwLogsClient; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /source/metrics-utils/lambda/helpers/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { MetricDataQuery } from "@aws-sdk/client-cloudwatch"; 5 | import { StartQueryCommandInput } from "@aws-sdk/client-cloudwatch-logs"; 6 | import { QueryDefinitionProps } from "aws-cdk-lib/aws-logs"; 7 | // eslint-disable-next-line import/no-unresolved 8 | import { EventBridgeEvent, SQSEvent } from "aws-lambda"; 9 | export type QueryProps = Pick; 10 | export interface EventBridgeQueryEvent 11 | extends Pick>, "detail-type" | "time"> { 12 | "metrics-data-query": MetricDataQuery[]; 13 | } 14 | 15 | export interface MetricDataProps 16 | extends Pick, 17 | Partial> { 18 | identifier?: string; 19 | region?: string; 20 | } 21 | 22 | export enum ExecutionDay { 23 | DAILY = "*", 24 | MONDAY = "MON", 25 | TUESDAY = "TUE", 26 | WEDNESDAY = "WED", 27 | THURSDAY = "THU", 28 | FRIDAY = "FRI", 29 | SATURDAY = "SAT", 30 | SUNDAY = "SUN", 31 | } 32 | 33 | export interface SolutionsMetricProps { 34 | uuid?: string; 35 | metricDataProps?: MetricDataProps[]; 36 | queryProps?: QueryDefinitionProps[]; 37 | executionDay?: string; 38 | } 39 | 40 | export interface SQSEventBody { 41 | queryIds: string[]; 42 | endTime: number; 43 | retry?: number; 44 | } 45 | 46 | export interface MetricData { 47 | [key: string]: number[] | number | string; 48 | } 49 | 50 | export interface MetricPayloadData extends MetricData { 51 | DataStartTime: string; 52 | DataEndTime: string; 53 | } 54 | 55 | export interface MetricPayload { 56 | Solution: string; 57 | Version: string; 58 | UUID: string; 59 | TimeStamp: string; 60 | Data: MetricPayloadData; 61 | } 62 | 63 | /** 64 | * 65 | * @param {EventBridgeQueryEvent | SQSEvent} event The event to be analyzed 66 | * @returns {boolean} Whether the event is an EventBridgeQueryEvent 67 | */ 68 | export function isEventBridgeQueryEvent(event: EventBridgeQueryEvent | SQSEvent): event is EventBridgeQueryEvent { 69 | return "detail-type" in event && "time" in event && "metrics-data-query" in event; 70 | } 71 | 72 | /** 73 | * 74 | * @param {EventBridgeQueryEvent | SQSEvent} event The event to be analyzed 75 | * @returns {boolean} Whether the event is an SQSEvent 76 | */ 77 | export function isSQSEvent(event: EventBridgeQueryEvent | SQSEvent): event is SQSEvent { 78 | return "Records" in event && Array.isArray(event.Records); 79 | } 80 | -------------------------------------------------------------------------------- /source/metrics-utils/lambda/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // eslint-disable-next-line import/no-unresolved 5 | import { SQSEvent, Context } from "aws-lambda"; 6 | import { MetricsHelper } from "./helpers/metrics-helper"; 7 | import { 8 | EventBridgeQueryEvent, 9 | ExecutionDay, 10 | MetricData, 11 | SQSEventBody, 12 | isEventBridgeQueryEvent, 13 | isSQSEvent, 14 | } from "./helpers/types"; 15 | 16 | /** 17 | * Metrics collector Lambda handler. 18 | * @param {EventBridgeQueryEvent | SQSEvent} event The EventBridge or SQS request event. 19 | * @param {Context}_context The request context 20 | * @returns {any} Processed request response. 21 | */ 22 | export async function handler(event: EventBridgeQueryEvent | SQSEvent, _context: Context) { 23 | const metricsHelper = new MetricsHelper(); 24 | console.log("Event: ", JSON.stringify(event, null, 2)); 25 | const { EXECUTION_DAY } = process.env; 26 | if (isEventBridgeQueryEvent(event)) { 27 | console.info("Processing EventBridge event."); 28 | 29 | const endTime = new Date(event.time); 30 | const metricsData = await metricsHelper.getMetricsData(event); 31 | console.info("Metrics data: ", JSON.stringify(metricsData, null, 2)); 32 | await metricsHelper.sendAnonymousMetric( 33 | metricsData, 34 | new Date(endTime.getTime() - (EXECUTION_DAY === ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), 35 | endTime 36 | ); 37 | await metricsHelper.startQueries(event); 38 | } else if (isSQSEvent(event)) { 39 | console.info("Processing SQS event."); 40 | const body: SQSEventBody = JSON.parse(event.Records[0].body); 41 | const resolvedQueries = await metricsHelper.resolveQueries(event); 42 | console.debug(`Resolved Queries: ${JSON.stringify(resolvedQueries)}`); 43 | const metricsData: MetricData = metricsHelper.processQueryResults(resolvedQueries, body); 44 | if (Object.keys(metricsData).length > 0) { 45 | await metricsHelper.sendAnonymousMetric( 46 | metricsData, 47 | new Date(body.endTime - (EXECUTION_DAY === ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), 48 | new Date(body.endTime) 49 | ); 50 | } 51 | } else { 52 | console.error("Invalid event type."); 53 | throw new Error("Invalid event type."); 54 | } 55 | return { 56 | statusCode: 200, 57 | body: JSON.stringify({ message: "Successfully processed event." }), 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /source/metrics-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "metrics-utils", 3 | "version": "7.0.3", 4 | "main": "index.ts", 5 | "description": "A construct for providing additional anonymous metrics.", 6 | "license": "Apache-2.0", 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest --coverage", 11 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.4.5", 16 | "aws-cdk-lib": "^2.182.0", 17 | "constructs": "^10.0.0", 18 | "jest": "^29.6.2", 19 | "ts-jest": "^29.2.0" 20 | }, 21 | "dependencies": { 22 | "@aws-sdk/client-cloudwatch": "^3.637.0", 23 | "@aws-sdk/client-cloudwatch-logs": "^3.637.0", 24 | "@aws-sdk/client-sqs": "^3.637.0", 25 | "@aws-solutions-constructs/aws-eventbridge-lambda": "^2.59.0", 26 | "@aws-solutions-constructs/aws-lambda-sqs-lambda": "^2.59.0", 27 | "@types/aws-lambda": "^8.10.143", 28 | "axios": "^1.7.4", 29 | "esbuild": "^0.25.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/metrics-utils/test/lambda/helpers/client-helper.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; 5 | import { SQSClient } from "@aws-sdk/client-sqs"; 6 | import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; 7 | import { ClientHelper } from "../../../lambda/helpers/client-helper"; 8 | 9 | // Mock AWS SDK clients 10 | jest.mock("@aws-sdk/client-cloudwatch"); 11 | jest.mock("@aws-sdk/client-sqs"); 12 | jest.mock("@aws-sdk/client-cloudwatch-logs"); 13 | 14 | describe("ClientHelper", () => { 15 | let clientHelper: ClientHelper; 16 | 17 | beforeEach(() => { 18 | clientHelper = new ClientHelper(); 19 | }); 20 | 21 | afterEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | it("should not create any instance until requested", () => { 26 | clientHelper = new ClientHelper(); 27 | expect(SQSClient).toHaveBeenCalledTimes(0); 28 | expect(CloudWatchClient).toHaveBeenCalledTimes(0); 29 | expect(CloudWatchLogsClient).toHaveBeenCalledTimes(0); 30 | }); 31 | 32 | it("should initialize and return an SQSClient instance", () => { 33 | const sqsClient = clientHelper.getSqsClient(); 34 | expect(sqsClient).toBeInstanceOf(SQSClient); 35 | expect(SQSClient).toHaveBeenCalledTimes(1); 36 | }); 37 | 38 | it("should return the same SQSClient instance on subsequent calls", () => { 39 | const sqsClient1 = clientHelper.getSqsClient(); 40 | const sqsClient2 = clientHelper.getSqsClient(); 41 | expect(sqsClient1).toBe(sqsClient2); 42 | expect(SQSClient).toHaveBeenCalledTimes(1); 43 | }); 44 | 45 | it("should initialize and return a CloudWatchClient instance", () => { 46 | const cwClient = clientHelper.getCwClient(); 47 | expect(cwClient).toBeInstanceOf(CloudWatchClient); 48 | expect(CloudWatchClient).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it("should return the same CloudWatchClient instance on subsequent calls", () => { 52 | const cwClient1 = clientHelper.getCwClient(); 53 | const cwClient2 = clientHelper.getCwClient(); 54 | expect(cwClient1).toBe(cwClient2); 55 | expect(CloudWatchClient).toHaveBeenCalledTimes(1); 56 | }); 57 | 58 | it("should return different CloudWatchClient instances on different provided regions", () => { 59 | const cwClient1 = clientHelper.getCwClient(); 60 | const cwClient2 = clientHelper.getCwClient("us-east-1"); 61 | expect(cwClient1).not.toBe(cwClient2); 62 | expect(CloudWatchClient).toHaveBeenCalledTimes(2); 63 | }); 64 | 65 | it("should return identical CloudWatchClient instances when AWS_REGION is set", () => { 66 | process.env.AWS_REGION = "us-east-1"; 67 | const cwClient1 = clientHelper.getCwClient(); 68 | const cwClient2 = clientHelper.getCwClient("us-east-1"); 69 | expect(cwClient1).toBe(cwClient2); 70 | expect(CloudWatchClient).toHaveBeenCalledTimes(1); 71 | }); 72 | 73 | it("should return different CloudWatchClient instances when AWS_REGION is set", () => { 74 | const cwClient1 = clientHelper.getCwClient("us-west-2"); 75 | const cwClient2 = clientHelper.getCwClient("us-east-1"); 76 | expect(cwClient1).not.toBe(cwClient2); 77 | expect(CloudWatchClient).toHaveBeenCalledTimes(2); 78 | }); 79 | 80 | it("should initialize and return a CloudWatchLogsClient instance", () => { 81 | const cwLogsClient = clientHelper.getCwLogsClient(); 82 | expect(cwLogsClient).toBeInstanceOf(CloudWatchLogsClient); 83 | expect(CloudWatchLogsClient).toHaveBeenCalledTimes(1); 84 | }); 85 | 86 | it("should return the same CloudWatchLogsClient instance on subsequent calls", () => { 87 | const cwLogsClient1 = clientHelper.getCwLogsClient(); 88 | const cwLogsClient2 = clientHelper.getCwLogsClient(); 89 | expect(cwLogsClient1).toBe(cwLogsClient2); 90 | expect(CloudWatchLogsClient).toHaveBeenCalledTimes(1); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /source/metrics-utils/test/lambda/index.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Context, SQSEvent } from "aws-lambda"; 5 | import { handler } from "../../lambda"; 6 | import { EventBridgeQueryEvent } from "../../lambda/helpers/types"; 7 | 8 | const mockMetricsHelper = { 9 | getMetricsData: jest.fn(), 10 | startQueries: jest.fn(), 11 | resolveQueries: jest.fn(), 12 | sendAnonymousMetric: jest.fn(), 13 | processQueryResults: jest.fn(), 14 | }; 15 | 16 | const BASE_CONTEXT: Context = { 17 | callbackWaitsForEmptyEventLoop: false, 18 | functionName: "test", 19 | functionVersion: "1", 20 | invokedFunctionArn: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 21 | memoryLimitInMB: "128", 22 | awsRequestId: "XXXXXXXXXX", 23 | logGroupName: "test", 24 | logStreamName: "test", 25 | getRemainingTimeInMillis: () => 1000, 26 | done: () => {}, 27 | fail: () => {}, 28 | succeed: () => {}, 29 | }; 30 | 31 | jest.mock("../../lambda/helpers/metrics-helper", () => { 32 | return { 33 | MetricsHelper: jest.fn().mockImplementation(() => { 34 | return { ...mockMetricsHelper }; 35 | }), 36 | }; 37 | }); 38 | 39 | describe("Lambda Handler", () => { 40 | beforeEach(() => { 41 | // Clear previous mock calls and instances 42 | jest.clearAllMocks(); 43 | }); 44 | 45 | it("should process an EventBridgeQueryEvent", async () => { 46 | // Arrange 47 | const event: EventBridgeQueryEvent = { 48 | "detail-type": "Scheduled Event", 49 | time: new Date().toISOString(), 50 | "metrics-data-query": [], 51 | }; 52 | 53 | //Mock Response 54 | mockMetricsHelper.getMetricsData.mockImplementationOnce(() => { 55 | return []; 56 | }); 57 | mockMetricsHelper.startQueries.mockImplementationOnce(() => { 58 | return []; 59 | }); 60 | // Act 61 | const response = await handler(event, BASE_CONTEXT); 62 | 63 | // Assert 64 | expect(mockMetricsHelper.getMetricsData).toHaveBeenCalledWith(event); 65 | expect(mockMetricsHelper.startQueries).toHaveBeenCalledWith(event); 66 | expect(response).toEqual({ 67 | statusCode: 200, 68 | body: JSON.stringify({ message: "Successfully processed event." }), 69 | }); 70 | }); 71 | 72 | it("should process an SQSEvent", async () => { 73 | // Arrange 74 | const event: SQSEvent = { 75 | Records: [ 76 | { 77 | messageId: "1", 78 | receiptHandle: "abc", 79 | body: JSON.stringify({ endTime: new Date() }), 80 | attributes: { 81 | ApproximateReceiveCount: "1", 82 | SentTimestamp: "1234567890", 83 | ApproximateFirstReceiveTimestamp: "1234567890", 84 | MessageDeduplicationId: "message-deduplication-id", 85 | MessageGroupId: "message-group-id", 86 | SenderId: "sender-id", 87 | }, 88 | messageAttributes: {}, 89 | md5OfBody: "", 90 | eventSource: "aws:sqs", 91 | eventSourceARN: "arn:aws:sqs:region:account-id:queue-name", 92 | awsRegion: "region", 93 | }, 94 | ], 95 | }; 96 | 97 | mockMetricsHelper.resolveQueries.mockImplementationOnce(() => { 98 | return []; 99 | }); 100 | 101 | mockMetricsHelper.processQueryResults.mockImplementationOnce(() => { 102 | return []; 103 | }); 104 | 105 | // Act 106 | const response = await handler(event, BASE_CONTEXT); 107 | 108 | // Assert 109 | expect(mockMetricsHelper.resolveQueries).toHaveBeenCalledWith(event); 110 | expect(response).toEqual({ 111 | statusCode: 200, 112 | body: JSON.stringify({ message: "Successfully processed event." }), 113 | }); 114 | }); 115 | 116 | it("should throw an error for an invalid event type", async () => { 117 | // Arrange 118 | const event = {} as any; 119 | 120 | // Act & Assert 121 | await expect(handler(event, BASE_CONTEXT)).rejects.toThrow("Invalid event type."); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /source/metrics-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "resolveJsonModule": true, 21 | "esModuleInterop": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | }, 24 | "exclude": ["cdk.out"] 25 | } 26 | -------------------------------------------------------------------------------- /source/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "source", 3 | "version": "7.0.3", 4 | "private": true, 5 | "description": "ESLint and prettier dependencies to be used within the solution", 6 | "license": "Apache-2.0", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/solutions" 10 | }, 11 | "scripts": { 12 | "lint": "npx eslint . --ext .ts --fix", 13 | "prettier-format": "npx prettier --config .prettierrc.yml '**/*.ts' --write", 14 | "install:custom-resource": "cd ./custom-resource && npm run clean && npm ci", 15 | "install:image-handler": "cd ./image-handler && npm run clean && npm ci", 16 | "install:demo-ui": "cd ./demo-ui && npm run clean && npm ci", 17 | "install:metrics-utils": "cd ./metrics-utils && npm ci", 18 | "install:dependencies": "npm run install:metrics-utils && npm run install:custom-resource && npm run install:image-handler && npm run install:demo-ui", 19 | "bump-version": "npm version $(cat ../VERSION.txt) --allow-same-version && npm run bump-child-version", 20 | "bump-child-version": " npm --prefix ./image-handler run bump-version && npm --prefix ./custom-resource run bump-version && npm --prefix ./constructs run bump-version && npm --prefix ./solution-utils run bump-version && npm --prefix ./demo-ui run bump-version && npm --prefix ./metrics-utils run bump-version" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^20.10.4", 24 | "@typescript-eslint/eslint-plugin": "^5.62.0", 25 | "@typescript-eslint/parser": "^5.62.0", 26 | "eslint": "^8.50.0", 27 | "eslint-config-prettier": "^8.10.0", 28 | "eslint-config-standard": "^17.1.0", 29 | "eslint-plugin-header": "^3.1.1", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsdoc": "^46.8.2", 32 | "eslint-plugin-node": "^11.1.0", 33 | "eslint-plugin-prettier": "^4.2.1", 34 | "prettier": "~2.8.8" 35 | }, 36 | "overrides": { 37 | "semver": "7.5.4", 38 | "word-wrap": "npm:@aashutoshrathi/word-wrap" 39 | }, 40 | "resolutions": { 41 | "semver": "7.5.4", 42 | "word-wrap": "aashutoshrathi/word-wrap" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /source/solution-utils/get-options.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /** 5 | * If the SOLUTION_ID and SOLUTION_VERSION environment variables are set, this will return 6 | * an object with a custom user agent string. Otherwise, the object returned will be empty. 7 | * @param options The current options. 8 | * @returns Either object with `customUserAgent` string or an empty object. 9 | */ 10 | export function getOptions(options: Record = {}): Record { 11 | const { SOLUTION_ID, SOLUTION_VERSION } = process.env; 12 | if (SOLUTION_ID && SOLUTION_VERSION) { 13 | if (SOLUTION_ID.trim() !== "" && SOLUTION_VERSION.trim() !== "") { 14 | options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${SOLUTION_VERSION}`; 15 | } 16 | } 17 | 18 | return options; 19 | } 20 | -------------------------------------------------------------------------------- /source/solution-utils/helpers.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /** 5 | * Indicates whether a specified string is null, empty, or consists only of white-space characters. 6 | * @param str String to test. 7 | * @returns `true` if the `str` parameter is null or empty, or if value consists exclusively of white-space characters. 8 | */ 9 | export function isNullOrWhiteSpace(str: string): boolean { 10 | return !str || str.replace(/\s/g, "") === ""; 11 | } 12 | -------------------------------------------------------------------------------- /source/solution-utils/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.spec.ts'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | }, 7 | coverageReporters: [ 8 | 'text', 9 | ['lcov', { 'projectRoot': '../' }] 10 | ], 11 | setupFiles: ['./test/setJestEnvironmentVariables.ts'] 12 | }; 13 | -------------------------------------------------------------------------------- /source/solution-utils/logger.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /** 5 | * The supported logging level. 6 | */ 7 | export enum LoggingLevel { 8 | ERROR = 1, 9 | WARN = 2, 10 | INFO = 3, 11 | DEBUG = 4, 12 | VERBOSE = 5, 13 | } 14 | 15 | /** 16 | * Logger class. 17 | */ 18 | export default class Logger { 19 | private readonly name: string; 20 | private readonly loggingLevel: LoggingLevel; 21 | 22 | /** 23 | * Sets up the default properties. 24 | * @param name The logger name which will be shown in the log. 25 | * @param loggingLevel The logging level to show the minimum logs. 26 | */ 27 | constructor(name: string, loggingLevel: string | LoggingLevel) { 28 | this.name = name; 29 | 30 | if (typeof loggingLevel === "string" || !loggingLevel) { 31 | this.loggingLevel = LoggingLevel[loggingLevel] || LoggingLevel.ERROR; 32 | } else { 33 | this.loggingLevel = loggingLevel; 34 | } 35 | } 36 | 37 | /** 38 | * Logs when the logging level is lower than the default logging level. 39 | * @param loggingLevel The logging level of the log. 40 | * @param messages The log messages. 41 | */ 42 | public log(loggingLevel: LoggingLevel, ...messages: unknown[]): void { 43 | if (loggingLevel <= this.loggingLevel) { 44 | this.logInternal(loggingLevel, ...messages); 45 | } 46 | } 47 | 48 | /** 49 | * Logs based on the logging level. 50 | * @param loggingLevel The logging level of the log. 51 | * @param messages The log messages. 52 | */ 53 | private logInternal(loggingLevel: LoggingLevel, ...messages: unknown[]): void { 54 | switch (loggingLevel) { 55 | case LoggingLevel.VERBOSE: 56 | case LoggingLevel.DEBUG: 57 | console.debug(`[${this.name}]`, ...messages); 58 | break; 59 | case LoggingLevel.INFO: 60 | console.info(`[${this.name}]`, ...messages); 61 | break; 62 | case LoggingLevel.WARN: 63 | console.warn(`[${this.name}]`, ...messages); 64 | break; 65 | default: 66 | console.error(`[${this.name}]`, ...messages); 67 | break; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /source/solution-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solution-utils", 3 | "version": "7.0.3", 4 | "private": true, 5 | "description": "Utilities to be used within this solution", 6 | "license": "Apache-2.0", 7 | "author": { 8 | "name": "Amazon Web Services", 9 | "url": "https://aws.amazon.com/solutions" 10 | }, 11 | "main": "get-options", 12 | "typings": "index", 13 | "scripts": { 14 | "build": "npm run clean && npm install && npm run build:tsc", 15 | "build:tsc": "tsc --project tsconfig.json", 16 | "clean": "rm -rf node_modules/ dist/ coverage/", 17 | "package": "npm run build && npm prune --production && rsync -avrq ./node_modules ./dist", 18 | "pretest": "npm run clean && npm install", 19 | "test": "jest --coverage --silent", 20 | "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" 21 | }, 22 | "devDependencies": { 23 | "@types/jest": "^29.5.5", 24 | "@types/node": "^20.10.4", 25 | "jest": "^29.7.0", 26 | "ts-jest": "^29.1.1", 27 | "ts-node": "^10.9.2", 28 | "typescript": "^5.3.3" 29 | }, 30 | "overrides": { 31 | "semver": "7.5.4" 32 | }, 33 | "resolutions": { 34 | "semver": "7.5.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /source/solution-utils/test/get-options.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /* eslint-disable @typescript-eslint/no-var-requires */ 5 | 6 | // Spy on the console messages 7 | const consoleLogSpy = jest.spyOn(console, "log"); 8 | const consoleErrorSpy = jest.spyOn(console, "error"); 9 | 10 | describe("getOptions", () => { 11 | const OLD_ENV = process.env; 12 | 13 | beforeEach(() => { 14 | process.env = { ...OLD_ENV }; 15 | jest.resetModules(); 16 | consoleLogSpy.mockClear(); 17 | consoleErrorSpy.mockClear(); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | afterAll(() => { 25 | process.env = OLD_ENV; 26 | }); 27 | 28 | it("will return an empty object when environment variables are missing", () => { 29 | const { getOptions } = require("../get-options"); 30 | expect.assertions(4); 31 | 32 | process.env.SOLUTION_ID = " "; // whitespace 33 | expect(getOptions()).toEqual({}); 34 | 35 | delete process.env.SOLUTION_ID; 36 | expect(getOptions()).toEqual({}); 37 | 38 | process.env.SOLUTION_ID = "foo"; 39 | process.env.SOLUTION_VERSION = " "; // whitespace 40 | expect(getOptions()).toEqual({}); 41 | 42 | delete process.env.SOLUTION_VERSION; 43 | expect(getOptions()).toEqual({}); 44 | }); 45 | 46 | it("will return an object with the custom user agent string", () => { 47 | const { getOptions } = require("../get-options"); 48 | expect.assertions(1); 49 | expect(getOptions()).toEqual({ 50 | customUserAgent: `AwsSolution/solution-id/solution-version`, 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /source/solution-utils/test/setJestEnvironmentVariables.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | process.env.SOLUTION_ID = "solution-id"; 5 | process.env.SOLUTION_VERSION = "solution-version"; 6 | process.env.LOGGING_LEVEL = "VERBOSE"; 7 | -------------------------------------------------------------------------------- /source/solution-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "types": ["node", "@types/jest"] 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["package", "dist", "**/*.map"] 15 | } 16 | --------------------------------------------------------------------------------