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 000000000..68a999e31 Binary files /dev/null and b/default_architecture.png differ 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 000000000..0c1e153e5 Binary files /dev/null and b/object_lambda_architecture.png differ 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");