From 42539759f91def8901b0f01b64748def1dc40c02 Mon Sep 17 00:00:00 2001 From: Simon Krol Date: Mon, 27 Jan 2025 16:04:23 -0500 Subject: [PATCH] Update to version 7.0.0 (#586) --- CHANGELOG.md | 42 +- NOTICE | 4 +- README.md | 38 +- VERSION.txt | 2 +- default_architecture.png | Bin 0 -> 137868 bytes .../asset-packager/asset-packager.ts | 2 +- object_lambda_architecture.png | Bin 0 -> 129889 bytes source/.eslintrc.json | 3 +- source/constructs/bin/constructs.ts | 2 +- source/constructs/cdk.json | 4 +- .../lib/back-end/api-gateway-architecture.ts | 172 ++ .../lib/back-end/back-end-construct.ts | 192 +- .../back-end/s3-object-lambda-architecture.ts | 243 +++ .../lib/back-end/s3-object-lambda-origin.ts | 14 + .../common-resources-construct.ts | 24 + .../custom-resource-construct.ts | 120 +- .../lib/dashboard/ops-insights-dashboard.ts | 120 ++ .../constructs/lib/dashboard/sih-metrics.ts | 142 ++ source/constructs/lib/dashboard/widgets.ts | 124 ++ .../lib/front-end/front-end-construct.ts | 2 +- .../constructs/lib/serverless-image-stack.ts | 160 +- source/constructs/lib/types.ts | 6 +- source/constructs/package-lock.json | 4 +- source/constructs/package.json | 4 +- .../__snapshots__/constructs.test.ts.snap | 1665 +++++++++++++++-- source/constructs/test/constructs.test.ts | 10 +- source/custom-resource/index.ts | 161 +- source/custom-resource/lib/enums.ts | 2 + source/custom-resource/lib/interfaces.ts | 16 + source/custom-resource/lib/types.ts | 6 +- source/custom-resource/package-lock.json | 4 +- source/custom-resource/package.json | 4 +- .../test/create-logging-bucket.spec.ts | 24 +- .../test/get-app-reg-application-name.spec.ts | 6 +- source/custom-resource/test/mock.ts | 7 + .../test/send-anonymous-metric.spec.ts | 6 + .../validate-existing-distribution.spec.ts | 106 ++ source/demo-ui/index.html | 4 +- source/demo-ui/package-lock.json | 4 +- source/demo-ui/package.json | 4 +- .../apig-request-modifier.js | 36 + .../ol-request-modifier.js | 37 + .../ol-response-modifier.js | 32 + source/image-handler/image-handler.ts | 223 ++- source/image-handler/image-request.ts | 97 +- source/image-handler/index.ts | 285 ++- source/image-handler/lib/constants.ts | 1 + source/image-handler/lib/enums.ts | 3 +- source/image-handler/lib/interfaces.ts | 41 +- source/image-handler/lib/types.ts | 7 + source/image-handler/package-lock.json | 16 +- source/image-handler/package.json | 4 +- source/image-handler/query-param-mapper.ts | 78 + .../apig-request-modifier.spec.ts | 94 + .../ol-request-modifier.spec.ts | 104 + .../ol-response-modifier.spec.ts | 82 + .../test/event-normalizer.spec.ts | 74 + .../test/image-handler/animated.spec.ts | 310 +-- .../image-handler/content-moderation.spec.ts | 8 +- .../test/image-handler/crop.spec.ts | 13 +- .../test/image-handler/error-response.spec.ts | 59 +- .../image-handler/limit-input-pixels.spec.ts | 195 ++ .../test/image-handler/overlay.spec.ts | 21 +- .../image-handler/query-param-mapper.spec.ts | 153 ++ .../test/image-handler/resize.spec.ts | 40 +- .../test/image-handler/rotate.spec.ts | 10 +- .../test/image-handler/round-crop.spec.ts | 4 +- .../test/image-handler/smart-crop.spec.ts | 18 +- .../test/image-handler/standard.spec.ts | 8 +- .../test/image-request/decode-request.spec.ts | 125 +- .../determine-output-format.spec.ts | 8 +- .../get-allowed-source-buckets.spec.ts | 1 - .../image-request/parse-image-bucket.spec.ts | 46 +- .../parse-query-param-edits.spec.ts | 37 + .../image-request/parse-request-type.spec.ts | 1 - .../recreate-query-string.spec.ts | 52 + .../test/image-request/setup.spec.ts | 169 +- source/image-handler/test/index.spec.ts | 287 ++- source/image-handler/test/mock.ts | 5 + .../test/thumbor-mapper/edits.spec.ts | 134 +- .../test/thumbor-mapper/filter.spec.ts | 212 +++ source/image-handler/thumbor-mapper.ts | 10 +- source/metrics-utils/index.ts | 2 +- .../lambda/helpers/client-helper.ts | 4 +- .../lambda/helpers/metrics-helper.ts | 23 +- source/metrics-utils/lambda/helpers/types.ts | 4 +- source/metrics-utils/lambda/index.ts | 4 +- source/metrics-utils/lib/query-builders.ts | 4 +- source/metrics-utils/lib/solutions-metrics.ts | 11 +- .../lambda/helpers/metrics-helper.spec.ts | 13 +- .../test/lib/solutions-metrics.spec.ts | 4 +- source/package-lock.json | 4 +- source/package.json | 2 +- source/solution-utils/package-lock.json | 4 +- source/solution-utils/package.json | 2 +- .../solution-utils/test/get-options.test.ts | 2 + 96 files changed, 5842 insertions(+), 834 deletions(-) create mode 100644 default_architecture.png create mode 100644 object_lambda_architecture.png create mode 100644 source/constructs/lib/back-end/api-gateway-architecture.ts create mode 100644 source/constructs/lib/back-end/s3-object-lambda-architecture.ts create mode 100644 source/constructs/lib/back-end/s3-object-lambda-origin.ts create mode 100644 source/constructs/lib/dashboard/ops-insights-dashboard.ts create mode 100644 source/constructs/lib/dashboard/sih-metrics.ts create mode 100644 source/constructs/lib/dashboard/widgets.ts create mode 100644 source/custom-resource/test/validate-existing-distribution.spec.ts create mode 100644 source/image-handler/cloudfront-function-handlers/apig-request-modifier.js create mode 100644 source/image-handler/cloudfront-function-handlers/ol-request-modifier.js create mode 100644 source/image-handler/cloudfront-function-handlers/ol-response-modifier.js create mode 100644 source/image-handler/query-param-mapper.ts create mode 100644 source/image-handler/test/cloudfront-function-handlers/apig-request-modifier.spec.ts create mode 100644 source/image-handler/test/cloudfront-function-handlers/ol-request-modifier.spec.ts create mode 100644 source/image-handler/test/cloudfront-function-handlers/ol-response-modifier.spec.ts create mode 100644 source/image-handler/test/event-normalizer.spec.ts create mode 100644 source/image-handler/test/image-handler/limit-input-pixels.spec.ts create mode 100644 source/image-handler/test/image-handler/query-param-mapper.spec.ts create mode 100644 source/image-handler/test/image-request/parse-query-param-edits.spec.ts create mode 100644 source/image-handler/test/image-request/recreate-query-string.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5c6b738..fc0edcfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,51 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0] - 2025-01-27 + +### Changed + +- Location of API Gateway infrastructure resources +- **Breaking** New condition on API gateway will cause a delete/create of ApiGateway::Deployment on stack update +- **Breaking:** Exception thrown on invalid resize parameters [#463](https://github.com/aws-solutions/serverless-image-handler/pull/463) +- Code formatting to align with ESLint rules +- **Breaking** Reduced passthrough of errors from external APIs to response body. Errors will still be logged. +- Modified CloudFront logging bucket to have versioning enabled by default +- CloudFront behaviour to redirect http requests to https rather than throwing forbidden error +- Set-Cookie was added to list of deny-listed response headers +- Name of solution from Serverless Image Handler on AWS to Dynamic Image Transformation for Amazon CloudFront. + +### Added + +- Ability to enable origin shield through a deployment parameter +- Ability to deploy solution without creating a CloudFront distribution +- CloudFront function to normalize accept headers when AutoWebP is enabled +- Alternative infrastructure using S3 Object Lambda to overcome 6 MB response size limit +- Query param named expires which can be used to define when a generated image should no longer be accessible +- Ability to include smart_crop as a filter for Thumbor style requests, taking advantage of AWS Rekognition face cropping +- Ability to set CloudWatch log retention period to Infinite +- Ability to specify Sharp input image size limit [#465](https://github.com/aws-solutions/serverless-image-handler/issues/465) [#476](https://github.com/aws-solutions/serverless-image-handler/pull/476) +- Query parameter based image editing [#184](https://github.com/aws-solutions/serverless-image-handler/issues/184) +- Query parameter normalization to improve cache hit rate +- CloudWatch dashboard to improve Solution observability +- Additional anonymized metrics to help understand how the solution is being used, identify areas of improvement, and drive future roadmap decisions. + +### Removed + +- Accept header being used in cache policy when AutoWebP is disabled + +### Fixed + +- Broken URLs in Signature and Fallback Image template parameters + ## [6.3.3] - 2024-12-27 ### Fixed + - Overlays not checking for valid S3 buckets - Failures when updating deployments created in version 6.1.0 and prior [#559](https://github.com/aws-solutions/serverless-image-handler/issues/559) -### Security +### Security - Added allowlist on sharp operations. [Info](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/create-and-use-image-requests.html#restricted-operations) - Added deny list on custom headers for base64 encoded requests. [Info](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/create-and-use-image-requests.html#include-custom-response-headers) @@ -20,8 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.3.2] - 2024-11-22 ### Fixed -- Upgrade cross-spawn to v7.0.6 for vulnerability [CVE-2024-9506](https://github.com/advisories/GHSA-5j4c-8p2g-v4jx) +- Upgrade cross-spawn to v7.0.6 for vulnerability [CVE-2024-9506](https://github.com/advisories/GHSA-5j4c-8p2g-v4jx) ## [6.3.1] - 2024-10-02 diff --git a/NOTICE b/NOTICE index 8bc497dde..026fd8ea2 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -Serverless Image Handler +Dynamic Image Transformation for Amazon CloudFront Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License Version 2.0 (the "License"). You may not use this file except @@ -24,6 +24,7 @@ This software includes third party software subject to the following copyrights: @aws-solutions-constructs/aws-cloudfront-s3 under the Apache License 2.0 @aws-solutions-constructs/core under the Apache License 2.0 @popperjs/core under the Massachusetts Institute of Technology (MIT) license +@types/aws-lambda under the Massachusetts Institute of Technology (MIT) license @types/color under the Massachusetts Institute of Technology (MIT) license @types/color-name under the Massachusetts Institute of Technology (MIT) license @types/jest under the Massachusetts Institute of Technology (MIT) license @@ -55,6 +56,7 @@ ts-jest under the Massachusetts Institute of Technology (MIT) license ts-node under the Massachusetts Institute of Technology (MIT) license typescript under the Apache License 2.0 uuid under the Massachusetts Institute of Technology (MIT) license +dayjs under the Massachusetts Institute of Technology (MIT) license @aws-sdk/client-cloudwatch under the Apache License 2.0 @aws-sdk/client-cloudwatch-logs under the Apache License 2.0 @aws-sdk/client-sqs under the Apache License 2.0 diff --git a/README.md b/README.md index 3a638b771..0adddf7d6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -**[Serverless Image Handler](https://aws.amazon.com/solutions/implementations/serverless-image-handler/)** | **[🚧 Feature request](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=)** | **[πŸ› Bug Report](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=bug&template=bug_report.md&title=)** | **[❓ General Question](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=question&template=general_question.md&title=)** +**[Dynamic Image Transformation for Amazon CloudFront](https://aws.amazon.com/solutions/implementations/dynamic-image-transformation-for-amazon-cloudfront/)** | **[🚧 Feature request](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=)** | **[πŸ› Bug Report](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=bug&template=bug_report.md&title=)** | **[❓ General Question](https://github.com/aws-solutions/serverless-image-handler/issues/new?assignees=&labels=question&template=general_question.md&title=)** -**Note**: If you want to use the solution without building from source, navigate to [Solution Landing Page](https://aws.amazon.com/solutions/implementations/serverless-image-handler/). +**Note**: If you want to use the solution without building from source, navigate to [Solution Landing Page](https://aws.amazon.com/solutions/implementations/dynamic-image-transformation-for-amazon-cloudfront/). ## Table of Content @@ -18,17 +18,26 @@ # Solution Overview -The Serverless Image Handler solution helps to embed images on websites and mobile applications to drive user engagement. It uses [Sharp](https://sharp.pixelplumbing.com/en/stable/) to provide high-speed image processing without sacrificing image quality. To minimize costs of image optimization, manipulation, and processing, this solution automates version control and provides flexible storage and compute options for file reprocessing. +The Dynamic Image Transformation for Amazon CloudFront solution helps to embed images on websites and mobile applications to drive user engagement. It uses [Sharp](https://sharp.pixelplumbing.com/en/stable/) to provide high-speed image processing without sacrificing image quality. To minimize costs of image optimization, manipulation, and processing, this solution automates version control and provides flexible storage and compute options for file reprocessing. This solution automatically deploys and configures a serverless architecture optimized for dynamic image manipulation. Images can be rendered and returned spontaneously. For example, an image can be resized based on different screen sizes by adding code on a website that leverages this solution to resize the image before being sent to the screen using the image. It uses [Amazon CloudFront](https://aws.amazon.com/cloudfront) for global content delivery and [Amazon Simple Storage Service](https://aws.amazon.com/s3) (Amazon S3) for reliable and durable cloud storage. -For more information and a detailed deployment guide, visit the [Serverless Image Handler](https://aws.amazon.com/solutions/implementations/serverless-image-handler/) solution page. +For more information and a detailed deployment guide, visit the [Dynamic Image Transformation for Amazon CloudFront](https://aws.amazon.com/solutions/implementations/dynamic-image-transformation-for-amazon-cloudfront/) solution page. # Architecture Diagram -![Architecture Diagram](./architecture.png) +Dynamic Image Transformation for Amazon CloudFront supports two architectures, one using an Amazon API Gateway REST API, and another using S3 Object Lambda. The Amazon API Gateway REST API architecture maintains the structure used in v6.3.3 and below of the Dynamic Image Transformation for Amazon CloudFront. The S3 Object Lambda architecture maintains very similar functionality, while also allowing for images larger than 6 MB to be returned. For more information, refer to the [Architecture Overview](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/architecture-overview.html) in the implementation guide. + +The AWS CloudFormation template deploys an Amazon CloudFront distribution, Amazon API Gateway REST API/S3 Object Lambda, and an AWS Lambda function. Amazon CloudFront provides a caching layer to reduce the cost of image processing and the latency of subsequent image delivery. The Amazon API Gateway/S3 Object Lambda provides endpoint resources and triggers the AWS Lambda function. The AWS Lambda function retrieves the image from the customer's Amazon Simple Storage Service (Amazon S3) bucket and uses Sharp to return a modified version of the image. Additionally, the solution generates a CloudFront domain name that provides cached access to the image handler API. There is limited use of CloudFront functions for consistency and cache hit rate purposes. + +## Default Architecture + +![Architecture Diagram (Default Architecture)](./default_architecture.png) + +## S3 Object Lambda Architecture + +![Architecture Diagram (S3 Object Lambda Architecture)](./object_lambda_architecture.png) -The AWS CloudFormation template deploys an Amazon CloudFront distribution, Amazon API Gateway REST API, and an AWS Lambda function. Amazon CloudFront provides a caching layer to reduce the cost of image processing and the latency of subsequent image delivery. The Amazon API Gateway provides endpoint resources and triggers the AWS Lambda function. The AWS Lambda function retrieves the image from the customer's Amazon Simple Storage Service (Amazon S3) bucket and uses Sharp to return a modified version of the image to the API Gateway. Additionally, the solution generates a CloudFront domain name that provides cached access to the image handler API. # AWS CDK and Solutions Constructs @@ -49,8 +58,8 @@ In addition to the AWS Solutions Constructs, the solution uses AWS CDK directly ### 1. Clone the repository ```bash -git clone https://github.com/aws-solutions/serverless-image-handler.git -cd serverless-image-handler +git clone https://github.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront.git +cd dynamic-image-transformation-for-amazon-cloudfront export MAIN_DIRECTORY=$PWD ``` @@ -76,12 +85,12 @@ overrideWarningsEnabled=false npx cdk deploy\ ``` _Note:_ -- **MY_BUCKET**: name of an existing bucket in your account +- **MY_BUCKET**: name of an existing bucket or the list of comma-separated bucket names in your account - **PROFILE_NAME**: name of an AWS CLI profile that has appropriate credentials for deploying in your preferred region # Collection of operational metrics -This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/op-metrics.html). +This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/reference.html#anonymized-data-collection). # External Contributors @@ -105,10 +114,15 @@ This solution collects anonymous operational metrics to help AWS improve the qua - [@Fjool](https://github.com/Fjool) for [#489](https://github.com/aws-solutions/serverless-image-handler/pull/489) - [@fvsnippets](https://github.com/fvsnippets) for [#373](https://github.com/aws-solutions/serverless-image-handler/pull/373), [#380](https://github.com/aws-solutions/serverless-image-handler/pull/380) - [@ccchapman](https://github.com/ccchapman) for [#490](https://github.com/aws-solutions/serverless-image-handler/pull/490) -- [@bennet-esyoil](https://github.com/bennet-esyoil) for [#521](https://github.com/aws-solutions/serverless-image-handler/pull/521) -- [@vaniyokk](https://github.com/vaniyokk) for [#511](https://github.com/aws-solutions/serverless-image-handler/pull/511) +- [@bennet-esyoil][https://github.com/bennet-esyoil] for [#521](https://github.com/aws-solutions/serverless-image-handler/pull/521) +- [@vaniyokk][https://github.com/vaniyokk] for [#511](https://github.com/aws-solutions/serverless-image-handler/pull/511) +- [@ericbuehl](https://github.com/ericbuehl) for [#463](https://github.com/aws-solutions/serverless-image-handler/pull/463) +- [@fvsnippets](https://github.com/fvsnippets) for [#372](https://github.com/aws-solutions/serverless-image-handler/pull/372) +- [@markuscolourbox](https://github.com/markuscolourbox) for [#349](https://github.com/aws-solutions/serverless-image-handler/pull/349) +- [@madhubalaji](https://github.com/madhubalaji) for [#476](https://github.com/aws-solutions/serverless-image-handler/pull/476) - [@nicolasbuch](https://github.com/nicolasbuch) for [#569](https://github.com/aws-solutions/serverless-image-handler/pull/569) - [@mrnonz](https://github.com/mrnonz) for [#567](https://github.com/aws-solutions/serverless-image-handler/pull/567) +- [@ilich](https://github.com/ilich) for [#574](https://github.com/aws-solutions/serverless-image-handler/pull/574) # License diff --git a/VERSION.txt b/VERSION.txt index d9b300f1c..66ce77b7e 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -6.3.3 \ No newline at end of file +7.0.0 diff --git a/default_architecture.png b/default_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..68a999e3113a209613546258d1a58fb0f5c876d9 GIT binary patch literal 137868 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�AF5#NiC$d2>x`x#IAA7&m= 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�s-06 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�dF7<@9*>jK|F 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 TfVQ>+{Apj)zxv_IohSbX2=wPx literal 0 HcmV?d00001 diff --git a/deployment/cdk-solution-helper/asset-packager/asset-packager.ts b/deployment/cdk-solution-helper/asset-packager/asset-packager.ts index e66be2fbe..6a6f10a4e 100644 --- a/deployment/cdk-solution-helper/asset-packager/asset-packager.ts +++ b/deployment/cdk-solution-helper/asset-packager/asset-packager.ts @@ -12,7 +12,7 @@ import AdmZip from "adm-zip"; * on solution internal pipelines */ export class CDKAssetPackager { - constructor(private readonly assetFolderPath: string) {} + constructor(private readonly assetFolderPath: string) { } /** * @description get cdk asset paths diff --git a/object_lambda_architecture.png b/object_lambda_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..0c1e153e5596f7b993c3b7bf197da3fac23d2442 GIT binary patch literal 129889 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 b$vo)r{?uDjcXpP8fajaQJptu@N3#C|LxLiz literal 0 HcmV?d00001 diff --git a/source/.eslintrc.json b/source/.eslintrc.json index c610898c7..c08f49341 100644 --- a/source/.eslintrc.json +++ b/source/.eslintrc.json @@ -40,6 +40,7 @@ "jsdoc/require-returns-type": ["off"], "jsdoc/newline-after-description": ["off"], - "import/no-unresolved": 1 // warn only on Unable to resolve path import/no-unresolved + "import/no-unresolved": 1, // warn only on Unable to resolve path import/no-unresolved + "dot-notation": "off" } } diff --git a/source/constructs/bin/constructs.ts b/source/constructs/bin/constructs.ts index 67b0c2ad7..6a3a03e7d 100644 --- a/source/constructs/bin/constructs.ts +++ b/source/constructs/bin/constructs.ts @@ -19,7 +19,7 @@ if (DIST_OUTPUT_BUCKET && SOLUTION_NAME && VERSION) }); const app = new App(); -const solutionDisplayName = "Serverless Image Handler"; +const solutionDisplayName = "Dynamic Image Transformation for Amazon CloudFront"; const solutionVersion = VERSION ?? app.node.tryGetContext("solutionVersion"); const description = `(${app.node.tryGetContext("solutionId")}) - ${solutionDisplayName}. Version ${solutionVersion}`; // eslint-disable-next-line no-new diff --git a/source/constructs/cdk.json b/source/constructs/cdk.json index b8658363a..24d3940c2 100644 --- a/source/constructs/cdk.json +++ b/source/constructs/cdk.json @@ -2,7 +2,7 @@ "app": "npx ts-node --prefer-ts-exts bin/constructs.ts", "context": { "solutionId": "SO0023", - "solutionVersion": "custom-v6.3.3", - "solutionName": "serverless-image-handler" + "solutionVersion": "custom-v7.0.0", + "solutionName": "dynamic-image-transformation-for-amazon-cloudfront" } } \ No newline at end of file diff --git a/source/constructs/lib/back-end/api-gateway-architecture.ts b/source/constructs/lib/back-end/api-gateway-architecture.ts new file mode 100644 index 000000000..8f65766cb --- /dev/null +++ b/source/constructs/lib/back-end/api-gateway-architecture.ts @@ -0,0 +1,172 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as path from "path"; +import { LambdaRestApiProps, RestApi } from "aws-cdk-lib/aws-apigateway"; +import { + AllowedMethods, + CachePolicy, + DistributionProps, + IOrigin, + OriginRequestPolicy, + OriginSslPolicy, + PriceClass, + ViewerProtocolPolicy, + Function, + FunctionCode, + FunctionEventType, + CfnDistribution, + Distribution, + FunctionRuntime, + IDistribution, +} from "aws-cdk-lib/aws-cloudfront"; +import { HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { CfnLogGroup } from "aws-cdk-lib/aws-logs"; +import { Aspects, Aws, CfnCondition, Duration, Fn, Lazy } from "aws-cdk-lib"; +import { CloudFrontToApiGatewayToLambda } from "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda"; + +import { addCfnSuppressRules } from "../../utils/utils"; +import * as api from "aws-cdk-lib/aws-apigateway"; +import { ConditionAspect } from "../../utils/aspects"; +import { readFileSync } from "fs"; +import { BackEnd, BackEndProps } from "./back-end-construct"; + +export interface ApiGatewayArchitectureProps extends BackEndProps { + originRequestPolicy: OriginRequestPolicy; + cachePolicy: CachePolicy; + imageHandlerLambdaFunction: NodejsFunction; + existingDistribution: IDistribution; +} + +export class ApiGatewayArchitecture { + public readonly imageHandlerCloudFrontDistribution: Distribution; + constructor(scope: BackEnd, props: ApiGatewayArchitectureProps) { + const apiGatewayRestApi = RestApi.fromRestApiId( + scope, + "ApiGatewayRestApi", + Lazy.string({ + produce: () => imageHandlerCloudFrontApiGatewayLambda.apiGateway.restApiId, + }) + ); + + const origin: IOrigin = new HttpOrigin(`${apiGatewayRestApi.restApiId}.execute-api.${Aws.REGION}.amazonaws.com`, { + originPath: "/image", + originSslProtocols: [OriginSslPolicy.TLS_V1_1, OriginSslPolicy.TLS_V1_2], + }); + + // Slice off the last line since CloudFront functions can't have module exports but we need to export the handler to unit test it. + const inlineCloudFrontFunction: string[] = readFileSync( + path.join(__dirname, "../../../image-handler/cloudfront-function-handlers/apig-request-modifier.js"), + "utf-8" + ) + .split("\n") + .slice(0, -1); + + const requestModifierFunction = new Function(scope, "ApigRequestModifierFunction", { + functionName: `sih-apig-request-modifier-${props.uuid}`, + code: FunctionCode.fromInline(inlineCloudFrontFunction.join("\n")), + runtime: FunctionRuntime.JS_2_0, + }); + Aspects.of(requestModifierFunction).add(new ConditionAspect(props.conditions.disableS3ObjectLambdaCondition)); + + const cloudFrontDistributionProps: DistributionProps = { + comment: "Image Handler Distribution for Dynamic Image Transformation for Amazon CloudFront", + defaultBehavior: { + origin, + allowedMethods: AllowedMethods.ALLOW_GET_HEAD, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + originRequestPolicy: props.originRequestPolicy, + cachePolicy: props.cachePolicy, + functionAssociations: [ + { + function: requestModifierFunction, + eventType: FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + priceClass: props.cloudFrontPriceClass as PriceClass, + enableLogging: true, + logBucket: props.logsBucket, + logFilePrefix: "api-cloudfront/", + errorResponses: [ + { httpStatus: 500, ttl: Duration.minutes(10) }, + { httpStatus: 501, ttl: Duration.minutes(10) }, + { httpStatus: 502, ttl: Duration.minutes(10) }, + { httpStatus: 503, ttl: Duration.minutes(10) }, + { httpStatus: 504, ttl: Duration.minutes(10) }, + ], + }; + + const apiGatewayProps: LambdaRestApiProps = { + handler: props.imageHandlerLambdaFunction, + deployOptions: { + stageName: "image", + }, + binaryMediaTypes: ["*/*"], + defaultMethodOptions: { + authorizationType: api.AuthorizationType.NONE, + }, + }; + + const imageHandlerCloudFrontApiGatewayLambda = new CloudFrontToApiGatewayToLambda( + scope, + "ImageHandlerCloudFrontApiGatewayLambda", + { + existingLambdaObj: props.imageHandlerLambdaFunction, + insertHttpSecurityHeaders: false, + cloudFrontDistributionProps, + apiGatewayProps, + } + ); + this.imageHandlerCloudFrontDistribution = imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution; + Aspects.of(imageHandlerCloudFrontApiGatewayLambda).add( + new ConditionAspect(props.conditions.disableS3ObjectLambdaCondition) + ); + + imageHandlerCloudFrontApiGatewayLambda.apiGateway.node.tryRemoveChild("Endpoint"); // we don't need the RestApi endpoint in the outputs + + const cfnDistribution = imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.node + .defaultChild as CfnDistribution; + cfnDistribution.addOverride("Properties.DistributionConfig.Origins.0.OriginShield", { + "Fn::If": [ + props.conditions.enableOriginShieldCondition.logicalId, + { Enabled: true, OriginShieldRegion: props.originShieldRegion }, + { Enabled: false }, + ], + }); + Aspects.of(cfnDistribution).add( + new ConditionAspect( + new CfnCondition(scope, "DeployAPIGDistribution", { + expression: Fn.conditionAnd( + props.conditions.disableS3ObjectLambdaCondition, + Fn.conditionNot(props.conditions.useExistingCloudFrontDistributionCondition) + ), + }) + ) + ); + + // Access the underlying CfnLogGroup to add conditions + const cfnLogGroup = imageHandlerCloudFrontApiGatewayLambda.apiGatewayLogGroup.node.defaultChild as CfnLogGroup; + + cfnLogGroup.addOverride( + "Properties.RetentionInDays", + Fn.conditionIf(props.conditions.isLogRetentionPeriodInfinite.logicalId, Aws.NO_VALUE, props.logRetentionPeriod) + ); + + addCfnSuppressRules(imageHandlerCloudFrontApiGatewayLambda.apiGateway, [ + { + id: "W59", + reason: + "AWS::ApiGateway::Method AuthorizationType is set to 'NONE' because API Gateway behind CloudFront does not support AWS_IAM authentication", + }, + ]); + + imageHandlerCloudFrontApiGatewayLambda.apiGateway.node.tryRemoveChild("Endpoint"); // we don't need the RestApi endpoint in the outputs + scope.domainName = Fn.conditionIf( + props.conditions.useExistingCloudFrontDistributionCondition.logicalId, + props.existingDistribution.distributionDomainName, + imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.distributionDomainName + ).toString(); + } +} diff --git a/source/constructs/lib/back-end/back-end-construct.ts b/source/constructs/lib/back-end/back-end-construct.ts index cf8d62785..bc448d1bf 100644 --- a/source/constructs/lib/back-end/back-end-construct.ts +++ b/source/constructs/lib/back-end/back-end-construct.ts @@ -2,49 +2,53 @@ // SPDX-License-Identifier: Apache-2.0 import * as path from "path"; -import { LambdaRestApiProps, RestApi } from "aws-cdk-lib/aws-apigateway"; import { - AllowedMethods, CacheHeaderBehavior, CachePolicy, CacheQueryStringBehavior, - DistributionProps, - IOrigin, + Distribution, + OriginRequestHeaderBehavior, OriginRequestPolicy, - OriginSslPolicy, - PriceClass, - ViewerProtocolPolicy, + OriginRequestQueryStringBehavior, } from "aws-cdk-lib/aws-cloudfront"; -import { HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins"; import { Policy, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"; +import { Conditions } from "../common-resources/common-resources-construct"; import { Runtime } from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; -import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; +import { CfnLogGroup, LogGroup, QueryString } from "aws-cdk-lib/aws-logs"; import { IBucket } from "aws-cdk-lib/aws-s3"; -import { ArnFormat, Aspects, Aws, CfnCondition, Duration, Fn, Lazy, Stack } from "aws-cdk-lib"; +import { ArnFormat, Aspects, Aws, CfnCondition, CfnResource, Duration, Fn, Stack } from "aws-cdk-lib"; import { Construct } from "constructs"; -import { CloudFrontToApiGatewayToLambda } from "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda"; - import { addCfnSuppressRules } from "../../utils/utils"; import { SolutionConstructProps } from "../types"; -import * as api from "aws-cdk-lib/aws-apigateway"; +import { ApiGatewayArchitecture } from "./api-gateway-architecture"; +import { S3ObjectLambdaArchitecture } from "./s3-object-lambda-architecture"; import { SolutionsMetrics, ExecutionDay } from "metrics-utils"; import { ConditionAspect } from "../../utils/aspects"; +import { OperationalInsightsDashboard } from "../dashboard/ops-insights-dashboard"; +import { Dashboard } from "aws-cdk-lib/aws-cloudwatch"; export interface BackEndProps extends SolutionConstructProps { readonly solutionVersion: string; readonly solutionId: string; readonly solutionName: string; readonly sendAnonymousStatistics: CfnCondition; + readonly deployCloudWatchDashboard: CfnCondition; readonly secretsManagerPolicy: Policy; readonly logsBucket: IBucket; readonly uuid: string; + readonly regionedBucketName: string; + readonly regionedBucketHash: string; readonly cloudFrontPriceClass: string; + readonly conditions: Conditions; + readonly sharpSizeLimit: string; readonly createSourceBucketsResource: (key?: string) => string[]; } export class BackEnd extends Construct { public domainName: string; + public olDomainName: string; + public operationalDashboard: Dashboard; constructor(scope: Construct, id: string, props: BackEndProps) { super(scope, id); @@ -112,8 +116,10 @@ export class BackEnd extends Construct { ENABLE_DEFAULT_FALLBACK_IMAGE: props.enableDefaultFallbackImage, DEFAULT_FALLBACK_IMAGE_BUCKET: props.fallbackImageS3Bucket, DEFAULT_FALLBACK_IMAGE_KEY: props.fallbackImageS3KeyBucket, + ENABLE_S3_OBJECT_LAMBDA: props.enableS3ObjectLambda, SOLUTION_VERSION: props.solutionVersion, SOLUTION_ID: props.solutionId, + SHARP_SIZE_LIMIT: props.sharpSizeLimit, }, bundling: { externalModules: ["sharp"], @@ -134,9 +140,16 @@ export class BackEnd extends Construct { const imageHandlerLogGroup = new LogGroup(this, "ImageHandlerLogGroup", { logGroupName: `/aws/lambda/${imageHandlerLambdaFunction.functionName}`, - retention: props.logRetentionPeriod as RetentionDays, }); + // Access the underlying CfnLogGroup to add conditions + const cfnLogGroup = imageHandlerLogGroup.node.defaultChild as CfnLogGroup; + + cfnLogGroup.addOverride( + "Properties.RetentionInDays", + Fn.conditionIf(props.conditions.isLogRetentionPeriodInfinite.logicalId, Aws.NO_VALUE, props.logRetentionPeriod) + ); + addCfnSuppressRules(imageHandlerLogGroup, [ { id: "W84", @@ -151,88 +164,43 @@ export class BackEnd extends Construct { maxTtl: Duration.days(365), enableAcceptEncodingGzip: false, headerBehavior: CacheHeaderBehavior.allowList("origin", "accept"), - queryStringBehavior: CacheQueryStringBehavior.allowList("signature"), + queryStringBehavior: CacheQueryStringBehavior.all(), }); + const cachePolicyResource = this.node.findChild("CachePolicy").node.defaultChild as CfnResource; + cachePolicyResource.addOverride( + "Properties.CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers", + { + "Fn::If": [props.conditions.autoWebPCondition.logicalId, ["origin", "accept"], ["origin"]], + } + ); + const originRequestPolicy = new OriginRequestPolicy(this, "OriginRequestPolicy", { originRequestPolicyName: `ServerlessImageHandler-${props.uuid}`, - headerBehavior: CacheHeaderBehavior.allowList("origin", "accept"), - queryStringBehavior: CacheQueryStringBehavior.allowList("signature"), + headerBehavior: OriginRequestHeaderBehavior.allowList("origin", "accept"), + queryStringBehavior: OriginRequestQueryStringBehavior.all(), }); - const apiGatewayRestApi = RestApi.fromRestApiId( - this, - "ApiGatewayRestApi", - Lazy.string({ - produce: () => imageHandlerCloudFrontApiGatewayLambda.apiGateway.restApiId, - }) - ); - - const origin: IOrigin = new HttpOrigin(`${apiGatewayRestApi.restApiId}.execute-api.${Aws.REGION}.amazonaws.com`, { - originPath: "/image", - originSslProtocols: [OriginSslPolicy.TLS_V1_1, OriginSslPolicy.TLS_V1_2], + const existingDistribution = Distribution.fromDistributionAttributes(this, "ExistingDistribution", { + domainName: "", + distributionId: props.existingCloudFrontDistributionId, }); - const cloudFrontDistributionProps: DistributionProps = { - comment: "Image Handler Distribution for Serverless Image Handler", - defaultBehavior: { - origin, - allowedMethods: AllowedMethods.ALLOW_GET_HEAD, - viewerProtocolPolicy: ViewerProtocolPolicy.HTTPS_ONLY, - originRequestPolicy, - cachePolicy, - }, - priceClass: props.cloudFrontPriceClass as PriceClass, - enableLogging: true, - logBucket: props.logsBucket, - logFilePrefix: "api-cloudfront/", - errorResponses: [ - { httpStatus: 500, ttl: Duration.minutes(10) }, - { httpStatus: 501, ttl: Duration.minutes(10) }, - { httpStatus: 502, ttl: Duration.minutes(10) }, - { httpStatus: 503, ttl: Duration.minutes(10) }, - { httpStatus: 504, ttl: Duration.minutes(10) }, - ], - }; - - const logGroupProps = { - retention: props.logRetentionPeriod as RetentionDays, - }; - - const apiGatewayProps: LambdaRestApiProps = { - handler: imageHandlerLambdaFunction, - deployOptions: { - stageName: "image", - }, - binaryMediaTypes: ["*/*"], - defaultMethodOptions: { - authorizationType: api.AuthorizationType.NONE, - }, - }; - - const imageHandlerCloudFrontApiGatewayLambda = new CloudFrontToApiGatewayToLambda( - this, - "ImageHandlerCloudFrontApiGatewayLambda", - { - existingLambdaObj: imageHandlerLambdaFunction, - insertHttpSecurityHeaders: false, - logGroupProps, - cloudFrontDistributionProps, - apiGatewayProps, - } - ); - - addCfnSuppressRules(imageHandlerCloudFrontApiGatewayLambda.apiGateway, [ - { - id: "W59", - reason: - "AWS::ApiGateway::Method AuthorizationType is set to 'NONE' because API Gateway behind CloudFront does not support AWS_IAM authentication", - }, - ]); - - imageHandlerCloudFrontApiGatewayLambda.apiGateway.node.tryRemoveChild("Endpoint"); // we don't need the RestApi endpoint in the outputs + const apiGatewayArchitecture = new ApiGatewayArchitecture(this, { + imageHandlerLambdaFunction, + originRequestPolicy, + cachePolicy, + existingDistribution, + ...props, + }); - this.domainName = imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.distributionDomainName; + const s3ObjectLambdaArchitecture = new S3ObjectLambdaArchitecture(this, { + imageHandlerLambdaFunction, + originRequestPolicy, + cachePolicy, + existingDistribution, + ...props, + }); const shortLogRetentionCondition: CfnCondition = new CfnCondition(this, "ShortLogRetentionCondition", { expression: Fn.conditionOr( @@ -249,18 +217,52 @@ export class BackEnd extends Construct { ExecutionDay.MONDAY ).toString(), }); + + const conditionalCloudFrontDistributionId = Fn.conditionIf( + props.conditions.useExistingCloudFrontDistributionCondition.logicalId, + existingDistribution.distributionId, + Fn.conditionIf( + props.conditions.enableS3ObjectLambdaCondition.logicalId, + s3ObjectLambdaArchitecture.imageHandlerCloudFrontDistribution.distributionId, + apiGatewayArchitecture.imageHandlerCloudFrontDistribution.distributionId + ).toString() + ).toString(); + solutionsMetrics.addLambdaInvocationCount(imageHandlerLambdaFunction.functionName); solutionsMetrics.addLambdaBilledDurationMemorySize([imageHandlerLogGroup], "BilledDurationMemorySizeQuery"); - solutionsMetrics.addCloudFrontMetric( - imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.distributionId, - "Requests" - ); + solutionsMetrics.addQueryDefinition({ + logGroups: [imageHandlerLogGroup], + queryString: new QueryString({ + parseStatements: [ + `@message "requestType: 'Default'" as DefaultRequests`, + `@message "requestType: 'Thumbor'" as ThumborRequests`, + `@message "requestType: 'Custom'" as CustomRequests`, + `@message "Query param edits:" as QueryParamRequests`, + `@message "expires" as ExpiresRequests`, + ], + stats: + "count(DefaultRequests) as DefaultRequestsCount, count(ThumborRequests) as ThumborRequestsCount, count(CustomRequests) as CustomRequestsCount, count(QueryParamRequests) as QueryParamRequestsCount, count(ExpiresRequests) as ExpiresRequestsCount", + }), + queryDefinitionName: "RequestInfoQuery", + }); - solutionsMetrics.addCloudFrontMetric( - imageHandlerCloudFrontApiGatewayLambda.cloudFrontWebDistribution.distributionId, - "BytesDownloaded" - ); + solutionsMetrics.addCloudFrontMetric(conditionalCloudFrontDistributionId, "Requests"); + solutionsMetrics.addCloudFrontMetric(conditionalCloudFrontDistributionId, "BytesDownloaded"); Aspects.of(solutionsMetrics).add(new ConditionAspect(props.sendAnonymousStatistics)); + + const operationalInsightsDashboard = new OperationalInsightsDashboard( + Stack.of(this), + "OperationalInsightsDashboard", + { + enabled: props.conditions.deployUICondition, + backendLambdaFunctionName: imageHandlerLambdaFunction.functionName, + cloudFrontDistributionId: conditionalCloudFrontDistributionId, + namespace: Aws.REGION, + } + ); + this.operationalDashboard = operationalInsightsDashboard.dashboard; + + Aspects.of(operationalInsightsDashboard).add(new ConditionAspect(props.deployCloudWatchDashboard)); } } diff --git a/source/constructs/lib/back-end/s3-object-lambda-architecture.ts b/source/constructs/lib/back-end/s3-object-lambda-architecture.ts new file mode 100644 index 000000000..de0a37a90 --- /dev/null +++ b/source/constructs/lib/back-end/s3-object-lambda-architecture.ts @@ -0,0 +1,243 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as path from "path"; +import { CfnAccessPoint } from "aws-cdk-lib/aws-s3"; +import * as s3objectlambda from "aws-cdk-lib/aws-s3objectlambda"; +import { + AllowedMethods, + CachePolicy, + DistributionProps, + IOrigin, + OriginRequestPolicy, + PriceClass, + ViewerProtocolPolicy, + Function, + FunctionCode, + FunctionEventType, + CfnDistribution, + FunctionRuntime, + Distribution, + CfnOriginAccessControl, + IDistribution, +} from "aws-cdk-lib/aws-cloudfront"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { Aspects, Aws, CfnCondition, Duration, Fn } from "aws-cdk-lib"; +import { ConditionAspect } from "../../utils/aspects"; +import { readFileSync } from "fs"; +import { BackEnd, BackEndProps } from "./back-end-construct"; +import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam"; +import { S3ObjectLambdaOrigin } from "./s3-object-lambda-origin"; + +export interface S3ObjectLambdaArchitectureProps extends BackEndProps { + originRequestPolicy: OriginRequestPolicy; + cachePolicy: CachePolicy; + imageHandlerLambdaFunction: NodejsFunction; + existingDistribution: IDistribution; +} + +export class S3ObjectLambdaArchitecture { + public readonly imageHandlerCloudFrontDistribution: Distribution; + constructor(scope: BackEnd, props: S3ObjectLambdaArchitectureProps) { + const accessPointName = `sih-ap-${props.uuid}-${props.regionedBucketHash}`; + + const s3AccessPointPolicy = new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:*"], + resources: [ + `arn:aws:s3:${Aws.REGION}:${Aws.ACCOUNT_ID}:accesspoint/${accessPointName}`, + `arn:aws:s3:${Aws.REGION}:${Aws.ACCOUNT_ID}:accesspoint/${accessPointName}/object/*`, + ], + principals: [new ServicePrincipal("cloudfront.amazonaws.com")], + conditions: { + "ForAnyValue:StringEquals": { + "aws:CalledVia": "s3-object-lambda.amazonaws.com", + }, + }, + }).toJSON(); + const accessPoint = new CfnAccessPoint(scope, "AccessPoint", { + bucket: props.regionedBucketName, + name: accessPointName, + policy: { Statement: s3AccessPointPolicy }, + }); + Aspects.of(accessPoint).add(new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition)); + + props.imageHandlerLambdaFunction.grantInvoke(new ServicePrincipal("cloudfront.amazonaws.com")); + + // Slice off the last line since CloudFront functions can't have module exports but we need to export the handler to unit test it. + const inlineResponseModifierCode: string[] = readFileSync( + path.join(__dirname, "../../../image-handler/cloudfront-function-handlers/ol-response-modifier.js"), + "utf-8" + ) + .split("\n") + .slice(0, -1); + + const responseModifierCloudFrontFunction = new Function(scope, "OlResponseModifierFunction", { + code: FunctionCode.fromInline(inlineResponseModifierCode.join("\n")), + functionName: `sih-ol-response-modifier-${props.uuid}`, + runtime: FunctionRuntime.JS_2_0, + }); + Aspects.of(responseModifierCloudFrontFunction).add( + new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition) + ); + + // Slice off the last line since CloudFront functions can't have module exports but we need to export the handler to unit test it. + const inlineRequestModifierCode: string[] = readFileSync( + path.join(__dirname, "../../../image-handler/cloudfront-function-handlers/ol-request-modifier.js"), + "utf-8" + ) + .split("\n") + .slice(0, -1); + + const requestModifierCloudFrontFunction = new Function(scope, "OlRequestModifierFunction", { + code: FunctionCode.fromInline(inlineRequestModifierCode.join("\n")), + functionName: `sih-ol-request-modifier-${props.uuid}`, + runtime: FunctionRuntime.JS_2_0, + }); + Aspects.of(requestModifierCloudFrontFunction).add( + new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition) + ); + + const objectLambdaAccessPointName = `sih-olap-${props.uuid}`; + const objectLambdaAccessPoint = new s3objectlambda.CfnAccessPoint(scope, "ObjectLambdaAccessPoint", { + objectLambdaConfiguration: { + supportingAccessPoint: accessPoint.attrArn, + transformationConfigurations: [ + { + actions: ["GetObject", "HeadObject"], + contentTransformation: { + AwsLambda: { + FunctionArn: props.imageHandlerLambdaFunction.functionArn, + }, + }, + }, + ], + }, + name: objectLambdaAccessPointName, + }); + Aspects.of(objectLambdaAccessPoint).add(new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition)); + + const writeGetObjectResponsePolicy = new Policy(scope, "WriteGetObjectResponsePolicy", { + statements: [ + new PolicyStatement({ + actions: ["s3-object-lambda:WriteGetObjectResponse"], + resources: [objectLambdaAccessPoint.attrArn], + }), + ], + }); + Aspects.of(writeGetObjectResponsePolicy).add(new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition)); + props.imageHandlerLambdaFunction.role?.attachInlinePolicy(writeGetObjectResponsePolicy); + + const origin: IOrigin = new S3ObjectLambdaOrigin( + `${objectLambdaAccessPoint.attrAliasValue}.s3.${Aws.REGION}.amazonaws.com`, + { originShieldEnabled: true, originShieldRegion: Aws.REGION, originPath: "/image", connectionAttempts: 1 } + ); + + const cloudFrontDistributionProps: DistributionProps = { + comment: "Image Handler Distribution for Dynamic Image Transformation for Amazon CloudFront", + defaultBehavior: { + origin, + allowedMethods: AllowedMethods.ALLOW_GET_HEAD, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + originRequestPolicy: props.originRequestPolicy, + cachePolicy: props.cachePolicy, + functionAssociations: [ + { + function: responseModifierCloudFrontFunction, + eventType: FunctionEventType.VIEWER_RESPONSE, + }, + { + function: requestModifierCloudFrontFunction, + eventType: FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + priceClass: props.cloudFrontPriceClass as PriceClass, + enableLogging: true, + logBucket: props.logsBucket, + logFilePrefix: "api-cloudfront/", + errorResponses: [ + { httpStatus: 500, ttl: Duration.minutes(10) }, + { httpStatus: 501, ttl: Duration.minutes(10) }, + { httpStatus: 502, ttl: Duration.minutes(10) }, + { httpStatus: 503, ttl: Duration.minutes(10) }, + { httpStatus: 504, ttl: Duration.minutes(10) }, + ], + }; + + this.imageHandlerCloudFrontDistribution = new Distribution( + scope, + "ImageHandlerCloudFrontDistribution", + cloudFrontDistributionProps + ); + Aspects.of(this.imageHandlerCloudFrontDistribution).add( + new ConditionAspect( + new CfnCondition(scope, "DeployS3OLDistribution", { + expression: Fn.conditionAnd( + props.conditions.enableS3ObjectLambdaCondition, + Fn.conditionNot(props.conditions.useExistingCloudFrontDistributionCondition) + ), + }) + ) + ); + + const conditionalCloudFrontDistributionId = Fn.conditionIf( + props.conditions.useExistingCloudFrontDistributionCondition.logicalId, + props.existingDistribution.distributionId, + this.imageHandlerCloudFrontDistribution.distributionId + ).toString(); + + const objectLambdaAccessPointPolicy = new s3objectlambda.CfnAccessPointPolicy( + scope, + "ObjectLambdaAccessPointPolicy", + { + objectLambdaAccessPoint: objectLambdaAccessPoint.ref, + policyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { + Service: "cloudfront.amazonaws.com", + }, + Action: "s3-object-lambda:Get*", + Resource: objectLambdaAccessPoint.attrArn, + Condition: { + StringEquals: { + "aws:SourceArn": `arn:aws:cloudfront::${Aws.ACCOUNT_ID}:distribution/${conditionalCloudFrontDistributionId}`, + }, + }, + }, + ], + }, + } + ); + + Aspects.of(objectLambdaAccessPointPolicy).add(new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition)); + + const oac = new CfnOriginAccessControl(scope, `SIH-origin-access-control`, { + originAccessControlConfig: { + name: `SIH-origin-access-control-${props.uuid}`, + originAccessControlOriginType: "s3", + signingBehavior: "always", + signingProtocol: "sigv4", + }, + }); + Aspects.of(oac).add(new ConditionAspect(props.conditions.enableS3ObjectLambdaCondition)); + + const cfnDistribution = this.imageHandlerCloudFrontDistribution.node.defaultChild as CfnDistribution; + cfnDistribution.addPropertyOverride("DistributionConfig.Origins.0.OriginAccessControlId", oac.attrId); + cfnDistribution.addOverride("Properties.DistributionConfig.Origins.0.OriginShield", { + "Fn::If": [ + props.conditions.enableOriginShieldCondition.logicalId, + { Enabled: true, OriginShieldRegion: props.originShieldRegion }, + { Enabled: false }, + ], + }); + scope.olDomainName = Fn.conditionIf( + props.conditions.useExistingCloudFrontDistributionCondition.logicalId, + props.existingDistribution.distributionDomainName, + this.imageHandlerCloudFrontDistribution.domainName + ).toString(); + } +} diff --git a/source/constructs/lib/back-end/s3-object-lambda-origin.ts b/source/constructs/lib/back-end/s3-object-lambda-origin.ts new file mode 100644 index 000000000..6ae707b0e --- /dev/null +++ b/source/constructs/lib/back-end/s3-object-lambda-origin.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CfnDistribution, OriginBase, OriginProps } from "aws-cdk-lib/aws-cloudfront"; + +export class S3ObjectLambdaOrigin extends OriginBase { + public constructor(domainName: string, props: OriginProps = {}) { + super(domainName, props); + } + + protected renderS3OriginConfig(): CfnDistribution.S3OriginConfigProperty { + return {}; + } +} diff --git a/source/constructs/lib/common-resources/common-resources-construct.ts b/source/constructs/lib/common-resources/common-resources-construct.ts index e3f84ff56..9ab2f2230 100644 --- a/source/constructs/lib/common-resources/common-resources-construct.ts +++ b/source/constructs/lib/common-resources/common-resources-construct.ts @@ -21,6 +21,12 @@ export interface Conditions { readonly enableSignatureCondition: CfnCondition; readonly enableDefaultFallbackImageCondition: CfnCondition; readonly enableCorsCondition: CfnCondition; + readonly autoWebPCondition: CfnCondition; + readonly enableOriginShieldCondition: CfnCondition; + readonly enableS3ObjectLambdaCondition: CfnCondition; + readonly disableS3ObjectLambdaCondition: CfnCondition; + readonly isLogRetentionPeriodInfinite: CfnCondition; + readonly useExistingCloudFrontDistributionCondition: CfnCondition; } export interface AppRegistryApplicationProps { @@ -56,6 +62,24 @@ export class CommonResources extends Construct { enableCorsCondition: new CfnCondition(this, "EnableCorsCondition", { expression: Fn.conditionEquals(props.corsEnabled, "Yes"), }), + autoWebPCondition: new CfnCondition(this, "AutoWebPCondition", { + expression: Fn.conditionEquals(props.autoWebP, "Yes"), + }), + enableOriginShieldCondition: new CfnCondition(this, "EnableOriginShieldCondition", { + expression: Fn.conditionNot(Fn.conditionEquals(props.originShieldRegion, "Disabled")), + }), + enableS3ObjectLambdaCondition: new CfnCondition(this, "EnableS3ObjectLambdaCondition", { + expression: Fn.conditionEquals(props.enableS3ObjectLambda, "Yes"), + }), + disableS3ObjectLambdaCondition: new CfnCondition(this, "DisableS3ObjectLambdaCondition", { + expression: Fn.conditionNot(Fn.conditionEquals(props.enableS3ObjectLambda, "Yes")), + }), + isLogRetentionPeriodInfinite: new CfnCondition(this, "IsLogRetentionPeriodInfinite", { + expression: Fn.conditionEquals(props.logRetentionPeriod, "Infinite"), + }), + useExistingCloudFrontDistributionCondition: new CfnCondition(this, "UseExistingCloudFrontDistributionCondition", { + expression: Fn.conditionEquals(props.useExistingCloudFrontDistribution, "Yes"), + }), }; this.secretsManagerPolicy = new Policy(this, "SecretsManagerPolicy", { diff --git a/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts b/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts index 2b38765c0..efd3b8e96 100644 --- a/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts +++ b/source/constructs/lib/common-resources/custom-resources/custom-resource-construct.ts @@ -7,7 +7,18 @@ import { Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import { Bucket, IBucket } from "aws-cdk-lib/aws-s3"; import { BucketDeployment, Source as S3Source } from "aws-cdk-lib/aws-s3-deployment"; -import { ArnFormat, Aspects, Aws, CfnCondition, CfnResource, CustomResource, Duration, Fn, Lazy, Stack } from "aws-cdk-lib"; +import { + ArnFormat, + Aspects, + Aws, + CfnCondition, + CfnResource, + CustomResource, + Duration, + Fn, + Lazy, + Stack, +} from "aws-cdk-lib"; import { Construct } from "constructs"; import { addCfnCondition, addCfnSuppressRules } from "../../../utils/utils"; @@ -28,6 +39,7 @@ export interface ValidateSourceAndFallbackImageBucketsCustomResourceProps { readonly sourceBuckets: string; readonly fallbackImageS3Bucket: string; readonly fallbackImageS3Key: string; + readonly enableS3ObjectLambda: string; } export interface SetupCopyWebsiteCustomResourceProps { @@ -44,12 +56,20 @@ export interface SetupValidateSecretsManagerProps { readonly secretsManagerKey: string; } +export interface SetupValidateExistingDistributionProps { + readonly existingDistributionId: string; + readonly condition: CfnCondition; +} + export class CustomResourcesConstruct extends Construct { private readonly conditions: Conditions; private readonly customResourceRole: Role; private readonly customResourceLambda: LambdaFunction; public readonly uuid: string; + public regionedBucketName: string; + public regionedBucketHash: string; public appRegApplicationName: string; + public existingDistributionDomainName: string; constructor(scope: Construct, id: string, props: CustomResourcesConstructProps) { super(scope, id); @@ -74,17 +94,17 @@ export class CustomResourcesConstruct extends Construct { }), ], }), + ], + }), + S3AccessPolicy: new PolicyDocument({ + statements: [ new PolicyStatement({ - actions: ['s3:ListBucket'], - resources: this.createSourceBucketsResource() + actions: ["s3:ListBucket", "s3:GetBucketLocation"], + resources: this.createSourceBucketsResource(), }), new PolicyStatement({ - actions: [ - "s3:GetObject", - ], - resources: [ - `arn:aws:s3:::${props.fallbackImageS3Bucket}/${props.fallbackImageS3KeyBucket}`, - ], + actions: ["s3:GetObject"], + resources: [`arn:aws:s3:::${props.fallbackImageS3Bucket}/${props.fallbackImageS3KeyBucket}`], }), new PolicyStatement({ actions: [ @@ -93,7 +113,8 @@ export class CustomResourcesConstruct extends Construct { "s3:putBucketPolicy", "s3:CreateBucket", "s3:PutBucketOwnershipControls", - "s3:PutBucketTagging" + "s3:PutBucketTagging", + "s3:PutBucketVersioning", ], resources: [ Stack.of(this).formatArn({ @@ -106,6 +127,10 @@ export class CustomResourcesConstruct extends Construct { }), ], }), + new PolicyStatement({ + actions: ["s3:ListBucket"], + resources: [`arn:aws:s3:::sih-dummy-*`], + }), ], }), EC2Policy: new PolicyDocument({ @@ -151,6 +176,24 @@ export class CustomResourcesConstruct extends Construct { }), ], }), + ExistingDistributionPolicy: new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["cloudfront:GetDistribution"], + resources: [ + Stack.of(this).formatArn({ + partition: Aws.PARTITION, + service: "cloudfront", + region: "", + account: Aws.ACCOUNT_ID, + resource: `distribution/${props.existingCloudFrontDistributionId}`, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }), + ], + }), + ], + }), }, }); @@ -190,15 +233,15 @@ export class CustomResourcesConstruct extends Construct { document: new PolicyDocument({ statements: [ new PolicyStatement({ - actions: ["s3:GetObject", "s3:PutObject",], + actions: ["s3:GetObject", "s3:PutObject"], resources: [websiteHostingBucket.bucketArn + "/*"], }), ], }), roles: [this.customResourceRole], - }) + }); addCfnCondition(websiteHostingBucketPolicy, this.conditions.deployUICondition); - }; + } public setupAnonymousMetric(props: AnonymousMetricCustomResourceProps) { this.createCustomResource("CustomResourceAnonymousMetric", this.customResourceLambda, { @@ -213,6 +256,9 @@ export class CustomResourcesConstruct extends Construct { AutoWebP: props.autoWebP, EnableSignature: props.enableSignature, EnableDefaultFallbackImage: props.enableDefaultFallbackImage, + EnableS3ObjectLambda: props.enableS3ObjectLambda, + OriginShieldRegion: props.originShieldRegion, + UseExistingCloudFrontDistribution: props.useExistingCloudFrontDistribution, }); } @@ -223,6 +269,24 @@ export class CustomResourcesConstruct extends Construct { SourceBuckets: props.sourceBuckets, }); + const regionedBucketValidationResults = this.createCustomResource( + "CustomResourceCheckFirstBucketRegion", + this.customResourceLambda, + { + CustomAction: "checkFirstBucketRegion", + Region: Aws.REGION, + SourceBuckets: Fn.select(0, Fn.split(",", props.sourceBuckets)), // Only pass the first bucket to prevent unecessary execution on SourceBucketsParameter changes + UUID: this.uuid, + S3ObjectLambda: props.enableS3ObjectLambda, + } + ); + this.regionedBucketName = Lazy.string({ + produce: () => regionedBucketValidationResults.getAttString("BucketName"), + }); + this.regionedBucketHash = Lazy.string({ + produce: () => regionedBucketValidationResults.getAttString("BucketHash"), + }); + const getAppRegApplicationNameResults = this.createCustomResource( "CustomResourceGetAppRegApplicationName", this.customResourceLambda, @@ -250,9 +314,7 @@ export class CustomResourcesConstruct extends Construct { // Stage static assets for the front-end from the local /* eslint-disable no-new */ const bucketDeployment = new BucketDeployment(this, "DeployWebsite", { - sources: [ - S3Source.asset(path.join(__dirname, "../../../../demo-ui"), { exclude: ["node_modules/*"] }), - ], + sources: [S3Source.asset(path.join(__dirname, "../../../../demo-ui"), { exclude: ["node_modules/*"] })], destinationBucket: props.hostingBucket, exclude: ["demo-ui-config.js"], }); @@ -266,7 +328,7 @@ export class CustomResourcesConstruct extends Construct { { CustomAction: "putConfigFile", Region: Aws.REGION, - ConfigItem: { apiEndpoint: `https://${props.apiEndpoint}` }, + ConfigItem: { apiEndpoint: props.apiEndpoint }, DestS3Bucket: props.hostingBucket.bucketName, DestS3key: "demo-ui-config.js", }, @@ -287,6 +349,20 @@ export class CustomResourcesConstruct extends Construct { ); } + public setupValidateExistingDistribution(props: SetupValidateExistingDistributionProps) { + const validateExistingDistributionResults = this.createCustomResource( + "CustomResourceValidateExistingDistribution", + this.customResourceLambda, + { + CustomAction: "validateExistingDistribution", + Region: Aws.REGION, + ExistingDistributionID: props.existingDistributionId, + }, + props.condition + ); + this.existingDistributionDomainName = validateExistingDistributionResults.getAttString("DistributionDomainName"); + } + public createLogBucket(): IBucket { const bucketSuffix = `${Aws.STACK_NAME}-${Aws.REGION}-${Aws.ACCOUNT_ID}`; const logBucketCreationResult = this.createCustomResource("LogBucketCustomResource", this.customResourceLambda, { @@ -308,18 +384,18 @@ export class CustomResourcesConstruct extends Construct { public createSourceBucketsResource(resourceName: string = "") { return Fn.split( - ',', + ",", Fn.sub( `arn:aws:s3:::\${rest}${resourceName}`, { rest: Fn.join( `${resourceName},arn:aws:s3:::`, - Fn.split(",", Fn.join("", Fn.split(" ", Fn.ref('SourceBucketsParameter')))) + Fn.split(",", Fn.join("", Fn.split(" ", Fn.ref("SourceBucketsParameter")))) ), - }, - ), - ) + } + ) + ); } private createCustomResource( diff --git a/source/constructs/lib/dashboard/ops-insights-dashboard.ts b/source/constructs/lib/dashboard/ops-insights-dashboard.ts new file mode 100644 index 000000000..b734cacca --- /dev/null +++ b/source/constructs/lib/dashboard/ops-insights-dashboard.ts @@ -0,0 +1,120 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Aws, CfnCondition, Duration } from "aws-cdk-lib"; +import { Dashboard, PeriodOverride, TextWidget } from "aws-cdk-lib/aws-cloudwatch"; +import { Size, DefaultGraphWidget, DefaultSingleValueWidget } from "./widgets"; +import { SIHMetrics, SUPPORTED_CLOUDFRONT_METRICS, SUPPORTED_LAMBDA_METRICS } from "./sih-metrics"; +import { Construct } from "constructs"; + +export interface OperationalInsightsDashboardProps { + readonly enabled: CfnCondition; + readonly backendLambdaFunctionName: string; + readonly cloudFrontDistributionId: string; + readonly namespace: string; +} +export class OperationalInsightsDashboard extends Construct { + public readonly dashboard: Dashboard; + constructor(scope: Construct, id: string, props: OperationalInsightsDashboardProps) { + super(scope, id); + this.dashboard = new Dashboard(this, id, { + dashboardName: `${Aws.STACK_NAME}-${props.namespace}-Operational-Insights-Dashboard`, + defaultInterval: Duration.days(7), + periodOverride: PeriodOverride.INHERIT, + }); + + if (!props.backendLambdaFunctionName || !props.cloudFrontDistributionId) { + throw new Error("backendLambdaFunctionName and cloudFrontDistributionId are required"); + } + + const metrics = new SIHMetrics({ + backendLambdaFunctionName: props.backendLambdaFunctionName, + cloudFrontDistributionId: props.cloudFrontDistributionId, + }); + + this.dashboard.addWidgets( + new TextWidget({ + markdown: "# Lambda", + width: Size.FULL_WIDTH, + height: 1, + }) + ); + + this.dashboard.addWidgets( + new DefaultGraphWidget({ + width: Size.THIRD_WIDTH, + height: Size.THIRD_WIDTH, + title: "Lambda Errors", + metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.ERRORS), + label: "Lambda Errors", + unit: "Count", + }), + new DefaultGraphWidget({ + width: Size.THIRD_WIDTH, + height: Size.THIRD_WIDTH, + title: "Lambda Duration", + metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.DURATION), + label: "Lambda Duration", + unit: "Milliseconds", + }), + new DefaultGraphWidget({ + width: Size.THIRD_WIDTH, + height: Size.THIRD_WIDTH, + title: "Lambda Invocations", + metric: metrics.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), + label: "Lambda Invocations", + unit: "Count", + }) + ); + + this.dashboard.addWidgets( + new TextWidget({ + markdown: "# CloudFront", + width: Size.FULL_WIDTH, + height: 1, + }) + ); + + this.dashboard.addWidgets( + new DefaultGraphWidget({ + title: "CloudFront Requests", + metric: metrics.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), + label: "CloudFront Requests", + unit: "Count", + }), + new DefaultGraphWidget({ + title: "CloudFront Bytes Downloaded", + metric: metrics.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), + label: "CloudFront Bytes Downloaded", + unit: "Bytes", + }), + new DefaultSingleValueWidget({ + title: "Cache Hit Rate", + metric: metrics.getCacheHitRate(), + label: "Cache Hit Rate (%)", + }), + new DefaultSingleValueWidget({ + title: "Average Image Size", + metric: metrics.getAverageImageSize(), + label: "Average Image Size (Bytes)", + }) + ); + + this.dashboard.addWidgets( + new TextWidget({ + markdown: "# Overall", + width: Size.FULL_WIDTH, + height: 1, + }) + ); + + this.dashboard.addWidgets( + new DefaultSingleValueWidget({ + title: "Estimated Cost", + width: Size.FULL_WIDTH, + metric: metrics.getEstimatedCost(), + label: "Estimated Cost($)", + fullPrecision: true, + }) + ); + } +} diff --git a/source/constructs/lib/dashboard/sih-metrics.ts b/source/constructs/lib/dashboard/sih-metrics.ts new file mode 100644 index 000000000..86cedd133 --- /dev/null +++ b/source/constructs/lib/dashboard/sih-metrics.ts @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { MathExpression, Metric } from "aws-cdk-lib/aws-cloudwatch"; + +/** Properties for configuring metrics + */ +export interface MetricProps { + /** The name of the backend Lambda function to monitor */ + readonly backendLambdaFunctionName: string; + /** The CloudFront distribution ID to monitor */ + readonly cloudFrontDistributionId: string; +} + +export enum SUPPORTED_LAMBDA_METRICS { + ERRORS = "Errors", + INVOCATIONS = "Invocations", + DURATION = "Duration", +} + +export enum SUPPORTED_CLOUDFRONT_METRICS { + REQUESTS = "Requests", + BYTES_DOWNLOAD = "BytesDownloaded", +} + +enum Namespace { + LAMBDA = "AWS/Lambda", + CLOUDFRONT = "AWS/CloudFront", +} + +// Relevant AWS Pricing as of Dec 2024 for us-east-1 +const PRICING = { + CLOUDFRONT_BYTES: 0.085 / 1024 / 1024 / 1024, + CLOUDFRONT_REQUESTS: 0.0075 / 10000, + LAMBDA_DURATION: 1.66667 / 100000 / 1000, + LAMBDA_INVOCATIONS: 0.2 / 1000000, +}; + +/** + * Helper class for defining the underlying metrics available to the solution for ingestion into dashboard widgets + */ +export class SIHMetrics { + private readonly props; + + constructor(props: MetricProps) { + this.props = props; + } + + /** + * + * @param metric Creates a MathExpression to represent the running sum of a given metric + * @returns {MathExpression} The running sum of the provided metric + */ + runningSum(metric: Metric) { + return new MathExpression({ + expression: `RUNNING_SUM(metric)`, + usingMetrics: { + metric, + }, + }); + } + + /** + * Creates a Lambda metric with standard dimensions and statistics + * @param metricName The name of the Lambda metric to create + * @returns {Metric} Configured Lambda metric + */ + createLambdaMetric(metricName: SUPPORTED_LAMBDA_METRICS) { + return new Metric({ + namespace: Namespace.LAMBDA, + metricName, + dimensionsMap: { + FunctionName: this.props.backendLambdaFunctionName, + }, + statistic: "SUM", + }); + } + + /** + * Creates a CloudFront metric with standard dimensions and statistics + * @param metricName The name of the CloudFront metric to create + * @returns {Metric} Configured CloudFront metric + */ + createCloudFrontMetric(metricName: SUPPORTED_CLOUDFRONT_METRICS) { + return new Metric({ + namespace: Namespace.CLOUDFRONT, + metricName, + region: "us-east-1", + dimensionsMap: { + Region: "Global", + DistributionId: this.props.cloudFrontDistributionId, + }, + statistic: "SUM", + }); + } + + /** + * Calculates the cache hit rate for the Image Handler distribution. This is represented as the % of requests which were returned from the cache. + * @returns {MathExpression} The cache hit rate as a percentage + */ + getCacheHitRate() { + return new MathExpression({ + expression: "100 * (cloudFrontRequests - lambdaInvocations) / (cloudFrontRequests)", + usingMetrics: { + cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), + lambdaInvocations: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), + }, + }); + } + + /** + * Calculates estimated cost in USD based on AWS pricing as of Dec 2024 in us-east-1. + * Note: This is an approximation for the us-east-1 region only and includes + * CloudFront data transfer and requests, and Lambda duration and invocations. + * Some additional charges may apply for other services or regions. + * @returns {MathExpression} Estimated cost in USD + */ + getEstimatedCost() { + return new MathExpression({ + expression: `${PRICING.CLOUDFRONT_BYTES} * cloudFrontBytesDownloaded + ${PRICING.CLOUDFRONT_REQUESTS} * cloudFrontRequests + ${PRICING.LAMBDA_DURATION} * lambdaDuration + ${PRICING.LAMBDA_INVOCATIONS} * lambdaInvocations`, + usingMetrics: { + cloudFrontBytesDownloaded: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), + cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), + lambdaDuration: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.DURATION), + lambdaInvocations: this.createLambdaMetric(SUPPORTED_LAMBDA_METRICS.INVOCATIONS), + }, + }); + } + + /** + * Calculates the average size of images served through CloudFront + * @returns {MathExpression} Average image size in bytes per request + */ + getAverageImageSize() { + return new MathExpression({ + expression: "cloudFrontBytesDownloaded / cloudFrontRequests", + usingMetrics: { + cloudFrontBytesDownloaded: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.BYTES_DOWNLOAD), + cloudFrontRequests: this.createCloudFrontMetric(SUPPORTED_CLOUDFRONT_METRICS.REQUESTS), + }, + }); + } +} diff --git a/source/constructs/lib/dashboard/widgets.ts b/source/constructs/lib/dashboard/widgets.ts new file mode 100644 index 000000000..e869587ea --- /dev/null +++ b/source/constructs/lib/dashboard/widgets.ts @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + GraphWidget, + GraphWidgetProps, + GraphWidgetView, + LegendPosition, + MathExpression, + Metric, + SingleValueWidget, + SingleValueWidgetProps, + Stats, +} from "aws-cdk-lib/aws-cloudwatch"; +import { Duration } from "aws-cdk-lib"; + +/** + * Represents standard widget sizes for CloudWatch dashboards + * Values indicate the number of grid units the widget will occupy + */ +export enum Size { + FULL_WIDTH = 24, + HALF_WIDTH = 12, + THIRD_WIDTH = 8, + QUARTER_WIDTH = 6, +} + +export interface WidgetProps { + width: number; + height: number; +} + +export interface DefaultGraphWidgetProps extends GraphWidgetProps { + title: string; + width?: number; + height?: number; + metric: Metric; + label: string; + unit: string; +} + +export interface DefaultSingleValueWidgetProps extends Omit { + title: string; + width?: number; + height?: number; + metric: Metric | MathExpression; + label: string; +} + +/** + * Creates a standardized graph widget which adds a RUNNING_SUM line to the metric being graphed + * @extends GraphWidget + */ +export class RunningSumGraphWidget extends GraphWidget { + constructor(props: GraphWidgetProps) { + if (!props?.left?.length) { + throw new Error("RunningSumGraphWidget requires at least one left metric to be defined"); + } + if (!props.leftYAxis || !props.leftYAxis.label) { + throw new Error("Left Y axis and Left Y axis label are required"); + } + super({ ...props, rightYAxis: { ...props.leftYAxis, label: `Running-Total ${props.leftYAxis?.label}`, min: 0 } }); + this.addRightMetric( + new MathExpression({ + expression: `RUNNING_SUM(metric)`, + usingMetrics: { + metric: props.left[0], + }, + }).with({ label: "Total" }) + ); + } +} + +/** + * Creates a standardized graph widget with running sum functionality + * @extends RunningSumGraphWidget + */ +export class DefaultGraphWidget extends RunningSumGraphWidget { + constructor(props: DefaultGraphWidgetProps) { + super({ + title: props.title, + width: props.width || Size.HALF_WIDTH, + height: props.height || Size.HALF_WIDTH, + view: GraphWidgetView.TIME_SERIES, + period: props.period || Duration.days(1), + liveData: props.liveData ?? true, + left: [ + props.metric.with({ + label: props.label, + }), + ], + leftYAxis: { + label: props.unit, + showUnits: false, + min: 0, + }, + legendPosition: LegendPosition.BOTTOM, + statistic: Stats.SUM, + }); + } +} + +/** + * Creates a standardized single value widget which adds the provided label to the metric being graphed + * and sets the period as time range by default. + * @extends SingleValueWidget + */ +export class DefaultSingleValueWidget extends SingleValueWidget { + constructor(props: DefaultSingleValueWidgetProps) { + super({ + title: props.title, + width: props.width || Size.HALF_WIDTH, + height: props.height || Size.QUARTER_WIDTH, + metrics: [ + props.metric.with({ + label: props.label, + }), + ], + period: props.period, + setPeriodToTimeRange: props.sparkline ? false : props.setPeriodToTimeRange ?? true, + fullPrecision: props.fullPrecision, + sparkline: props.sparkline, + }); + } +} diff --git a/source/constructs/lib/front-end/front-end-construct.ts b/source/constructs/lib/front-end/front-end-construct.ts index adb732c54..5cdcda967 100644 --- a/source/constructs/lib/front-end/front-end-construct.ts +++ b/source/constructs/lib/front-end/front-end-construct.ts @@ -28,7 +28,7 @@ export class FrontEndConstruct extends Construct { const cloudFrontToS3 = new CloudFrontToS3(this, "DistributionToS3", { bucketProps: { serverAccessLogsBucket: undefined }, cloudFrontDistributionProps: { - comment: "Demo UI Distribution for Serverless Image Handler", + comment: "Demo UI Distribution for Dynamic Image Transformation for Amazon CloudFront", enableLogging: true, logBucket: props.logsBucket, logFilePrefix: "ui-cloudfront/", diff --git a/source/constructs/lib/serverless-image-stack.ts b/source/constructs/lib/serverless-image-stack.ts index 61dd2cb48..03fb1b6bb 100644 --- a/source/constructs/lib/serverless-image-stack.ts +++ b/source/constructs/lib/serverless-image-stack.ts @@ -2,7 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { PriceClass } from "aws-cdk-lib/aws-cloudfront"; -import { Aspects, CfnCondition, CfnMapping, CfnOutput, CfnParameter, Fn, Stack, StackProps, Tags } from "aws-cdk-lib"; +import { + Aspects, + CfnCondition, + CfnMapping, + CfnOutput, + CfnParameter, + CfnRule, + Fn, + Stack, + StackProps, + Tags, +} from "aws-cdk-lib"; import { Construct } from "constructs"; import { ConditionAspect, SuppressLambdaFunctionCfnRulesAspect } from "../utils/aspects"; import { BackEnd } from "./back-end/back-end-construct"; @@ -50,7 +61,7 @@ export class ServerlessImageHandlerStack extends Stack { }); const logRetentionPeriodParameter = new CfnParameter(this, "LogRetentionPeriodParameter", { - type: "Number", + type: "String", description: "This solution automatically logs events to Amazon CloudWatch. Select the amount of time for CloudWatch logs from this solution to be retained (in days).", allowedValues: [ @@ -71,13 +82,14 @@ export class ServerlessImageHandlerStack extends Stack { "731", "1827", "3653", + "Infinite", ], default: "180", }); const autoWebPParameter = new CfnParameter(this, "AutoWebPParameter", { type: "String", - description: `Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.`, + description: `Would you like to enable automatic formatting to WebP images when accept headers include "image/webp"? Select 'Yes' if so.`, allowedValues: ["Yes", "No"], default: "No", }); @@ -125,33 +137,96 @@ export class ServerlessImageHandlerStack extends Stack { const cloudFrontPriceClassParameter = new CfnParameter(this, "CloudFrontPriceClassParameter", { type: "String", description: - "The AWS CloudFront price class to use. For more information see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html", + "The AWS CloudFront price class to use. Lower price classes will avoid high cost edge locations, reducing cost at the expense of possibly increasing request latency. For more information see: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_cloudfront/PriceClass.html", allowedValues: [PriceClass.PRICE_CLASS_ALL, PriceClass.PRICE_CLASS_200, PriceClass.PRICE_CLASS_100], default: PriceClass.PRICE_CLASS_ALL, }); + const originShieldRegionParameter = new CfnParameter(this, "OriginShieldRegionParameter", { + type: "String", + description: + "Enabling Origin Shield may see reduced latency and increased cache hit ratios if your requests often come from many regions. If a region is selected, Origin Shield will be enabled and the Origin Shield caching layer will be set up in that region. For information on choosing a region, see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html#choose-origin-shield-region", + allowedValues: [ + "Disabled", + "us-east-1", + "us-east-2", + "us-west-2", + "ap-south-1", + "ap-northeast-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "sa-east-1", + ], + default: "Disabled", + }); + + const enableS3ObjectLambdaParameter = new CfnParameter(this, "EnableS3ObjectLambdaParameter", { + type: "String", + description: + "Enable S3 Object Lambda to improve the maximum response size of image requests beyond 6 MB. If enabled, an S3 Object Lambda Access Point will replace the API Gateway proxying requests to your image handler function. **Important: Modifying this value after initial template deployment will delete the existing CloudFront Distribution and create a new one, providing a new domain name and clearing the cache**", + allowedValues: ["Yes", "No"], + default: "No", + }); + + const useExistingCloudFrontDistribution = new CfnParameter(this, "UseExistingCloudFrontDistributionParameter", { + type: "String", + description: + "If you would like to use an existing CloudFront distribution, select 'Yes'. Otherwise, select 'No' to create a new CloudFront distribution. This option will require additional manual setup after deployment. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/attaching-existing-distribution.html", + allowedValues: ["Yes", "No"], + default: "No", + }); + + const existingCloudFrontDistributionId = new CfnParameter(this, "ExistingCloudFrontDistributionIdParameter", { + type: "String", + description: + "The ID of the existing CloudFront distribution. This parameter is required if 'Use Existing CloudFront Distribution' is set to 'Yes'.", + default: "", + allowedPattern: "^$|^E[A-Z0-9]{8,}$", + }); + + new CfnRule(this, "ExistingDistributionIdRequiredRule", { + ruleCondition: Fn.conditionEquals(useExistingCloudFrontDistribution.valueAsString, "Yes"), + assertions: [ + { + assert: Fn.conditionNot(Fn.conditionEquals(existingCloudFrontDistributionId.valueAsString, "")), + assertDescription: + "If 'UseExistingCloudFrontDistribution' is set to 'Yes', 'ExistingCloudFrontDistributionId' must be provided.", + }, + ], + }); + const solutionMapping = new CfnMapping(this, "Solution", { mapping: { Config: { AnonymousUsage: "Yes", + DeployCloudWatchDashboard: "Yes", SolutionId: props.solutionId, Version: props.solutionVersion, + SharpSizeLimit: "", }, }, lazy: false, }); const anonymousUsage = `${solutionMapping.findInMap("Config", "AnonymousUsage")}`; + const sharpSizeLimit = `${solutionMapping.findInMap("Config", "SharpSizeLimit")}`; const sendAnonymousStatistics = new CfnCondition(this, "SendAnonymousStatistics", { expression: Fn.conditionEquals(anonymousUsage, "Yes"), }); + const deployCloudWatchDashboard = new CfnCondition(this, "DeployCloudWatchDashboard", { + expression: Fn.conditionEquals(`${solutionMapping.findInMap("Config", "DeployCloudWatchDashboard")}`, "Yes"), + }); const solutionConstructProps: SolutionConstructProps = { corsEnabled: corsEnabledParameter.valueAsString, corsOrigin: corsOriginParameter.valueAsString, sourceBuckets: sourceBucketsParameter.valueAsString, deployUI: deployDemoUIParameter.valueAsString as YesNo, - logRetentionPeriod: logRetentionPeriodParameter.valueAsNumber, + logRetentionPeriod: logRetentionPeriodParameter.valueAsString, autoWebP: autoWebPParameter.valueAsString, enableSignature: enableSignatureParameter.valueAsString as YesNo, secretsManager: secretsManagerSecretParameter.valueAsString, @@ -159,6 +234,10 @@ export class ServerlessImageHandlerStack extends Stack { enableDefaultFallbackImage: enableDefaultFallbackImageParameter.valueAsString as YesNo, fallbackImageS3Bucket: fallbackImageS3BucketParameter.valueAsString, fallbackImageS3KeyBucket: fallbackImageS3KeyParameter.valueAsString, + originShieldRegion: originShieldRegionParameter.valueAsString, + enableS3ObjectLambda: enableS3ObjectLambdaParameter.valueAsString, + useExistingCloudFrontDistribution: useExistingCloudFrontDistribution.valueAsString as YesNo, + existingCloudFrontDistributionId: existingCloudFrontDistributionId.valueAsString, }; const commonResources = new CommonResources(this, "CommonResources", { @@ -168,6 +247,13 @@ export class ServerlessImageHandlerStack extends Stack { ...solutionConstructProps, }); + commonResources.customResources.setupValidateSourceAndFallbackImageBuckets({ + sourceBuckets: sourceBucketsParameter.valueAsString, + fallbackImageS3Bucket: fallbackImageS3BucketParameter.valueAsString, + fallbackImageS3Key: fallbackImageS3KeyParameter.valueAsString, + enableS3ObjectLambda: enableS3ObjectLambdaParameter.valueAsString, + }); + const frontEnd = new FrontEnd(this, "FrontEnd", { logsBucket: commonResources.logsBucket, conditions: commonResources.conditions, @@ -179,9 +265,14 @@ export class ServerlessImageHandlerStack extends Stack { solutionName: props.solutionName, secretsManagerPolicy: commonResources.secretsManagerPolicy, sendAnonymousStatistics, + deployCloudWatchDashboard, logsBucket: commonResources.logsBucket, uuid: commonResources.customResources.uuid, + regionedBucketName: commonResources.customResources.regionedBucketName, + regionedBucketHash: commonResources.customResources.regionedBucketHash, cloudFrontPriceClass: cloudFrontPriceClassParameter.valueAsString, + conditions: commonResources.conditions, + sharpSizeLimit, createSourceBucketsResource: commonResources.customResources.createSourceBucketsResource, ...solutionConstructProps, }); @@ -193,26 +284,35 @@ export class ServerlessImageHandlerStack extends Stack { ...solutionConstructProps, }); - commonResources.customResources.setupValidateSourceAndFallbackImageBuckets({ - sourceBuckets: sourceBucketsParameter.valueAsString, - fallbackImageS3Bucket: fallbackImageS3BucketParameter.valueAsString, - fallbackImageS3Key: fallbackImageS3KeyParameter.valueAsString, - }); - commonResources.customResources.setupValidateSecretsManager({ secretsManager: secretsManagerSecretParameter.valueAsString, secretsManagerKey: secretsManagerKeyParameter.valueAsString, }); + commonResources.customResources.setupValidateExistingDistribution({ + existingDistributionId: existingCloudFrontDistributionId.valueAsString, + condition: commonResources.conditions.useExistingCloudFrontDistributionCondition, + }); + commonResources.customResources.setupCopyWebsiteCustomResource({ hostingBucket: frontEnd.websiteHostingBucket, }); const singletonFunction = this.node.findChild("Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C"); Aspects.of(singletonFunction).add(new ConditionAspect(commonResources.conditions.deployUICondition)); + const apiEndpointConditionString = Fn.conditionIf( + commonResources.conditions.useExistingCloudFrontDistributionCondition.logicalId, + `https://` + commonResources.customResources.existingDistributionDomainName, + Fn.conditionIf( + commonResources.conditions.disableS3ObjectLambdaCondition.logicalId, + `https://` + backEnd.domainName, + `https://` + backEnd.olDomainName + ) + ).toString(); + commonResources.customResources.setupPutWebsiteConfigCustomResource({ hostingBucket: frontEnd.websiteHostingBucket, - apiEndpoint: backEnd.domainName, + apiEndpoint: apiEndpointConditionString, }); commonResources.appRegistryApplication({ @@ -226,6 +326,10 @@ export class ServerlessImageHandlerStack extends Stack { this.templateOptions.metadata = { "AWS::CloudFormation::Interface": { ParameterGroups: [ + { + Label: { default: "S3 Object Lambda" }, + Parameters: [enableS3ObjectLambdaParameter.logicalId], + }, { Label: { default: "CORS Options" }, Parameters: [corsEnabledParameter.logicalId, corsOriginParameter.logicalId], @@ -245,7 +349,7 @@ export class ServerlessImageHandlerStack extends Stack { { Label: { default: - "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)", + "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/architecture-details.html#image-url-signature)", }, Parameters: [ enableSignatureParameter.logicalId, @@ -256,7 +360,7 @@ export class ServerlessImageHandlerStack extends Stack { { Label: { default: - "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)", + "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/architecture-details.html#default-fallback-image)", }, Parameters: [ enableDefaultFallbackImageParameter.logicalId, @@ -268,8 +372,20 @@ export class ServerlessImageHandlerStack extends Stack { Label: { default: "Auto WebP" }, Parameters: [autoWebPParameter.logicalId], }, + { + Label: { default: "CloudFront" }, + Parameters: [ + originShieldRegionParameter.logicalId, + cloudFrontPriceClassParameter.logicalId, + useExistingCloudFrontDistribution.logicalId, + existingCloudFrontDistributionId.logicalId, + ], + }, ], ParameterLabels: { + [enableS3ObjectLambdaParameter.logicalId]: { + default: "Enable S3 Object Lambda", + }, [corsEnabledParameter.logicalId]: { default: "CORS Enabled" }, [corsOriginParameter.logicalId]: { default: "CORS Origin" }, [sourceBucketsParameter.logicalId]: { default: "Source Buckets" }, @@ -297,13 +413,22 @@ export class ServerlessImageHandlerStack extends Stack { [cloudFrontPriceClassParameter.logicalId]: { default: "CloudFront PriceClass", }, + [originShieldRegionParameter.logicalId]: { + default: "Origin Shield Region", + }, + [useExistingCloudFrontDistribution.logicalId]: { + default: "Use Existing CloudFront Distribution", + }, + [existingCloudFrontDistributionId.logicalId]: { + default: "Existing CloudFront Distribution Id", + }, }, }, }; /* eslint-disable no-new */ new CfnOutput(this, "ApiEndpoint", { - value: `https://${backEnd.domainName}`, + value: apiEndpointConditionString, description: "Link to API endpoint for sending image requests to.", }); new CfnOutput(this, "DemoUrl", { @@ -332,6 +457,11 @@ export class ServerlessImageHandlerStack extends Stack { value: commonResources.logsBucket.bucketName, description: "Amazon S3 bucket for storing CloudFront access logs.", }); + new CfnOutput(this, "CloudFrontDashboard", { + value: `https://console.aws.amazon.com/cloudwatch/home?#dashboards/dashboard/${backEnd.operationalDashboard.dashboardName}`, + description: "CloudFront metrics dashboard for the distribution.", + condition: deployCloudWatchDashboard, + }); Aspects.of(this).add(new SuppressLambdaFunctionCfnRulesAspect()); Tags.of(this).add("SolutionId", props.solutionId); diff --git a/source/constructs/lib/types.ts b/source/constructs/lib/types.ts index 1ffe07735..9f2d2037f 100644 --- a/source/constructs/lib/types.ts +++ b/source/constructs/lib/types.ts @@ -8,12 +8,16 @@ export interface SolutionConstructProps { readonly corsOrigin: string; readonly sourceBuckets: string; readonly deployUI: YesNo; - readonly logRetentionPeriod: number; + readonly logRetentionPeriod: string; readonly autoWebP: string; readonly enableSignature: YesNo; + readonly originShieldRegion: string; readonly secretsManager: string; readonly secretsManagerKey: string; readonly enableDefaultFallbackImage: YesNo; readonly fallbackImageS3Bucket: string; readonly fallbackImageS3KeyBucket: string; + readonly enableS3ObjectLambda: string; + readonly useExistingCloudFrontDistribution: YesNo; + readonly existingCloudFrontDistributionId: string; } diff --git a/source/constructs/package-lock.json b/source/constructs/package-lock.json index 62985b40e..1a5b5c9d8 100644 --- a/source/constructs/package-lock.json +++ b/source/constructs/package-lock.json @@ -1,12 +1,12 @@ { "name": "constructs", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "constructs", - "version": "6.3.3", + "version": "7.0.0", "license": "Apache-2.0", "dependencies": { "metrics-utils": "file:../metrics-utils", diff --git a/source/constructs/package.json b/source/constructs/package.json index 207a72548..70f04d602 100644 --- a/source/constructs/package.json +++ b/source/constructs/package.json @@ -1,7 +1,7 @@ { "name": "constructs", - "version": "6.3.3", - "description": "Serverless Image Handler Constructs", + "version": "7.0.0", + "description": "Dynamic Image Transformation for Amazon CloudFront Constructs", "license": "Apache-2.0", "author": { "name": "Amazon Web Services", diff --git a/source/constructs/test/__snapshots__/constructs.test.ts.snap b/source/constructs/test/__snapshots__/constructs.test.ts.snap index 5181a56c5..80b31b7d5 100644 --- a/source/constructs/test/__snapshots__/constructs.test.ts.snap +++ b/source/constructs/test/__snapshots__/constructs.test.ts.snap @@ -1,8 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Serverless Image Handler Stack Snapshot 1`] = ` +exports[`Dynamic Image Transformation for Amazon CloudFront Stack Snapshot 1`] = ` { "Conditions": { + "BackEndDeployAPIGDistributionF4E75280": { + "Fn::And": [ + { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", + }, + { + "Fn::Not": [ + { + "Condition": "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + }, + ], + }, + ], + }, + "BackEndDeployS3OLDistribution869FCC7C": { + "Fn::And": [ + { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + }, + { + "Fn::Not": [ + { + "Condition": "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + }, + ], + }, + ], + }, "BackEndShortLogRetentionCondition72EA1A33": { "Fn::Or": [ { @@ -31,6 +59,14 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, ], }, + "CommonResourcesAutoWebPCondition7119C768": { + "Fn::Equals": [ + { + "Ref": "AutoWebPParameter", + }, + "Yes", + ], + }, "CommonResourcesDeployDemoUICondition308D3B09": { "Fn::Equals": [ { @@ -39,6 +75,18 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Yes", ], }, + "CommonResourcesDisableS3ObjectLambdaCondition017AC33C": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "EnableS3ObjectLambdaParameter", + }, + "Yes", + ], + }, + ], + }, "CommonResourcesEnableCorsConditionA0615348": { "Fn::Equals": [ { @@ -55,6 +103,26 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Yes", ], }, + "CommonResourcesEnableOriginShieldConditionCEE94847": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "OriginShieldRegionParameter", + }, + "Disabled", + ], + }, + ], + }, + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD": { + "Fn::Equals": [ + { + "Ref": "EnableS3ObjectLambdaParameter", + }, + "Yes", + ], + }, "CommonResourcesEnableSignatureCondition909DC7A1": { "Fn::Equals": [ { @@ -63,6 +131,34 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Yes", ], }, + "CommonResourcesIsLogRetentionPeriodInfinite129C8A82": { + "Fn::Equals": [ + { + "Ref": "LogRetentionPeriodParameter", + }, + "Infinite", + ], + }, + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184": { + "Fn::Equals": [ + { + "Ref": "UseExistingCloudFrontDistributionParameter", + }, + "Yes", + ], + }, + "DeployCloudWatchDashboard": { + "Fn::Equals": [ + { + "Fn::FindInMap": [ + "Solution", + "Config", + "DeployCloudWatchDashboard", + ], + }, + "Yes", + ], + }, "SendAnonymousStatistics": { "Fn::Equals": [ { @@ -80,14 +176,24 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Solution": { "Config": { "AnonymousUsage": "Yes", + "DeployCloudWatchDashboard": "Yes", + "SharpSizeLimit": "", "SolutionId": "S0ABC", - "Version": "v6.3.3", + "Version": "v7.0.0", }, }, }, "Metadata": { "AWS::CloudFormation::Interface": { "ParameterGroups": [ + { + "Label": { + "default": "S3 Object Lambda", + }, + "Parameters": [ + "EnableS3ObjectLambdaParameter", + ], + }, { "Label": { "default": "CORS Options", @@ -123,7 +229,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, { "Label": { - "default": "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)", + "default": "Image URL Signature (Note: Enabling signature is not compatible with previous image URLs, which could result in broken image links. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/architecture-details.html#image-url-signature)", }, "Parameters": [ "EnableSignatureParameter", @@ -133,7 +239,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, { "Label": { - "default": "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/considerations.html)", + "default": "Default Fallback Image (Note: Enabling default fallback image returns the default fallback image instead of JSON object when error happens. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/architecture-details.html#default-fallback-image)", }, "Parameters": [ "EnableDefaultFallbackImageParameter", @@ -149,6 +255,17 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "AutoWebPParameter", ], }, + { + "Label": { + "default": "CloudFront", + }, + "Parameters": [ + "OriginShieldRegionParameter", + "CloudFrontPriceClassParameter", + "UseExistingCloudFrontDistributionParameter", + "ExistingCloudFrontDistributionIdParameter", + ], + }, ], "ParameterLabels": { "AutoWebPParameter": { @@ -169,9 +286,15 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "EnableDefaultFallbackImageParameter": { "default": "Enable Default Fallback Image", }, + "EnableS3ObjectLambdaParameter": { + "default": "Enable S3 Object Lambda", + }, "EnableSignatureParameter": { "default": "Enable Signature", }, + "ExistingCloudFrontDistributionIdParameter": { + "default": "Existing CloudFront Distribution Id", + }, "FallbackImageS3BucketParameter": { "default": "Fallback Image S3 Bucket", }, @@ -181,6 +304,9 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "LogRetentionPeriodParameter": { "default": "Log Retention Period", }, + "OriginShieldRegionParameter": { + "default": "Origin Shield Region", + }, "SecretsManagerKeyParameter": { "default": "SecretsManager Key", }, @@ -190,22 +316,90 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "SourceBucketsParameter": { "default": "Source Buckets", }, + "UseExistingCloudFrontDistributionParameter": { + "default": "Use Existing CloudFront Distribution", + }, }, }, }, "Outputs": { "ApiEndpoint": { "Description": "Link to API endpoint for sending image requests to.", + "Value": { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceValidateExistingDistribution34A6673C", + "DistributionDomainName", + ], + }, + ], + ], + }, + { + "Fn::If": [ + "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + "", + { + "Fn::GetAtt": [ + "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + "DomainName", + ], + }, + ], + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + "", + { + "Fn::GetAtt": [ + "BackEndImageHandlerCloudFrontDistributionB5464C90", + "DomainName", + ], + }, + ], + }, + ], + ], + }, + ], + }, + ], + }, + }, + "CloudFrontDashboard": { + "Condition": "DeployCloudWatchDashboard", + "Description": "CloudFront metrics dashboard for the distribution.", "Value": { "Fn::Join": [ "", [ - "https://", + "https://console.aws.amazon.com/cloudwatch/home?#dashboards/dashboard/", { - "Fn::GetAtt": [ - "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", - "DomainName", - ], + "Ref": "OperationalInsightsDashboard00409C46", }, ], ], @@ -272,7 +466,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "No", ], "Default": "No", - "Description": "Would you like to enable automatic WebP based on accept headers? Select 'Yes' if so.", + "Description": "Would you like to enable automatic formatting to WebP images when accept headers include "image/webp"? Select 'Yes' if so.", "Type": "String", }, "BootstrapVersion": { @@ -287,7 +481,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "PriceClass_100", ], "Default": "PriceClass_All", - "Description": "The AWS CloudFront price class to use. For more information see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html", + "Description": "The AWS CloudFront price class to use. Lower price classes will avoid high cost edge locations, reducing cost at the expense of possibly increasing request latency. For more information see: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_cloudfront/PriceClass.html", "Type": "String", }, "CorsEnabledParameter": { @@ -322,6 +516,15 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Description": "Would you like to enable the default fallback image? If so, select 'Yes' and provide FallbackImageS3Bucket and FallbackImageS3Key values.", "Type": "String", }, + "EnableS3ObjectLambdaParameter": { + "AllowedValues": [ + "Yes", + "No", + ], + "Default": "No", + "Description": "Enable S3 Object Lambda to improve the maximum response size of image requests beyond 6 MB. If enabled, an S3 Object Lambda Access Point will replace the API Gateway proxying requests to your image handler function. **Important: Modifying this value after initial template deployment will delete the existing CloudFront Distribution and create a new one, providing a new domain name and clearing the cache**", + "Type": "String", + }, "EnableSignatureParameter": { "AllowedValues": [ "Yes", @@ -331,6 +534,12 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Description": "Would you like to enable the signature? If so, select 'Yes' and provide SecretsManagerSecret and SecretsManagerKey values.", "Type": "String", }, + "ExistingCloudFrontDistributionIdParameter": { + "AllowedPattern": "^$|^E[A-Z0-9]{8,}$", + "Default": "", + "Description": "The ID of the existing CloudFront distribution. This parameter is required if 'Use Existing CloudFront Distribution' is set to 'Yes'.", + "Type": "String", + }, "FallbackImageS3BucketParameter": { "Default": "", "Description": "The name of the Amazon S3 bucket which contains the default fallback image. e.g. my-fallback-image-bucket", @@ -360,10 +569,31 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "731", "1827", "3653", + "Infinite", ], "Default": "180", "Description": "This solution automatically logs events to Amazon CloudWatch. Select the amount of time for CloudWatch logs from this solution to be retained (in days).", - "Type": "Number", + "Type": "String", + }, + "OriginShieldRegionParameter": { + "AllowedValues": [ + "Disabled", + "us-east-1", + "us-east-2", + "us-west-2", + "ap-south-1", + "ap-northeast-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "sa-east-1", + ], + "Default": "Disabled", + "Description": "Enabling Origin Shield may see reduced latency and increased cache hit ratios if your requests often come from many regions. If a region is selected, Origin Shield will be enabled and the Origin Shield caching layer will be set up in that region. For information on choosing a region, see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html#choose-origin-shield-region", + "Type": "String", }, "SecretsManagerKeyParameter": { "Default": "", @@ -381,11 +611,20 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Description": "(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will default to the first bucket listed in this field.", "Type": "String", }, + "UseExistingCloudFrontDistributionParameter": { + "AllowedValues": [ + "Yes", + "No", + ], + "Default": "No", + "Description": "If you would like to use an existing CloudFront distribution, select 'Yes'. Otherwise, select 'No' to create a new CloudFront distribution. This option will require additional manual setup after deployment. Please refer to the implementation guide for details: https://docs.aws.amazon.com/solutions/latest/serverless-image-handler/attaching-existing-distribution.html", + "Type": "String", + }, }, "Resources": { "AppRegistry968496A3": { "Properties": { - "Description": "Service Catalog application to track and manage all your resources for the solution sih", + "Description": "Service Catalog application to track and manage all your resources for the solution dit", "Name": { "Fn::GetAtt": [ "CommonResourcesCustomResourcesCustomResourceGetAppRegApplicationName62472E55", @@ -396,8 +635,8 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "SolutionId": "S0ABC", "Solutions:ApplicationType": "AWS-Solutions", "Solutions:SolutionID": "S0ABC", - "Solutions:SolutionName": "sih", - "Solutions:SolutionVersion": "v6.3.3", + "Solutions:SolutionName": "dit", + "Solutions:SolutionVersion": "v7.0.0", }, }, "Type": "AWS::ServiceCatalogAppRegistry::Application", @@ -417,17 +656,159 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::ServiceCatalogAppRegistry::ResourceAssociation", }, - "BackEndCachePolicy1DCE9B1B": { + "BackEndAccessPoint6DA9B104": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", "Properties": { - "CachePolicyConfig": { - "DefaultTTL": 86400, - "MaxTTL": 31536000, - "MinTTL": 1, - "Name": { + "Bucket": { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceCheckFirstBucketRegionE663CC31", + "BucketName", + ], + }, + "Name": { + "Fn::Join": [ + "", + [ + "sih-ap-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + "-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceCheckFirstBucketRegionE663CC31", + "BucketHash", + ], + }, + ], + ], + }, + "Policy": { + "Statement": { + "Action": "s3:*", + "Condition": { + "ForAnyValue:StringEquals": { + "aws:CalledVia": "s3-object-lambda.amazonaws.com", + }, + }, + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com", + }, + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:aws:s3:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":accesspoint/sih-ap-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + "-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceCheckFirstBucketRegionE663CC31", + "BucketHash", + ], + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:aws:s3:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":accesspoint/sih-ap-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + "-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceCheckFirstBucketRegionE663CC31", + "BucketHash", + ], + }, + "/object/*", + ], + ], + }, + ], + }, + }, + }, + "Type": "AWS::S3::AccessPoint", + }, + "BackEndApigRequestModifierFunction400C4F08": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", + "Properties": { + "AutoPublish": true, + "FunctionCode": "// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +function handler(event) { + // Normalize accept header to only include values used on the backend + if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { + event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" + } + event.request.querystring = processQueryParams(event.request.querystring).join('&') + return event.request; +} + +function processQueryParams(querystring) { + if (querystring == null) { + return []; + } + + const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; + + let qs = []; + for (const key in querystring) { + if (!ALLOWED_PARAMS.includes(key)) { + continue; + } + const value = querystring[key]; + qs.push( + value.multiValue + ? \`\${key}=\${value.multiValue[value.multiValue.length - 1].value}\` + : \`\${key}=\${value.value}\` + ) + } + + return qs.sort(); +}", + "FunctionConfig": { + "Comment": { "Fn::Join": [ "", [ - "ServerlessImageHandler-", + "sih-apig-request-modifier-", { "Fn::GetAtt": [ "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", @@ -437,35 +818,84 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], ], }, - "ParametersInCacheKeyAndForwardedToOrigin": { - "CookiesConfig": { - "CookieBehavior": "none", - }, - "EnableAcceptEncodingBrotli": false, - "EnableAcceptEncodingGzip": false, - "HeadersConfig": { - "HeaderBehavior": "whitelist", - "Headers": [ - "origin", - "accept", - ], - }, - "QueryStringsConfig": { - "QueryStringBehavior": "whitelist", - "QueryStrings": [ - "signature", - ], - }, - }, + "Runtime": "cloudfront-js-2.0", + }, + "Name": { + "Fn::Join": [ + "", + [ + "sih-apig-request-modifier-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + }, + "Type": "AWS::CloudFront::Function", + }, + "BackEndCachePolicy1DCE9B1B": { + "Properties": { + "CachePolicyConfig": { + "DefaultTTL": 86400, + "MaxTTL": 31536000, + "MinTTL": 1, + "Name": { + "Fn::Join": [ + "", + [ + "ServerlessImageHandler-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + "ParametersInCacheKeyAndForwardedToOrigin": { + "CookiesConfig": { + "CookieBehavior": "none", + }, + "EnableAcceptEncodingBrotli": false, + "EnableAcceptEncodingGzip": false, + "HeadersConfig": { + "HeaderBehavior": "whitelist", + "Headers": { + "Fn::If": [ + "CommonResourcesAutoWebPCondition7119C768", + [ + "origin", + "accept", + ], + [ + "origin", + ], + ], + }, + }, + "QueryStringsConfig": { + "QueryStringBehavior": "all", + }, + }, }, }, "Type": "AWS::CloudFront::CachePolicy", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaApiAccessLogGroup9B786692": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "DeletionPolicy": "Retain", "Metadata": { "cfn_nag": { "rules_to_suppress": [ + { + "id": "W86", + "reason": "Retention period for CloudWatchLogs LogGroups are set to 'Never Expire' to preserve customer data indefinitely", + }, { "id": "W84", "reason": "By default CloudWatchLogs LogGroups data is encrypted using the CloudWatch server-side encryption keys (AWS Managed Keys)", @@ -475,7 +905,15 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Properties": { "RetentionInDays": { - "Ref": "LogRetentionPeriodParameter", + "Fn::If": [ + "CommonResourcesIsLogRetentionPeriodInfinite129C8A82", + { + "Ref": "AWS::NoValue", + }, + { + "Ref": "LogRetentionPeriodParameter", + }, + ], }, "Tags": [ { @@ -488,6 +926,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "UpdateReplacePolicy": "Retain", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2": { + "Condition": "BackEndDeployAPIGDistributionF4E75280", "Metadata": { "cfn_nag": { "rules_to_suppress": [ @@ -500,7 +939,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Properties": { "DistributionConfig": { - "Comment": "Image Handler Distribution for Serverless Image Handler", + "Comment": "Image Handler Distribution for Dynamic Image Transformation for Amazon CloudFront", "CustomErrorResponses": [ { "ErrorCachingMinTTL": 600, @@ -532,11 +971,22 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Ref": "BackEndCachePolicy1DCE9B1B", }, "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "BackEndApigRequestModifierFunction400C4F08", + "FunctionARN", + ], + }, + }, + ], "OriginRequestPolicyId": { "Ref": "BackEndOriginRequestPolicy771345D7", }, "TargetOriginId": "TestStackBackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistributionOrigin1A053AEB7", - "ViewerProtocolPolicy": "https-only", + "ViewerProtocolPolicy": "redirect-to-https", }, "Enabled": true, "HttpVersion": "http2", @@ -594,6 +1044,20 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Id": "TestStackBackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistributionOrigin1A053AEB7", "OriginPath": "/image", + "OriginShield": { + "Fn::If": [ + "CommonResourcesEnableOriginShieldConditionCEE94847", + { + "Enabled": true, + "OriginShieldRegion": { + "Ref": "OriginShieldRegionParameter", + }, + }, + { + "Enabled": false, + }, + ], + }, }, ], "PriceClass": { @@ -610,6 +1074,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::CloudFront::Distribution", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Metadata": { "cfn_nag": { "rules_to_suppress": [ @@ -640,6 +1105,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::RestApi", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYApiPermissionTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANY979F1429": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -681,6 +1147,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::Lambda::Permission", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYApiPermissionTestTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANY932D3700": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -718,6 +1185,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::Lambda::Permission", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiANYE4494B31": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "AuthorizationType": "NONE", "HttpMethod": "ANY", @@ -761,6 +1229,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::Method", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiAccountE5522E5D": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "DependsOn": [ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi5A77D109", ], @@ -775,6 +1244,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::Account", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiCloudWatchRole12575C4D": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "AssumeRolePolicyDocument": { "Statement": [ @@ -839,7 +1309,8 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::IAM::Role", }, - "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D6bb2931f4d7f47b51b7b40b3fd0d7001b": { + "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D68d2558b8e7783b0edad2e0b11793c95c": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "DependsOn": [ "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANY8F9763E1", "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyBDF0A131", @@ -864,6 +1335,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::Deployment", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeploymentStageimageB55D20E3": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "AccessLogSetting": { "DestinationArn": { @@ -875,7 +1347,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Format": "{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","user":"$context.identity.user","caller":"$context.identity.caller","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}", }, "DeploymentId": { - "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D6bb2931f4d7f47b51b7b40b3fd0d7001b", + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiDeployment663240D68d2558b8e7783b0edad2e0b11793c95c", }, "MethodSettings": [ { @@ -900,6 +1372,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::Stage", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiUsagePlan76CA1E70": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "ApiStages": [ { @@ -922,6 +1395,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::UsagePlan", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANY8F9763E1": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "AuthorizationType": "NONE", "HttpMethod": "ANY", @@ -962,6 +1436,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::ApiGateway::Method", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANYApiPermissionTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANYproxyB5CBD1F7": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -1003,6 +1478,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::Lambda::Permission", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyANYApiPermissionTestTestStackBackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApi9D692DD2ANYproxyAEADD71A": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -1040,6 +1516,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::Lambda::Permission", }, "BackEndImageHandlerCloudFrontApiGatewayLambdaLambdaRestApiproxyBDF0A131": { + "Condition": "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", "Properties": { "ParentId": { "Fn::GetAtt": [ @@ -1054,6 +1531,157 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::ApiGateway::Resource", }, + "BackEndImageHandlerCloudFrontDistributionB5464C90": { + "Condition": "BackEndDeployS3OLDistribution869FCC7C", + "Properties": { + "DistributionConfig": { + "Comment": "Image Handler Distribution for Dynamic Image Transformation for Amazon CloudFront", + "CustomErrorResponses": [ + { + "ErrorCachingMinTTL": 600, + "ErrorCode": 500, + }, + { + "ErrorCachingMinTTL": 600, + "ErrorCode": 501, + }, + { + "ErrorCachingMinTTL": 600, + "ErrorCode": 502, + }, + { + "ErrorCachingMinTTL": 600, + "ErrorCode": 503, + }, + { + "ErrorCachingMinTTL": 600, + "ErrorCode": 504, + }, + ], + "DefaultCacheBehavior": { + "AllowedMethods": [ + "GET", + "HEAD", + ], + "CachePolicyId": { + "Ref": "BackEndCachePolicy1DCE9B1B", + }, + "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-response", + "FunctionARN": { + "Fn::GetAtt": [ + "BackEndOlResponseModifierFunctionB47B3834", + "FunctionARN", + ], + }, + }, + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "BackEndOlRequestModifierFunction7E5192E3", + "FunctionARN", + ], + }, + }, + ], + "OriginRequestPolicyId": { + "Ref": "BackEndOriginRequestPolicy771345D7", + }, + "TargetOriginId": "TestStackBackEndImageHandlerCloudFrontDistributionOrigin1781ABF9C", + "ViewerProtocolPolicy": "redirect-to-https", + }, + "Enabled": true, + "HttpVersion": "http2", + "IPV6Enabled": true, + "Logging": { + "Bucket": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB", + "BucketName", + ], + }, + ".s3.", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB", + "Region", + ], + }, + ".", + { + "Ref": "AWS::URLSuffix", + }, + ], + ], + }, + "Prefix": "api-cloudfront/", + }, + "Origins": [ + { + "ConnectionAttempts": 1, + "DomainName": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BackEndObjectLambdaAccessPointBEE6B960", + "Alias.Value", + ], + }, + ".s3.", + { + "Ref": "AWS::Region", + }, + ".amazonaws.com", + ], + ], + }, + "Id": "TestStackBackEndImageHandlerCloudFrontDistributionOrigin1781ABF9C", + "OriginAccessControlId": { + "Fn::GetAtt": [ + "BackEndSIHoriginaccesscontrolAFC8496A", + "Id", + ], + }, + "OriginPath": "/image", + "OriginShield": { + "Fn::If": [ + "CommonResourcesEnableOriginShieldConditionCEE94847", + { + "Enabled": true, + "OriginShieldRegion": { + "Ref": "OriginShieldRegionParameter", + }, + }, + { + "Enabled": false, + }, + ], + }, + "S3OriginConfig": {}, + }, + ], + "PriceClass": { + "Ref": "CloudFrontPriceClassParameter", + }, + }, + "Tags": [ + { + "Key": "SolutionId", + "Value": "S0ABC", + }, + ], + }, + "Type": "AWS::CloudFront::Distribution", + }, "BackEndImageHandlerFunctionPolicy437940B5": { "Metadata": { "cfn_nag": { @@ -1266,7 +1894,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "S3Key": "Omitted to remove snapshot dependency on hash", }, - "Description": "sih (v6.3.3): Performs image edits and manipulations", + "Description": "dit (v7.0.0): Performs image edits and manipulations", "Environment": { "Variables": { "AUTO_WEBP": { @@ -1287,6 +1915,9 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "ENABLE_DEFAULT_FALLBACK_IMAGE": { "Ref": "EnableDefaultFallbackImageParameter", }, + "ENABLE_S3_OBJECT_LAMBDA": { + "Ref": "EnableS3ObjectLambdaParameter", + }, "ENABLE_SIGNATURE": { "Ref": "EnableSignatureParameter", }, @@ -1298,68 +1929,339 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "SECRET_KEY": { "Ref": "SecretsManagerKeyParameter", }, + "SHARP_SIZE_LIMIT": { + "Fn::FindInMap": [ + "Solution", + "Config", + "SharpSizeLimit", + ], + }, "SOLUTION_ID": "S0ABC", "SOLUTION_VERSION": "Omitted to remove snapshot dependency on solution version", "SOURCE_BUCKETS": { "Ref": "SourceBucketsParameter", }, - }, - }, - "Handler": "index.handler", - "MemorySize": 1024, - "Role": { - "Fn::GetAtt": [ - "BackEndImageHandlerFunctionRoleABF81E5C", - "Arn", + }, + }, + "Handler": "index.handler", + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "BackEndImageHandlerFunctionRoleABF81E5C", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Tags": [ + { + "Key": "SolutionId", + "Value": "S0ABC", + }, + ], + "Timeout": 29, + }, + "Type": "AWS::Lambda::Function", + }, + "BackEndImageHandlerLambdaFunctionInvokeOmXtXvfmnVyX0ul6SNprKOdkal6YvZiIw5QCpGsJFo09B7B011": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "BackEndImageHandlerLambdaFunctionADEF7FF2", + "Arn", + ], + }, + "Principal": "cloudfront.amazonaws.com", + }, + "Type": "AWS::Lambda::Permission", + }, + "BackEndImageHandlerLogGroupA0941EEC": { + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W84", + "reason": "CloudWatch log group is always encrypted by default.", + }, + ], + }, + }, + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + ], + ], + }, + "RetentionInDays": { + "Fn::If": [ + "CommonResourcesIsLogRetentionPeriodInfinite129C8A82", + { + "Ref": "AWS::NoValue", + }, + { + "Ref": "LogRetentionPeriodParameter", + }, + ], + }, + "Tags": [ + { + "Key": "SolutionId", + "Value": "S0ABC", + }, + ], + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "BackEndObjectLambdaAccessPointBEE6B960": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + "Properties": { + "Name": { + "Fn::Join": [ + "", + [ + "sih-olap-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + "ObjectLambdaConfiguration": { + "SupportingAccessPoint": { + "Fn::GetAtt": [ + "BackEndAccessPoint6DA9B104", + "Arn", + ], + }, + "TransformationConfigurations": [ + { + "Actions": [ + "GetObject", + "HeadObject", + ], + "ContentTransformation": { + "AwsLambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "BackEndImageHandlerLambdaFunctionADEF7FF2", + "Arn", + ], + }, + }, + }, + }, + ], + }, + }, + "Type": "AWS::S3ObjectLambda::AccessPoint", + }, + "BackEndObjectLambdaAccessPointPolicy1FC842E3": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + "Properties": { + "ObjectLambdaAccessPoint": { + "Ref": "BackEndObjectLambdaAccessPointBEE6B960", + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:Get*", + "Condition": { + "StringEquals": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:aws:cloudfront::", + { + "Ref": "AWS::AccountId", + }, + ":distribution/", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + ], + }, + ], + ], + }, + }, + }, + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com", + }, + "Resource": { + "Fn::GetAtt": [ + "BackEndObjectLambdaAccessPointBEE6B960", + "Arn", + ], + }, + }, ], + "Version": "2012-10-17", }, - "Runtime": "nodejs20.x", - "Tags": [ - { - "Key": "SolutionId", - "Value": "S0ABC", - }, - ], - "Timeout": 29, }, - "Type": "AWS::Lambda::Function", + "Type": "AWS::S3ObjectLambda::AccessPointPolicy", }, - "BackEndImageHandlerLogGroupA0941EEC": { - "DeletionPolicy": "Retain", - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W84", - "reason": "CloudWatch log group is always encrypted by default.", - }, + "BackEndOlRequestModifierFunction7E5192E3": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + "Properties": { + "AutoPublish": true, + "FunctionCode": "// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +function handler(event) { + // Normalize accept header to only include values used on the backend + if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { + event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" + } + event.request.querystring = processQueryParams(event.request.querystring).join('&') + return event.request; +} + +function processQueryParams(querystring) { + if (querystring == null) { + return []; + } + + const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; + const OL_PARAMS = {'signature': 'ol-signature', 'expires': 'ol-expires'}; + + let qs = []; + for (const key in querystring) { + if (!ALLOWED_PARAMS.includes(key)) { + continue; + } + const value = querystring[key]; + const mappedKey = OL_PARAMS[key] || key; + qs.push( + value.multiValue + ? \`\${mappedKey}=\${value.multiValue[value.multiValue.length - 1].value}\` + : \`\${mappedKey}=\${value.value}\` + ) + } + + return qs.sort(); +}", + "FunctionConfig": { + "Comment": { + "Fn::Join": [ + "", + [ + "sih-ol-request-modifier-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + "Runtime": "cloudfront-js-2.0", + }, + "Name": { + "Fn::Join": [ + "", + [ + "sih-ol-request-modifier-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], ], }, }, + "Type": "AWS::CloudFront::Function", + }, + "BackEndOlResponseModifierFunctionB47B3834": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", "Properties": { - "LogGroupName": { + "AutoPublish": true, + "FunctionCode": "// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +function handler(event) { + const response = event.response; + + try { + Object.keys(response.headers).forEach(key => { + if (key.startsWith("x-amz-meta-") && key !== "x-amz-meta-statuscode") { + const headerName = key.replace("x-amz-meta-", ""); + response.headers[headerName] = response.headers[key]; + delete response.headers[key]; + } + }); + + const statusCodeHeader = response.headers["x-amz-meta-statuscode"]; + if (statusCodeHeader) { + const status = parseInt(statusCodeHeader.value); + if (status >= 400 && status <= 599) { + response.statusCode = status; + } + + delete response.headers["x-amz-meta-statuscode"]; + } + } catch (e) { + console.log("Error: ", e); + } + return response; +} +", + "FunctionConfig": { + "Comment": { + "Fn::Join": [ + "", + [ + "sih-ol-response-modifier-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + "Runtime": "cloudfront-js-2.0", + }, + "Name": { "Fn::Join": [ "", [ - "/aws/lambda/", + "sih-ol-response-modifier-", { - "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], }, ], ], }, - "RetentionInDays": { - "Ref": "LogRetentionPeriodParameter", - }, - "Tags": [ - { - "Key": "SolutionId", - "Value": "S0ABC", - }, - ], }, - "Type": "AWS::Logs::LogGroup", - "UpdateReplacePolicy": "Retain", + "Type": "AWS::CloudFront::Function", }, "BackEndOriginRequestPolicy771345D7": { "Properties": { @@ -1389,15 +2291,37 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, "QueryStringsConfig": { - "QueryStringBehavior": "whitelist", - "QueryStrings": [ - "signature", - ], + "QueryStringBehavior": "all", }, }, }, "Type": "AWS::CloudFront::OriginRequestPolicy", }, + "BackEndSIHoriginaccesscontrolAFC8496A": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + "Properties": { + "OriginAccessControlConfig": { + "Name": { + "Fn::Join": [ + "", + [ + "SIH-origin-access-control-", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + ], + ], + }, + "OriginAccessControlOriginType": "s3", + "SigningBehavior": "always", + "SigningProtocol": "sigv4", + }, + }, + "Type": "AWS::CloudFront::OriginAccessControl", + }, "BackEndSolutionMetricsBilledDurationMemorySizeQuery39F16D58": { "Condition": "SendAnonymousStatistics", "Properties": { @@ -1479,7 +2403,23 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "_0"},{"MetricStat":{"Metric":{"Namespace":"AWS/CloudFront","Dimensions":[{"Name":"DistributionId","Value":"", { - "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], }, ""},{"Name":"Region","Value":"Global"}],"MetricName":"Requests"},"Stat":"Sum","Period":604800},"region":"us-east-1","Id":"id_", { @@ -1497,7 +2437,23 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "_1"},{"MetricStat":{"Metric":{"Namespace":"AWS/CloudFront","Dimensions":[{"Name":"DistributionId","Value":"", { - "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], }, ""},{"Name":"Region","Value":"Global"}],"MetricName":"BytesDownloaded"},"Stat":"Sum","Period":604800},"region":"us-east-1","Id":"id_", { @@ -1669,7 +2625,6 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, "SOLUTION_ID": "SO0023", - "SOLUTION_NAME": "serverless-image-handler", "SOLUTION_VERSION": "Omitted to remove snapshot dependency on solution version", "SQS_QUEUE_URL": { "Ref": "BackEndSolutionMetricsLambdaToSqsToLambdalambdatosqsqueue60A92083", @@ -1824,6 +2779,61 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::Lambda::EventSourceMapping", }, + "BackEndSolutionMetricsRequestInfoQueryB4BC2094": { + "Condition": "SendAnonymousStatistics", + "Properties": { + "LogGroupNames": [ + { + "Ref": "BackEndImageHandlerLogGroupA0941EEC", + }, + ], + "Name": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName", + }, + "-RequestInfoQuery", + ], + ], + }, + "QueryString": "parse @message "requestType: 'Default'" as DefaultRequests +| parse @message "requestType: 'Thumbor'" as ThumborRequests +| parse @message "requestType: 'Custom'" as CustomRequests +| parse @message "Query param edits:" as QueryParamRequests +| parse @message "expires" as ExpiresRequests +| stats count(DefaultRequests) as DefaultRequestsCount, count(ThumborRequests) as ThumborRequestsCount, count(CustomRequests) as CustomRequestsCount, count(QueryParamRequests) as QueryParamRequestsCount, count(ExpiresRequests) as ExpiresRequestsCount", + }, + "Type": "AWS::Logs::QueryDefinition", + }, + "BackEndWriteGetObjectResponsePolicyF3008955": { + "Condition": "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:WriteGetObjectResponse", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "BackEndObjectLambdaAccessPointBEE6B960", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "BackEndWriteGetObjectResponsePolicyF3008955", + "Roles": [ + { + "Ref": "BackEndImageHandlerFunctionRoleABF81E5C", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, "CommonResourcesCustomResourcesCustomResourceAnonymousMetric51363F57": { "DeletionPolicy": "Delete", "Properties": { @@ -1847,12 +2857,18 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "EnableDefaultFallbackImage": { "Ref": "EnableDefaultFallbackImageParameter", }, + "EnableS3ObjectLambda": { + "Ref": "EnableS3ObjectLambdaParameter", + }, "EnableSignature": { "Ref": "EnableSignatureParameter", }, "LogRetentionPeriod": { "Ref": "LogRetentionPeriodParameter", }, + "OriginShieldRegion": { + "Ref": "OriginShieldRegionParameter", + }, "Region": { "Ref": "AWS::Region", }, @@ -1871,6 +2887,9 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "UUID", ], }, + "UseExistingCloudFrontDistribution": { + "Ref": "UseExistingCloudFrontDistributionParameter", + }, }, "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", @@ -1896,6 +2915,45 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, + "CommonResourcesCustomResourcesCustomResourceCheckFirstBucketRegionE663CC31": { + "DeletionPolicy": "Delete", + "Properties": { + "CustomAction": "checkFirstBucketRegion", + "Region": { + "Ref": "AWS::Region", + }, + "S3ObjectLambda": { + "Ref": "EnableS3ObjectLambdaParameter", + }, + "ServiceToken": { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceFunction0D924235", + "Arn", + ], + }, + "SourceBuckets": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + ",", + { + "Ref": "SourceBucketsParameter", + }, + ], + }, + ], + }, + "UUID": { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceUuid64E7CCAD", + "UUID", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, "CommonResourcesCustomResourcesCustomResourceCheckSecretsManagerAEEEC776": { "Condition": "CommonResourcesEnableSignatureCondition909DC7A1", "DeletionPolicy": "Delete", @@ -1966,7 +3024,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "S3Key": "Omitted to remove snapshot dependency on hash", }, - "Description": "sih (v6.3.3): Custom resource", + "Description": "dit (v7.0.0): Custom resource", "Environment": { "Variables": { "RETRY_SECONDS": "5", @@ -2084,8 +3142,19 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CloudWatchLogsPolicy", + }, + { + "PolicyDocument": { + "Statement": [ { - "Action": "s3:ListBucket", + "Action": [ + "s3:ListBucket", + "s3:GetBucketLocation", + ], "Effect": "Allow", "Resource": { "Fn::Split": [ @@ -2150,6 +3219,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "s3:CreateBucket", "s3:PutBucketOwnershipControls", "s3:PutBucketTagging", + "s3:PutBucketVersioning", ], "Effect": "Allow", "Resource": { @@ -2165,10 +3235,15 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, }, + { + "Action": "s3:ListBucket", + "Effect": "Allow", + "Resource": "arn:aws:s3:::sih-dummy-*", + }, ], "Version": "2012-10-17", }, - "PolicyName": "CloudWatchLogsPolicy", + "PolicyName": "S3AccessPolicy", }, { "PolicyDocument": { @@ -2215,7 +3290,39 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, }, { - "Action": "servicecatalog:GetApplication", + "Action": "servicecatalog:GetApplication", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":servicecatalog:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":/applications/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppRegistryPolicy", + }, + { + "PolicyDocument": { + "Statement": [ + { + "Action": "cloudfront:GetDistribution", "Effect": "Allow", "Resource": { "Fn::Join": [ @@ -2225,15 +3332,14 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` { "Ref": "AWS::Partition", }, - ":servicecatalog:", + ":cloudfront::", { - "Ref": "AWS::Region", + "Ref": "AWS::AccountId", }, - ":", + ":distribution/", { - "Ref": "AWS::AccountId", + "Ref": "ExistingCloudFrontDistributionIdParameter", }, - ":/applications/*", ], ], }, @@ -2241,7 +3347,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], "Version": "2012-10-17", }, - "PolicyName": "AppRegistryPolicy", + "PolicyName": "ExistingDistributionPolicy", }, ], "Tags": [ @@ -2270,6 +3376,27 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, + "CommonResourcesCustomResourcesCustomResourceValidateExistingDistribution34A6673C": { + "Condition": "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + "DeletionPolicy": "Delete", + "Properties": { + "CustomAction": "validateExistingDistribution", + "ExistingDistributionID": { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + "Region": { + "Ref": "AWS::Region", + }, + "ServiceToken": { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceFunction0D924235", + "Arn", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, "CommonResourcesCustomResourcesDeployWebsiteAwsCliLayerBC025F39": { "Condition": "CommonResourcesDeployDemoUICondition308D3B09", "Properties": { @@ -2350,17 +3477,67 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Properties": { "ConfigItem": { "apiEndpoint": { - "Fn::Join": [ - "", - [ - "https://", - { - "Fn::GetAtt": [ - "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", - "DomainName", + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesCustomResourceValidateExistingDistribution34A6673C", + "DistributionDomainName", + ], + }, ], - }, - ], + ], + }, + { + "Fn::If": [ + "CommonResourcesDisableS3ObjectLambdaCondition017AC33C", + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + "", + { + "Fn::GetAtt": [ + "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + "DomainName", + ], + }, + ], + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + "", + { + "Fn::GetAtt": [ + "BackEndImageHandlerCloudFrontDistributionB5464C90", + "DomainName", + ], + }, + ], + }, + ], + ], + }, + ], + }, ], }, }, @@ -2665,8 +3842,8 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Attributes": { "applicationType": "AWS-Solutions", "solutionID": "S0ABC", - "solutionName": "sih", - "version": "v6.3.3", + "solutionName": "dit", + "version": "v7.0.0", }, "Description": "Attribute group for solution information", "Name": { @@ -2717,7 +3894,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Properties": { "DistributionConfig": { - "Comment": "Demo UI Distribution for Serverless Image Handler", + "Comment": "Demo UI Distribution for Dynamic Image Transformation for Amazon CloudFront", "CustomErrorResponses": [ { "ErrorCode": 403, @@ -2987,6 +4164,231 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::S3::BucketPolicy", }, + "OperationalInsightsDashboard00409C46": { + "Condition": "DeployCloudWatchDashboard", + "Properties": { + "DashboardBody": { + "Fn::Join": [ + "", + [ + "{"start":"-P7D","periodOverride":"inherit","widgets":[{"type":"text","width":24,"height":1,"x":0,"y":0,"properties":{"markdown":"# Lambda"}},{"type":"metric","width":8,"height":8,"x":0,"y":1,"properties":{"view":"timeSeries","title":"Lambda Errors","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["AWS/Lambda","Errors","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"label":"Lambda Errors","stat":"Sum","id":"metric"}],[{"label":"Total","expression":"RUNNING_SUM(metric)","yAxis":"right"}]],"yAxis":{"left":{"label":"Count","showUnits":false,"min":0},"right":{"label":"Running-Total Count","showUnits":false,"min":0}},"legend":{"position":"bottom"},"liveData":true,"period":86400,"stat":"Sum"}},{"type":"metric","width":8,"height":8,"x":8,"y":1,"properties":{"view":"timeSeries","title":"Lambda Duration","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["AWS/Lambda","Duration","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"label":"Lambda Duration","stat":"Sum","id":"metric"}],[{"label":"Total","expression":"RUNNING_SUM(metric)","yAxis":"right"}]],"yAxis":{"left":{"label":"Milliseconds","showUnits":false,"min":0},"right":{"label":"Running-Total Milliseconds","showUnits":false,"min":0}},"legend":{"position":"bottom"},"liveData":true,"period":86400,"stat":"Sum"}},{"type":"metric","width":8,"height":8,"x":16,"y":1,"properties":{"view":"timeSeries","title":"Lambda Invocations","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["AWS/Lambda","Invocations","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"label":"Lambda Invocations","stat":"Sum","id":"metric"}],[{"label":"Total","expression":"RUNNING_SUM(metric)","yAxis":"right"}]],"yAxis":{"left":{"label":"Count","showUnits":false,"min":0},"right":{"label":"Running-Total Count","showUnits":false,"min":0}},"legend":{"position":"bottom"},"liveData":true,"period":86400,"stat":"Sum"}},{"type":"text","width":24,"height":1,"x":0,"y":9,"properties":{"markdown":"# CloudFront"}},{"type":"metric","width":12,"height":12,"x":0,"y":10,"properties":{"view":"timeSeries","title":"CloudFront Requests","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["AWS/CloudFront","Requests","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"label":"CloudFront Requests","region":"us-east-1","stat":"Sum","id":"metric"}],[{"label":"Total","expression":"RUNNING_SUM(metric)","yAxis":"right"}]],"yAxis":{"left":{"label":"Count","showUnits":false,"min":0},"right":{"label":"Running-Total Count","showUnits":false,"min":0}},"legend":{"position":"bottom"},"liveData":true,"period":86400,"stat":"Sum"}},{"type":"metric","width":12,"height":12,"x":12,"y":10,"properties":{"view":"timeSeries","title":"CloudFront Bytes Downloaded","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["AWS/CloudFront","BytesDownloaded","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"label":"CloudFront Bytes Downloaded","region":"us-east-1","stat":"Sum","id":"metric"}],[{"label":"Total","expression":"RUNNING_SUM(metric)","yAxis":"right"}]],"yAxis":{"left":{"label":"Bytes","showUnits":false,"min":0},"right":{"label":"Running-Total Bytes","showUnits":false,"min":0}},"legend":{"position":"bottom"},"liveData":true,"period":86400,"stat":"Sum"}},{"type":"metric","width":12,"height":6,"x":0,"y":22,"properties":{"view":"singleValue","title":"Cache Hit Rate","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[[{"label":"Cache Hit Rate (%)","expression":"100 * (cloudFrontRequests - lambdaInvocations) / (cloudFrontRequests)"}],["AWS/CloudFront","Requests","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"region":"us-east-1","stat":"Sum","visible":false,"id":"cloudFrontRequests"}],["AWS/Lambda","Invocations","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"stat":"Sum","visible":false,"id":"lambdaInvocations"}]],"setPeriodToTimeRange":true}},{"type":"metric","width":12,"height":6,"x":12,"y":22,"properties":{"view":"singleValue","title":"Average Image Size","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[[{"label":"Average Image Size (Bytes)","expression":"cloudFrontBytesDownloaded / cloudFrontRequests"}],["AWS/CloudFront","BytesDownloaded","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"region":"us-east-1","stat":"Sum","visible":false,"id":"cloudFrontBytesDownloaded"}],["AWS/CloudFront","Requests","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"region":"us-east-1","stat":"Sum","visible":false,"id":"cloudFrontRequests"}]],"setPeriodToTimeRange":true}},{"type":"text","width":24,"height":1,"x":0,"y":28,"properties":{"markdown":"# Overall"}},{"type":"metric","width":24,"height":6,"x":0,"y":29,"properties":{"view":"singleValue","title":"Estimated Cost","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[[{"label":"Estimated Cost($)","expression":"7.916241884231568e-11 * cloudFrontBytesDownloaded + 7.5e-7 * cloudFrontRequests + 1.66667e-8 * lambdaDuration + 2.0000000000000002e-7 * lambdaInvocations"}],["AWS/CloudFront","BytesDownloaded","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"region":"us-east-1","stat":"Sum","visible":false,"id":"cloudFrontBytesDownloaded"}],["AWS/CloudFront","Requests","DistributionId","", + { + "Fn::If": [ + "CommonResourcesUseExistingCloudFrontDistributionConditionEBC48184", + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + { + "Fn::If": [ + "CommonResourcesEnableS3ObjectLambdaConditionF2C07DCD", + { + "Ref": "BackEndImageHandlerCloudFrontDistributionB5464C90", + }, + { + "Ref": "BackEndImageHandlerCloudFrontApiGatewayLambdaCloudFrontToApiGatewayCloudFrontDistribution03AA31B2", + }, + ], + }, + ], + }, + "","Region","Global",{"region":"us-east-1","stat":"Sum","visible":false,"id":"cloudFrontRequests"}],["AWS/Lambda","Duration","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"stat":"Sum","visible":false,"id":"lambdaDuration"}],["AWS/Lambda","Invocations","FunctionName","", + { + "Ref": "BackEndImageHandlerLambdaFunctionADEF7FF2", + }, + "",{"stat":"Sum","visible":false,"id":"lambdaInvocations"}]],"setPeriodToTimeRange":true,"singleValueFullPrecision":true}}]}", + ], + ], + }, + "DashboardName": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName", + }, + "-", + { + "Ref": "AWS::Region", + }, + "-Operational-Insights-Dashboard", + ], + ], + }, + }, + "Type": "AWS::CloudWatch::Dashboard", + }, }, "Rules": { "CheckBootstrapVersion": { @@ -3014,6 +4416,33 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, ], }, + "ExistingDistributionIdRequiredRule": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "ExistingCloudFrontDistributionIdParameter", + }, + "", + ], + }, + ], + }, + "AssertDescription": "If 'UseExistingCloudFrontDistribution' is set to 'Yes', 'ExistingCloudFrontDistributionId' must be provided.", + }, + ], + "RuleCondition": { + "Fn::Equals": [ + { + "Ref": "UseExistingCloudFrontDistributionParameter", + }, + "Yes", + ], + }, + }, }, } `; diff --git a/source/constructs/test/constructs.test.ts b/source/constructs/test/constructs.test.ts index e6193ca4e..8b862bc49 100644 --- a/source/constructs/test/constructs.test.ts +++ b/source/constructs/test/constructs.test.ts @@ -6,19 +6,19 @@ import { App } from "aws-cdk-lib"; import { ServerlessImageHandlerStack } from "../lib/serverless-image-stack"; -test("Serverless Image Handler Stack Snapshot", () => { +test("Dynamic Image Transformation for Amazon CloudFront Stack Snapshot", () => { const app = new App({ context: { solutionId: "SO0023", - solutionName: "serverless-image-handler", - solutionVersion: "v6.3.3", + solutionName: "dynamic-image-transformation-for-amazon-cloudfront", + solutionVersion: "v7.0.0", }, }); const stack = new ServerlessImageHandlerStack(app, "TestStack", { solutionId: "S0ABC", - solutionName: "sih", - solutionVersion: "v6.3.3", + solutionName: "dit", + solutionVersion: "v7.0.0", }); const template = Template.fromStack(stack); diff --git a/source/custom-resource/index.ts b/source/custom-resource/index.ts index fac886b30..55c1e84c5 100644 --- a/source/custom-resource/index.ts +++ b/source/custom-resource/index.ts @@ -3,8 +3,13 @@ import CloudFormation from "aws-sdk/clients/cloudformation"; import EC2, { DescribeRegionsRequest } from "aws-sdk/clients/ec2"; -import S3, { CreateBucketRequest, PutBucketEncryptionRequest, PutBucketPolicyRequest } from "aws-sdk/clients/s3"; import ServiceCatalogAppRegistry from "aws-sdk/clients/servicecatalogappregistry"; +import S3, { + CreateBucketRequest, + PutBucketEncryptionRequest, + PutBucketPolicyRequest, + PutBucketVersioningRequest, +} from "aws-sdk/clients/s3"; import SecretsManager from "aws-sdk/clients/secretsmanager"; import axios, { RawAxiosRequestConfig, AxiosResponse } from "axios"; import { createHash } from "crypto"; @@ -30,8 +35,11 @@ import { ResourcePropertyTypes, SendMetricsRequestProperties, StatusTypes, + CheckFirstBucketRegionRequestProperties, GetAppRegApplicationNameRequestProperties, + ValidateExistingDistributionRequestProperties, } from "./lib"; +import CloudFront from "aws-sdk/clients/cloudfront"; const awsSdkOptions = getOptions(); const s3Client = new S3(awsSdkOptions); @@ -39,6 +47,7 @@ const ec2Client = new EC2(awsSdkOptions); const cloudformationClient = new CloudFormation(awsSdkOptions); const serviceCatalogClient = new ServiceCatalogAppRegistry(awsSdkOptions); const secretsManager = new SecretsManager(awsSdkOptions); +const cloudfrontClient = new CloudFront(awsSdkOptions); const { SOLUTION_ID, SOLUTION_VERSION, AWS_REGION, RETRY_SECONDS } = process.env; const METRICS_ENDPOINT = "https://metrics.awssolutionsbuilder.com/generic"; @@ -51,8 +60,8 @@ const RETRY_COUNT = 3; * @returns Processed request response. */ export async function handler(event: CustomResourceRequest, context: LambdaContext) { - console.info("Received event:", JSON.stringify(event, null, 2)); - + console.info(`Received event: ${event.RequestType}::${event.ResourceProperties.CustomAction}`); + console.info(`Resource properties: ${JSON.stringify(event.ResourceProperties)}`); const { RequestType, ResourceProperties } = event; const response: CompletionStatus = { Status: StatusTypes.SUCCESS, @@ -95,6 +104,14 @@ export async function handler(event: CustomResourceRequest, context: LambdaConte ); break; } + case CustomResourceActions.CHECK_FIRST_BUCKET_REGION: { + const allowedRequestTypes = [CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE]; + await performRequest(checkFirstBucketRegion, RequestType, allowedRequestTypes, response, { + ...ResourceProperties, + StackId: event.StackId, + } as CheckFirstBucketRegionRequestProperties); + break; + } case CustomResourceActions.GET_APP_REG_APPLICATION_NAME: { const allowedRequestTypes = [CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE]; await performRequest(getAppRegApplicationName, RequestType, allowedRequestTypes, response, { @@ -103,6 +120,13 @@ export async function handler(event: CustomResourceRequest, context: LambdaConte } as GetAppRegApplicationNameRequestProperties); break; } + case CustomResourceActions.VALIDATE_EXISTING_DISTRIBUTION: { + const allowedRequestTypes = [CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE]; + await performRequest(validateExistingDistribution, RequestType, allowedRequestTypes, response, { + ...ResourceProperties, + } as ValidateExistingDistributionRequestProperties); + break; + } case CustomResourceActions.CHECK_SECRETS_MANAGER: { const allowedRequestTypes = [CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE]; await performRequest( @@ -127,13 +151,10 @@ export async function handler(event: CustomResourceRequest, context: LambdaConte } case CustomResourceActions.CREATE_LOGGING_BUCKET: { const allowedRequestTypes = [CustomResourceRequestTypes.CREATE]; - await performRequest( - createCloudFrontLoggingBucket, - RequestType, - allowedRequestTypes, - response, - { ...ResourceProperties, StackId: event.StackId } as CreateLoggingBucketRequestProperties - ); + await performRequest(createCloudFrontLoggingBucket, RequestType, allowedRequestTypes, response, { + ...ResourceProperties, + StackId: event.StackId, + } as CreateLoggingBucketRequestProperties); break; } default: @@ -286,6 +307,9 @@ async function sendAnonymousMetric( AutoWebP: requestProperties.AutoWebP, EnableSignature: requestProperties.EnableSignature, EnableDefaultFallbackImage: requestProperties.EnableDefaultFallbackImage, + EnableS3ObjectLambda: requestProperties.EnableS3ObjectLambda, + OriginShieldRegion: requestProperties.OriginShieldRegion, + UseExistingCloudFrontDistribution: requestProperties.UseExistingCloudFrontDistribution, }, }; @@ -418,10 +442,86 @@ async function validateBuckets(requestProperties: CheckSourceBucketsRequestPrope } /** - * Provides the existing app registry application name if it exists, otherwise, returns the default. + * Validates if the first bucket is located in the same region as the deployment. * @param requestProperties The request properties. * @returns The result of validation. */ +async function checkFirstBucketRegion( + requestProperties: CheckFirstBucketRegionRequestProperties +): Promise<{ BucketName: string; BucketHash: string }> { + const { SourceBuckets } = requestProperties; + const bucket = SourceBuckets.replace(/\s/g, ""); + const dummyBucketName = `sih-dummy-${requestProperties.UUID}`; + + if (requestProperties.S3ObjectLambda != "Yes") { + console.info("Detected non-S3 Object Lambda deployment. Returning first bucket."); + return { BucketName: bucket, BucketHash: "" }; + } + // Generate unique bucket hash to support unique Access Point names + const generateBucketHash = (bucketName: string): string => { + // Simple hashing algorithm + let hash = 0; + for (let i = 0; i < bucketName.length; i++) { + hash = (hash << 5) - hash + bucketName.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return Math.abs(hash).toString(36).slice(0, 6).toLowerCase(); + }; + console.info("Detected S3 Object Lambda deployment."); + console.info(`Attempting to check if the following bucket exists in the same region as deployment: ${bucket}`); + + try { + const bucketLocation = await s3Client.getBucketLocation({ Bucket: bucket }).promise(); + const bucketRegion = bucketLocation.LocationConstraint || "us-east-1"; + if (bucketRegion === AWS_REGION) { + console.info(`Bucket '${bucket}' is in the same region (${bucketRegion}) as the S3 client.`); + return { BucketName: bucket, BucketHash: generateBucketHash(bucket) }; + } else { + try { + const params = { Bucket: dummyBucketName }; + await s3Client.headBucket(params).promise(); + + console.info(`Found bucket: ${dummyBucketName}`); + return { BucketName: dummyBucketName, BucketHash: generateBucketHash(dummyBucketName) }; + } catch (error) { + console.info(`Could not find dummy bucket. Creating bucket in region: ${AWS_REGION}`); + await s3Client.createBucket({ Bucket: dummyBucketName }).promise(); + try { + console.info("Adding tag..."); + + const taggingParams = { + Bucket: dummyBucketName, + Tagging: { + TagSet: [ + { + Key: "stack-id", + Value: requestProperties.StackId, + }, + ], + }, + }; + await s3Client.putBucketTagging(taggingParams).promise(); + + console.info(`Successfully added tag to bucket '${dummyBucketName}'`); + } catch (error) { + console.error(`Failed to add tag to bucket '${dummyBucketName}'`); + console.error(error); + // Continue, failure here shouldn't block + } + return { BucketName: dummyBucketName, BucketHash: generateBucketHash(dummyBucketName) }; + } + } + } catch (error) { + console.error(error); + throw new CustomResourceError("BucketNotFound", `Could not validate the existence of a bucket in ${AWS_REGION}.`); + } +} + +/** + * Provides the existing app registry application name if it exists, otherwise, returns the default. + * @param requestProperties The request properties. + * @returns The application name to use. + */ async function getAppRegApplicationName( requestProperties: GetAppRegApplicationNameRequestProperties ): Promise<{ ApplicationName?: string }> { @@ -438,7 +538,6 @@ async function getAppRegApplicationName( application: stackResources.StackResources[0].PhysicalResourceId, }) .promise(); - console.log(application); return { ApplicationName: application?.name ?? requestProperties.DefaultName, }; @@ -450,6 +549,28 @@ async function getAppRegApplicationName( } } +/** + * Validates the existences of the CloudFront distribution provided. Retrieves the domain name. + * @param requestProperties The request properties. + * @returns The domain name of the existing distribution. + */ +async function validateExistingDistribution( + requestProperties: ValidateExistingDistributionRequestProperties +): Promise<{ DistributionDomainName?: string }> { + try { + const response = await cloudfrontClient + .getDistribution({ + Id: requestProperties.ExistingDistributionID, + }) + .promise(); + + return { DistributionDomainName: response.Distribution?.DomainName }; + } catch (error) { + console.error("Error validating distribution:", error); + throw error; + } +} + /** * Checks if AWS Secrets Manager secret is valid. * @param requestProperties The request properties. @@ -588,8 +709,15 @@ async function createCloudFrontLoggingBucket(requestProperties: CreateLoggingBuc await s3Client.createBucket(createBucketRequestParams).promise(); console.info(`Successfully created bucket '${bucketName}' in '${targetRegion}' region`); + + const putBucketVersioningRequestParams: PutBucketVersioningRequest = { + Bucket: bucketName, + VersioningConfiguration: { Status: "Enabled" }, + }; + await s3Client.putBucketVersioning(putBucketVersioningRequestParams).promise(); + console.info(`Successfully enabled versioning on '${bucketName}'`); } catch (error) { - console.error(`Could not create bucket '${bucketName}'`); + console.error(`Could not create bucket '${bucketName}' or failed to enable versioning`); console.error(error); throw error; @@ -656,9 +784,10 @@ async function createCloudFrontLoggingBucket(requestProperties: CreateLoggingBuc TagSet: [ { Key: "stack-id", - Value: requestProperties.StackId - }] - } + Value: requestProperties.StackId, + }, + ], + }, }; await s3Client.putBucketTagging(taggingParams).promise(); diff --git a/source/custom-resource/lib/enums.ts b/source/custom-resource/lib/enums.ts index fa7778199..e67f6719d 100644 --- a/source/custom-resource/lib/enums.ts +++ b/source/custom-resource/lib/enums.ts @@ -6,10 +6,12 @@ export enum CustomResourceActions { PUT_CONFIG_FILE = "putConfigFile", CREATE_UUID = "createUuid", CHECK_SOURCE_BUCKETS = "checkSourceBuckets", + CHECK_FIRST_BUCKET_REGION = "checkFirstBucketRegion", CHECK_SECRETS_MANAGER = "checkSecretsManager", CHECK_FALLBACK_IMAGE = "checkFallbackImage", CREATE_LOGGING_BUCKET = "createCloudFrontLoggingBucket", GET_APP_REG_APPLICATION_NAME = "getAppRegApplicationName", + VALIDATE_EXISTING_DISTRIBUTION = "validateExistingDistribution", } export enum CustomResourceRequestTypes { diff --git a/source/custom-resource/lib/interfaces.ts b/source/custom-resource/lib/interfaces.ts index c37acfbf1..39d08ee77 100644 --- a/source/custom-resource/lib/interfaces.ts +++ b/source/custom-resource/lib/interfaces.ts @@ -18,6 +18,9 @@ export interface SendMetricsRequestProperties extends CustomResourceRequestPrope AutoWebP: string; EnableSignature: string; EnableDefaultFallbackImage: string; + EnableS3ObjectLambda: string; + OriginShieldRegion: string; + UseExistingCloudFrontDistribution: string; } export interface PutConfigRequestProperties extends CustomResourceRequestPropertiesBase { @@ -30,11 +33,21 @@ export interface CheckSourceBucketsRequestProperties extends CustomResourceReque SourceBuckets: string; } +export interface CheckFirstBucketRegionRequestProperties extends CheckSourceBucketsRequestProperties { + UUID: string; + S3ObjectLambda: string; + StackId: string; +} + export interface GetAppRegApplicationNameRequestProperties extends CustomResourceRequestPropertiesBase { StackId: string; DefaultName: string; } +export interface ValidateExistingDistributionRequestProperties extends CustomResourceRequestPropertiesBase { + ExistingDistributionID: string; +} + export interface CheckSecretManagerRequestProperties extends CustomResourceRequestPropertiesBase { SecretsManagerName: string; SecretsManagerKey: string; @@ -90,6 +103,9 @@ export interface MetricsPayloadData { AutoWebP: string; EnableSignature: string; EnableDefaultFallbackImage: string; + EnableS3ObjectLambda: string; + OriginShieldRegion: string; + UseExistingCloudFrontDistribution: string; } export interface MetricPayload { diff --git a/source/custom-resource/lib/types.ts b/source/custom-resource/lib/types.ts index 31460f26c..0663b9d94 100644 --- a/source/custom-resource/lib/types.ts +++ b/source/custom-resource/lib/types.ts @@ -9,7 +9,9 @@ import { CustomResourceRequestPropertiesBase, PutConfigRequestProperties, SendMetricsRequestProperties, + CheckFirstBucketRegionRequestProperties, GetAppRegApplicationNameRequestProperties, + ValidateExistingDistributionRequestProperties, } from "./interfaces"; export type ResourcePropertyTypes = @@ -20,7 +22,9 @@ export type ResourcePropertyTypes = | CheckSecretManagerRequestProperties | CheckFallbackImageRequestProperties | CreateLoggingBucketRequestProperties - | GetAppRegApplicationNameRequestProperties; + | CheckFirstBucketRegionRequestProperties + | GetAppRegApplicationNameRequestProperties + | ValidateExistingDistributionRequestProperties; export class CustomResourceError extends Error { constructor(public readonly code: string, public readonly message: string) { diff --git a/source/custom-resource/package-lock.json b/source/custom-resource/package-lock.json index b9f504965..c3b77574c 100644 --- a/source/custom-resource/package-lock.json +++ b/source/custom-resource/package-lock.json @@ -1,12 +1,12 @@ { "name": "custom-resource", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "custom-resource", - "version": "6.3.3", + "version": "7.0.0", "license": "Apache-2.0", "dependencies": { "aws-sdk": "^2.1529.0", diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json index 01004b345..994b9b20f 100644 --- a/source/custom-resource/package.json +++ b/source/custom-resource/package.json @@ -1,8 +1,8 @@ { "name": "custom-resource", - "version": "6.3.3", + "version": "7.0.0", "private": true, - "description": "Serverless Image Handler custom resource", + "description": "Dynamic Image Transformation for Amazon CloudFront custom resource", "license": "Apache-2.0", "author": { "name": "Amazon Web Services", diff --git a/source/custom-resource/test/create-logging-bucket.spec.ts b/source/custom-resource/test/create-logging-bucket.spec.ts index 2a70d3789..17abcd530 100644 --- a/source/custom-resource/test/create-logging-bucket.spec.ts +++ b/source/custom-resource/test/create-logging-bucket.spec.ts @@ -23,8 +23,8 @@ describe("CREATE_LOGGING_BUCKET", () => { }; beforeEach(() => { - consoleInfoSpy.mockReset() - consoleErrorSpy.mockReset() + consoleInfoSpy.mockReset(); + consoleErrorSpy.mockReset(); }); it("Should return success and bucket name", async () => { @@ -38,6 +38,11 @@ describe("CREATE_LOGGING_BUCKET", () => { return Promise.resolve(); }, })); + mockAwsS3.putBucketVersioning.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); mockAwsS3.putBucketEncryption.mockImplementation(() => ({ promise() { return Promise.resolve(); @@ -137,6 +142,11 @@ describe("CREATE_LOGGING_BUCKET", () => { return Promise.resolve(); }, })); + mockAwsS3.putBucketVersioning.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); mockAwsS3.putBucketEncryption.mockImplementation(() => ({ promise() { return Promise.reject(new CustomResourceError(null, "putBucketEncryption failed")); @@ -177,6 +187,11 @@ describe("CREATE_LOGGING_BUCKET", () => { return Promise.resolve(); }, })); + mockAwsS3.putBucketVersioning.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); mockAwsS3.putBucketEncryption.mockImplementation(() => ({ promise() { return Promise.resolve(); @@ -225,6 +240,11 @@ describe("CREATE_LOGGING_BUCKET", () => { return Promise.resolve(); }, })); + mockAwsS3.putBucketVersioning.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); mockAwsS3.putBucketEncryption.mockImplementation(() => ({ promise() { return Promise.resolve(); diff --git a/source/custom-resource/test/get-app-reg-application-name.spec.ts b/source/custom-resource/test/get-app-reg-application-name.spec.ts index 366a52fad..533e322ec 100644 --- a/source/custom-resource/test/get-app-reg-application-name.spec.ts +++ b/source/custom-resource/test/get-app-reg-application-name.spec.ts @@ -1,11 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - mockCloudFormation, - mockServiceCatalogAppRegistry, - mockContext, -} from "./mock"; +import { mockCloudFormation, mockServiceCatalogAppRegistry, mockContext } from "./mock"; import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; import { handler } from "../index"; diff --git a/source/custom-resource/test/mock.ts b/source/custom-resource/test/mock.ts index 793e58531..8530f931e 100644 --- a/source/custom-resource/test/mock.ts +++ b/source/custom-resource/test/mock.ts @@ -19,6 +19,7 @@ export const mockAwsS3 = { putBucketEncryption: jest.fn(), putBucketPolicy: jest.fn(), putBucketTagging: jest.fn(), + putBucketVersioning: jest.fn(), }; jest.mock("aws-sdk/clients/s3", () => jest.fn(() => ({ ...mockAwsS3 }))); @@ -35,6 +36,12 @@ export const mockCloudFormation = { jest.mock("aws-sdk/clients/cloudformation", () => jest.fn(() => ({ ...mockCloudFormation }))); +export const mockCloudFront = { + getDistribution: jest.fn(), +}; + +jest.mock("aws-sdk/clients/cloudfront", () => jest.fn(() => ({ ...mockCloudFront }))); + export const mockServiceCatalogAppRegistry = { getApplication: jest.fn(), }; diff --git a/source/custom-resource/test/send-anonymous-metric.spec.ts b/source/custom-resource/test/send-anonymous-metric.spec.ts index b390fb156..409cdc14c 100644 --- a/source/custom-resource/test/send-anonymous-metric.spec.ts +++ b/source/custom-resource/test/send-anonymous-metric.spec.ts @@ -32,6 +32,8 @@ describe("SEND_ANONYMOUS_METRIC", () => { EnableSignature: "Yes", LogRetentionPeriod: 5, SourceBuckets: "bucket-1, bucket-2, bucket-3", + EnableS3ObjectLambda: "Yes", + OriginShieldRegion: "Disabled", }, }; @@ -79,6 +81,8 @@ describe("SEND_ANONYMOUS_METRIC", () => { EnableSignature: "Yes", LogRetentionPeriod: 5, NumberOfSourceBuckets: 3, + EnableS3ObjectLambda: "Yes", + OriginShieldRegion: "Disabled", }, }, }, @@ -122,6 +126,8 @@ describe("SEND_ANONYMOUS_METRIC", () => { EnableSignature: "Yes", LogRetentionPeriod: 5, NumberOfSourceBuckets: 3, + EnableS3ObjectLambda: "Yes", + OriginShieldRegion: "Disabled", }, }, }, diff --git a/source/custom-resource/test/validate-existing-distribution.spec.ts b/source/custom-resource/test/validate-existing-distribution.spec.ts new file mode 100644 index 000000000..07e51a5b7 --- /dev/null +++ b/source/custom-resource/test/validate-existing-distribution.spec.ts @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mockCloudFront, mockContext } from "./mock"; +import { CustomResourceActions, CustomResourceRequestTypes, CustomResourceRequest } from "../lib"; +import { handler } from "../index"; + +describe("VALIDATE_EXISTING_DISTRIBUTION", () => { + // Mock event data + const distributionId = "E1234ABCDEF"; + const event: CustomResourceRequest = { + RequestType: CustomResourceRequestTypes.CREATE, + ResponseURL: "/cfn-response", + PhysicalResourceId: "mock-physical-id", + StackId: "mock-stack-id", + ServiceToken: "mock-service-token", + RequestId: "mock-request-id", + LogicalResourceId: "mock-logical-resource-id", + ResourceType: "mock-resource-type", + ResourceProperties: { + CustomAction: CustomResourceActions.VALIDATE_EXISTING_DISTRIBUTION, + ExistingDistributionID: distributionId, + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("Should return success when distribution exists and is valid", async () => { + // Mock CloudFront getDistribution response + mockCloudFront.getDistribution.mockImplementation(() => ({ + promise() { + return Promise.resolve({ + Distribution: { + DomainName: `${distributionId}.cloudfront.net`, + Status: "Deployed", + DistributionConfig: { + Enabled: true, + Origins: { + Items: [ + { + DomainName: "example-bucket.s3.amazonaws.com", + Id: "S3Origin", + }, + ], + Quantity: 1, + }, + }, + }, + }); + }, + })); + + const result = await handler(event, mockContext); + expect(result).toEqual({ + Status: "SUCCESS", + Data: { + DistributionDomainName: `${distributionId}.cloudfront.net`, + }, + }); + }); + + it("Should return failure when distribution does not exist", async () => { + // Mock CloudFront getDistribution to throw error + mockCloudFront.getDistribution.mockImplementation(() => ({ + promise() { + return Promise.reject(new Error("NoSuchDistribution")); + }, + })); + + const result = await handler(event, mockContext); + expect(result).toEqual({ + Status: "FAILED", + Data: { + Error: { + Code: "CustomResourceError", + Message: "NoSuchDistribution", + }, + }, + }); + }); + + it("Should return failure on unexpected error", async () => { + mockCloudFront.getDistribution.mockImplementation(() => ({ + promise() { + return Promise.reject(new Error("Unexpected error")); + }, + })); + + const result = await handler(event, mockContext); + expect(result).toEqual({ + Status: "FAILED", + Data: { + Error: { + Code: "CustomResourceError", + Message: "Unexpected error", + }, + }, + }); + }); +}); diff --git a/source/demo-ui/index.html b/source/demo-ui/index.html index f843a0f5b..8f19bbaff 100644 --- a/source/demo-ui/index.html +++ b/source/demo-ui/index.html @@ -7,7 +7,7 @@ - Serverless Image Handler + Dynamic Image Transformation for Amazon CloudFront @@ -22,7 +22,7 @@
- Serverless Image Handler Demo + Dynamic Image Transformation for Amazon CloudFront Demo
diff --git a/source/demo-ui/package-lock.json b/source/demo-ui/package-lock.json index aa49dff22..23d3f84dd 100644 --- a/source/demo-ui/package-lock.json +++ b/source/demo-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "demo-ui", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "demo-ui", - "version": "6.3.3", + "version": "7.0.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/source/demo-ui/package.json b/source/demo-ui/package.json index 1cd4105a5..d29f25727 100644 --- a/source/demo-ui/package.json +++ b/source/demo-ui/package.json @@ -1,8 +1,8 @@ { "name": "demo-ui", - "version": "6.3.3", + "version": "7.0.0", "private": true, - "description": "Serverless Image Handler demo ui", + "description": "Dynamic Image Transformation for Amazon CloudFront demo ui", "license": "Apache-2.0", "author": { "name": "Amazon Web Services", diff --git a/source/image-handler/cloudfront-function-handlers/apig-request-modifier.js b/source/image-handler/cloudfront-function-handlers/apig-request-modifier.js new file mode 100644 index 000000000..576ef9728 --- /dev/null +++ b/source/image-handler/cloudfront-function-handlers/apig-request-modifier.js @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +function handler(event) { + // Normalize accept header to only include values used on the backend + if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { + event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" + } + event.request.querystring = processQueryParams(event.request.querystring).join('&') + return event.request; +} + +function processQueryParams(querystring) { + if (querystring == null) { + return []; + } + + const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; + + let qs = []; + for (const key in querystring) { + if (!ALLOWED_PARAMS.includes(key)) { + continue; + } + const value = querystring[key]; + qs.push( + value.multiValue + ? `${key}=${value.multiValue[value.multiValue.length - 1].value}` + : `${key}=${value.value}` + ) + } + + return qs.sort(); +} +module.exports = { handler }; \ No newline at end of file diff --git a/source/image-handler/cloudfront-function-handlers/ol-request-modifier.js b/source/image-handler/cloudfront-function-handlers/ol-request-modifier.js new file mode 100644 index 000000000..9d70cb182 --- /dev/null +++ b/source/image-handler/cloudfront-function-handlers/ol-request-modifier.js @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +function handler(event) { + // Normalize accept header to only include values used on the backend + if(event.request.headers && event.request.headers.accept && event.request.headers.accept.value) { + event.request.headers.accept.value = event.request.headers.accept.value.indexOf("image/webp") > -1 ? "image/webp" : "" + } + event.request.querystring = processQueryParams(event.request.querystring).join('&') + return event.request; +} + +function processQueryParams(querystring) { + if (querystring == null) { + return []; + } + + const ALLOWED_PARAMS = ['signature', 'expires', 'format', 'fit', 'width', 'height', 'rotate', 'flip', 'flop', 'grayscale']; + const OL_PARAMS = {'signature': 'ol-signature', 'expires': 'ol-expires'}; + + let qs = []; + for (const key in querystring) { + if (!ALLOWED_PARAMS.includes(key)) { + continue; + } + const value = querystring[key]; + const mappedKey = OL_PARAMS[key] || key; + qs.push( + value.multiValue + ? `${mappedKey}=${value.multiValue[value.multiValue.length - 1].value}` + : `${mappedKey}=${value.value}` + ) + } + + return qs.sort(); +} +module.exports = { handler }; \ No newline at end of file diff --git a/source/image-handler/cloudfront-function-handlers/ol-response-modifier.js b/source/image-handler/cloudfront-function-handlers/ol-response-modifier.js new file mode 100644 index 000000000..74c120374 --- /dev/null +++ b/source/image-handler/cloudfront-function-handlers/ol-response-modifier.js @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +function handler(event) { + const response = event.response; + + try { + Object.keys(response.headers).forEach(key => { + if (key.startsWith("x-amz-meta-") && key !== "x-amz-meta-statuscode") { + const headerName = key.replace("x-amz-meta-", ""); + response.headers[headerName] = response.headers[key]; + delete response.headers[key]; + } + }); + + const statusCodeHeader = response.headers["x-amz-meta-statuscode"]; + if (statusCodeHeader) { + const status = parseInt(statusCodeHeader.value); + if (status >= 400 && status <= 599) { + response.statusCode = status; + } + + delete response.headers["x-amz-meta-statuscode"]; + } + } catch (e) { + console.log("Error: ", e); + } + return response; +} + +module.exports = { handler }; \ No newline at end of file diff --git a/source/image-handler/image-handler.ts b/source/image-handler/image-handler.ts index 28f9ae64c..6213696c9 100644 --- a/source/image-handler/image-handler.ts +++ b/source/image-handler/image-handler.ts @@ -9,6 +9,7 @@ import { BoundingBox, BoxSize, ContentTypes, + ErrorMapping, ImageEdits, ImageFitTypes, ImageFormatTypes, @@ -21,8 +22,6 @@ import { getAllowedSourceBuckets } from "./image-request"; import { SHARP_EDIT_ALLOWLIST_ARRAY } from "./lib/constants"; export class ImageHandler { - private readonly LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024; - constructor(private readonly s3Client: S3, private readonly rekognitionClient: Rekognition) {} /** @@ -35,17 +34,30 @@ export class ImageHandler { // eslint-disable-next-line @typescript-eslint/ban-types private async instantiateSharpImage(originalImage: Buffer, edits: ImageEdits, options: Object): Promise { let image: sharp.Sharp = null; + try { + if (!edits || !Object.keys(edits).length) { + return sharp(originalImage, options); + } + if (edits.rotate !== undefined && edits.rotate === null) { + image = sharp(originalImage, options); + } else { + const metadata = await sharp(originalImage, options).metadata(); + image = metadata.orientation + ? sharp(originalImage, options).withMetadata({ orientation: metadata.orientation }) + : sharp(originalImage, options).withMetadata(); + } - if (edits.rotate !== undefined && edits.rotate === null) { - image = sharp(originalImage, options); - } else { - const metadata = await sharp(originalImage, options).metadata(); - image = metadata.orientation - ? sharp(originalImage, options).withMetadata({ orientation: metadata.orientation }) - : sharp(originalImage, options).withMetadata(); + return image; + } catch (error) { + this.handleError( + error, + new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "InstantiationError", + "Input image could not be instantiated. Please choose a valid image." + ) + ); } - - return image; } /** @@ -75,15 +87,34 @@ export class ImageHandler { * @param imageRequestInfo An image request. * @returns Processed and modified image encoded as base64 string. */ - async process(imageRequestInfo: ImageRequestInfo): Promise { + async process(imageRequestInfo: ImageRequestInfo): Promise { const { originalImage, edits } = imageRequestInfo; - const options = { failOnError: false, animated: imageRequestInfo.contentType === ContentTypes.GIF }; - let base64EncodedImage = ""; + const { SHARP_SIZE_LIMIT } = process.env; + const limitInputPixels: number | boolean = + SHARP_SIZE_LIMIT === "" || isNaN(Number(SHARP_SIZE_LIMIT)) || Number(SHARP_SIZE_LIMIT); + const options = { + failOnError: false, + animated: imageRequestInfo.contentType === ContentTypes.GIF, + limitInputPixels, + }; + try { + // Return early if no edits are required + if (!edits || !Object.keys(edits).length) { + if (imageRequestInfo.outputFormat !== undefined) { + // convert image to Sharp and change output format if specified + const modifiedImage = this.modifyImageOutput( + await this.instantiateSharpImage(originalImage, edits, options), + imageRequestInfo + ); + return await modifiedImage.toBuffer(); + } + // no edits or output format changes, convert to base64 encoded image + return originalImage; + } - // Apply edits if specified - if (edits && Object.keys(edits).length) { - // convert image to Sharp object - options.animated = (typeof edits.animated !== 'undefined') ? edits.animated : (imageRequestInfo.contentType === ContentTypes.GIF) + // Apply edits if specified + options.animated = + typeof edits.animated !== "undefined" ? edits.animated : imageRequestInfo.contentType === ContentTypes.GIF; let image = await this.instantiateSharpImage(originalImage, edits, options); // default to non animated if image does not have multiple pages @@ -94,38 +125,32 @@ export class ImageHandler { image = await this.instantiateSharpImage(originalImage, edits, options); } } - // apply image edits let modifiedImage = await this.applyEdits(image, edits, options.animated); // modify image output if requested modifiedImage = this.modifyImageOutput(modifiedImage, imageRequestInfo); - // convert to base64 encoded string - const imageBuffer = await modifiedImage.toBuffer(); - base64EncodedImage = imageBuffer.toString("base64"); - } else { - if (imageRequestInfo.outputFormat !== undefined) { - // convert image to Sharp and change output format if specified - const modifiedImage = this.modifyImageOutput(sharp(originalImage, options), imageRequestInfo); - // convert to base64 encoded string - const imageBuffer = await modifiedImage.toBuffer(); - base64EncodedImage = imageBuffer.toString("base64"); - } else { - // no edits or output format changes, convert to base64 encoded image - base64EncodedImage = originalImage.toString("base64"); - } - } - - // binary data need to be base64 encoded to pass to the API Gateway proxy https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html. - // checks whether base64 encoded image fits in 6M limit, see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html. - if (base64EncodedImage.length > this.LAMBDA_PAYLOAD_LIMIT) { - throw new ImageHandlerError( - StatusCodes.REQUEST_TOO_LONG, - "TooLargeImageException", - "The converted image is too large to return." + return await modifiedImage.toBuffer(); + } catch (error) { + const errorMapping: ErrorMapping[] = [ + { + pattern: "Image to composite must have same dimensions or smaller", + statusCode: StatusCodes.BAD_REQUEST, + errorType: "BadRequest", + message: (err: Error) => err.message.replace("composite", "overlay"), + }, + { + pattern: "Bitstream not supported by this decoder", + statusCode: StatusCodes.BAD_REQUEST, + errorType: "BadRequest", + message: "Invalid base image. AVIF images with a bit-depth other than 8 are not supported for image edits.", + }, + ]; + this.handleError( + error, + new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, "ProcessingFailure", "Image processing failed."), + errorMapping ); } - - return base64EncodedImage; } /** @@ -207,8 +232,9 @@ export class ImageHandler { /** * Validates resize edit parameters. * @param resize The resize parameters. + * @returns Validated resize inputs */ - private validateResizeInputs(resize: any) { + private validateResizeInputs(resize) { if (resize.width) resize.width = Math.round(Number(resize.width)); if (resize.height) resize.height = Math.round(Number(resize.height)); @@ -309,10 +335,13 @@ export class ImageHandler { originalImage.toFormat(format); } } catch (error) { - throw new ImageHandlerError( - StatusCodes.BAD_REQUEST, - "SmartCrop::PaddingOutOfBounds", - "The padding value you provided exceeds the boundaries of the original image. Please try choosing a smaller value or applying padding via Sharp for greater specificity." + this.handleError( + error, + new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "SmartCrop::PaddingOutOfBounds", + "The padding value you provided exceeds the boundaries of the original image. Please try choosing a smaller value or applying padding via Sharp for greater specificity." + ) ); } } @@ -339,6 +368,7 @@ export class ImageHandler { * Applies round crop edit. * @param originalImage The original sharp image. * @param edits The edits to be made to the original image. + * @returns Sharp object with round crop performed */ private async applyRoundCrop(originalImage: sharp.Sharp, edits: ImageEdits): Promise { // round crop can be boolean or object @@ -517,10 +547,13 @@ export class ImageHandler { ]) .toBuffer(); } catch (error) { - throw new ImageHandlerError( - error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, - error.code, - error.message + this.handleError( + error, + new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "OverlayImageException", + "The overlay image could not be applied. Please contact the system administrator." + ) ); } } @@ -613,24 +646,31 @@ export class ImageHandler { width: boundingBox.Width, }; } catch (error) { - console.error(error); - - if ( - error.message === "Cannot read property 'BoundingBox' of undefined" || - error.message === "Cannot read properties of undefined (reading 'BoundingBox')" - ) { - throw new ImageHandlerError( - StatusCodes.BAD_REQUEST, - "SmartCrop::FaceIndexOutOfRange", - "You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range." - ); - } else { - throw new ImageHandlerError( - error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, - error.code, - error.message - ); - } + const errorMapping: ErrorMapping[] = [ + { + pattern: "Cannot read property 'BoundingBox' of undefined", + statusCode: StatusCodes.BAD_REQUEST, + errorType: "SmartCrop::FaceIndexOutOfRange", + message: + "You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range.", + }, + { + pattern: "Cannot read properties of undefined (reading 'BoundingBox')", + statusCode: StatusCodes.BAD_REQUEST, + errorType: "SmartCrop::FaceIndexOutOfRange", + message: + "You have provided a FaceIndex value that exceeds the length of the zero-based detectedFaces array. Please specify a value that is in-range.", + }, + ]; + this.handleError( + error, + new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "SmartCrop::Error", + "Smart Crop could not be applied. Please contact the system administrator." + ), + errorMapping + ); } } @@ -651,17 +691,19 @@ export class ImageHandler { }; return await this.rekognitionClient.detectModerationLabels(params).promise(); } catch (error) { - console.error(error); - throw new ImageHandlerError( - error.statusCode ? error.statusCode : StatusCodes.INTERNAL_SERVER_ERROR, - error.code, - error.message + this.handleError( + error, + new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "Rekognition::DetectModerationLabelsError", + "Rekognition call failed. Please contact the system administrator." + ) ); } } /** - * Converts serverless image handler image format type to 'sharp' format. + * Converts Dynamic Image Transformation for Amazon CloudFront image format type to 'sharp' format. * @param imageFormatType Result output file type. * @returns Converted 'sharp' format. */ @@ -700,8 +742,8 @@ export class ImageHandler { * @returns object containing image buffer data and original image format. */ private async getRekognitionCompatibleImage(image: sharp.Sharp): Promise { - const sharp_image = sharp(await image.toBuffer()); // Reload sharp image to ensure current metadata - const metadata = await sharp_image.metadata(); + const sharpImage = sharp(await image.toBuffer()); // Reload sharp image to ensure current metadata + const metadata = await sharpImage.metadata(); const format = metadata.format; let imageBuffer: { data: Buffer; info: sharp.OutputInfo }; @@ -714,4 +756,27 @@ export class ImageHandler { return { imageBuffer, format }; } + + private handleError(error: Error, defaultError: Error, errorMappings: ErrorMapping[] = []): never { + console.error(error); + + // If it's already an ImageHandlerError, rethrow it + if (error instanceof ImageHandlerError) { + throw error; + } + + // Check for specific error patterns + for (const mapping of errorMappings) { + if (error.message.includes(mapping.pattern)) { + throw new ImageHandlerError( + mapping.statusCode, + mapping.errorType, + typeof mapping.message === "function" ? mapping.message(error) : mapping.message + ); + } + } + + // Default error if no specific patterns match + throw defaultError; + } } diff --git a/source/image-handler/image-request.ts b/source/image-handler/image-request.ts index a06203273..9c202a12b 100644 --- a/source/image-handler/image-request.ts +++ b/source/image-handler/image-request.ts @@ -19,6 +19,12 @@ import { } from "./lib"; import { SecretProvider } from "./secret-provider"; import { ThumborMapper } from "./thumbor-mapper"; +import dayjs from "dayjs"; +import customParseFormat from "dayjs/plugin/customParseFormat"; +import utc from "dayjs/plugin/utc"; +import { QueryParamMapper } from "./query-param-mapper"; +dayjs.extend(customParseFormat); +dayjs.extend(utc); type OriginalImageInfo = Partial<{ contentType: string; @@ -98,13 +104,16 @@ export class ImageRequest { public async setup(event: ImageHandlerEvent): Promise { try { await this.validateRequestSignature(event); + const secondsToExpiry = this.validateRequestExpires(event); let imageRequestInfo: ImageRequestInfo = {}; + imageRequestInfo.secondsToExpiry = secondsToExpiry; imageRequestInfo.requestType = this.parseRequestType(event); imageRequestInfo.bucket = this.parseImageBucket(event, imageRequestInfo.requestType); imageRequestInfo.key = this.parseImageKey(event, imageRequestInfo.requestType, imageRequestInfo.bucket); imageRequestInfo.edits = this.parseImageEdits(event, imageRequestInfo.requestType); + imageRequestInfo.edits = this.parseQueryParamEdits(event, imageRequestInfo.edits); const originalImage = await this.getOriginalImage(imageRequestInfo.bucket, imageRequestInfo.key); imageRequestInfo = { ...imageRequestInfo, ...originalImage }; @@ -192,13 +201,12 @@ export class ImageRequest { return result; } catch (error) { console.error(error); - let status = StatusCodes.INTERNAL_SERVER_ERROR; - let message = error.message; - if (error.code === "NoSuchKey") { - status = StatusCodes.NOT_FOUND; - message = `The image ${key} does not exist or the request may not be base64 encoded properly.`; - } - throw new ImageHandlerError(status, error.code, message); + if (error instanceof ImageHandlerError) throw error; + throw new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "ImageRetrieval::CannotRetrieveImage", + "Image could not be retrieved from S3." + ); } } @@ -282,11 +290,29 @@ export class ImageRequest { } } + /** + * Parses query parameters to generate image edits + * @param event - Lambda event containing query parameters + * @param edits - Existing image edits to merge with + * @returns Combined image edits + */ + public parseQueryParamEdits(event: ImageHandlerEvent, edits: ImageEdits): ImageEdits { + if (event.queryStringParameters) { + const queryParamMapping = new QueryParamMapper(); + const newEdits = queryParamMapping.mapQueryParamsToEdits(event.queryStringParameters); + if (Object.keys(newEdits).length > 0) { + console.info(`Query param edits: ${JSON.stringify(newEdits)}`); + return { ...edits, ...newEdits }; + } + } + return edits; + } + /** * Parses the name of the appropriate Amazon S3 key corresponding to the original image. * @param event Lambda request body. * @param requestType Type of the request. - * @param bucket + * @param bucket The bucket name if the s3:bucketName tag was provided * @returns The name of the appropriate Amazon S3 key. */ public parseImageKey(event: ImageHandlerEvent, requestType: RequestTypes, bucket: string = null): string { @@ -480,6 +506,19 @@ export class ImageRequest { ); } + /** + * Creates a query string similar to API Gateway 2.0 payload's $.rawQueryString + * @param queryStringParameters Request's query parameters + * @returns URL encoded queryString + */ + private recreateQueryString(queryStringParameters: ImageHandlerEvent["queryStringParameters"]): string { + return Object.entries(queryStringParameters) + .filter(([key]) => key !== "signature") + .sort() + .map(([key, value]) => [key, value].join("=")) + .join("&"); + } + /** * Validates the request's signature. * @param event Lambda request body. @@ -505,7 +544,9 @@ export class ImageRequest { const { signature } = queryStringParameters; const secret = JSON.parse(await this.secretProvider.getSecret(SECRETS_MANAGER)); const key = secret[SECRET_KEY]; - const hash = createHmac("sha256", key).update(path).digest("hex"); + const queryString = this.recreateQueryString(queryStringParameters); + const stringToSign = queryString !== "" ? [path, queryString].join("?") : path; + const hash = createHmac("sha256", key).update(stringToSign).digest("hex"); // Signature should be made with the full path. if (signature !== hash) { @@ -525,6 +566,44 @@ export class ImageRequest { } } } + + private validateRequestExpires(event: ImageHandlerEvent): number | undefined { + try { + const { queryStringParameters } = event; + const expires = queryStringParameters?.expires; + + if (expires === undefined) { + return; + } + const expiry = dayjs.utc(expires, "YYYYMMDDTHHmmss[Z]", true); + const now = dayjs.utc(); + + if (!expiry.isValid()) { + throw new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "ImageRequestExpiryFormat", + "Request has invalid expires value. The expires query param should map to a real date and follow the following format: YYYYMMDDTHHmmssZ (Ex: Jan 2nd, 1970 at 12:03:04PM UTC becomes 19700102T120304Z)." + ); + } + if (expiry.isBefore(now)) { + throw new ImageHandlerError(StatusCodes.BAD_REQUEST, "ImageRequestExpired", "Request has expired."); + } + return expiry.diff(now, "seconds"); + } catch (error) { + if (error.code === "ImageRequestExpired") { + throw error; + } + if (error.code === "ImageRequestExpiryFormat") { + throw error; + } + console.error("Error occurred while checking expiry.", error); + throw new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "ExpiryDateCheckFailure", + "Expiry date check failed." + ); + } + } } /** diff --git a/source/image-handler/index.ts b/source/image-handler/index.ts index d4c13e49b..a10935ebe 100755 --- a/source/image-handler/index.ts +++ b/source/image-handler/index.ts @@ -2,15 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import Rekognition from "aws-sdk/clients/rekognition"; -import S3 from "aws-sdk/clients/s3"; +import S3, { WriteGetObjectResponseRequest } from "aws-sdk/clients/s3"; import SecretsManager from "aws-sdk/clients/secretsmanager"; import { getOptions } from "../solution-utils/get-options"; import { isNullOrWhiteSpace } from "../solution-utils/helpers"; import { ImageHandler } from "./image-handler"; import { ImageRequest } from "./image-request"; -import { Headers, ImageHandlerEvent, ImageHandlerExecutionResult, RequestTypes, StatusCodes } from "./lib"; +import { + Headers, + ImageHandlerError, + ImageHandlerEvent, + ImageHandlerExecutionResult, + S3Event, + S3GetObjectEvent, + S3HeadObjectResult, + RequestTypes, + StatusCodes, +} from "./lib"; import { SecretProvider } from "./secret-provider"; +// eslint-disable-next-line import/no-unresolved +import { Context } from "aws-lambda"; const awsSdkOptions = getOptions(); const s3Client = new S3(awsSdkOptions); @@ -18,23 +30,95 @@ const rekognitionClient = new Rekognition(awsSdkOptions); const secretsManagerClient = new SecretsManager(awsSdkOptions); const secretProvider = new SecretProvider(secretsManagerClient); +const LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024; + /** * Image handler Lambda handler. * @param event The image handler request event. + * @param context The request context * @returns Processed request response. */ -export async function handler(event: ImageHandlerEvent): Promise { - console.info("Received event:", JSON.stringify(event, null, 2)); +export async function handler( + event: ImageHandlerEvent | S3Event, + context: Context = undefined +): Promise { + const { ENABLE_S3_OBJECT_LAMBDA } = process.env; + + const normalizedEvent = normalizeEvent(event, ENABLE_S3_OBJECT_LAMBDA); + console.info(`Path: ${normalizedEvent.path}`); + console.info(`QueryParams: ${JSON.stringify(normalizedEvent.queryStringParameters)}`); + + const response = handleRequest(normalizedEvent); + // If deployment is set to use an API Gateway origin + if (ENABLE_S3_OBJECT_LAMBDA !== "Yes") { + return response; + } + + // Assume request is from Object Lambda + const { timeoutPromise, timeoutId } = createS3ObjectLambdaTimeout(context); + const finalResponse = await Promise.race([response, timeoutPromise]); + clearTimeout(timeoutId); + + const responseHeaders = buildResponseHeaders(finalResponse); + + // Check if getObjectContext is not in event, indicating a HeadObject request + if (!("getObjectContext" in event)) { + console.info(`Invalid S3GetObjectEvent, assuming HeadObject request. Status: ${finalResponse.statusCode}`); + + return { + statusCode: finalResponse.statusCode, + headers: { ...responseHeaders, "Content-Length": finalResponse.body.length }, + }; + } + + const getObjectEvent = event as S3GetObjectEvent; + const params = buildWriteResponseParams(getObjectEvent, finalResponse, responseHeaders); + try { + await s3Client.writeGetObjectResponse(params).promise(); + } catch (error) { + console.error("Error occurred while writing the response to S3 Object Lambda.", error); + const errorParams = buildErrorResponseParams( + getObjectEvent, + new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "S3ObjectLambdaWriteError", + "It was not possible to write the response to S3 Object Lambda." + ) + ); + await s3Client.writeGetObjectResponse(errorParams).promise(); + } +} + +/** + * Image handler request handler. + * @param event The normalized request event. + * @returns Processed request response. + */ +async function handleRequest(event: ImageHandlerEvent): Promise { + const { ENABLE_S3_OBJECT_LAMBDA } = process.env; const imageRequest = new ImageRequest(s3Client, secretProvider); const imageHandler = new ImageHandler(s3Client, rekognitionClient); const isAlb = event.requestContext && Object.prototype.hasOwnProperty.call(event.requestContext, "elb"); - try { const imageRequestInfo = await imageRequest.setup(event); console.info(imageRequestInfo); - const processedRequest = await imageHandler.process(imageRequestInfo); + let processedRequest: Buffer | string = await imageHandler.process(imageRequestInfo); + + if (ENABLE_S3_OBJECT_LAMBDA !== "Yes") { + processedRequest = processedRequest.toString("base64"); + + // binary data need to be base64 encoded to pass to the API Gateway proxy https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html. + // checks whether base64 encoded image fits in 6M limit, see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html. + if (processedRequest.length > LAMBDA_PAYLOAD_LIMIT) { + throw new ImageHandlerError( + StatusCodes.REQUEST_TOO_LONG, + "TooLargeImageException", + "The converted image is too large to return." + ); + } + } let headers: Headers = {}; // Define headers that can be overwritten @@ -44,6 +128,10 @@ export async function handler(event: ImageHandlerEvent): Promise - Processed headers with encoded values and cache settings + * + * Cache-Control rules: + * - 4xx errors: max-age=10,public + * - 5xx errors: max-age=600,public + */ +function buildResponseHeaders(finalResponse: ImageHandlerExecutionResult): Record { + const filteredHeaders = Object.entries(finalResponse.headers).filter(([_, value]) => value !== undefined); + let responseHeaders = Object.fromEntries(filteredHeaders); + + responseHeaders = Object.fromEntries( + Object.entries(responseHeaders).map(([key, value]) => [key, encodeURI(value).replace(/%20/g, " ")]) + ); + if (finalResponse.statusCode >= 400 && finalResponse.statusCode <= 499) { + responseHeaders["Cache-Control"] = "max-age=10,public"; + } + if (finalResponse.statusCode >= 500 && finalResponse.statusCode < 599) { + responseHeaders["Cache-Control"] = "max-age=600,public"; + } + return responseHeaders; +} + +/** + * Builds parameters for S3 Object Lambda's WriteGetObjectResponse operation. + * Processes response headers and metadata, handling Cache-Control separately + * and encoding remaining headers as metadata. + * + * @param getObjectEvent - The S3 GetObject event containing output route and token + * @param finalResponse - The execution result containing response body and status code + * @param responseHeaders - Key-value pairs of response headers to be processed + * @returns WriteGetObjectResponseRequest parameters including body, routing info, and metadata + */ +function buildWriteResponseParams( + getObjectEvent: S3GetObjectEvent, + finalResponse: ImageHandlerExecutionResult, + responseHeaders: { [k: string]: any } +): WriteGetObjectResponseRequest { + const params: WriteGetObjectResponseRequest = { + Body: finalResponse.body, + RequestRoute: getObjectEvent.getObjectContext.outputRoute, + RequestToken: getObjectEvent.getObjectContext.outputToken, + }; + + if (responseHeaders["Cache-Control"]) { + params.CacheControl = responseHeaders["Cache-Control"]; + delete responseHeaders["Cache-Control"]; + } + + params.Metadata = { + StatusCode: JSON.stringify(finalResponse.statusCode), + ...responseHeaders, + }; + return params; +} + /** * Retrieve the default fallback image and construct the ImageHandlerExecutionResult * @param imageRequest The ImageRequest object @@ -98,7 +275,7 @@ export async function handleDefaultFallbackImage( isAlb: boolean, error ): Promise { - const { DEFAULT_FALLBACK_IMAGE_BUCKET, DEFAULT_FALLBACK_IMAGE_KEY } = process.env; + const { DEFAULT_FALLBACK_IMAGE_BUCKET, DEFAULT_FALLBACK_IMAGE_KEY, ENABLE_S3_OBJECT_LAMBDA } = process.env; const defaultFallbackImage = await s3Client .getObject({ Bucket: DEFAULT_FALLBACK_IMAGE_BUCKET, @@ -120,10 +297,82 @@ export async function handleDefaultFallbackImage( statusCode: error.status ? error.status : StatusCodes.INTERNAL_SERVER_ERROR, isBase64Encoded: true, headers, - body: defaultFallbackImage.Body.toString("base64"), + body: + ENABLE_S3_OBJECT_LAMBDA === "Yes" + ? Buffer.from(defaultFallbackImage.Body as Uint8Array) + : defaultFallbackImage.Body.toString("base64"), }; } +/** + * Creates a timeout promise to write a graceful response if S3 Object Lambda processing won't finish in time + * @param context The Image Handler request context + * @returns A promise that resolves with the ImageHandlerExecutionResult to write to the response, as well as the timeoutID to allow for cancellation. + */ +function createS3ObjectLambdaTimeout( + context: Context + // eslint-disable-next-line no-undef +): { timeoutPromise: Promise; timeoutId: NodeJS.Timeout } { + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + const error = new ImageHandlerError(StatusCodes.TIMEOUT, "TimeoutException", "Image processing timed out."); + const { statusCode, body } = getErrorResponse(error); + // Call writeGetObjectResponse when the timeout is approaching + resolve({ + statusCode, + isBase64Encoded: false, + headers: getResponseHeaders(true), + body, + }); + }, Math.max(context.getRemainingTimeInMillis() - 1000, 0)); // 30 seconds in milliseconds + }); + return { timeoutPromise, timeoutId }; +} + +/** + * Generates a normalized event usable by the event handler regardless of which infrastructure is being used(RestAPI or S3 Object Lambda). + * @param event The RestAPI event (ImageHandlerEvent) or S3 Object Lambda event (S3GetObjectEvent). + * @param s3ObjectLambdaEnabled Whether we're using the S3 Object Lambda or RestAPI infrastructure. + * @returns Normalized ImageHandlerEvent object + */ +export function normalizeEvent(event: ImageHandlerEvent | S3Event, s3ObjectLambdaEnabled: string): ImageHandlerEvent { + if (s3ObjectLambdaEnabled === "Yes") { + const { userRequest } = event as S3Event; + const fullPath = userRequest.url.split(userRequest.headers.Host)[1]; + const [pathString, queryParamsString] = fullPath.split("?"); + + // S3 Object Lambda blocks certain query params including `signature` and `expires`, we use ol- as a prefix to overcome this. + const queryParams = extractObjectLambdaQueryParams(queryParamsString); + return { + // URLs from S3 Object Lambda include the origin path + path: pathString.split("/image").slice(1).join("/image"), + queryStringParameters: queryParams, + requestContext: {}, + headers: userRequest.headers, + }; + } + return event as ImageHandlerEvent; +} + +/** + * Extracts 'ol-' prefixed query parameters from the query string. The `ol-` prefix is used to overcome + * S3 Object Lambda restrictions on what query parameters can be sent. + * @param queryString The querystring attached to the end of the initial URL + * @returns A dictionary of query params + */ +function extractObjectLambdaQueryParams(queryString: string | undefined): { [key: string]: string } { + const results = {}; + if (queryString === undefined) { + return results; + } + + for (const [key, value] of new URLSearchParams(queryString).entries()) { + results[key.slice(0, 3).replace("ol-", "") + key.slice(3)] = value; + } + return results; +} + /** * Generates the appropriate set of response headers based on a success or error condition. * @param isError Has an error been thrown. @@ -139,7 +388,7 @@ function getResponseHeaders(isError: boolean = false, isAlb: boolean = false): H }; if (!isAlb) { - headers["Access-Control-Allow-Credentials"] = true; + headers["Access-Control-Allow-Credentials"] = "true"; } if (corsEnabled) { @@ -165,24 +414,6 @@ export function getErrorResponse(error) { body: JSON.stringify(error), }; } - /** - * if an image overlay is attempted and the overlaying image has greater dimensions - * that the base image, sharp will throw an exception and return this string - */ - if (error?.message === "Image to composite must have same dimensions or smaller") { - return { - statusCode: StatusCodes.BAD_REQUEST, - body: JSON.stringify({ - /** - * return a message indicating overlay dimensions is the issue, the caller may not - * know that the sharp composite function was used - */ - message: "Image to overlay must have same dimensions or smaller", - code: "BadRequest", - status: StatusCodes.BAD_REQUEST, - }), - }; - } return { statusCode: StatusCodes.INTERNAL_SERVER_ERROR, body: JSON.stringify({ diff --git a/source/image-handler/lib/constants.ts b/source/image-handler/lib/constants.ts index d01e1a6d7..9aef65047 100644 --- a/source/image-handler/lib/constants.ts +++ b/source/image-handler/lib/constants.ts @@ -66,6 +66,7 @@ export const HEADER_DENY_LIST = [ /^www-authenticate$/i, /^proxy-authenticate$/i, /^x-api-key$/i, + /^set-cookie$/i, // Security Header Patterns /^x-frame-.*$/i, diff --git a/source/image-handler/lib/enums.ts b/source/image-handler/lib/enums.ts index d6a96122c..41beb7585 100644 --- a/source/image-handler/lib/enums.ts +++ b/source/image-handler/lib/enums.ts @@ -8,6 +8,7 @@ export enum StatusCodes { NOT_FOUND = 404, REQUEST_TOO_LONG = 413, INTERNAL_SERVER_ERROR = 500, + TIMEOUT = 503, } export enum RequestTypes { @@ -44,5 +45,5 @@ export enum ContentTypes { TIFF = "image/tiff", GIF = "image/gif", SVG = "image/svg+xml", - AVIF= "image/avif", + AVIF = "image/avif", } diff --git a/source/image-handler/lib/interfaces.ts b/source/image-handler/lib/interfaces.ts index 898cc725f..280b7da67 100644 --- a/source/image-handler/lib/interfaces.ts +++ b/source/image-handler/lib/interfaces.ts @@ -8,15 +8,47 @@ import { Headers, ImageEdits } from "./types"; export interface ImageHandlerEvent { path?: string; - queryStringParameters?: { - signature: string; - }; + queryStringParameters?: QueryStringParameters; requestContext?: { elb?: unknown; }; headers?: Headers; } +export interface QueryStringParameters { + signature?: string; + expires?: string; + format?: string; + fit?: string; + width?: string; + height?: string; + rotate?: string; + flip?: string; + flop?: string; + grayscale?: string; +} + +export interface S3UserRequest { + url: string; + headers: Headers; +} + +export interface S3Event { + userRequest: S3UserRequest; +} + +export interface S3GetObjectEvent extends S3Event { + getObjectContext: { + outputRoute: string; + outputToken: string; + }; +} + +export interface S3HeadObjectResult { + statusCode: number; + headers: Headers; +} + export interface DefaultImageRequest { bucket?: string; key: string; @@ -51,6 +83,7 @@ export interface ImageRequestInfo { cacheControl?: string; outputFormat?: ImageFormatTypes; effort?: number; + secondsToExpiry?: number; } export interface RekognitionCompatibleImage { @@ -65,5 +98,5 @@ export interface ImageHandlerExecutionResult { statusCode: StatusCodes; isBase64Encoded: boolean; headers: Headers; - body: string; + body: Buffer | string; } diff --git a/source/image-handler/lib/types.ts b/source/image-handler/lib/types.ts index cc107c478..85257b98d 100644 --- a/source/image-handler/lib/types.ts +++ b/source/image-handler/lib/types.ts @@ -16,4 +16,11 @@ export class ImageHandlerError extends Error { } } +export interface ErrorMapping { + pattern: string; + statusCode: number; + errorType: string; + message: string | Function; +} + type AllowlistedEdit = (typeof SHARP_EDIT_ALLOWLIST_ARRAY)[number] | (typeof ALTERNATE_EDIT_ALLOWLIST_ARRAY)[number]; diff --git a/source/image-handler/package-lock.json b/source/image-handler/package-lock.json index e334d9f12..cc115ac44 100644 --- a/source/image-handler/package-lock.json +++ b/source/image-handler/package-lock.json @@ -1,17 +1,19 @@ { "name": "image-handler", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "image-handler", - "version": "6.3.3", + "version": "7.0.0", "license": "Apache-2.0", "dependencies": { + "@types/aws-lambda": "^8.10.136", "aws-sdk": "^2.1529.0", "color": "4.2.3", "color-name": "1.1.4", + "dayjs": "1.11.10", "sharp": "^0.32.6" }, "devDependencies": { @@ -1081,6 +1083,11 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/aws-lambda": { + "version": "8.10.136", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.136.tgz", + "integrity": "sha512-cmmgqxdVGhxYK9lZMYYXYRJk6twBo53ivtXjIUEFZxfxe4TkZTZBK3RRWrY2HjJcUIix0mdifn15yjOAat5lTA==" + }, "node_modules/@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -1821,6 +1828,11 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 0a3146a59..9c0b81c4d 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -1,6 +1,6 @@ { "name": "image-handler", - "version": "6.3.3", + "version": "7.0.0", "private": true, "description": "A Lambda function for performing on-demand image edits and manipulations.", "license": "Apache-2.0", @@ -16,9 +16,11 @@ "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" }, "dependencies": { + "@types/aws-lambda": "^8.10.136", "aws-sdk": "^2.1529.0", "color": "4.2.3", "color-name": "1.1.4", + "dayjs": "1.11.10", "sharp": "^0.32.6" }, "devDependencies": { diff --git a/source/image-handler/query-param-mapper.ts b/source/image-handler/query-param-mapper.ts new file mode 100644 index 000000000..cacd7e7df --- /dev/null +++ b/source/image-handler/query-param-mapper.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ImageEdits, ImageHandlerError, QueryStringParameters, StatusCodes } from "./lib"; + +export class QueryParamMapper { + mapToBoolean = (value: string): boolean => { + return value === "true"; + }; + private static readonly QUERY_PARAM_MAPPING: Record< + string, + { path: string[]; key: string; transform?: (value: string) => any } + > = { + format: { path: [], key: "toFormat" }, + fit: { path: ["resize"], key: "fit" }, + width: { path: ["resize"], key: "width", transform: zeroStringToNullInt }, + height: { path: ["resize"], key: "height", transform: zeroStringToNullInt }, + rotate: { path: [], key: "rotate", transform: stringToNullInt }, + flip: { path: [], key: "flip", transform: stringToBoolean }, + flop: { path: [], key: "flop", transform: stringToBoolean }, + grayscale: { path: [], key: "greyscale", transform: stringToBoolean }, + greyscale: { path: [], key: "greyscale", transform: stringToBoolean }, + }; + public static readonly QUERY_PARAM_KEYS = Object.keys(this.QUERY_PARAM_MAPPING); + + /** + * Initializer function for creating a new Thumbor mapping, used by the image + * handler to perform image modifications based on legacy URL path requests. + * @param path The request path. + * @returns Image edits based on the request path. + */ + public mapQueryParamsToEdits(queryParameters: QueryStringParameters): ImageEdits { + try { + const result: Record = {}; + + Object.entries(queryParameters).forEach(([param, value]) => { + if (value !== undefined && QueryParamMapper.QUERY_PARAM_MAPPING[param]) { + const { path, key, transform } = QueryParamMapper.QUERY_PARAM_MAPPING[param]; + + // Traverse and create nested objects as needed + let current = result; + for (const segment of path) { + current[segment] = current[segment] || {}; + current = current[segment]; + } + + if (transform) { + value = transform(value); + } + // Assign the value at the final destination + current[key] = value; + } + }); + + return result; + } catch (error) { + console.error(error); + throw new ImageHandlerError( + StatusCodes.BAD_REQUEST, + "QueryParameterParsingError", + "Query parameter parsing failed" + ); + } + } +} + +function stringToBoolean(input: string): boolean { + const falsyValues = ["0", "false", ""]; + return !falsyValues.includes(input.toLowerCase()); +} + +function stringToNullInt(input: string): number | null { + return input === "" ? null : parseInt(input); +} + +function zeroStringToNullInt(input: string): number | null { + return input === "0" ? null : stringToNullInt(input); +} diff --git a/source/image-handler/test/cloudfront-function-handlers/apig-request-modifier.spec.ts b/source/image-handler/test/cloudfront-function-handlers/apig-request-modifier.spec.ts new file mode 100644 index 000000000..79a8dca25 --- /dev/null +++ b/source/image-handler/test/cloudfront-function-handlers/apig-request-modifier.spec.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { handler } from "../../cloudfront-function-handlers/apig-request-modifier"; + +describe("index", () => { + test("should sort values and filter invalid query params", () => { + const testCases = [ + { + // Should sort query params + input: { signature: { value: "value1" }, expires: { value: "value2" }, format: { value: "value3" } }, + expected: "expires=value2&format=value3&signature=value1", + }, + { + // Empty inputs are allowed + input: {}, + expected: "", + }, + { + // Should filter invalid params + input: { key2: { value: "value2" }, format: { value: "value3" } }, + expected: "format=value3", + }, + { + // Multi value keys use the last option + input: { + signature: { value: "value1" }, + expires: { value: "value2", multiValue: [{ value: "value2" }, { value: "value4" }] }, + format: { value: "value3" }, + key2: { value: "value4" }, + }, + expected: "expires=value4&format=value3&signature=value1", + }, + ]; + + testCases.forEach(({ input, expected }) => { + const event = { request: { querystring: input, uri: "test.com/" } }; + const result = handler(event); + expect(result.querystring).toEqual(expected); + }); + }); + + test("should normalize accept header allowing webp images to `image/webp`", () => { + const event = { + request: { + headers: { + accept: { + value: "image/webp,other/test", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Ensure only image/webp is left + expect(result.headers.accept.value).toBe("image/webp"); + }); + + test("should not set request accept header if not present", () => { + const event = { + request: { + headers: {}, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + expect(result.headers).toStrictEqual({}); + }); + + test("should normalize accept header disallowing webp images to empty string", () => { + const event = { + request: { + headers: { + accept: { + value: "image/jpeg,other/test", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Ensure an empty string is left + expect(result.headers.accept.value).toBe(""); + }); +}); diff --git a/source/image-handler/test/cloudfront-function-handlers/ol-request-modifier.spec.ts b/source/image-handler/test/cloudfront-function-handlers/ol-request-modifier.spec.ts new file mode 100644 index 000000000..3b6d897f4 --- /dev/null +++ b/source/image-handler/test/cloudfront-function-handlers/ol-request-modifier.spec.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { handler } from "../../cloudfront-function-handlers/ol-request-modifier"; + +describe("index", () => { + test('should add "ol-" prefix to signature and expires querystring keys, sort by key, and filter invalid query params', () => { + const testCases = [ + { + // Signature and Expires query strings are prefixed with -ol + input: { signature: { value: "value1" }, expires: { value: "value2" }, format: { value: "value3" } }, + expected: "format=value3&ol-expires=value2&ol-signature=value1", + }, + { + // Empty inputs are allowed + input: {}, + expected: "", + }, + { + // Should filter invalid params + input: { key2: { value: "value2" }, format: { value: "value3" } }, + expected: "format=value3", + }, + { + // Keys are sorted + input: { rotate: { value: "value3" }, format: { value: "value2" } }, + expected: "format=value2&rotate=value3", + }, + { + // Multi value keys use the last option + input: { + signature: { value: "value1" }, + expires: { value: "value2", multiValue: [{ value: "value2" }, { value: "value4" }] }, + format: { value: "value3" }, + key2: { value: "value4" }, + }, + expected: "format=value3&ol-expires=value4&ol-signature=value1", + }, + // ol-signature is an invalid key, and is removed + { + input: { "ol-signature": { value: "some_value" } }, + expected: "", + }, + ]; + + testCases.forEach(({ input, expected }) => { + const event = { request: { querystring: input, uri: "test.com/" } }; + const result = handler(event); + expect(result.querystring).toEqual(expected); + }); + }); + + test("should normalize accept header allowing webp images to `image/webp`", () => { + const event = { + request: { + headers: { + accept: { + value: "image/webp,other/test", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Ensure only image/webp is left + expect(result.headers.accept.value).toBe("image/webp"); + }); + + test("should not set request accept header if not present", () => { + const event = { + request: { + headers: {}, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + expect(result.headers).toStrictEqual({}); + }); + + test("should normalize accept header disallowing webp images to empty string", () => { + const event = { + request: { + headers: { + accept: { + value: "image/jpeg,other/test", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Ensure an empty string is left + expect(result.headers.accept.value).toBe(""); + }); +}); diff --git a/source/image-handler/test/cloudfront-function-handlers/ol-response-modifier.spec.ts b/source/image-handler/test/cloudfront-function-handlers/ol-response-modifier.spec.ts new file mode 100644 index 000000000..03e3c0c66 --- /dev/null +++ b/source/image-handler/test/cloudfront-function-handlers/ol-response-modifier.spec.ts @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { handler } from "../../cloudfront-function-handlers/ol-response-modifier"; + +describe("index", () => { + test("should set response statusCode if x-amz-meta-statuscode header is present and status is between 400 and 599", () => { + // Mock event with a response having x-amz-meta-statuscode header + const event = { + response: { + headers: { + "x-amz-meta-statuscode": { + value: "500", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Check if statusCode is updated + expect(result.statusCode).toBe(500); + }); + + test("should not set response statusCode if x-amz-meta-statuscode header is not present", () => { + // Mock event with a response without x-amz-meta-statuscode header + const event = { + response: { + headers: {}, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Check if statusCode remains the same + expect(result.statusCode).toBe(200); + }); + + test("should not set response statusCode if x-amz-meta-statuscode header value is not a valid number", () => { + // Mock event with a response having a non-numeric x-amz-meta-statuscode value + const event = { + response: { + headers: { + "x-amz-meta-statuscode": { + value: "not-a-number", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Check if statusCode remains the same + expect(result.statusCode).toBe(200); + }); + + test("should not set response statusCode if x-amz-meta-statuscode header value is not an error", () => { + // Mock event with a response having a successful statusCode + const event = { + response: { + headers: { + "x-amz-meta-statuscode": { + value: "204", + }, + }, + statusCode: 200, + }, + }; + + // Call the handler + const result = handler(event); + + // Check if statusCode remains the same + expect(result.statusCode).toBe(200); + }); +}); diff --git a/source/image-handler/test/event-normalizer.spec.ts b/source/image-handler/test/event-normalizer.spec.ts new file mode 100644 index 000000000..f029f6a88 --- /dev/null +++ b/source/image-handler/test/event-normalizer.spec.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeEvent } from ".."; +import { ImageHandlerEvent, S3GetObjectEvent } from "../lib"; + +describe("normalizeEvent function", () => { + const imageHandlerEvent: ImageHandlerEvent = { + path: "/test.jpg", + queryStringParameters: { + signature: "testSignature", + width: "100", + }, + requestContext: {}, + headers: { Host: "example.com" }, + }; + + const s3GetObjectEvent: S3GetObjectEvent = { + userRequest: { + url: "https://example.com/image/test.jpg?width=100&ol-signature=testSignature", + headers: { + Host: "example.com", + }, + }, + getObjectContext: { + outputRoute: "", + outputToken: "", + }, + }; + + it('should return the event as is when s3_object_lambda_enabled is "No"', () => { + const result = normalizeEvent(imageHandlerEvent, "No"); + expect(result).toEqual(imageHandlerEvent); + }); + + it('should normalize Object Lambda event when s3_object_lambda_enabled is "Yes"', () => { + const result = normalizeEvent(s3GetObjectEvent, "Yes"); + expect(result).toEqual(imageHandlerEvent); + }); + + it('should handle Object Lambda event with empty queryStringParameters when s3_object_lambda_enabled is "Yes"', () => { + const s3GetObjectEvent: S3GetObjectEvent = { + userRequest: { + url: "https://example.com/image/test.jpg", + headers: { + Host: "example.com", + }, + }, + getObjectContext: { + outputRoute: "", + outputToken: "", + }, + }; + const result = normalizeEvent(s3GetObjectEvent, "Yes"); + expect(result.queryStringParameters).toEqual({ signature: undefined }); + }); + + it("should handle Object Lambda event with s3KeyPath including /image", () => { + const s3GetObjectEvent: S3GetObjectEvent = { + userRequest: { + url: "https://example.com/image/image/test.jpg", + headers: { + Host: "example.com", + }, + }, + getObjectContext: { + outputRoute: "", + outputToken: "", + }, + }; + const result = normalizeEvent(s3GetObjectEvent, "Yes"); + expect(result.path).toEqual("/image/test.jpg"); + }); +}); diff --git a/source/image-handler/test/image-handler/animated.spec.ts b/source/image-handler/test/image-handler/animated.spec.ts index ddb3ed7dc..2510fcaa9 100644 --- a/source/image-handler/test/image-handler/animated.spec.ts +++ b/source/image-handler/test/image-handler/animated.spec.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +/* eslint-disable @typescript-eslint/no-explicit-any */ + import Rekognition from "aws-sdk/clients/rekognition"; import S3 from "aws-sdk/clients/s3"; import fs from "fs"; @@ -14,155 +16,175 @@ const image = fs.readFileSync("./test/image/25x15.png"); const gifImage = fs.readFileSync("./test/image/transparent-5x5-2page.gif"); describe("animated", () => { - beforeEach(() => { - jest.resetAllMocks(); + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should create non animated image if the input image is a GIF but does not have multiple pages", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: "sample-bucket", + key: "sample-image-001.gif", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(2); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, }); - - it("Should create non animated image if the input image is a GIF but does not have multiple pages", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.GIF, - bucket: "sample-bucket", - key: "sample-image-001.gif", - edits: { grayscale: true }, - originalImage: image, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(2); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: false }); - + }); + + it("Should create animated image if the input image is GIF and has multiple pages", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: "sample-bucket", + key: "sample-image-001.gif", + edits: { grayscale: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: true, + limitInputPixels: true, }); - - it("Should create animated image if the input image is GIF and has multiple pages", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.GIF, - bucket: "sample-bucket", - key: "sample-image-001.gif", - edits: { grayscale: true }, - originalImage: gifImage, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(1); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: true }); - + }); + + it("Should create non animated image if the input image is not a GIF", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.PNG, + bucket: "sample-bucket", + key: "sample-image-001.png", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, }); - - - it("Should create non animated image if the input image is not a GIF", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.PNG, - bucket: "sample-bucket", - key: "sample-image-001.png", - edits: { grayscale: true }, - originalImage: image, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(1); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: false }); - + }); + + it("Should create non animated image if AutoWebP is enabled and the animated edit is not provided", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.WEBP, + bucket: "sample-bucket", + key: "sample-image-001.gif", + edits: { grayscale: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, }); - - it("Should create non animated image if AutoWebP is enabled and the animated edit is not provided", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.WEBP, - bucket: "sample-bucket", - key: "sample-image-001.gif", - edits: { grayscale: true }, - originalImage: gifImage, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(1); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: false }); - - }); - - it("Should create animated image if AutoWebP is enabled and the animated edit is true", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.WEBP, - bucket: "sample-bucket", - key: "sample-image-001.gif", - edits: { grayscale: true, animated: true }, - originalImage: gifImage, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(1); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: true }); - + }); + + it("Should create animated image if AutoWebP is enabled and the animated edit is true", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.WEBP, + bucket: "sample-bucket", + key: "sample-image-001.gif", + edits: { grayscale: true, animated: true }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: true, + limitInputPixels: true, }); - - it("Should create non animated image if image is multipage gif, but animated edit is set to false", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.GIF, - bucket: "sample-bucket", - key: "sample-image-001.gif", - edits: { grayscale: true, animated: false }, - originalImage: gifImage, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(1); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: false }); - + }); + + it("Should create non animated image if image is multipage gif, but animated edit is set to false", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.GIF, + bucket: "sample-bucket", + key: "sample-image-001.gif", + edits: { grayscale: true, animated: false }, + originalImage: gifImage, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(1); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, }); - - it("Should attempt to create animated image if animated edit is set to true, regardless of original image and content type", async () => { - // Arrange - const request: ImageRequestInfo = { - requestType: RequestTypes.DEFAULT, - contentType: ContentTypes.PNG, - bucket: "sample-bucket", - key: "sample-image-001.png", - edits: { grayscale: true, animated: true }, - originalImage: image, - }; - - // Act - const imageHandler = new ImageHandler(s3Client, rekognitionClient); - // SpyOn InstantiateSharpImage - const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); - await imageHandler.process(request); - expect(instantiateSpy).toHaveBeenCalledTimes(2); - expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { failOnError: false, animated: false }); - + }); + + it("Should attempt to create animated image if animated edit is set to true, regardless of original image and content type", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.PNG, + bucket: "sample-bucket", + key: "sample-image-001.png", + edits: { grayscale: true, animated: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledTimes(2); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, }); + }); }); diff --git a/source/image-handler/test/image-handler/content-moderation.spec.ts b/source/image-handler/test/image-handler/content-moderation.spec.ts index 50e36c15e..ffcaf3e66 100644 --- a/source/image-handler/test/image-handler/content-moderation.spec.ts +++ b/source/image-handler/test/image-handler/content-moderation.spec.ts @@ -249,8 +249,8 @@ describe("contentModeration", () => { return Promise.reject( new ImageHandlerError( StatusCodes.INTERNAL_SERVER_ERROR, - "InternalServerError", - "Amazon Rekognition experienced a service issue. Try your call again." + "Rekognition::DetectModerationLabelsError", + "Rekognition call failed. Please contact the system administrator." ) ); }, @@ -268,8 +268,8 @@ describe("contentModeration", () => { }); expect(error).toMatchObject({ status: StatusCodes.INTERNAL_SERVER_ERROR, - code: "InternalServerError", - message: "Amazon Rekognition experienced a service issue. Try your call again.", + code: "Rekognition::DetectModerationLabelsError", + message: "Rekognition call failed. Please contact the system administrator.", }); } }); diff --git a/source/image-handler/test/image-handler/crop.spec.ts b/source/image-handler/test/image-handler/crop.spec.ts index d1ce20733..d69ae53cf 100644 --- a/source/image-handler/test/image-handler/crop.spec.ts +++ b/source/image-handler/test/image-handler/crop.spec.ts @@ -11,17 +11,16 @@ import { ImageEdits, StatusCodes } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); - // base64 encoded images -const image_png_white_5x5 = +const imagePngWhite5x5 = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFAQAAAAClFBtIAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAd2KE6QAAAAHdElNRQfnAxYODhUMhxdmAAAADElEQVQI12P4wQCFABhCBNn4i/hQAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIzLTAzLTIyVDE0OjE0OjIxKzAwOjAwtK8ALAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMy0wMy0yMlQxNDoxNDoyMSswMDowMMXyuJAAAAAASUVORK5CYII="; -const image_png_white_1x1 = +const imagePngWhite1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"; describe("crop", () => { it("Should fail if a cropping area value is out of bounds", async () => { // Arrange - const originalImage = Buffer.from(image_png_white_1x1, "base64"); + const originalImage = Buffer.from(imagePngWhite1x1, "base64"); const image = sharp(originalImage, { failOnError: false }).withMetadata(); const edits: ImageEdits = { crop: { left: 0, top: 0, width: 100, height: 100 }, @@ -45,7 +44,7 @@ describe("crop", () => { // confirm that crops perform as expected it("Should pass with a standard crop", async () => { // 5x5 png - const originalImage = Buffer.from(image_png_white_5x5, "base64"); + const originalImage = Buffer.from(imagePngWhite5x5, "base64"); const image = sharp(originalImage, { failOnError: true }); const edits: ImageEdits = { crop: { left: 0, top: 0, width: 1, height: 1 }, @@ -55,7 +54,7 @@ describe("crop", () => { const imageHandler = new ImageHandler(s3Client, rekognitionClient); const result = await imageHandler.applyEdits(image, edits, false); const resultBuffer = await result.toBuffer(); - expect(resultBuffer).toEqual(Buffer.from(image_png_white_1x1, "base64")); + expect(resultBuffer).toEqual(Buffer.from(imagePngWhite1x1, "base64")); }); // confirm that an invalid attribute sharp crop request containing *right* rather than *top* returns as a cropping error, @@ -63,7 +62,7 @@ describe("crop", () => { // it is not an accurate description of the actual error it("Should fail with an invalid crop request", async () => { // 5x5 png - const originalImage = Buffer.from(image_png_white_5x5, "base64"); + const originalImage = Buffer.from(imagePngWhite5x5, "base64"); const image = sharp(originalImage, { failOnError: false }).withMetadata(); const edits: ImageEdits = { crop: { left: 0, right: 0, width: 1, height: 1 }, diff --git a/source/image-handler/test/image-handler/error-response.spec.ts b/source/image-handler/test/image-handler/error-response.spec.ts index c4f053cf6..9247a29f7 100644 --- a/source/image-handler/test/image-handler/error-response.spec.ts +++ b/source/image-handler/test/image-handler/error-response.spec.ts @@ -1,46 +1,31 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - import { getErrorResponse } from "../../index"; import { StatusCodes } from "../../lib"; -describe('getErrorResponse', () => { - it('should return an error response with the provided status code and error message', () => { - const error = { status: 404, message: 'Not Found' }; - const result = getErrorResponse(error); - - expect(result).toEqual({ - statusCode: 404, - body: JSON.stringify(error), - }); - }); - - it('should handle "Image to composite must have same dimensions or smaller" error', () => { - const error = { message: 'Image to composite must have same dimensions or smaller' }; - const result = getErrorResponse(error); +describe("getErrorResponse", () => { + it("should return an error response with the provided status code and error message", () => { + const error = { status: 404, message: "Not Found" }; + const result = getErrorResponse(error); - expect(result).toEqual({ - statusCode: StatusCodes.BAD_REQUEST, - body: JSON.stringify({ - message: 'Image to overlay must have same dimensions or smaller', - code: 'BadRequest', - status: StatusCodes.BAD_REQUEST, - }), - }); + expect(result).toEqual({ + statusCode: 404, + body: JSON.stringify(error), }); - - it('should handle other errors and return INTERNAL_SERVER_ERROR', () => { - const error = { message: 'Some other error' }; - const result = getErrorResponse(error); - - expect(result).toEqual({ - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - body: JSON.stringify({ - message: 'Internal error. Please contact the system administrator.', - code: 'InternalError', - status: StatusCodes.INTERNAL_SERVER_ERROR, - }), - }); + }); + + it("should handle other errors and return INTERNAL_SERVER_ERROR", () => { + const error = { message: "Some other error" }; + const result = getErrorResponse(error); + + expect(result).toEqual({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ + message: "Internal error. Please contact the system administrator.", + code: "InternalError", + status: StatusCodes.INTERNAL_SERVER_ERROR, + }), }); -}); \ No newline at end of file + }); +}); diff --git a/source/image-handler/test/image-handler/limit-input-pixels.spec.ts b/source/image-handler/test/image-handler/limit-input-pixels.spec.ts new file mode 100644 index 000000000..d44f5b7cf --- /dev/null +++ b/source/image-handler/test/image-handler/limit-input-pixels.spec.ts @@ -0,0 +1,195 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import Rekognition from "aws-sdk/clients/rekognition"; +import S3 from "aws-sdk/clients/s3"; +import fs from "fs"; + +import { ImageHandler } from "../../image-handler"; +import { ContentTypes, ImageRequestInfo, RequestTypes } from "../../lib"; + +const s3Client = new S3(); +const rekognitionClient = new Rekognition(); +const image = fs.readFileSync("./test/image/25x15.png"); + +describe("limit-input-pixels", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + jest.resetAllMocks(); + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("Should resort to default input image limit when not provided", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = undefined; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, + }); + }); + + it("Should resort to default input image limit when not provided ", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, + }); + }); + + it("Should use default input image limit when limit is Default ", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = "Default"; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, + }); + }); + + it("Should use defined input image limit when limit is a number ", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = "1000000"; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: 1000000, + }); + }); + + it("Should resort to default input image limit when Invalid value is provided", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = "Invalid"; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, + }); + }); + + it("Should resort to infinite input image limit when 0 is provided", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = "0"; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: 0, + }); + }); + + it("Should resort to default input image limit when empty string is provided", async () => { + // Arrange + process.env.SHARP_SIZE_LIMIT = ""; + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + contentType: ContentTypes.JPEG, + bucket: "sample-bucket", + key: "sample-image-001.jpg", + edits: { grayscale: true }, + originalImage: image, + }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + // SpyOn InstantiateSharpImage + const instantiateSpy = jest.spyOn(imageHandler, "instantiateSharpImage"); + await imageHandler.process(request); + expect(instantiateSpy).toHaveBeenCalledWith(request.originalImage, request.edits, { + failOnError: false, + animated: false, + limitInputPixels: true, + }); + }); +}); diff --git a/source/image-handler/test/image-handler/overlay.spec.ts b/source/image-handler/test/image-handler/overlay.spec.ts index ab5c42d58..6f54b9668 100644 --- a/source/image-handler/test/image-handler/overlay.spec.ts +++ b/source/image-handler/test/image-handler/overlay.spec.ts @@ -9,7 +9,7 @@ import fs from "fs"; import sharp from "sharp"; import { ImageHandler } from "../../image-handler"; -import { ImageEdits, ImageHandlerError, StatusCodes, ImageRequestInfo, RequestTypes } from "../../lib"; +import { ImageEdits, StatusCodes, ImageRequestInfo, RequestTypes } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); @@ -310,13 +310,7 @@ describe("overlay", () => { // Mock mockAwsS3.getObject.mockImplementationOnce(() => ({ promise() { - return Promise.reject( - new ImageHandlerError( - StatusCodes.INTERNAL_SERVER_ERROR, - "InternalServerError", - "SimulatedInvalidParameterException" - ) - ); + return Promise.reject(new Error()); }, })); @@ -337,9 +331,9 @@ describe("overlay", () => { Key: "invalidKey", }); expect(error).toMatchObject({ - status: StatusCodes.INTERNAL_SERVER_ERROR, - code: "InternalServerError", - message: "SimulatedInvalidParameterException", + status: StatusCodes.BAD_REQUEST, + code: "OverlayImageException", + message: "The overlay image could not be applied. Please contact the system administrator.", }); } }); @@ -359,7 +353,8 @@ describe("overlay", () => { expect(error).toMatchObject({ status: StatusCodes.FORBIDDEN, code: "ImageBucket::CannotAccessBucket", - message: "The overlay image bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.", + message: + "The overlay image bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.", }); } }); @@ -474,7 +469,7 @@ describe("calcOverlaySizeOption", () => { * - height is greater */ describe("overlay-dimensions", () => { - const SHARP_ERROR = "Image to composite must have same dimensions or smaller"; + const SHARP_ERROR = "Image to overlay must have same dimensions or smaller"; it("Should pass and not throw an exception when the overlay image dimensions are both equal - png", async () => { // Mock const originalImage = fs.readFileSync("./test/image/25x15.png"); diff --git a/source/image-handler/test/image-handler/query-param-mapper.spec.ts b/source/image-handler/test/image-handler/query-param-mapper.spec.ts new file mode 100644 index 000000000..2b23fa80f --- /dev/null +++ b/source/image-handler/test/image-handler/query-param-mapper.spec.ts @@ -0,0 +1,153 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ImageHandlerError } from "../../lib"; +import { QueryParamMapper } from "../../query-param-mapper"; + +describe("QueryParamMapper", () => { + let mapper: QueryParamMapper; + + beforeEach(() => { + mapper = new QueryParamMapper(); + }); + + describe("mapQueryParamsToEdits", () => { + it("should map format parameter correctly", () => { + const result = mapper.mapQueryParamsToEdits({ format: "jpeg" }); + expect(result).toEqual({ toFormat: "jpeg" }); + }); + + it("should map resize parameters correctly", () => { + const result = mapper.mapQueryParamsToEdits({ + width: "100", + height: "200", + fit: "cover", + }); + expect(result).toEqual({ + resize: { + width: 100, + height: 200, + fit: "cover", + }, + }); + }); + + it("should map zeroed width parameters to null", () => { + const result = mapper.mapQueryParamsToEdits({ + width: "0", + height: "200", + fit: "cover", + }); + expect(result).toEqual({ + resize: { + width: null, + height: 200, + fit: "cover", + }, + }); + }); + + it("should transform boolean parameters correctly, should map grayscale to greyscale", () => { + const result = mapper.mapQueryParamsToEdits({ + flip: "true", + flop: "false", + grayscale: "true", + }); + expect(result).toEqual({ + flip: true, + flop: false, + greyscale: true, + }); + }); + + it("should transform rotate parameter correctly", () => { + const result = mapper.mapQueryParamsToEdits({ + rotate: "90", + }); + expect(result).toEqual({ + rotate: 90, + }); + }); + + it("should handle empty rotate value", () => { + const result = mapper.mapQueryParamsToEdits({ + rotate: "", + }); + expect(result).toEqual({ + rotate: null, + }); + }); + + it("should ignore undefined values", () => { + const result = mapper.mapQueryParamsToEdits({ + format: undefined, + width: "100", + }); + expect(result).toEqual({ + resize: { + width: 100, + }, + }); + }); + + it("should ignore unknown parameters", () => { + const result = mapper.mapQueryParamsToEdits({ + // @ts-ignore + unknown: "value", + width: "100", + }); + expect(result).toEqual({ + resize: { + width: 100, + }, + }); + }); + + it("should throw ImageHandlerError on parsing failure", () => { + // Mock console.error to avoid logging during test + console.error = jest.fn(); + + // Force an error by passing invalid input + const invalidInput = null as any; + + expect(() => { + mapper.mapQueryParamsToEdits(invalidInput); + }).toThrow(ImageHandlerError); + + expect(() => { + mapper.mapQueryParamsToEdits(invalidInput); + }).toThrow("Query parameter parsing failed"); + }); + }); + + describe("stringToBoolean helper", () => { + it('should return false for "false" and "0"', () => { + const result1 = mapper.mapQueryParamsToEdits({ flip: "false" }); + const result2 = mapper.mapQueryParamsToEdits({ flip: "0" }); + + expect(result1).toEqual({ flip: false }); + expect(result2).toEqual({ flip: false }); + }); + + it("should return true for other values", () => { + const result1 = mapper.mapQueryParamsToEdits({ flip: "true" }); + const result2 = mapper.mapQueryParamsToEdits({ flip: "1" }); + + expect(result1).toEqual({ flip: true }); + expect(result2).toEqual({ flip: true }); + }); + }); + + describe("QUERY_PARAM_KEYS", () => { + it("should contain all supported parameter keys", () => { + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("format"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("fit"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("width"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("height"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("rotate"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("flip"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("flop"); + expect(QueryParamMapper.QUERY_PARAM_KEYS).toContain("grayscale"); + }); + }); +}); diff --git a/source/image-handler/test/image-handler/resize.spec.ts b/source/image-handler/test/image-handler/resize.spec.ts index 14193d4ae..8b806bfc4 100644 --- a/source/image-handler/test/image-handler/resize.spec.ts +++ b/source/image-handler/test/image-handler/resize.spec.ts @@ -6,7 +6,7 @@ import S3 from "aws-sdk/clients/s3"; import sharp from "sharp"; import { ImageHandler } from "../../image-handler"; -import { ImageEdits } from "../../lib"; +import { ImageEdits, ImageHandlerError } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); @@ -33,4 +33,42 @@ describe("resize", () => { .toBuffer(); expect(resultBuffer).toEqual(convertedImage); }); + + it("Should throw an error if image edits dimensions are invalid", async () => { + // Arrange + const originalImage = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" + ); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const edits: ImageEdits = { resize: { width: 0, height: 0 } }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + + // Assert + await expect(imageHandler.applyEdits(image, edits, false)).rejects.toThrow(ImageHandlerError); + }); + + it("Should not throw an error if image edits dimensions contain null", async () => { + // Arrange + const originalImage = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" + ); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const edits: ImageEdits = { resize: { width: 100, height: null } }; + + // Act + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + + // Assert + const result = await imageHandler.applyEdits(image, edits, false); + const resultBuffer = await result.toBuffer(); + const convertedImage = await sharp(originalImage, { failOnError: false }) + .withMetadata() + .resize({ width: 100, height: null }) + .toBuffer(); + expect(resultBuffer).toEqual(convertedImage); + }); }); diff --git a/source/image-handler/test/image-handler/rotate.spec.ts b/source/image-handler/test/image-handler/rotate.spec.ts index ccaab48d2..a1c5ffa0c 100644 --- a/source/image-handler/test/image-handler/rotate.spec.ts +++ b/source/image-handler/test/image-handler/rotate.spec.ts @@ -21,7 +21,7 @@ describe("rotate", () => { bucket: "sample-bucket", key: "test.jpg", edits: { rotate: null }, - originalImage: originalImage, + originalImage, }; // Act @@ -29,7 +29,7 @@ describe("rotate", () => { const result = await imageHandler.process(request); // Assert - const metadata = await sharp(Buffer.from(result, "base64")).metadata(); + const metadata = await sharp(result).metadata(); expect(metadata).not.toHaveProperty("exif"); expect(metadata).not.toHaveProperty("icc"); expect(metadata).not.toHaveProperty("orientation"); @@ -43,7 +43,7 @@ describe("rotate", () => { bucket: "sample-bucket", key: "test.jpg", edits: {}, - originalImage: originalImage, + originalImage, }; // Act @@ -51,7 +51,7 @@ describe("rotate", () => { const result = await imageHandler.process(request); // Assert - const metadata = await sharp(Buffer.from(result, "base64")).metadata(); + const metadata = await sharp(result).metadata(); expect(metadata).toHaveProperty("icc"); expect(metadata).toHaveProperty("exif"); expect(metadata.orientation).toEqual(3); @@ -75,7 +75,7 @@ describe("rotate", () => { const result = await imageHandler.process(request); // Assert - const metadata = await sharp(Buffer.from(result, "base64")).metadata(); + const metadata = await sharp(result).metadata(); expect(metadata).not.toHaveProperty("orientation"); }); }); diff --git a/source/image-handler/test/image-handler/round-crop.spec.ts b/source/image-handler/test/image-handler/round-crop.spec.ts index 31fc53937..ea8614016 100644 --- a/source/image-handler/test/image-handler/round-crop.spec.ts +++ b/source/image-handler/test/image-handler/round-crop.spec.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +/* eslint-disable @typescript-eslint/no-explicit-any */ + import Rekognition from "aws-sdk/clients/rekognition"; import S3 from "aws-sdk/clients/s3"; import sharp from "sharp"; @@ -11,7 +13,7 @@ import { ImageEdits } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); -//jest spies +// jest spies const hasRoundCropSpy = jest.spyOn(ImageHandler.prototype as any, "hasRoundCrop"); const validRoundCropParamSpy = jest.spyOn(ImageHandler.prototype as any, "validRoundCropParam"); const compositeSpy = jest.spyOn(sharp.prototype, "composite"); diff --git a/source/image-handler/test/image-handler/smart-crop.spec.ts b/source/image-handler/test/image-handler/smart-crop.spec.ts index 022760cc9..67a345193 100644 --- a/source/image-handler/test/image-handler/smart-crop.spec.ts +++ b/source/image-handler/test/image-handler/smart-crop.spec.ts @@ -57,7 +57,7 @@ describe("smartCrop", () => { const image = sharp(originalImage, { failOnError: false }).withMetadata(); const edits: ImageEdits = { toFormat: "webp", - smartCrop: { padding: 60 } + smartCrop: { padding: 60 }, }; // Mock @@ -289,7 +289,11 @@ describe("smartCrop", () => { mockAwsRekognition.detectFaces.mockImplementationOnce(() => ({ promise() { return Promise.reject( - new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, "InternalServerError", "SimulatedError") + new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "SmartCrop::Error", + "Smart Crop could not be applied. Please contact the system administrator." + ) ); }, })); @@ -305,8 +309,8 @@ describe("smartCrop", () => { }); expect(error).toMatchObject({ status: StatusCodes.INTERNAL_SERVER_ERROR, - code: "InternalServerError", - message: "SimulatedError", + code: "SmartCrop::Error", + message: "Smart Crop could not be applied. Please contact the system administrator.", }); } }); @@ -456,7 +460,7 @@ describe("handleBounds", () => { BoundingBox: { Height: 0.6968063116073608, Left: 0.26937249302864075, - Top: 0.51424895375967026, + Top: 0.5142489537596702, Width: 0.42325547337532043, }, }, @@ -469,9 +473,9 @@ describe("handleBounds", () => { // Assert expect(boundingBox).toEqual({ - Height: 1 - 0.51424895375967026, + Height: 1 - 0.5142489537596702, Left: 0.26937249302864075, - Top: 0.51424895375967026, + Top: 0.5142489537596702, Width: 0.42325547337532043, }); }); diff --git a/source/image-handler/test/image-handler/standard.spec.ts b/source/image-handler/test/image-handler/standard.spec.ts index e23ea4829..51a8cf254 100644 --- a/source/image-handler/test/image-handler/standard.spec.ts +++ b/source/image-handler/test/image-handler/standard.spec.ts @@ -53,7 +53,7 @@ describe("standard", () => { const result = await imageHandler.process(request); // Assert - expect(result).toEqual(request.originalImage.toString("base64")); + expect(result).toEqual(request.originalImage); }); }); @@ -73,7 +73,7 @@ describe("instantiateSharpImage", () => { // Act await imageHandler["instantiateSharpImage"](image, edits, options); - //Assert + // Assert expect(withMetatdataSpy).not.toHaveBeenCalled(); }); @@ -88,7 +88,7 @@ describe("instantiateSharpImage", () => { // Act await imageHandler["instantiateSharpImage"](image, edits, options); - //Assert + // Assert expect(withMetatdataSpy).toHaveBeenCalled(); expect(withMetatdataSpy).not.toHaveBeenCalledWith(expect.objectContaining({ orientation: expect.anything })); }); @@ -105,7 +105,7 @@ describe("instantiateSharpImage", () => { // Act await imageHandler["instantiateSharpImage"](modifiedImage, edits, options); - //Assert + // Assert expect(withMetatdataSpy).toHaveBeenCalledWith({ orientation: 1 }); }); }); diff --git a/source/image-handler/test/image-request/decode-request.spec.ts b/source/image-handler/test/image-request/decode-request.spec.ts index ce290055b..e8d02dad7 100644 --- a/source/image-handler/test/image-request/decode-request.spec.ts +++ b/source/image-handler/test/image-request/decode-request.spec.ts @@ -1,11 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { mockAwsS3 } from "../mock"; import S3 from "aws-sdk/clients/s3"; import SecretsManager from "aws-sdk/clients/secretsmanager"; import { ImageRequest } from "../../image-request"; -import { StatusCodes } from "../../lib"; +import { ImageHandlerEvent, StatusCodes } from "../../lib"; import { SecretProvider } from "../../secret-provider"; describe("decodeRequest", () => { @@ -70,4 +71,126 @@ describe("decodeRequest", () => { }); } }); + + describe("expires", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetAllMocks(); + process.env = { ...OLD_ENV }; + }); + + afterEach(() => { + jest.clearAllMocks(); + process.env = OLD_ENV; + }); + + const baseRequest = { + bucket: "validBucket", + requestType: "Default", + key: "validKey", + }; + const path = `/${Buffer.from(JSON.stringify(baseRequest)).toString("base64")}`; + const mockBody = Buffer.from("SampleImageContent\n"); + it.each([ + { + expires: "19700101T000000Z", + error: { + code: "ImageRequestExpired", + status: StatusCodes.BAD_REQUEST, + }, + }, + { + expires: "19700001T000000Z", + error: { + code: "ImageRequestExpiryFormat", + status: StatusCodes.BAD_REQUEST, + }, + }, + { + expires: "19700101S000000Z", + error: { + code: "ImageRequestExpiryFormat", + status: StatusCodes.BAD_REQUEST, + }, + }, + { + expires: "19700101T000000", + error: { + code: "ImageRequestExpiryFormat", + status: StatusCodes.BAD_REQUEST, + }, + }, + ] as { expires: ImageHandlerEvent["queryStringParameters"]["expires"]; error: object }[])( + "Should throw an error when expires: $expires", + async ({ error: expectedError, expires }) => { + // Arrange + const event: ImageHandlerEvent = { + path, + queryStringParameters: { + expires, + }, + }; + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + await expect(imageRequest.setup(event)).rejects.toMatchObject(expectedError); + } + ); + + it("Should validate request if expires is not provided", async () => { + // Arrange + process.env = { SOURCE_BUCKETS: "validBucket, validBucket2" }; + const event: ImageHandlerEvent = { + path, + }; + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: mockBody }); + }, + })); + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + + const imageRequestInfo = await imageRequest.setup(event); + + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "validBucket", + Key: "validKey", + }); + + expect(imageRequestInfo.originalImage).toEqual(mockBody); + }); + + it("Should validate request if expires is valid", async () => { + // Arrange + const validDate = new Date(); + validDate.setFullYear(validDate.getFullYear() + 1); + const validDateString = validDate.toISOString().replace(/-/g, "").replace(/:/g, "").slice(0, 15) + "Z"; + + process.env = { SOURCE_BUCKETS: "validBucket, validBucket2" }; + + const event: ImageHandlerEvent = { + path, + queryStringParameters: { + expires: validDateString, + }, + }; + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: mockBody }); + }, + })); + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const imageRequestInfo = await imageRequest.setup(event); + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "validBucket", + Key: "validKey", + }); + expect(imageRequestInfo.originalImage).toEqual(mockBody); + }); + }); }); diff --git a/source/image-handler/test/image-request/determine-output-format.spec.ts b/source/image-handler/test/image-request/determine-output-format.spec.ts index 3e092650b..17876241c 100644 --- a/source/image-handler/test/image-request/determine-output-format.spec.ts +++ b/source/image-handler/test/image-request/determine-output-format.spec.ts @@ -8,7 +8,7 @@ import { ImageRequest } from "../../image-request"; import { ImageHandlerEvent, ImageFormatTypes, ImageRequestInfo, RequestTypes } from "../../lib"; import { SecretProvider } from "../../secret-provider"; -const request: Record = { +const request: Record = { bucket: "bucket", key: "key", edits: { @@ -20,9 +20,9 @@ const request: Record = { }, }; -const createEvent = (request): ImageHandlerEvent => { - return { path: `${Buffer.from(JSON.stringify(request)).toString("base64")}` }; -}; +const createEvent = (request): ImageHandlerEvent => ({ + path: `${Buffer.from(JSON.stringify(request)).toString("base64")}`, +}); describe("determineOutputFormat", () => { const s3Client = new S3(); diff --git a/source/image-handler/test/image-request/get-allowed-source-buckets.spec.ts b/source/image-handler/test/image-request/get-allowed-source-buckets.spec.ts index f38182ca9..5585b59fc 100644 --- a/source/image-handler/test/image-request/get-allowed-source-buckets.spec.ts +++ b/source/image-handler/test/image-request/get-allowed-source-buckets.spec.ts @@ -5,7 +5,6 @@ import { getAllowedSourceBuckets } from "../../image-request"; import { StatusCodes } from "../../lib"; describe("getAllowedSourceBuckets", () => { - it("Should pass if the SOURCE_BUCKETS environment variable is not empty and contains valid inputs", () => { // Arrange process.env.SOURCE_BUCKETS = "allowedBucket001, allowedBucket002"; diff --git a/source/image-handler/test/image-request/parse-image-bucket.spec.ts b/source/image-handler/test/image-request/parse-image-bucket.spec.ts index 1cd9ada3c..4dc65e193 100644 --- a/source/image-handler/test/image-request/parse-image-bucket.spec.ts +++ b/source/image-handler/test/image-request/parse-image-bucket.spec.ts @@ -137,8 +137,8 @@ describe("parseImageBucket", () => { const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); // Assert - expect(bucket).toEqual("allowedBucket001") - }) + expect(bucket).toEqual("allowedBucket001"); + }); it("should parse bucket-name from any section in the url", () => { // Arrange @@ -150,8 +150,8 @@ describe("parseImageBucket", () => { const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); // Assert - expect(bucket).toEqual("test-bucket") - }) + expect(bucket).toEqual("test-bucket"); + }); it("should only parse bucket-names in source_buckets", () => { // Arrange @@ -163,8 +163,8 @@ describe("parseImageBucket", () => { const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); // Assert - expect(bucket).toEqual("test-bucket") - }) + expect(bucket).toEqual("test-bucket"); + }); it("should parse bucket-name from first part in thumbor request and return it", () => { // Arrange @@ -176,8 +176,8 @@ describe("parseImageBucket", () => { const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); // Assert - expect(bucket).toEqual("test-bucket") - }) + expect(bucket).toEqual("test-bucket"); + }); it("should take bucket-name from env-variable if not present in the URL", () => { // Arrange @@ -189,6 +189,32 @@ describe("parseImageBucket", () => { const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); // Assert - expect(bucket).toEqual("allowedBucket001") - }) + expect(bucket).toEqual("allowedBucket001"); + }); + + it("should parse bucket-name from first part in thumbor request and return it when using legacy multiple filters", () => { + // Arrange + const event = { path: "/filters:grayscale()/filters:rotate(180)/s3:test-bucket/test-image-001.jpg" }; + process.env.SOURCE_BUCKETS = "allowedBucket001, test-bucket"; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + + const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); + // Assert + expect(bucket).toEqual("test-bucket"); + }); + + it("should parse bucket-name from first part in thumbor request and return it when chaining multiple filters", () => { + // Arrange + const event = { path: "/filters:grayscale():rotate(180)/s3:test-bucket/test-image-001.jpg" }; + process.env.SOURCE_BUCKETS = "allowedBucket001, test-bucket"; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + + const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); + // Assert + expect(bucket).toEqual("test-bucket"); + }); }); diff --git a/source/image-handler/test/image-request/parse-query-param-edits.spec.ts b/source/image-handler/test/image-request/parse-query-param-edits.spec.ts new file mode 100644 index 000000000..01353a263 --- /dev/null +++ b/source/image-handler/test/image-request/parse-query-param-edits.spec.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ImageRequest } from "../../image-request"; +import { ImageHandlerEvent } from "../../lib"; + +describe("parseImageEdits", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should parse rotate and width parameters from query string into image edits object", () => { + // Arrange + const event: ImageHandlerEvent = { + queryStringParameters: { + rotate: "90", + width: "100", + flip: "true", + flop: "0", + }, + }; + + // Act + const imageRequest = new ImageRequest(undefined, undefined); + const result = imageRequest.parseQueryParamEdits(event, undefined); + + // Assert + const expectedResult = { rotate: 90, resize: { width: 100 }, flip: true, flop: false }; + expect(result).toEqual(expectedResult); + }); +}); diff --git a/source/image-handler/test/image-request/parse-request-type.spec.ts b/source/image-handler/test/image-request/parse-request-type.spec.ts index 8f85bff6f..c1c2cd367 100644 --- a/source/image-handler/test/image-request/parse-request-type.spec.ts +++ b/source/image-handler/test/image-request/parse-request-type.spec.ts @@ -80,7 +80,6 @@ describe("parseRequestType", () => { { value: ".tiff" }, { value: ".tif" }, { value: ".svg" }, - { value: ".gif" }, { value: ".avif" }, ])("Should pass if get a request with supported image extension: $value", ({ value }) => { process.env = {}; diff --git a/source/image-handler/test/image-request/recreate-query-string.spec.ts b/source/image-handler/test/image-request/recreate-query-string.spec.ts new file mode 100644 index 000000000..219d83c98 --- /dev/null +++ b/source/image-handler/test/image-request/recreate-query-string.spec.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import S3 from "aws-sdk/clients/s3"; +import SecretsManager from "aws-sdk/clients/secretsmanager"; + +import { ImageRequest } from "../../image-request"; +import { SecretProvider } from "../../secret-provider"; + +describe("recreateQueryString", () => { + const s3Client = new S3(); + const secretsManager = new SecretsManager(); + const secretProvider = new SecretProvider(secretsManager); + + it("Should accurately recreate query strings", () => { + const testCases = [ + { + // Signature should be removed + queryParams: { signature: "test-signature", expires: "test-expires", format: "png" }, + expected: "expires=test-expires&format=png", + }, + { + queryParams: { grayscale: "true", expires: "test-expires", format: "png" }, + expected: "expires=test-expires&format=png&grayscale=true", + }, + { + queryParams: { + signature: "test-signature", + expires: "test-expires", + format: "png", + fit: "cover", + width: "100", + height: "100", + rotate: "90", + flip: "true", + flop: "true", + grayscale: "true", + }, + + expected: + "expires=test-expires&fit=cover&flip=true&flop=true&format=png&grayscale=true&height=100&rotate=90&width=100", + }, + ]; + + const imageRequest = new ImageRequest(s3Client, secretProvider); + testCases.forEach(({ queryParams, expected }) => { + // @ts-ignore + const result = imageRequest.recreateQueryString(queryParams); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/source/image-handler/test/image-request/setup.spec.ts b/source/image-handler/test/image-request/setup.spec.ts index 57824b5b3..989152ee7 100644 --- a/source/image-handler/test/image-request/setup.spec.ts +++ b/source/image-handler/test/image-request/setup.spec.ts @@ -7,7 +7,7 @@ import S3 from "aws-sdk/clients/s3"; import SecretsManager from "aws-sdk/clients/secretsmanager"; import { ImageRequest } from "../../image-request"; -import { ImageHandlerError, RequestTypes, StatusCodes } from "../../lib"; +import { ImageHandlerError, ImageHandlerEvent, RequestTypes, StatusCodes } from "../../lib"; import { SecretProvider } from "../../secret-provider"; describe("setup", () => { @@ -326,7 +326,7 @@ describe("setup", () => { describe("enableSignature", () => { beforeAll(() => { process.env.ENABLE_SIGNATURE = "Yes"; - process.env.SECRETS_MANAGER = "serverless-image-handler"; + process.env.SECRETS_MANAGER = "dynamic-image-transformation-for-amazon-cloudfront"; process.env.SECRET_KEY = "signatureKey"; process.env.SOURCE_BUCKETS = "validBucket"; }); @@ -472,7 +472,11 @@ describe("setup", () => { mockAwsSecretManager.getSecretValue.mockImplementationOnce(() => ({ promise() { return Promise.reject( - new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, "InternalServerError", "SimulatedError") + new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "SmartCrop::Error", + "Smart Crop could not be applied. Please contact the system administrator." + ) ); }, })); @@ -795,38 +799,157 @@ describe("setup", () => { expect(imageRequestInfo).toEqual(expectedResult); }); - it('Should pass when a default image request is provided and populate the ImageRequest object with the proper values and a utf-8 key', async function () { + it("Should pass and use query param edit on default requests", async () => { + const event: ImageHandlerEvent = { + path: "/eyJidWNrZXQiOiJ0ZXN0Iiwia2V5IjoidGVzdC5wbmciLCJvdXRwdXRGb3JtYXQiOiJ3ZWJwIn0=", + queryStringParameters: { + format: "png", + }, + }; + process.env.SOURCE_BUCKETS = "test, validBucket, validBucket2"; + + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from("SampleImageContent\n") }); + }, + })); + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: "Default", + bucket: "test", + key: "test.png", + edits: { toFormat: "png" }, + headers: undefined, + outputFormat: "png", + originalImage: Buffer.from("SampleImageContent\n"), + cacheControl: "max-age=31536000,public", + contentType: "image/png", + }; + + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "test", + Key: "test.png", + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it("Should pass when a default image request is provided and populate the ImageRequest object with the proper values and a utf-8 key", async () => { // Arrange const event = { - path: 'eyJidWNrZXQiOiJ0ZXN0Iiwia2V5Ijoi5Lit5paHIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9' - } + path: "eyJidWNrZXQiOiJ0ZXN0Iiwia2V5Ijoi5Lit5paHIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9", + }; process.env = { - SOURCE_BUCKETS: "test, test2" - } + SOURCE_BUCKETS: "test, test2", + }; // Mock - mockAwsS3.getObject.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') }); - } - }; - }); + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from("SampleImageContent\n") }); + }, + })); // Act const imageRequest = new ImageRequest(s3Client, secretProvider); const imageRequestInfo = await imageRequest.setup(event); const expectedResult = { - requestType: 'Default', - bucket: 'test', - key: 'δΈ­ζ–‡', + requestType: "Default", + bucket: "test", + key: "δΈ­ζ–‡", edits: { grayscale: true }, headers: undefined, - outputFormat: 'jpeg', - originalImage: Buffer.from('SampleImageContent\n'), - cacheControl: 'max-age=31536000,public', - contentType: 'image/jpeg' + outputFormat: "jpeg", + originalImage: Buffer.from("SampleImageContent\n"), + cacheControl: "max-age=31536000,public", + contentType: "image/jpeg", + }; + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: "test", Key: "δΈ­ζ–‡" }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it("Should pass when a query-param image request is provided and populate the ImageRequest object with the proper values", async () => { + // Arrange + const event: ImageHandlerEvent = { + path: "/test-image-001.jpg", + queryStringParameters: { + format: "png", + }, + }; + process.env.SOURCE_BUCKETS = "allowedBucket001, allowedBucket002"; + + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from("SampleImageContent\n") }); + }, + })); + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: "Thumbor", + bucket: "allowedBucket001", + key: "test-image-001.jpg", + edits: { + toFormat: "png", + }, + originalImage: Buffer.from("SampleImageContent\n"), + cacheControl: "max-age=31536000,public", + outputFormat: "png", + contentType: "image/png", }; + // Assert - expect(mockAwsS3.getObject).toHaveBeenCalledWith({ Bucket: 'test', Key: 'δΈ­ζ–‡' }); + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "allowedBucket001", + Key: "test-image-001.jpg", + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + + it("Should pass when a query-param/thumbor request is provided and have query overwrite existing values", async () => { + // Arrange + const event: ImageHandlerEvent = { + path: "/filters:format(jpg)/test-image-001.jpg", + queryStringParameters: { + format: "png", + }, + }; + process.env.SOURCE_BUCKETS = "allowedBucket001, allowedBucket002"; + + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from("SampleImageContent\n") }); + }, + })); + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: "Thumbor", + bucket: "allowedBucket001", + key: "test-image-001.jpg", + edits: { + toFormat: "png", + }, + originalImage: Buffer.from("SampleImageContent\n"), + cacheControl: "max-age=31536000,public", + outputFormat: "png", + contentType: "image/png", + }; + + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "allowedBucket001", + Key: "test-image-001.jpg", + }); expect(imageRequestInfo).toEqual(expectedResult); }); }); diff --git a/source/image-handler/test/index.spec.ts b/source/image-handler/test/index.spec.ts index 77f22e368..c78c975bd 100644 --- a/source/image-handler/test/index.spec.ts +++ b/source/image-handler/test/index.spec.ts @@ -3,18 +3,38 @@ import fs from "fs"; -import { mockAwsS3 } from "./mock"; +import { mockAwsS3, mockContext } from "./mock"; import { handler } from "../index"; -import { ImageHandlerError, ImageHandlerEvent, StatusCodes } from "../lib"; +import { ImageHandlerError, ImageHandlerEvent, S3GetObjectEvent, StatusCodes } from "../lib"; +// eslint-disable-next-line import/no-unresolved +import { Context } from "aws-lambda"; describe("index", () => { // Arrange process.env.SOURCE_BUCKETS = "source-bucket"; + const OLD_ENV = process.env; const mockImage = Buffer.from("SampleImageContent\n"); const mockFallbackImage = Buffer.from("SampleFallbackImageContent\n"); - it("should return the image when there is no error", async () => { + const commonMetadata = { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET", + "Content-Type": "image/jpeg", + StatusCode: "200", + }; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + jest.resetAllMocks(); + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should return the image when there is no error using RestAPI handler", async () => { // Mock mockAwsS3.getObject.mockImplementationOnce(() => ({ promise() { @@ -32,7 +52,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Content-Type": "image/jpeg", Expires: undefined, "Cache-Control": "max-age=31536000,public", @@ -49,6 +69,110 @@ describe("index", () => { expect(result).toEqual(expectedResult); }); + it("should return the image when there is no error using S3 Object Lambda handler", async () => { + process.env.ENABLE_S3_OBJECT_LAMBDA = "Yes"; + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: mockImage, ContentType: "image/jpeg" }); + }, + })); + mockAwsS3.writeGetObjectResponse.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ status: 200, Body: undefined }); + }, + })); + mockContext.getRemainingTimeInMillis.mockImplementationOnce(() => 60000); + // Arrange + const event: S3GetObjectEvent = { + getObjectContext: { + outputRoute: "testOutputRoute", + outputToken: "testOutputToken", + }, + userRequest: { + url: "example.com/image/test.jpg", + headers: { Host: "example.com" }, + }, + }; + + // Act + const result = await handler(event, mockContext as unknown as Context); + + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "source-bucket", + Key: "test.jpg", + }); + expect(result).toEqual(undefined); + expect(mockAwsS3.writeGetObjectResponse).toHaveBeenCalledWith({ + Body: mockImage, + RequestRoute: event.getObjectContext.outputRoute, + RequestToken: event.getObjectContext.outputToken, + CacheControl: "max-age=31536000,public", + Metadata: { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET", + "Content-Type": "image/jpeg", + StatusCode: "200", + }, + }); + }); + + it("should return timeout error when s3 object lambda duration is exceeded", async () => { + process.env.ENABLE_S3_OBJECT_LAMBDA = "Yes"; + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ Body: mockImage, ContentType: "image/jpeg" }); + }, 100); + }); + }, + })); + mockAwsS3.writeGetObjectResponse.mockImplementation(() => ({ + promise() { + return Promise.resolve({ status: 200, Body: undefined }); + }, + })); + mockContext.getRemainingTimeInMillis.mockImplementationOnce(() => 1000); + // Arrange + const event: S3GetObjectEvent = { + getObjectContext: { + outputRoute: "testOutputRoute", + outputToken: "testOutputToken", + }, + userRequest: { + url: "example.com/image/test.jpg", + headers: { Host: "example.com" }, + }, + }; + + // Act + const result = await handler(event, mockContext as unknown as Context); + + // Assert + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "source-bucket", + Key: "test.jpg", + }); + expect(result).toEqual(undefined); + expect(mockAwsS3.writeGetObjectResponse).toHaveBeenCalledWith({ + Body: '{"status":503,"code":"TimeoutException","message":"Image processing timed out."}', + RequestRoute: event.getObjectContext.outputRoute, + RequestToken: event.getObjectContext.outputToken, + CacheControl: "max-age=600,public", + Metadata: { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET", + "Content-Type": "application/json", + StatusCode: "503", + }, + }); + }); + it("should return the image with custom headers when custom headers are provided", async () => { // Mock mockAwsS3.getObject.mockImplementationOnce(() => ({ @@ -68,7 +192,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Content-Type": "image/jpeg", Expires: undefined, "Cache-Control": "max-age=31536000,public", @@ -144,7 +268,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Content-Type": "application/json", }, body: JSON.stringify({ @@ -162,7 +286,7 @@ describe("index", () => { expect(result).toEqual(expectedResult); }); - it("should return 500 error when there is no error status in the error", async () => { + it("should return 400 error when sharp is passed invalid image format", async () => { // Arrange const event: ImageHandlerEvent = { path: "eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidGVzdC5qcGciLCJlZGl0cyI6eyJ3cm9uZ0ZpbHRlciI6dHJ1ZX19", @@ -178,18 +302,18 @@ describe("index", () => { // Act const result = await handler(event); const expectedResult = { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + statusCode: StatusCodes.BAD_REQUEST, isBase64Encoded: false, headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Content-Type": "application/json", }, body: JSON.stringify({ - message: "Internal error. Please contact the system administrator.", - code: "InternalError", - status: StatusCodes.INTERNAL_SERVER_ERROR, + status: StatusCodes.BAD_REQUEST, + code: "InstantiationError", + message: "Input image could not be instantiated. Please choose a valid image.", }), }; @@ -235,11 +359,10 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "image/png", "Cache-Control": "max-age=31536000,public", - "Last-Modified": undefined, }, body: mockFallbackImage.toString("base64"), }; @@ -295,7 +418,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "image/png", "Cache-Control": "max-age=12,public", @@ -353,7 +476,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "image/png", "Cache-Control": "max-age=11,public", @@ -376,6 +499,12 @@ describe("index", () => { it("should return an error JSON when getting the default fallback image fails if the default fallback image is enabled", async () => { // Arrange + process.env.ENABLE_DEFAULT_FALLBACK_IMAGE = "Yes"; + process.env.DEFAULT_FALLBACK_IMAGE_BUCKET = "fallback-image-bucket"; + process.env.DEFAULT_FALLBACK_IMAGE_KEY = "fallback-image.png"; + process.env.CORS_ENABLED = "Yes"; + process.env.CORS_ORIGIN = "*"; + const event: ImageHandlerEvent = { path: "/test.jpg", }; @@ -396,7 +525,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "application/json", }, @@ -422,6 +551,8 @@ describe("index", () => { it("should return an error JSON when the default fallback image key is not provided if the default fallback image is enabled", async () => { // Arrange process.env.DEFAULT_FALLBACK_IMAGE_KEY = ""; + process.env.CORS_ENABLED = "Yes"; + process.env.CORS_ORIGIN = "*"; const event: ImageHandlerEvent = { path: "/test.jpg" }; // Mock @@ -439,7 +570,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "application/json", }, @@ -461,6 +592,8 @@ describe("index", () => { it("should return an error JSON when the default fallback image bucket is not provided if the default fallback image is enabled", async () => { // Arrange process.env.DEFAULT_FALLBACK_IMAGE_BUCKET = ""; + process.env.CORS_ENABLED = "Yes"; + process.env.CORS_ORIGIN = "*"; const event: ImageHandlerEvent = { path: "/test.jpg" }; // Mock @@ -478,7 +611,7 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "application/json", }, @@ -499,6 +632,8 @@ describe("index", () => { it("Should return an error JSON when ALB request is failed", async () => { // Arrange + process.env.CORS_ENABLED = "Yes"; + process.env.CORS_ORIGIN = "*"; const event: ImageHandlerEvent = { path: "/test.jpg", requestContext: { @@ -540,14 +675,20 @@ describe("index", () => { }); it("should return an error JSON with the expected message when one or both overlay image dimensions are greater than the base image dimensions", async () => { - // Arrange - // {"bucket":"source-bucket","key":"transparent-10x10.png","edits":{"overlayWith":{"bucket":"source-bucket","key":"transparent-5x5.png"}},"headers":{"Custom-Header":"Custom header test","Cache-Control":"max-age:1,public"}} + process.env.CORS_ENABLED = "Yes"; + process.env.CORS_ORIGIN = "*"; + const imageRequest = { + bucket: "source-bucket", + key: "transparent-5x5.png", + edits: { overlayWith: { bucket: "source-bucket", key: "transparent-10x10.png" } }, + headers: { "Custom-Header": "Custom header test", "Cache-Control": "max-age:1,public" }, + }; + const encStr = Buffer.from(JSON.stringify(imageRequest)).toString("base64"); const event: ImageHandlerEvent = { - path: "eyJidWNrZXQiOiJzb3VyY2UtYnVja2V0Iiwia2V5IjoidHJhbnNwYXJlbnQtMTB4MTAucG5nIiwiZWRpdHMiOnsib3ZlcmxheVdpdGgiOnsiYnVja2V0Ijoic291cmNlLWJ1Y2tldCIsImtleSI6InRyYW5zcGFyZW50LTV4NS5wbmcifX0sImhlYWRlcnMiOnsiQ3VzdG9tLUhlYWRlciI6IkN1c3RvbSBoZWFkZXIgdGVzdCIsIkNhY2hlLUNvbnRyb2wiOiJtYXgtYWdlOjEscHVibGljIn19", + path: `${encStr}`, }; - // Mock - const overlayImage = fs.readFileSync("./test/image/transparent-5x5.jpeg"); - const baseImage = fs.readFileSync("./test/image/transparent-10x10.jpeg"); + const overlayImage = fs.readFileSync("./test/image/transparent-10x10.jpeg"); + const baseImage = fs.readFileSync("./test/image/transparent-5x5.jpeg"); // Mock mockAwsS3.getObject.mockImplementation((data) => ({ @@ -566,16 +707,106 @@ describe("index", () => { headers: { "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": "*", "Content-Type": "application/json", }, body: JSON.stringify({ - message: `Image to overlay must have same dimensions or smaller`, - code: "BadRequest", status: StatusCodes.BAD_REQUEST, + code: "BadRequest", + message: `Image to overlay must have same dimensions or smaller`, }), }; expect(result).toEqual(expectedResult); }); + + const writeGetObjectAssertion = ( + event: S3GetObjectEvent, + cacheControl: string | RegExp, + additionalMetadata: {} = {} + ) => { + expect(mockAwsS3.writeGetObjectResponse).toHaveBeenCalledWith({ + Body: mockImage, + RequestRoute: event.getObjectContext.outputRoute, + RequestToken: event.getObjectContext.outputToken, + CacheControl: cacheControl, + Metadata: { ...commonMetadata, ...additionalMetadata }, + }); + }; + + it("should return the image with properly encoded headers when custom headers are provided to S3 OL implementation", async () => { + // Mock + const imageRequest = { bucket: "source-bucket", key: "test.jpg", headers: { "Custom-Header": "CustomValue\n" } }; + const event = setupObjectLambdaB64EncodedTest(imageRequest); + + // Act + await handler(event, mockContext as unknown as Context); + + // Assert + writeGetObjectAssertion(event, "max-age=31536000,public", { "Custom-Header": "CustomValue%0A" }); + }); + + it("should allow overwriting of CacheControl header when expires is not provided", async () => { + // Mock + const imageRequest = { + bucket: "source-bucket", + key: "test.jpg", + headers: { "Cache-Control": "max-age=50,public" }, + }; + const event = setupObjectLambdaB64EncodedTest(imageRequest); + + // Act + await handler(event, mockContext as unknown as Context); + + // Assert + writeGetObjectAssertion(event, "max-age=50,public"); + }); + + it("should disallow overwriting of CacheControl header when expires is provided", async () => { + // Mock + const imageRequest = { + bucket: "source-bucket", + key: "test.jpg", + headers: { "Cache-Control": "max-age=50,public" }, + }; + const event = setupObjectLambdaB64EncodedTest(imageRequest); + const validDate = new Date(); + validDate.setSeconds(validDate.getSeconds() + 5); + const validDateString = validDate.toISOString().replace(/-/g, "").replace(/:/g, "").slice(0, 15) + "Z"; + event.userRequest.url = `${event.userRequest.url}?expires=${validDateString}`; + + // Act + await handler(event, mockContext as unknown as Context); + + // Assert + writeGetObjectAssertion(event, expect.stringMatching(/^max-age=[0-5],public$/)); + }); + + function setupObjectLambdaB64EncodedTest(eventObject: Object, image: Buffer = mockImage): S3GetObjectEvent { + process.env.ENABLE_S3_OBJECT_LAMBDA = "Yes"; + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: image, ContentType: "image/jpeg" }); + }, + })); + + const encStr = Buffer.from(JSON.stringify(eventObject)).toString("base64"); + + mockAwsS3.writeGetObjectResponse.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ status: 200, Body: undefined }); + }, + })); + mockContext.getRemainingTimeInMillis.mockImplementationOnce(() => 60000); + return { + getObjectContext: { + outputRoute: "testOutputRoute", + outputToken: "testOutputToken", + }, + userRequest: { + url: `example.com/image/${encStr}`, + headers: { Host: "example.com" }, + }, + }; + } }); diff --git a/source/image-handler/test/mock.ts b/source/image-handler/test/mock.ts index 61f9acd7d..6830252e9 100644 --- a/source/image-handler/test/mock.ts +++ b/source/image-handler/test/mock.ts @@ -10,6 +10,7 @@ export const mockAwsS3 = { createBucket: jest.fn(), putBucketEncryption: jest.fn(), putBucketPolicy: jest.fn(), + writeGetObjectResponse: jest.fn(), }; jest.mock("aws-sdk/clients/s3", () => jest.fn(() => ({ ...mockAwsS3 }))); @@ -28,3 +29,7 @@ export const mockAwsRekognition = { jest.mock("aws-sdk/clients/rekognition", () => jest.fn(() => ({ ...mockAwsRekognition }))); export const consoleInfoSpy = jest.spyOn(console, "info"); + +export const mockContext = { + getRemainingTimeInMillis: jest.fn(), +}; diff --git a/source/image-handler/test/thumbor-mapper/edits.spec.ts b/source/image-handler/test/thumbor-mapper/edits.spec.ts index 70d740363..53ebb385d 100644 --- a/source/image-handler/test/thumbor-mapper/edits.spec.ts +++ b/source/image-handler/test/thumbor-mapper/edits.spec.ts @@ -1,84 +1,84 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import {ThumborMapper} from "../../thumbor-mapper"; +import { ThumborMapper } from "../../thumbor-mapper"; describe("edits", () => { - it("Should pass if filters are chained", () => { - const path = "/filters:rotate(90):grayscale()/thumbor-image.jpg"; + it("Should pass if filters are chained", () => { + const path = "/filters:rotate(90):grayscale()/thumbor-image.jpg"; - // Act - const thumborMapper = new ThumborMapper(); - const edits = thumborMapper.mapPathToEdits(path); + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); - // Assert - const expectedResult = { - edits: { - rotate: 90, - grayscale: true, - }, - }; - expect(edits).toEqual(expectedResult.edits); - }); + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); - it("Should pass if filters are not chained", () => { - const path = "/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg"; + it("Should pass if filters are not chained", () => { + const path = "/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg"; - // Act - const thumborMapper = new ThumborMapper(); - const edits = thumborMapper.mapPathToEdits(path); + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); - // Assert - const expectedResult = { - edits: { - rotate: 90, - grayscale: true, - }, - }; - expect(edits).toEqual(expectedResult.edits); - }); + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); - it("Should pass if filters are both chained and individual", () => { - const path = "/filters:rotate(90):grayscale()/filters:blur(20)/thumbor-image.jpg"; + it("Should pass if filters are both chained and individual", () => { + const path = "/filters:rotate(90):grayscale()/filters:blur(20)/thumbor-image.jpg"; - // Act - const thumborMapper = new ThumborMapper(); - const edits = thumborMapper.mapPathToEdits(path); + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); - // Assert - const expectedResult = { - edits: { - rotate: 90, - grayscale: true, - blur: 10, - }, - }; - expect(edits).toEqual(expectedResult.edits); - }); + // Assert + const expectedResult = { + edits: { + rotate: 90, + grayscale: true, + blur: 10, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); - it("Should pass even if there are slashes in the filter", () => { - const path = "/filters:watermark(bucket,folder/key.png,0,0)/image.jpg"; + it("Should pass even if there are slashes in the filter", () => { + const path = "/filters:watermark(bucket,folder/key.png,0,0)/image.jpg"; - // Act - const thumborMapper = new ThumborMapper(); - const edits = thumborMapper.mapPathToEdits(path); + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); - // Assert - const expectedResult = { - edits: { - "overlayWith": { - "alpha": undefined, - "bucket": "bucket", - "hRatio": undefined, - "key": "folder/key.png", - "options": { - "left": "0", - "top": "0", - }, - "wRatio": undefined, - }, - }, - }; - expect(edits).toEqual(expectedResult.edits); - }); + // Assert + const expectedResult = { + edits: { + overlayWith: { + alpha: undefined, + bucket: "bucket", + hRatio: undefined, + key: "folder/key.png", + options: { + left: "0", + top: "0", + }, + wRatio: undefined, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); }); diff --git a/source/image-handler/test/thumbor-mapper/filter.spec.ts b/source/image-handler/test/thumbor-mapper/filter.spec.ts index 5474630cb..bdbef4dbd 100644 --- a/source/image-handler/test/thumbor-mapper/filter.spec.ts +++ b/source/image-handler/test/thumbor-mapper/filter.spec.ts @@ -767,4 +767,216 @@ describe("filter", () => { }; expect(edits).toEqual(expectedResult.edits); }); + + it("Should pass if smart crop filter is provided with no values", () => { + // Arrange + const path = "filters:smart_crop()/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: undefined, + padding: undefined, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with valid numbers", () => { + // Arrange + const path = "filters:smart_crop(1,40)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: 1, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with valid numbers with a space", () => { + // Arrange + const path = "filters:smart_crop(1, 40)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: 1, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with only padding", () => { + // Arrange + const path = "filters:smart_crop(,40)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: undefined, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with only faceIndex", () => { + // Arrange + const path = "filters:smart_crop(1)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: 1, + padding: undefined, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with only faceIndex and comma", () => { + // Arrange + const path = "filters:smart_crop(1,)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: 1, + padding: undefined, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if smart crop filter is provided with too many parameters", () => { + // Arrange + const path = "filters:smart_crop(1,40,50)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: 1, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass using smart crop with thumbor filter chaining", () => { + // Arrange + const path = "/fit-in/200x300/filters:grayscale():smart_crop(1,40)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + resize: { + width: 200, + height: 300, + fit: "inside", + }, + grayscale: true, + smartCrop: { + faceIndex: 1, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass using smart crop with regular thumbor filters", () => { + // Arrange + const path = "/fit-in/200x300/filters:grayscale()/filters:smart_crop(1,40)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + resize: { + width: 200, + height: 300, + fit: "inside", + }, + grayscale: true, + smartCrop: { + faceIndex: 1, + padding: 40, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should return NaN if non numeric values are provided to smart crop", () => { + // Arrange + const path = "/filters:smart_crop(some,value)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + smartCrop: { + faceIndex: NaN, + padding: NaN, + }, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); }); diff --git a/source/image-handler/thumbor-mapper.ts b/source/image-handler/thumbor-mapper.ts index 58017d07f..94858640c 100644 --- a/source/image-handler/thumbor-mapper.ts +++ b/source/image-handler/thumbor-mapper.ts @@ -370,7 +370,15 @@ export class ThumborMapper { break; } case "animated": { - currentEdits.animated = filterValue.toLowerCase() != "false"; + currentEdits.animated = filterValue.toLowerCase() !== "false"; + break; + } + case "smart_crop": { + const [faceIndex, padding] = filterValue.split(","); + currentEdits.smartCrop = { + faceIndex: faceIndex ? parseInt(faceIndex) : undefined, + padding: padding ? parseInt(padding) : undefined, + }; break; } } diff --git a/source/metrics-utils/index.ts b/source/metrics-utils/index.ts index 1909bcb25..dbf96742a 100644 --- a/source/metrics-utils/index.ts +++ b/source/metrics-utils/index.ts @@ -2,4 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { SolutionsMetrics } from "./lib/solutions-metrics"; -export * from "./lambda/helpers/types" \ No newline at end of file +export * from "./lambda/helpers/types"; diff --git a/source/metrics-utils/lambda/helpers/client-helper.ts b/source/metrics-utils/lambda/helpers/client-helper.ts index 957ca56e2..1b8a6a8d7 100644 --- a/source/metrics-utils/lambda/helpers/client-helper.ts +++ b/source/metrics-utils/lambda/helpers/client-helper.ts @@ -7,7 +7,7 @@ import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; export class ClientHelper { private sqsClient: SQSClient; - private cwClients: {[key: string]: CloudWatchClient }; + private cwClients: { [key: string]: CloudWatchClient }; private cwLogsClient: CloudWatchLogsClient; constructor() { @@ -23,7 +23,7 @@ export class ClientHelper { getCwClient(region: string = "default"): CloudWatchClient { if (region === "default") { - region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "default" + region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "default"; } if (!(region in this.cwClients)) { this.cwClients[region] = region === "default" ? new CloudWatchClient({}) : new CloudWatchClient({ region }); diff --git a/source/metrics-utils/lambda/helpers/metrics-helper.ts b/source/metrics-utils/lambda/helpers/metrics-helper.ts index 7529800dc..b34e3b71b 100644 --- a/source/metrics-utils/lambda/helpers/metrics-helper.ts +++ b/source/metrics-utils/lambda/helpers/metrics-helper.ts @@ -18,7 +18,15 @@ import { StartQueryCommandInput, QueryDefinition, } from "@aws-sdk/client-cloudwatch-logs"; -import { EventBridgeQueryEvent, MetricPayload, MetricData, QueryProps, SQSEventBody, ExecutionDay, MetricDataProps } from "./types"; +import { + EventBridgeQueryEvent, + MetricPayload, + MetricData, + QueryProps, + SQSEventBody, + ExecutionDay, + MetricDataProps, +} from "./types"; import { SQSEvent } from "aws-lambda"; import { ClientHelper } from "./client-helper"; import axios, { RawAxiosRequestConfig } from "axios"; @@ -40,20 +48,20 @@ export class MetricsHelper { const regionedMetricProps = {}; for (const metric of metricsDataProps) { const region = metric.region ?? "default"; - if (!regionedMetricProps[region]) regionedMetricProps[region] = []; + if (!regionedMetricProps[region]) regionedMetricProps[region] = []; regionedMetricProps[region].push(metric); } - let results: MetricData = {} + let results: MetricData = {}; for (const region in regionedMetricProps) { const metricProps = regionedMetricProps[region]; const cloudFrontInput: GetMetricDataCommandInput = { MetricDataQueries: metricProps, - StartTime: new Date(endTime.getTime() - ((EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000)), // 7 or 1 day(s) previous + StartTime: new Date(endTime.getTime() - (EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), // 7 or 1 day(s) previous EndTime: endTime, }; - results = {...results, ...await this.fetchMetricsData(cloudFrontInput, region)}; + results = { ...results, ...(await this.fetchMetricsData(cloudFrontInput, region)) }; } - + return results; } @@ -64,7 +72,6 @@ export class MetricsHelper { const results: MetricData = {}; do { response = await this.clientHelper.getCwClient(region).send(command); - console.info(response); input.MetricDataQueries?.forEach((item, index) => { const key = `${item.MetricStat?.Metric?.Namespace}/${item.MetricStat?.Metric?.MetricName}`; @@ -138,7 +145,7 @@ export class MetricsHelper { async startQuery(queryProp: QueryProps, endTime: Date): Promise { const input: StartQueryCommandInput = { - startTime: endTime.getTime() - ((EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), + startTime: endTime.getTime() - (EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000, endTime: endTime.getTime(), ...queryProp, }; diff --git a/source/metrics-utils/lambda/helpers/types.ts b/source/metrics-utils/lambda/helpers/types.ts index 72f7b61de..5112d1b4c 100644 --- a/source/metrics-utils/lambda/helpers/types.ts +++ b/source/metrics-utils/lambda/helpers/types.ts @@ -12,8 +12,8 @@ export interface EventBridgeQueryEvent extends Pick { - region?: string - } + region?: string; +} export enum ExecutionDay { DAILY = "*", diff --git a/source/metrics-utils/lambda/index.ts b/source/metrics-utils/lambda/index.ts index 99598c40b..8b7d78c97 100644 --- a/source/metrics-utils/lambda/index.ts +++ b/source/metrics-utils/lambda/index.ts @@ -31,7 +31,7 @@ export async function handler(event: EventBridgeQueryEvent | SQSEvent, _context: console.info("Metrics data: ", JSON.stringify(metricsData, null, 2)); await metricsHelper.sendAnonymousMetric( metricsData, - new Date(endTime.getTime() - ((EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000)), + new Date(endTime.getTime() - (EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), endTime ); await metricsHelper.startQueries(event); @@ -45,7 +45,7 @@ export async function handler(event: EventBridgeQueryEvent | SQSEvent, _context: if (Object.keys(metricsData).length > 0) { await metricsHelper.sendAnonymousMetric( metricsData, - new Date(body.endTime - ((EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000)), + new Date(body.endTime - (EXECUTION_DAY == ExecutionDay.DAILY ? 1 : 7) * 86400 * 1000), new Date(body.endTime) ); } diff --git a/source/metrics-utils/lib/query-builders.ts b/source/metrics-utils/lib/query-builders.ts index e4a055063..29eeda2c5 100644 --- a/source/metrics-utils/lib/query-builders.ts +++ b/source/metrics-utils/lib/query-builders.ts @@ -20,7 +20,7 @@ export function addLambdaInvocationCount(this: SolutionsMetrics, functionName: s Stat: "Sum", Period: period, }, - Id: undefined + Id: undefined, }); } @@ -50,7 +50,7 @@ export function addCloudFrontMetric( Period: period, }, region: "us-east-1", - Id: undefined + Id: undefined, }); } diff --git a/source/metrics-utils/lib/solutions-metrics.ts b/source/metrics-utils/lib/solutions-metrics.ts index 72e414590..c81f4651c 100644 --- a/source/metrics-utils/lib/solutions-metrics.ts +++ b/source/metrics-utils/lib/solutions-metrics.ts @@ -33,16 +33,19 @@ export class SolutionsMetrics extends Construct { environment: { QUERY_PREFIX: `${Aws.STACK_NAME}-`, SOLUTION_ID: SOLUTION_ID ?? scope.node.tryGetContext("solutionId"), - SOLUTION_NAME: SOLUTION_NAME ?? scope.node.tryGetContext("solutionName"), SOLUTION_VERSION: VERSION ?? scope.node.tryGetContext("solutionVersion"), UUID: props.uuid ?? "", - EXECUTION_DAY: props.executionDay ? props.executionDay : ExecutionDay.MONDAY + EXECUTION_DAY: props.executionDay ? props.executionDay : ExecutionDay.MONDAY, }, }); const ruleToLambda = new EventbridgeToLambda(this, "EventbridgeRuleToLambda", { eventRuleProps: { - schedule: Schedule.cron({ minute: "0", hour: "23", weekDay: props.executionDay ? props.executionDay : ExecutionDay.MONDAY }), + schedule: Schedule.cron({ + minute: "0", + hour: "23", + weekDay: props.executionDay ? props.executionDay : ExecutionDay.MONDAY, + }), }, existingLambdaObj: this.metricsLambdaFunction, }); @@ -94,7 +97,7 @@ export class SolutionsMetrics extends Construct { } this.metricDataQueries.push({ ...metricDataProp, - Id: `id_${Fn.join('_', Fn.split('-', Aws.STACK_NAME))}_${this.metricDataQueries.length}`, + Id: `id_${Fn.join("_", Fn.split("-", Aws.STACK_NAME))}_${this.metricDataQueries.length}`, }); this.eventBridgeRule.addOverride("Properties.Targets.0.InputTransformer", { InputPathsMap: { diff --git a/source/metrics-utils/test/lambda/helpers/metrics-helper.spec.ts b/source/metrics-utils/test/lambda/helpers/metrics-helper.spec.ts index 7f5055736..1e82514c0 100644 --- a/source/metrics-utils/test/lambda/helpers/metrics-helper.spec.ts +++ b/source/metrics-utils/test/lambda/helpers/metrics-helper.spec.ts @@ -7,7 +7,7 @@ import { QueryDefinition, GetQueryResultsCommandOutput } from "@aws-sdk/client-c import { SQSEvent } from "aws-lambda"; import { MetricsHelper } from "../../../lambda/helpers/metrics-helper"; import { ClientHelper } from "../../../lambda/helpers/client-helper"; -import { EventBridgeQueryEvent, MetricData } from '../../../lambda/helpers/types'; +import { EventBridgeQueryEvent, MetricData } from "../../../lambda/helpers/types"; // Mock AWS SDK clients jest.mock("@aws-sdk/client-cloudwatch"); @@ -71,7 +71,7 @@ describe("MetricsHelper", () => { const result = await metricsHelper.getMetricsData(mockEvent); expect(clientHelperMock.getCwClient().send).toHaveBeenCalled(); - expect(result).toEqual({"SomeNamespace/SomeMetricName": [9999]}); + expect(result).toEqual({ "SomeNamespace/SomeMetricName": [9999] }); }); it("should get query definitions", async () => { @@ -160,9 +160,8 @@ describe("MetricsHelper", () => { }); it("should properly populate anonymous metric data", async () => { - // Arrange - const metricData : MetricData = { + const metricData: MetricData = { metric1: [1, 2, 3], metric2: [4, 5, 6], }; @@ -174,9 +173,9 @@ describe("MetricsHelper", () => { axios.post = jest.fn().mockResolvedValue({ statusText: "OK", status: 200 }); // Act - const result = await metricsHelper.sendAnonymousMetric(metricData, startTime, endTime) + const result = await metricsHelper.sendAnonymousMetric(metricData, startTime, endTime); // Assert - expect(result.Message).toEqual("Anonymous data was sent successfully.") + expect(result.Message).toEqual("Anonymous data was sent successfully."); // Assert payload Data DataStartTime sent with axios is in expected format expect(axios.post).toHaveBeenCalledWith( @@ -184,5 +183,5 @@ describe("MetricsHelper", () => { expect.stringContaining(`"DataStartTime":"2020-09-10 04:00:00.000"`), expect.anything() ); - }) + }); }); diff --git a/source/metrics-utils/test/lib/solutions-metrics.spec.ts b/source/metrics-utils/test/lib/solutions-metrics.spec.ts index 08be3141f..271d2f45a 100644 --- a/source/metrics-utils/test/lib/solutions-metrics.spec.ts +++ b/source/metrics-utils/test/lib/solutions-metrics.spec.ts @@ -148,7 +148,7 @@ test("Test that multiple query definitions are defined correctly", () => { "", [ { - "Ref": "AWS::StackName", + Ref: "AWS::StackName", }, "-ExampleQuery", ], @@ -166,7 +166,7 @@ test("Test that multiple query definitions are defined correctly", () => { "", [ { - "Ref": "AWS::StackName", + Ref: "AWS::StackName", }, "-ExampleQuery2", ], diff --git a/source/package-lock.json b/source/package-lock.json index 7306732f2..319894d76 100644 --- a/source/package-lock.json +++ b/source/package-lock.json @@ -1,12 +1,12 @@ { "name": "source", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "source", - "version": "6.3.3", + "version": "7.0.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.10.4", diff --git a/source/package.json b/source/package.json index 0c6a2e2d3..aa483cbe1 100644 --- a/source/package.json +++ b/source/package.json @@ -1,6 +1,6 @@ { "name": "source", - "version": "6.3.3", + "version": "7.0.0", "private": true, "description": "ESLint and prettier dependencies to be used within the solution", "license": "Apache-2.0", diff --git a/source/solution-utils/package-lock.json b/source/solution-utils/package-lock.json index 5d6115216..883be0da7 100644 --- a/source/solution-utils/package-lock.json +++ b/source/solution-utils/package-lock.json @@ -1,12 +1,12 @@ { "name": "solution-utils", - "version": "6.3.3", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solution-utils", - "version": "6.3.3", + "version": "7.0.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.5", diff --git a/source/solution-utils/package.json b/source/solution-utils/package.json index b8a7646ba..282c525b4 100644 --- a/source/solution-utils/package.json +++ b/source/solution-utils/package.json @@ -1,6 +1,6 @@ { "name": "solution-utils", - "version": "6.3.3", + "version": "7.0.0", "private": true, "description": "Utilities to be used within this solution", "license": "Apache-2.0", diff --git a/source/solution-utils/test/get-options.test.ts b/source/solution-utils/test/get-options.test.ts index b8da776f6..bcff962a0 100644 --- a/source/solution-utils/test/get-options.test.ts +++ b/source/solution-utils/test/get-options.test.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +/* eslint-disable @typescript-eslint/no-var-requires */ + // Spy on the console messages const consoleLogSpy = jest.spyOn(console, "log"); const consoleErrorSpy = jest.spyOn(console, "error");