From c6f8c7798bd264781ec5158702ee4e576a9c8ff9 Mon Sep 17 00:00:00 2001 From: Simon Krol Date: Thu, 27 Jun 2024 16:21:20 -0400 Subject: [PATCH] Update to version 6.2.6 (#554) --- .gitignore | 1 + CHANGELOG.md | 40 ++- README.md | 2 + VERSION.txt | 1 + deployment/build-s3-dist.sh | 3 + .../cdk-solution-helper/package-lock.json | 30 +- deployment/cdk-solution-helper/package.json | 2 +- source/constructs/bin/constructs.ts | 9 +- source/constructs/cdk.json | 2 +- .../lib/back-end/back-end-construct.ts | 23 +- .../custom-resource-construct.ts | 57 +++- .../constructs/lib/serverless-image-stack.ts | 10 +- source/constructs/package-lock.json | 140 +++++---- source/constructs/package.json | 17 +- .../__snapshots__/constructs.test.ts.snap | 294 +++++++++++++++--- source/constructs/test/constructs.test.ts | 7 +- source/custom-resource/index.ts | 108 ++----- source/custom-resource/lib/enums.ts | 1 - source/custom-resource/lib/interfaces.ts | 8 +- source/custom-resource/lib/types.ts | 2 - source/custom-resource/package-lock.json | 24 +- source/custom-resource/package.json | 5 +- .../test/create-logging-bucket.spec.ts | 62 +++- source/custom-resource/test/mock.ts | 1 + source/demo-ui/index.html | 15 +- source/demo-ui/package-lock.json | 51 +++ source/demo-ui/package.json | 22 ++ source/image-handler/image-handler.ts | 74 +++-- source/image-handler/image-request.ts | 88 ++++-- source/image-handler/lib/enums.ts | 2 + source/image-handler/package-lock.json | 18 +- source/image-handler/package.json | 5 +- .../test/image-handler/animated.spec.ts | 90 +++++- .../test/image-handler/crop.spec.ts | 56 +++- .../image-request/get-original-image.spec.ts | 26 +- .../image-request/infer-image-type.spec.ts | 32 +- .../image-request/parse-image-bucket.spec.ts | 65 ++++ .../image-request/parse-image-key.spec.ts | 30 ++ .../image-request/parse-request-type.spec.ts | 72 ++++- .../test/image-request/setup.spec.ts | 35 +++ source/image-handler/test/index.spec.ts | 2 +- .../test/thumbor-mapper/filter.spec.ts | 65 ++++ source/image-handler/thumbor-mapper.ts | 6 + source/package-lock.json | 18 +- source/package.json | 7 +- source/solution-utils/package-lock.json | 18 +- source/solution-utils/package.json | 9 +- 47 files changed, 1259 insertions(+), 396 deletions(-) create mode 100644 VERSION.txt create mode 100644 source/demo-ui/package-lock.json create mode 100644 source/demo-ui/package.json diff --git a/.gitignore b/.gitignore index c49eecd99..05881a558 100755 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # dependencies **/node_modules +**/modules # test assets **/coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a91ac3f..52d8ef893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [6.2.5] - 2024-01-12 +## [6.2.6] - 2024-06-27 + +### Added +- StackId tag to CloudFrontLoggingBucket and its bucket name as a CfnOutput [#529](https://github.com/aws-solutions/serverless-image-handler/issues/529) +- Test case to verify UTF-8 support in object key [#320](https://github.com/aws-solutions/serverless-image-handler/pull/320) +- Test cases to verify crop functionality [#459](https://github.com/aws-solutions/serverless-image-handler/pull/459) +- VERSION.txt and build script change to auto-update local package versions +- S3:bucket-name tag for defining which source bucket to use in thumbor style requests [#521](https://github.com/aws-solutions/serverless-image-handler/pull/521) +- Ability to override whether an image should be animated [#456](https://github.com/aws-solutions/serverless-image-handler/issues/456) +- Support for 8-bit depth AVIF image type inference [#360](https://github.com/aws-solutions/serverless-image-handler/issues/360) + +### Changed +- Decreased permissions allotted to CustomResource Lambda and ImageHandler Lambda +- cdk update to 2.124.0 +- aws-solutions-constructs update to 2.51.0 +- SourceBucketsParameter to require explicit bucket names +- Demo-ui dependency update +- Demo-ui to be a package and manage script/stylesheet dependencies through NPM +- Modified JPEG SOI marker parsing to only check first 2 bytes [#429] + +### Security +- Upgraded follow-redirects to v1.15.6 for vulnerability CVE-2024-28849 +- Upgraded braces to v3.0.3 for vulnerability CVE-2024-4068 + +### Removed +- Unused CopyS3Assets custom resource + +### Fixed +- Some error messages indicating incorrect file types +- Solution version and id not being passed to Backend Lambda +- Thumbor-style URL matching being overly permissive + + +## [6.2.5] - 2024-01-03 ### Fixed - Ensure accurate image metadata when generating Amazon Rekognition compatible images [#374](https://github.com/aws-solutions/serverless-image-handler/issues/374) -- Upgraded axios to v1.6.5 for vulnerability CVE-2023-26159 - Exclude demo-ui-config from being deleted upon BucketDeployment update sync when updating to a new version ### Changed @@ -20,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - typescript update to 5.3.3 - GIF files without multiple pages are now treated as non-animated, allowing all filters to be used on them [#460](https://github.com/aws-solutions/serverless-image-handler/issues/460) +### Security + +- Upgraded axios to v1.6.5 for vulnerability CVE-2023-26159 + ## [6.2.4] - 2023-12-06 ### Changed diff --git a/README.md b/README.md index 5fa3cdd2d..56b417c67 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ 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) # License diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 000000000..417c02022 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +6.2.6 \ No newline at end of file diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh index e37653a4b..c223eb6b1 100755 --- a/deployment/build-s3-dist.sh +++ b/deployment/build-s3-dist.sh @@ -32,6 +32,9 @@ mkdir -p "$template_dist_dir" rm -rf "$build_dist_dir" mkdir -p "$build_dist_dir" +headline "[Init] Ensure package versions are updated" +npm --prefix "$source_dir" run bump-version + headline "[Build] Synthesize cdk template and assets" cd "$cdk_source_dir" npm run clean:install diff --git a/deployment/cdk-solution-helper/package-lock.json b/deployment/cdk-solution-helper/package-lock.json index eb7b554b4..f8b88f293 100644 --- a/deployment/cdk-solution-helper/package-lock.json +++ b/deployment/cdk-solution-helper/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "adm-zip": "^0.5.10", - "aws-cdk-lib": "^2.118.0" + "aws-cdk-lib": "^2.124.0" }, "devDependencies": { "@types/adm-zip": "^0.5.2", @@ -36,9 +36,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.201", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.201.tgz", - "integrity": "sha512-INZqcwDinNaIdb5CtW3ez5s943nX5stGBQS6VOP2JDlOFP81hM3fds/9NDknipqfUkZM43dx+HgVvkXYXXARCQ==" + "version": "2.2.202", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.202.tgz", + "integrity": "sha512-JqlF0D4+EVugnG5dAsNZMqhu3HW7ehOXm5SDMxMbXNDMdsF0pxtQKNHRl52z1U9igsHmaFpUgSGjbhAJ+0JONg==" }, "node_modules/@aws-cdk/asset-kubectl-v20": { "version": "2.1.2", @@ -1313,9 +1313,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.118.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.118.0.tgz", - "integrity": "sha512-i3At9HOuXNVLxQCo/y7Sb2Zj4Ir4tichG+755AAldebRcqXrCJizZq+sMMt6/Bkjggj2imWmhwHPZw518M9VMw==", + "version": "2.125.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.125.0.tgz", + "integrity": "sha512-yRcHuvpPYHuvffeJCnTSIqo6y+Qjeuf+BKmr/oyMcMhyfIzcGFFhh+ZQRCTYIJTfTyU6nh73TLhsZ4TmzFuBBA==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -1329,7 +1329,7 @@ "yaml" ], "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.201", + "@aws-cdk/asset-awscli-v1": "^2.2.202", "@aws-cdk/asset-kubectl-v20": "^2.1.2", "@aws-cdk/asset-node-proxy-agent-v6": "^2.0.1", "@balena/dockerignore": "^1.0.2", @@ -1776,12 +1776,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2235,9 +2235,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/deployment/cdk-solution-helper/package.json b/deployment/cdk-solution-helper/package.json index 5d5dd1173..31dac2f80 100644 --- a/deployment/cdk-solution-helper/package.json +++ b/deployment/cdk-solution-helper/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "adm-zip": "^0.5.10", - "aws-cdk-lib": "^2.118.0" + "aws-cdk-lib": "^2.124.0" }, "overrides": { "semver": "7.5.4" diff --git a/source/constructs/bin/constructs.ts b/source/constructs/bin/constructs.ts index 0999d2005..67b0c2ad7 100644 --- a/source/constructs/bin/constructs.ts +++ b/source/constructs/bin/constructs.ts @@ -20,12 +20,13 @@ if (DIST_OUTPUT_BUCKET && SOLUTION_NAME && VERSION) const app = new App(); const solutionDisplayName = "Serverless Image Handler"; -const description = `(${app.node.tryGetContext("solutionId")}) - ${solutionDisplayName}. Version ${VERSION ?? app.node.tryGetContext("solutionVersion")}`; +const solutionVersion = VERSION ?? app.node.tryGetContext("solutionVersion"); +const description = `(${app.node.tryGetContext("solutionId")}) - ${solutionDisplayName}. Version ${solutionVersion}`; // eslint-disable-next-line no-new new ServerlessImageHandlerStack(app, "ServerlessImageHandlerStack", { - synthesizer: synthesizer, - description: description, + synthesizer, + description, solutionId: app.node.tryGetContext("solutionId"), - solutionVersion: app.node.tryGetContext("solutionVersion"), + solutionVersion, solutionName: app.node.tryGetContext("solutionName"), }); diff --git a/source/constructs/cdk.json b/source/constructs/cdk.json index e28db9f53..3b4d97733 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.2.5", + "solutionVersion": "custom-v6.2.6", "solutionName": "serverless-image-handler" } } \ No newline at end of file diff --git a/source/constructs/lib/back-end/back-end-construct.ts b/source/constructs/lib/back-end/back-end-construct.ts index d1ce111c6..b1d5d616e 100644 --- a/source/constructs/lib/back-end/back-end-construct.ts +++ b/source/constructs/lib/back-end/back-end-construct.ts @@ -31,11 +31,13 @@ import * as api from "aws-cdk-lib/aws-apigateway"; export interface BackEndProps extends SolutionConstructProps { readonly solutionVersion: string; + readonly solutionId: string; readonly solutionName: string; readonly secretsManagerPolicy: Policy; readonly logsBucket: IBucket; readonly uuid: string; readonly cloudFrontPriceClass: string; + readonly createSourceBucketsResource: (key?: string) => string[]; } export class BackEnd extends Construct { @@ -64,15 +66,16 @@ export class BackEnd extends Construct { ], }), new PolicyStatement({ - actions: ["s3:GetObject", "s3:PutObject", "s3:ListBucket"], - resources: [ - Stack.of(this).formatArn({ - service: "s3", - resource: "*", - region: "", - account: "", - }), - ], + actions: ["s3:GetObject"], + resources: props.createSourceBucketsResource("/*"), + }), + new PolicyStatement({ + actions: ["s3:ListBucket"], + resources: props.createSourceBucketsResource(), + }), + new PolicyStatement({ + actions: ["s3:GetObject"], + resources: [`arn:aws:s3:::${props.fallbackImageS3Bucket}/${props.fallbackImageS3KeyBucket}`], }), new PolicyStatement({ actions: ["rekognition:DetectFaces", "rekognition:DetectModerationLabels"], @@ -106,6 +109,8 @@ export class BackEnd extends Construct { ENABLE_DEFAULT_FALLBACK_IMAGE: props.enableDefaultFallbackImage, DEFAULT_FALLBACK_IMAGE_BUCKET: props.fallbackImageS3Bucket, DEFAULT_FALLBACK_IMAGE_KEY: props.fallbackImageS3KeyBucket, + SOLUTION_VERSION: props.solutionVersion, + SOLUTION_ID: props.solutionId, }, bundling: { externalModules: ["sharp"], 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 14f1bd0e7..cbc43b91e 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,9 +7,9 @@ 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, 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 { addCfnSuppressRules } from "../../../utils/utils"; +import { addCfnCondition, addCfnSuppressRules } from "../../../utils/utils"; import { SolutionConstructProps } from "../../types"; import { CommonResourcesProps, Conditions } from "../common-resources-construct"; @@ -45,7 +45,6 @@ export interface SetupValidateSecretsManagerProps { } export class CustomResourcesConstruct extends Construct { - private readonly solutionVersion: string; private readonly conditions: Conditions; private readonly customResourceRole: Role; private readonly customResourceLambda: LambdaFunction; @@ -54,7 +53,6 @@ export class CustomResourcesConstruct extends Construct { constructor(scope: Construct, id: string, props: CustomResourcesConstructProps) { super(scope, id); - this.solutionVersion = props.solutionVersion; this.conditions = props.conditions; this.customResourceRole = new Role(this, "CustomResourceRole", { @@ -75,16 +73,26 @@ export class CustomResourcesConstruct extends Construct { }), ], }), + new PolicyStatement({ + actions: ['s3:ListBucket'], + resources: this.createSourceBucketsResource() + }), + new PolicyStatement({ + actions: [ + "s3:GetObject", + ], + resources: [ + `arn:aws:s3:::${props.fallbackImageS3Bucket}/${props.fallbackImageS3KeyBucket}`, + ], + }), new PolicyStatement({ actions: [ "s3:putBucketAcl", "s3:putEncryptionConfiguration", "s3:putBucketPolicy", "s3:CreateBucket", - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket", "s3:PutBucketOwnershipControls", + "s3:PutBucketTagging" ], resources: [ Stack.of(this).formatArn({ @@ -142,6 +150,21 @@ export class CustomResourcesConstruct extends Construct { this.uuid = customResourceUuid.getAttString("UUID"); } + public setupWebsiteHostingBucketPolicy(websiteHostingBucket: IBucket) { + const websiteHostingBucketPolicy = new Policy(this, "WebsiteHostingBucketPolicy", { + document: new PolicyDocument({ + statements: [ + new PolicyStatement({ + 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, { CustomAction: "sendMetric", @@ -181,7 +204,9 @@ 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"))], + sources: [ + S3Source.asset(path.join(__dirname, "../../../../demo-ui"), { exclude: ["node_modules/*"] }), + ], destinationBucket: props.hostingBucket, exclude: ["demo-ui-config.js"], }); @@ -235,6 +260,22 @@ export class CustomResourcesConstruct extends Construct { return optInRegionAccessLogBucket; } + 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')))) + ), + }, + ), + ) + } + private createCustomResource( id: string, customResourceFunction: LambdaFunction, diff --git a/source/constructs/lib/serverless-image-stack.ts b/source/constructs/lib/serverless-image-stack.ts index f92104947..ebf50161c 100644 --- a/source/constructs/lib/serverless-image-stack.ts +++ b/source/constructs/lib/serverless-image-stack.ts @@ -36,7 +36,7 @@ export class ServerlessImageHandlerStack extends Stack { const sourceBucketsParameter = new CfnParameter(this, "SourceBucketsParameter", { type: "String", description: - "(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will be the first bucket listed in this field.", + "(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.", allowedPattern: ".+", default: "defaultBucket, bucketNo2, bucketNo3, ...", }); @@ -172,14 +172,18 @@ export class ServerlessImageHandlerStack extends Stack { const backEnd = new BackEnd(this, "BackEnd", { solutionVersion: props.solutionVersion, + solutionId: props.solutionId, solutionName: props.solutionName, secretsManagerPolicy: commonResources.secretsManagerPolicy, logsBucket: commonResources.logsBucket, uuid: commonResources.customResources.uuid, cloudFrontPriceClass: cloudFrontPriceClassParameter.valueAsString, + createSourceBucketsResource: commonResources.customResources.createSourceBucketsResource, ...solutionConstructProps, }); + commonResources.customResources.setupWebsiteHostingBucketPolicy(frontEnd.websiteHostingBucket); + commonResources.customResources.setupAnonymousMetric({ anonymousData: anonymousUsage, ...solutionConstructProps, @@ -319,6 +323,10 @@ export class ServerlessImageHandlerStack extends Stack { value: logRetentionPeriodParameter.valueAsString, description: "Number of days for event logs from Lambda to be retained in CloudWatch.", }); + new CfnOutput(this, "CloudFrontLoggingBucket", { + value: commonResources.logsBucket.bucketName, + description: "Amazon S3 bucket for storing CloudFront access logs.", + }) Aspects.of(this).add(new SuppressLambdaFunctionCfnRulesAspect()); Tags.of(this).add("SolutionId", props.solutionId); diff --git a/source/constructs/package-lock.json b/source/constructs/package-lock.json index 00f6fbfcd..921e951aa 100644 --- a/source/constructs/package-lock.json +++ b/source/constructs/package-lock.json @@ -1,12 +1,12 @@ { "name": "constructs", - "version": "6.2.5", + "version": "6.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "constructs", - "version": "6.2.5", + "version": "6.2.6", "license": "Apache-2.0", "dependencies": { "sharp": "^0.32.6" @@ -16,14 +16,14 @@ }, "devDependencies": { "@aws-cdk/aws-servicecatalogappregistry-alpha": "v2.118.0-alpha.0", - "@aws-solutions-constructs/aws-apigateway-lambda": "2.47.0", - "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "2.47.0", - "@aws-solutions-constructs/aws-cloudfront-s3": "2.47.0", - "@aws-solutions-constructs/core": "2.47.0", + "@aws-solutions-constructs/aws-apigateway-lambda": "2.51.0", + "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "2.51.0", + "@aws-solutions-constructs/aws-cloudfront-s3": "2.51.0", + "@aws-solutions-constructs/core": "2.51.0", "@types/jest": "^29.5.6", "@types/node": "^20.10.4", - "aws-cdk": "^2.118.0", - "aws-cdk-lib": "^2.118.0", + "aws-cdk": "^2.124.0", + "aws-cdk-lib": "^2.124.0", "constructs": "^10.3.0", "esbuild": "^0.19.10", "jest": "^29.7.0", @@ -46,9 +46,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.201", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.201.tgz", - "integrity": "sha512-INZqcwDinNaIdb5CtW3ez5s943nX5stGBQS6VOP2JDlOFP81hM3fds/9NDknipqfUkZM43dx+HgVvkXYXXARCQ==", + "version": "2.2.202", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.202.tgz", + "integrity": "sha512-JqlF0D4+EVugnG5dAsNZMqhu3HW7ehOXm5SDMxMbXNDMdsF0pxtQKNHRl52z1U9igsHmaFpUgSGjbhAJ+0JONg==", "dev": true }, "node_modules/@aws-cdk/asset-kubectl-v20": { @@ -77,67 +77,73 @@ } }, "node_modules/@aws-solutions-constructs/aws-apigateway-lambda": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-apigateway-lambda/-/aws-apigateway-lambda-2.47.0.tgz", - "integrity": "sha512-MqK7UlMxptymPX5MUI2q6w4xHIrLqq5xaZYHTCDOTcPxUKgzp5gOAKruga27x24MIIBWRojtfp0wE6Tc6YWv4g==", + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-apigateway-lambda/-/aws-apigateway-lambda-2.51.0.tgz", + "integrity": "sha512-7RWaXGa//9j3YVB8tZxfsy8S0c9EokdMv+JdSNwBzygPuaZBnR8eDnjiPTor8MHv9fhhtSaLolMDpqdYpzFo7g==", "dev": true, "dependencies": { - "@aws-solutions-constructs/core": "2.47.0" + "@aws-solutions-constructs/core": "2.51.0", + "constructs": "^10.0.0" }, "peerDependencies": { - "@aws-solutions-constructs/core": "2.47.0", - "aws-cdk-lib": "^2.111.0", + "@aws-solutions-constructs/core": "2.51.0", + "aws-cdk-lib": "^2.118.0", "constructs": "^10.0.0" } }, "node_modules/@aws-solutions-constructs/aws-cloudfront-apigateway": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-apigateway/-/aws-cloudfront-apigateway-2.47.0.tgz", - "integrity": "sha512-xOQJG4lg6CzTMe2Bhk95x4L3esNVsel1e1HYbkGwYmIVYq47owSp93z3FX+Cliz4ZpgTZE2K1XZEurgAFSbnbg==", + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-apigateway/-/aws-cloudfront-apigateway-2.51.0.tgz", + "integrity": "sha512-3UTEc+76cNOe7qn3qAwhNHlq7JIfwe07nijS9cfkUThAsfi4faP49+rcAzluZPx7dPGFUp0zK4MYiiXaVuN8vQ==", "dev": true, "dependencies": { - "@aws-solutions-constructs/core": "2.47.0" + "@aws-solutions-constructs/core": "2.51.0", + "constructs": "^10.0.0" }, "peerDependencies": { - "@aws-solutions-constructs/core": "2.47.0", - "aws-cdk-lib": "^2.111.0", + "@aws-solutions-constructs/core": "2.51.0", + "aws-cdk-lib": "^2.118.0", "constructs": "^10.0.0" } }, "node_modules/@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-apigateway-lambda/-/aws-cloudfront-apigateway-lambda-2.47.0.tgz", - "integrity": "sha512-spTY2A8g3jvxTDYwyCVSoubdZoGsgVlgfiK+J7UuUNtUMNA9ss6egmSJjf2rR15UYyRGhbnDkJ+7OJOrfyfMYA==", + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-apigateway-lambda/-/aws-cloudfront-apigateway-lambda-2.51.0.tgz", + "integrity": "sha512-juVPg0h9hP1i2YKh+avyBCpud9pOPPzZHW9+hcf6LlqdcYp0VrrmNFta3W7GhFFmXLxU77Nwn80i56Wn2chzxg==", "dev": true, "dependencies": { - "@aws-solutions-constructs/aws-cloudfront-apigateway": "2.47.0", - "@aws-solutions-constructs/core": "2.47.0" + "@aws-solutions-constructs/aws-cloudfront-apigateway": "2.51.0", + "@aws-solutions-constructs/core": "2.51.0", + "constructs": "^10.0.0" }, "peerDependencies": { - "@aws-solutions-constructs/aws-cloudfront-apigateway": "2.47.0", - "@aws-solutions-constructs/core": "2.47.0", - "aws-cdk-lib": "^2.111.0", + "@aws-solutions-constructs/aws-cloudfront-apigateway": "2.51.0", + "@aws-solutions-constructs/core": "2.51.0", + "aws-cdk-lib": "^2.118.0", "constructs": "^10.0.0" } }, "node_modules/@aws-solutions-constructs/aws-cloudfront-s3": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-s3/-/aws-cloudfront-s3-2.47.0.tgz", - "integrity": "sha512-5p0cF0bwt8gN83mLAcH2sP4PvPMFaXA1dVttlJ+uMMOeDQD7N/CTfXrHU4UaU6zIv4B/ESiwYr0kaLFq5L3Qqw==", + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/aws-cloudfront-s3/-/aws-cloudfront-s3-2.51.0.tgz", + "integrity": "sha512-11TV5xdrT48lQams3fR8b7GYcECqrQ6lbvCfyENqbmozWwVrnmNZxoFcIy4VF9oQPwx07kKsAsgsHnuDJhQjBQ==", "dev": true, "dependencies": { - "@aws-solutions-constructs/core": "2.47.0" + "@aws-solutions-constructs/core": "2.51.0", + "@aws-solutions-constructs/resources": "2.51.0", + "constructs": "^10.0.0" }, "peerDependencies": { - "@aws-solutions-constructs/core": "2.47.0", - "aws-cdk-lib": "^2.111.0", + "@aws-solutions-constructs/core": "2.51.0", + "@aws-solutions-constructs/resources": "2.51.0", + "aws-cdk-lib": "^2.118.0", "constructs": "^10.0.0" } }, "node_modules/@aws-solutions-constructs/core": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/core/-/core-2.47.0.tgz", - "integrity": "sha512-k7KOnAYlFoEjxeU5Z5ijRkmc8/CVjaza3x934HWK+9K3tzVRicNwC1zlHNrH95Oq86ICjYgSH2AQEYgt2r8lCQ==", + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/core/-/core-2.51.0.tgz", + "integrity": "sha512-55LQfMgxbXSpfLduOygTNUrAOxlj09lDufbL+mcncB/1hDkJiawYQYx/OUcFWFfGSheXRwNoxOP4FYVS/7cCDQ==", "bundleDependencies": [ "deepmerge", "npmlog", @@ -145,12 +151,13 @@ ], "dev": true, "dependencies": { + "constructs": "^10.0.0", "deep-diff": "^1.0.2", "deepmerge": "^4.0.0", "npmlog": "^4.1.2" }, "peerDependencies": { - "aws-cdk-lib": "^2.111.0", + "aws-cdk-lib": "^2.118.0", "constructs": "^10.0.0" } }, @@ -436,6 +443,29 @@ "node": ">=8" } }, + "node_modules/@aws-solutions-constructs/resources": { + "version": "2.51.0", + "resolved": "https://registry.npmjs.org/@aws-solutions-constructs/resources/-/resources-2.51.0.tgz", + "integrity": "sha512-QYZjnyGnMsdKYxYPfQ6vVn2AfbnuwuzVBbWiC/mJhWBE6N5IPXd7YRj+dWT0D440fdFx/sBUpsWWPWeWbgwNWw==", + "bundleDependencies": [ + "@aws-sdk/client-kms", + "@aws-sdk/client-s3", + "aws-sdk-client-mock" + ], + "dev": true, + "dependencies": { + "@aws-sdk/client-kms": "^3.478.0", + "@aws-sdk/client-s3": "^3.478.0", + "@aws-solutions-constructs/core": "2.51.0", + "aws-sdk-client-mock": "^3.0.0", + "constructs": "^10.0.0" + }, + "peerDependencies": { + "@aws-solutions-constructs/core": "2.51.0", + "aws-cdk-lib": "^2.118.0", + "constructs": "^10.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -2050,9 +2080,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.118.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.118.0.tgz", - "integrity": "sha512-va4F7fyj+l9oNV39supHeGr+oHBrVds6+3mruLxGmCRnGf3nKfPB8Jy/jd6TnljY8Y6yPZ6bmYFS3CiUZbOATA==", + "version": "2.124.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.124.0.tgz", + "integrity": "sha512-kUOfqwIAaTEx4ZozojZEhWa8G+O9KU+P0tERtDVmTw9ip4QXNMwTTkjj/IPtoH8qfXGdeibTQ9MJwRvHOR8kXQ==", "dev": true, "bin": { "cdk": "bin/cdk" @@ -2065,9 +2095,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.118.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.118.0.tgz", - "integrity": "sha512-i3At9HOuXNVLxQCo/y7Sb2Zj4Ir4tichG+755AAldebRcqXrCJizZq+sMMt6/Bkjggj2imWmhwHPZw518M9VMw==", + "version": "2.124.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.124.0.tgz", + "integrity": "sha512-K/Tey8TMw30GO6UD0qb19CPhBMZhleGshz520ZnbDUJwNfFtejwZOnpmRMOdUP9f4tHc5BrXl1VGsZtXtUaGhg==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -2082,7 +2112,7 @@ ], "dev": true, "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.201", + "@aws-cdk/asset-awscli-v1": "^2.2.202", "@aws-cdk/asset-kubectl-v20": "^2.1.2", "@aws-cdk/asset-node-proxy-agent-v6": "^2.0.1", "@balena/dockerignore": "^1.0.2", @@ -2597,12 +2627,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3192,9 +3222,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/source/constructs/package.json b/source/constructs/package.json index d08e491a6..ee8fb6e22 100644 --- a/source/constructs/package.json +++ b/source/constructs/package.json @@ -1,6 +1,6 @@ { "name": "constructs", - "version": "6.2.5", + "version": "6.2.6", "description": "Serverless Image Handler Constructs", "license": "Apache-2.0", "author": { @@ -17,18 +17,19 @@ "pretest": "npm run clean:install", "build": "tsc", "watch": "tsc -w", - "test": "overrideWarningsEnabled=false jest --coverage" + "test": "overrideWarningsEnabled=false jest --coverage", + "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" }, "devDependencies": { "@aws-cdk/aws-servicecatalogappregistry-alpha": "v2.118.0-alpha.0", - "@aws-solutions-constructs/aws-apigateway-lambda": "2.47.0", - "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "2.47.0", - "@aws-solutions-constructs/aws-cloudfront-s3": "2.47.0", - "@aws-solutions-constructs/core": "2.47.0", + "@aws-solutions-constructs/aws-apigateway-lambda": "2.51.0", + "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "2.51.0", + "@aws-solutions-constructs/aws-cloudfront-s3": "2.51.0", + "@aws-solutions-constructs/core": "2.51.0", "@types/jest": "^29.5.6", "@types/node": "^20.10.4", - "aws-cdk": "^2.118.0", - "aws-cdk-lib": "^2.118.0", + "aws-cdk": "^2.124.0", + "aws-cdk-lib": "^2.124.0", "constructs": "^10.3.0", "esbuild": "^0.19.10", "jest": "^29.7.0", diff --git a/source/constructs/test/__snapshots__/constructs.test.ts.snap b/source/constructs/test/__snapshots__/constructs.test.ts.snap index e1c211371..d872ccdd5 100644 --- a/source/constructs/test/__snapshots__/constructs.test.ts.snap +++ b/source/constructs/test/__snapshots__/constructs.test.ts.snap @@ -162,6 +162,15 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, }, + "CloudFrontLoggingBucket": { + "Description": "Amazon S3 bucket for storing CloudFront access logs.", + "Value": { + "Fn::GetAtt": [ + "CommonResourcesCustomResourcesLogBucketCustomResource2445A3AB", + "BucketName", + ], + }, + }, "CorsEnabled": { "Description": "Indicates whether Cross-Origin Resource Sharing (CORS) has been enabled for the image handler API.", "Value": { @@ -320,7 +329,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "SourceBucketsParameter": { "AllowedPattern": ".+", "Default": "defaultBucket, bucketNo2, bucketNo3, ...", - "Description": "(Required) List the buckets (comma-separated) within your account that contain original image files. If you plan to use Thumbor or Custom image requests with this solution, the source bucket for those requests will be the first bucket listed in this field.", + "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", }, }, @@ -350,7 +359,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Solutions:ApplicationType": "AWS-Solutions", "Solutions:SolutionID": "S0ABC", "Solutions:SolutionName": "sih", - "Solutions:SolutionVersion": "v6.2.5", + "Solutions:SolutionVersion": "v6.2.6", }, }, "Type": "AWS::ServiceCatalogAppRegistry::Application", @@ -1050,21 +1059,98 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, }, { - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket", - ], + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": { + "Fn::Split": [ + ",", + { + "Fn::Sub": [ + "arn:aws:s3:::\${rest}/*", + { + "rest": { + "Fn::Join": [ + "/*,arn:aws:s3:::", + { + "Fn::Split": [ + ",", + { + "Fn::Join": [ + "", + { + "Fn::Split": [ + " ", + { + "Ref": "SourceBucketsParameter", + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }, + { + "Action": "s3:ListBucket", + "Effect": "Allow", + "Resource": { + "Fn::Split": [ + ",", + { + "Fn::Sub": [ + "arn:aws:s3:::\${rest}", + { + "rest": { + "Fn::Join": [ + ",arn:aws:s3:::", + { + "Fn::Split": [ + ",", + { + "Fn::Join": [ + "", + { + "Fn::Split": [ + " ", + { + "Ref": "SourceBucketsParameter", + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }, + { + "Action": "s3:GetObject", "Effect": "Allow", "Resource": { "Fn::Join": [ "", [ - "arn:", + "arn:aws:s3:::", { - "Ref": "AWS::Partition", + "Ref": "FallbackImageS3BucketParameter", + }, + "/", + { + "Ref": "FallbackImageS3KeyParameter", }, - ":s3:::*", ], ], }, @@ -1142,7 +1228,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "S3Key": "Omitted to remove snapshot dependency on hash", }, - "Description": "sih (v6.2.5): Performs image edits and manipulations", + "Description": "sih (v6.2.6): Performs image edits and manipulations", "Environment": { "Variables": { "AUTO_WEBP": { @@ -1175,6 +1261,8 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "SECRET_KEY": { "Ref": "SecretsManagerKeyParameter", }, + "SOLUTION_ID": "S0ABC", + "SOLUTION_VERSION": "v6.2.6", "SOURCE_BUCKETS": { "Ref": "SourceBucketsParameter", }, @@ -1409,13 +1497,13 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "S3Key": "Omitted to remove snapshot dependency on hash", }, - "Description": "sih (v6.2.5): Custom resource", + "Description": "sih (v6.2.6): Custom resource", "Environment": { "Variables": { "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", "RETRY_SECONDS": "5", "SOLUTION_ID": "S0ABC", - "SOLUTION_VERSION": "v6.2.5", + "SOLUTION_VERSION": "v6.2.6", }, }, "Handler": "index.handler", @@ -1494,16 +1582,72 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, }, + { + "Action": "s3:ListBucket", + "Effect": "Allow", + "Resource": { + "Fn::Split": [ + ",", + { + "Fn::Sub": [ + "arn:aws:s3:::\${rest}", + { + "rest": { + "Fn::Join": [ + ",arn:aws:s3:::", + { + "Fn::Split": [ + ",", + { + "Fn::Join": [ + "", + { + "Fn::Split": [ + " ", + { + "Ref": "SourceBucketsParameter", + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }, + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "FallbackImageS3BucketParameter", + }, + "/", + { + "Ref": "FallbackImageS3KeyParameter", + }, + ], + ], + }, + }, { "Action": [ "s3:putBucketAcl", "s3:putEncryptionConfiguration", "s3:putBucketPolicy", "s3:CreateBucket", - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket", "s3:PutBucketOwnershipControls", + "s3:PutBucketTagging", ], "Effect": "Allow", "Resource": { @@ -1600,7 +1744,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, ], "SourceObjectKeys": [ - "c301c2cce52bb1b36720a115d657907f3ec9fa20bd385b4d81bf451fbe77fe4b.zip", + "Omitted to remove snapshot dependency on demo ui module hash", ], }, "Type": "Custom::CDKBucketDeployment", @@ -1676,6 +1820,44 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, + "CommonResourcesCustomResourcesWebsiteHostingBucketPolicy3C526944": { + "Condition": "CommonResourcesDeployDemoUICondition308D3B09", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject", + "s3:PutObject", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "FrontEndDistributionToS3S3Bucket3A171D78", + "Arn", + ], + }, + "/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CommonResourcesCustomResourcesWebsiteHostingBucketPolicy3C526944", + "Roles": [ + { + "Ref": "CommonResourcesCustomResourcesCustomResourceRole8958A1ED", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, "CommonResourcesSecretsManagerPolicy45FE005E": { "Condition": "CommonResourcesEnableSignatureCondition909DC7A1", "Properties": { @@ -1922,7 +2104,7 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` "applicationType": "AWS-Solutions", "solutionID": "S0ABC", "solutionName": "sih", - "version": "v6.2.5", + "version": "v6.2.6", }, "Description": "Attribute group for solution information", "Name": { @@ -2032,18 +2214,14 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` ], }, "Id": "TestStackFrontEndDistributionToS3CloudFrontDistributionOrigin12FCDC222", + "OriginAccessControlId": { + "Fn::GetAtt": [ + "FrontEndDistributionToS3CloudFrontOac2BE9C90D", + "Id", + ], + }, "S3OriginConfig": { - "OriginAccessIdentity": { - "Fn::Join": [ - "", - [ - "origin-access-identity/cloudfront/", - { - "Ref": "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E", - }, - ], - ], - }, + "OriginAccessIdentity": "", }, }, ], @@ -2057,14 +2235,38 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, "Type": "AWS::CloudFront::Distribution", }, - "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E": { + "FrontEndDistributionToS3CloudFrontOac2BE9C90D": { "Condition": "CommonResourcesDeployDemoUICondition308D3B09", "Properties": { - "CloudFrontOriginAccessIdentityConfig": { - "Comment": "Identity for TestStackFrontEndDistributionToS3CloudFrontDistributionOrigin12FCDC222", + "OriginAccessControlConfig": { + "Description": "Origin access control provisioned by aws-cloudfront-s3", + "Name": { + "Fn::Join": [ + "", + [ + "aws-cloudfront-s3-DistnToS3-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId", + }, + ], + }, + ], + }, + ], + ], + }, + "OriginAccessControlOriginType": "s3", + "SigningBehavior": "always", + "SigningProtocol": "sigv4", }, }, - "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity", + "Type": "AWS::CloudFront::OriginAccessControl", }, "FrontEndDistributionToS3S3Bucket3A171D78": { "Condition": "CommonResourcesDeployDemoUICondition308D3B09", @@ -2179,14 +2381,28 @@ exports[`Serverless Image Handler Stack Snapshot 1`] = ` }, { "Action": "s3:GetObject", + "Condition": { + "StringEquals": { + "AWS:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:aws:cloudfront::", + { + "Ref": "AWS::AccountId", + }, + ":distribution/", + { + "Ref": "FrontEndDistributionToS3CloudFrontDistribution15FE13D0", + }, + ], + ], + }, + }, + }, "Effect": "Allow", "Principal": { - "CanonicalUser": { - "Fn::GetAtt": [ - "FrontEndDistributionToS3CloudFrontDistributionOrigin1S3OriginD10E575E", - "S3CanonicalUserId", - ], - }, + "Service": "cloudfront.amazonaws.com", }, "Resource": { "Fn::Join": [ diff --git a/source/constructs/test/constructs.test.ts b/source/constructs/test/constructs.test.ts index b47f1f30a..3609dde8d 100644 --- a/source/constructs/test/constructs.test.ts +++ b/source/constructs/test/constructs.test.ts @@ -12,7 +12,7 @@ test("Serverless Image Handler Stack Snapshot", () => { const stack = new ServerlessImageHandlerStack(app, "TestStack", { solutionId: "S0ABC", solutionName: "sih", - solutionVersion: "v6.2.5", + solutionVersion: "v6.2.6", }); const template = Template.fromStack(stack); @@ -30,6 +30,11 @@ test("Serverless Image Handler Stack Snapshot", () => { if (templateJson.Resources[key].Properties?.Content?.S3Key) { templateJson.Resources[key].Properties.Content.S3Key = "Omitted to remove snapshot dependency on hash"; } + if (templateJson.Resources[key].Properties?.SourceObjectKeys) { + templateJson.Resources[key].Properties.SourceObjectKeys = [ + "Omitted to remove snapshot dependency on demo ui module hash", + ]; + } }); expect.assertions(1); diff --git a/source/custom-resource/index.ts b/source/custom-resource/index.ts index 336226f3b..62f3923aa 100644 --- a/source/custom-resource/index.ts +++ b/source/custom-resource/index.ts @@ -16,7 +16,6 @@ import { CheckSecretManagerRequestProperties, CheckSourceBucketsRequestProperties, CompletionStatus, - CopyS3AssetsRequestProperties, CreateLoggingBucketRequestProperties, CustomResourceActions, CustomResourceError, @@ -75,17 +74,6 @@ export async function handler(event: CustomResourceRequest, context: LambdaConte ); break; } - case CustomResourceActions.COPY_S3_ASSETS: { - const allowedRequestTypes = [CustomResourceRequestTypes.CREATE, CustomResourceRequestTypes.UPDATE]; - await performRequest( - copyS3Assets, - RequestType, - allowedRequestTypes, - response, - ResourceProperties as CopyS3AssetsRequestProperties - ); - break; - } case CustomResourceActions.CREATE_UUID: { const allowedRequestTypes = [CustomResourceRequestTypes.CREATE]; await performRequest(generateUUID, RequestType, allowedRequestTypes, response); @@ -131,7 +119,7 @@ export async function handler(event: CustomResourceRequest, context: LambdaConte RequestType, allowedRequestTypes, response, - ResourceProperties as CreateLoggingBucketRequestProperties + { ...ResourceProperties, StackId: event.StackId } as CreateLoggingBucketRequestProperties ); break; } @@ -369,75 +357,6 @@ async function putConfigFile( }; } -/** - * Copies assets from the source S3 bucket to the destination S3 bucket. - * @param requestProperties The request properties. - * @returns The result of copying assets. - */ -async function copyS3Assets( - requestProperties: CopyS3AssetsRequestProperties -): Promise<{ Message: string; Manifest: { Files: string[] } }> { - const { ManifestKey, SourceS3Bucket, SourceS3key, DestS3Bucket } = requestProperties; - - console.info(`Source bucket: ${SourceS3Bucket}`); - console.info(`Source prefix: ${SourceS3key}`); - console.info(`Destination bucket: ${DestS3Bucket}`); - - let manifest: { files: string[] }; - - // Download manifest - for (let retry = 1; retry <= RETRY_COUNT; retry++) { - try { - const getParams = { - Bucket: SourceS3Bucket, - Key: ManifestKey, - }; - const response = await s3Client.getObject(getParams).promise(); - manifest = JSON.parse(response.Body.toString()); - - break; - } catch (error) { - if (retry === RETRY_COUNT || error.code !== ErrorCodes.ACCESS_DENIED) { - console.error("Error occurred while getting manifest file."); - console.error(error); - - throw new CustomResourceError("GetManifestFailure", "Copy of website assets failed."); - } else { - console.info("Waiting for retry..."); - - await sleep(getRetryTimeout(retry)); - } - } - } - - // Copy asset files - try { - await Promise.all( - manifest.files.map(async (fileName: string) => { - const copyObjectParams = { - Bucket: DestS3Bucket, - CopySource: `${SourceS3Bucket}/${SourceS3key}/${fileName}`, - Key: fileName, - ContentType: getContentType(fileName), - }; - - console.debug(`Copying ${fileName} to ${DestS3Bucket}`); - return s3Client.copyObject(copyObjectParams).promise(); - }) - ); - - return { - Message: "Copy assets completed.", - Manifest: { Files: manifest.files }, - }; - } catch (error) { - console.error("Error occurred while copying assets."); - console.error(error); - - throw new CustomResourceError("CopyAssetsFailure", "Copy of website assets failed."); - } -} - /** * Generates UUID. * @returns Generated UUID. @@ -673,7 +592,7 @@ async function createCloudFrontLoggingBucket(requestProperties: CreateLoggingBuc await s3Client.putBucketPolicy(putBucketPolicyRequestParams).promise(); - console.info(`Successfully added policy added to bucket '${bucketName}'`); + console.info(`Successfully added policy to bucket '${bucketName}'`); } catch (error) { console.error(`Failed to add policy to bucket '${bucketName}'`); console.error(error); @@ -681,6 +600,29 @@ async function createCloudFrontLoggingBucket(requestProperties: CreateLoggingBuc throw error; } + // Add Stack tag + try { + console.info("Adding tag..."); + + const taggingParams = { + Bucket: bucketName, + Tagging: { + TagSet: [ + { + Key: "stack-id", + Value: requestProperties.StackId + }] + } + }; + await s3Client.putBucketTagging(taggingParams).promise(); + + console.info(`Successfully added tag to bucket '${bucketName}'`); + } catch (error) { + console.error(`Failed to add tag to bucket '${bucketName}'`); + console.error(error); + // Continue, failure here shouldn't block + } + return { BucketName: bucketName, Region: targetRegion }; } diff --git a/source/custom-resource/lib/enums.ts b/source/custom-resource/lib/enums.ts index 26731399a..c709dadec 100644 --- a/source/custom-resource/lib/enums.ts +++ b/source/custom-resource/lib/enums.ts @@ -4,7 +4,6 @@ export enum CustomResourceActions { SEND_ANONYMOUS_METRIC = "sendMetric", PUT_CONFIG_FILE = "putConfigFile", - COPY_S3_ASSETS = "copyS3assets", CREATE_UUID = "createUuid", CHECK_SOURCE_BUCKETS = "checkSourceBuckets", CHECK_SECRETS_MANAGER = "checkSecretsManager", diff --git a/source/custom-resource/lib/interfaces.ts b/source/custom-resource/lib/interfaces.ts index 2faf9fb12..f909f1a75 100644 --- a/source/custom-resource/lib/interfaces.ts +++ b/source/custom-resource/lib/interfaces.ts @@ -26,13 +26,6 @@ export interface PutConfigRequestProperties extends CustomResourceRequestPropert DestS3key: string; } -export interface CopyS3AssetsRequestProperties extends CustomResourceRequestPropertiesBase { - ManifestKey: string; - SourceS3Bucket: string; - SourceS3key: string; - DestS3Bucket: string; -} - export interface CheckSourceBucketsRequestProperties extends CustomResourceRequestPropertiesBase { SourceBuckets: string; } @@ -58,6 +51,7 @@ export interface PolicyStatement { export interface CreateLoggingBucketRequestProperties extends CustomResourceRequestPropertiesBase { BucketSuffix: string; + StackId: string; } export interface CustomResourceRequest { diff --git a/source/custom-resource/lib/types.ts b/source/custom-resource/lib/types.ts index ca6c7a5c1..b58ef2534 100644 --- a/source/custom-resource/lib/types.ts +++ b/source/custom-resource/lib/types.ts @@ -5,7 +5,6 @@ import { CheckFallbackImageRequestProperties, CheckSecretManagerRequestProperties, CheckSourceBucketsRequestProperties, - CopyS3AssetsRequestProperties, CreateLoggingBucketRequestProperties, CustomResourceRequestPropertiesBase, PutConfigRequestProperties, @@ -16,7 +15,6 @@ export type ResourcePropertyTypes = | CustomResourceRequestPropertiesBase | SendMetricsRequestProperties | PutConfigRequestProperties - | CopyS3AssetsRequestProperties | CheckSourceBucketsRequestProperties | CheckSecretManagerRequestProperties | CheckFallbackImageRequestProperties diff --git a/source/custom-resource/package-lock.json b/source/custom-resource/package-lock.json index fed789cfd..9bf970bb1 100644 --- a/source/custom-resource/package-lock.json +++ b/source/custom-resource/package-lock.json @@ -1,12 +1,12 @@ { "name": "custom-resource", - "version": "6.2.5", + "version": "6.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "custom-resource", - "version": "6.2.5", + "version": "6.2.6", "license": "Apache-2.0", "dependencies": { "aws-sdk": "^2.1529.0", @@ -1485,12 +1485,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1984,9 +1984,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -2009,9 +2009,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json index d81cd5f6a..a2e85d717 100644 --- a/source/custom-resource/package.json +++ b/source/custom-resource/package.json @@ -1,6 +1,6 @@ { "name": "custom-resource", - "version": "6.2.5", + "version": "6.2.6", "private": true, "description": "Serverless Image Handler custom resource", "license": "Apache-2.0", @@ -12,7 +12,8 @@ "scripts": { "clean": "rm -rf node_modules/ dist/ coverage/", "pretest": "npm run clean && npm ci", - "test": "jest --coverage --silent" + "test": "jest --coverage --silent", + "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" }, "dependencies": { "aws-sdk": "^2.1529.0", diff --git a/source/custom-resource/test/create-logging-bucket.spec.ts b/source/custom-resource/test/create-logging-bucket.spec.ts index f817e2680..2a70d3789 100644 --- a/source/custom-resource/test/create-logging-bucket.spec.ts +++ b/source/custom-resource/test/create-logging-bucket.spec.ts @@ -22,6 +22,11 @@ describe("CREATE_LOGGING_BUCKET", () => { }, }; + beforeEach(() => { + consoleInfoSpy.mockReset() + consoleErrorSpy.mockReset() + }); + it("Should return success and bucket name", async () => { mockAwsEc2.describeRegions.mockImplementationOnce(() => ({ promise() { @@ -43,10 +48,15 @@ describe("CREATE_LOGGING_BUCKET", () => { return Promise.resolve(); }, })); + mockAwsS3.putBucketTagging.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); await handler(event, mockContext); - expect.assertions(4); + expect.assertions(5); expect(consoleInfoSpy).toHaveBeenCalledWith( expect.stringContaining("The opt-in status of the 'mock-region-1' region is 'opted-in'") @@ -60,7 +70,10 @@ describe("CREATE_LOGGING_BUCKET", () => { expect.stringMatching(/^Successfully enabled encryption on bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/) ); expect(consoleInfoSpy).toHaveBeenCalledWith( - expect.stringMatching(/^Successfully added policy added to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/) + expect.stringMatching(/^Successfully added policy to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/) + ); + expect(consoleInfoSpy).toHaveBeenCalledWith( + expect.stringMatching(/^Successfully added tag to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/) ); }); @@ -136,7 +149,7 @@ describe("CREATE_LOGGING_BUCKET", () => { expect(consoleInfoSpy).toHaveBeenCalledWith( expect.stringMatching( - /^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/ + /^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'mock-region-1' region/ ) ); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -181,7 +194,7 @@ describe("CREATE_LOGGING_BUCKET", () => { expect(consoleInfoSpy).toHaveBeenCalledWith( expect.stringMatching( - /^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/ + /^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'mock-region-1' region/ ) ); expect(consoleInfoSpy).toHaveBeenCalledWith( @@ -200,4 +213,45 @@ describe("CREATE_LOGGING_BUCKET", () => { }, }); }); + + it("Should log a failure when there is an error adding a tag to the created bucket", async () => { + mockAwsEc2.describeRegions.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Regions: [{ RegionName: "mock-region-1" }] }); + }, + })); + mockAwsS3.createBucket.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); + mockAwsS3.putBucketEncryption.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); + mockAwsS3.putBucketPolicy.mockImplementation(() => ({ + promise() { + return Promise.resolve(); + }, + })); + mockAwsS3.putBucketTagging.mockImplementation(() => ({ + promise() { + return Promise.reject(new CustomResourceError(null, "putBucketTagging failed")); + }, + })); + + await handler(event, mockContext); + + expect.assertions(2); + + expect(consoleInfoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /^Successfully created bucket 'serverless-image-handler-logs-[a-z0-9]{8}' in 'us-east-1' region/ + ) + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching(/^Failed to add tag to bucket 'serverless-image-handler-logs-[a-z0-9]{8}'/) + ); + }); }); diff --git a/source/custom-resource/test/mock.ts b/source/custom-resource/test/mock.ts index f66c2f83e..14c8134cf 100644 --- a/source/custom-resource/test/mock.ts +++ b/source/custom-resource/test/mock.ts @@ -18,6 +18,7 @@ export const mockAwsS3 = { createBucket: jest.fn(), putBucketEncryption: jest.fn(), putBucketPolicy: jest.fn(), + putBucketTagging: jest.fn(), }; jest.mock("aws-sdk/clients/s3", () => jest.fn(() => ({ ...mockAwsS3 }))); diff --git a/source/demo-ui/index.html b/source/demo-ui/index.html index 1b00c69aa..f843a0f5b 100644 --- a/source/demo-ui/index.html +++ b/source/demo-ui/index.html @@ -9,17 +9,10 @@ Serverless Image Handler - - - - + + + + diff --git a/source/demo-ui/package-lock.json b/source/demo-ui/package-lock.json new file mode 100644 index 000000000..a41475eef --- /dev/null +++ b/source/demo-ui/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "demo-ui", + "version": "6.2.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "demo-ui", + "version": "6.2.6", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.3", + "jquery": "^3.7.1" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + } + } +} diff --git a/source/demo-ui/package.json b/source/demo-ui/package.json new file mode 100644 index 000000000..4ba489b55 --- /dev/null +++ b/source/demo-ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "demo-ui", + "version": "6.2.6", + "private": true, + "description": "Serverless Image Handler demo ui", + "license": "Apache-2.0", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com/solutions" + }, + "main": "index.js", + "scripts": { + "clean": "rm -rf node_modules/ dist/ coverage/ modules/", + "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version", + "postinstall": "mkdir -p modules && cp -f node_modules/jquery/dist/jquery.slim.min.js node_modules/@popperjs/core/dist/umd/popper.min.js node_modules/bootstrap/dist/js/bootstrap.min.js node_modules/bootstrap/dist/css/bootstrap.min.css modules/" + }, + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.3", + "jquery": "^3.7.1" + } +} diff --git a/source/image-handler/image-handler.ts b/source/image-handler/image-handler.ts index dc7b99282..92efc18d2 100644 --- a/source/image-handler/image-handler.ts +++ b/source/image-handler/image-handler.ts @@ -21,7 +21,7 @@ import { export class ImageHandler { private readonly LAMBDA_PAYLOAD_LIMIT = 6 * 1024 * 1024; - constructor(private readonly s3Client: S3, private readonly rekognitionClient: Rekognition) { } + constructor(private readonly s3Client: S3, private readonly rekognitionClient: Rekognition) {} /** * Creates a Sharp object from Buffer @@ -81,9 +81,10 @@ export class ImageHandler { // Apply edits if specified if (edits && Object.keys(edits).length) { // convert image to Sharp object + options.animated = (typeof edits.animated !== 'undefined') ? edits.animated : (imageRequestInfo.contentType === ContentTypes.GIF) let image = await this.instantiateSharpImage(originalImage, edits, options); - // default to non animated if image is a GIF without multiple pages + // default to non animated if image does not have multiple pages if (options.animated) { const metadata = await image.metadata(); if (!metadata.pages || metadata.pages <= 1) { @@ -129,7 +130,7 @@ export class ImageHandler { * Applies image modifications to the original image based on edits. * @param originalImage The original sharp image. * @param edits The edits to be made to the original image. - * @param isAnimation a flag whether the edit applies to `gif` file or not. + * @param isAnimation a flag whether the edit applies to animated files or not. * @returns A modifications to the original image. */ public async applyEdits(originalImage: sharp.Sharp, edits: ImageEdits, isAnimation: boolean): Promise { @@ -160,6 +161,9 @@ export class ImageHandler { this.applyCrop(originalImage, edits); break; } + case "animated": { + break; + } default: { if (edit in originalImage) { originalImage[edit](edits[edit]); @@ -180,24 +184,36 @@ export class ImageHandler { if (edits.resize === undefined) { edits.resize = {}; edits.resize.fit = ImageFitTypes.INSIDE; - } else { - if (edits.resize.width) edits.resize.width = Math.round(Number(edits.resize.width)); - if (edits.resize.height) edits.resize.height = Math.round(Number(edits.resize.height)); + return; + } + const resize = this.validateResizeInputs(edits.resize); - if (edits.resize.ratio) { - const ratio = edits.resize.ratio; + if (resize.ratio) { + const ratio = resize.ratio; - const { width, height } = - edits.resize.width && edits.resize.height ? edits.resize : await originalImage.metadata(); + const { width, height } = resize.width && resize.height ? resize : await originalImage.metadata(); - edits.resize.width = Math.round(width * ratio); - edits.resize.height = Math.round(height * ratio); - // Sharp doesn't have such parameter for resize(), we got it from Thumbor mapper. We don't need to keep this field in the `resize` object - delete edits.resize.ratio; + resize.width = Math.round(width * ratio); + resize.height = Math.round(height * ratio); + // Sharp doesn't have such parameter for resize(), we got it from Thumbor mapper. We don't need to keep this field in the `resize` object + delete resize.ratio; - if (!edits.resize.fit) edits.resize.fit = ImageFitTypes.INSIDE; - } + if (!resize.fit) resize.fit = ImageFitTypes.INSIDE; + } + } + + /** + * Validates resize edit parameters. + * @param resize The resize parameters. + */ + private validateResizeInputs(resize: any) { + if (resize.width) resize.width = Math.round(Number(resize.width)); + if (resize.height) resize.height = Math.round(Number(resize.height)); + + if ((resize.width != null && resize.width <= 0) || (resize.height != null && resize.height <= 0)) { + throw new ImageHandlerError(StatusCodes.BAD_REQUEST, "InvalidResizeException", "The image size is invalid."); } + return resize; } /** @@ -273,9 +289,9 @@ export class ImageHandler { typeof edits.smartCrop === "object" ? edits.smartCrop : { - faceIndex: undefined, - padding: undefined, - }; + faceIndex: undefined, + padding: undefined, + }; const { imageBuffer, format } = await this.getRekognitionCompatibleImage(originalImage); const boundingBox = await this.getBoundingBox(imageBuffer.data, faceIndex ?? 0); const cropArea = this.getCropArea(boundingBox, padding ?? 0, imageBuffer.info); @@ -324,11 +340,11 @@ export class ImageHandler { typeof edits.roundCrop === "object" ? edits.roundCrop : { - top: undefined, - left: undefined, - rx: undefined, - ry: undefined, - }; + top: undefined, + left: undefined, + rx: undefined, + ry: undefined, + }; const imageBuffer = await originalImage.toBuffer({ resolveWithObject: true }); const width = imageBuffer.info.width; const height = imageBuffer.info.height; @@ -393,10 +409,10 @@ export class ImageHandler { typeof edits.contentModeration === "object" ? edits.contentModeration : { - minConfidence: undefined, - blur: undefined, - moderationLabels: undefined, - }; + minConfidence: undefined, + blur: undefined, + moderationLabels: undefined, + }; const { imageBuffer, format } = await this.getRekognitionCompatibleImage(originalImage); const inappropriateContent = await this.detectInappropriateContent(imageBuffer.data, minConfidence); @@ -653,6 +669,8 @@ export class ImageHandler { return "raw"; case ImageFormatTypes.GIF: return "gif"; + case ImageFormatTypes.AVIF: + return "avif"; default: throw new ImageHandlerError( StatusCodes.INTERNAL_SERVER_ERROR, diff --git a/source/image-handler/image-request.ts b/source/image-handler/image-request.ts index 563577acc..6b22608d3 100644 --- a/source/image-handler/image-request.ts +++ b/source/image-handler/image-request.ts @@ -30,7 +30,7 @@ type OriginalImageInfo = Partial<{ export class ImageRequest { private static readonly DEFAULT_EFFORT = 4; - constructor(private readonly s3Client: S3, private readonly secretProvider: SecretProvider) { } + constructor(private readonly s3Client: S3, private readonly secretProvider: SecretProvider) {} /** * Determines the output format of an image @@ -69,6 +69,7 @@ export class ImageRequest { ImageFormatTypes.TIFF, ImageFormatTypes.HEIF, ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, ]; imageRequestInfo.contentType = `image/${imageRequestInfo.outputFormat}`; @@ -101,7 +102,7 @@ export class ImageRequest { imageRequestInfo.requestType = this.parseRequestType(event); imageRequestInfo.bucket = this.parseImageBucket(event, imageRequestInfo.requestType); - imageRequestInfo.key = this.parseImageKey(event, imageRequestInfo.requestType); + imageRequestInfo.key = this.parseImageKey(event, imageRequestInfo.requestType, imageRequestInfo.bucket); imageRequestInfo.edits = this.parseImageEdits(event, imageRequestInfo.requestType); const originalImage = await this.getOriginalImage(imageRequestInfo.bucket, imageRequestInfo.key); @@ -154,7 +155,18 @@ export class ImageRequest { const result: OriginalImageInfo = {}; const imageLocation = { Bucket: bucket, Key: key }; - const originalImage = await this.s3Client.getObject(imageLocation).promise(); + let originalImage; + try { + console.info("Getting image from S3:", imageLocation); + originalImage = await this.s3Client.getObject(imageLocation).promise(); + } catch (error) { + console.error(error); + throw new ImageHandlerError( + StatusCodes.NOT_FOUND, + "NoSuchKey", + `The image ${key} does not exist or the request may not be base64 encoded properly.` + ); + } const imageBuffer = Buffer.from(originalImage.Body as Uint8Array); if (originalImage.ContentType) { @@ -181,6 +193,7 @@ export class ImageRequest { return result; } catch (error) { + console.error(error); let status = StatusCodes.INTERNAL_SERVER_ERROR; let message = error.message; if (error.code === "NoSuchKey") { @@ -206,7 +219,7 @@ export class ImageRequest { // Check the provided bucket against the allowed list const sourceBuckets = this.getAllowedSourceBuckets(); - if (sourceBuckets.includes(request.bucket) || new RegExp("^" + sourceBuckets[0] + "$").exec(request.bucket)) { + if (sourceBuckets.includes(request.bucket)) { return request.bucket; } else { throw new ImageHandlerError( @@ -223,6 +236,18 @@ export class ImageRequest { } else if (requestType === RequestTypes.THUMBOR || requestType === RequestTypes.CUSTOM) { // Use the default image source bucket env var const sourceBuckets = this.getAllowedSourceBuckets(); + // Take the path and split it at "/" to get each "word" in the url as array + let potentialBucket = event.path + .split("/") + .filter((e) => e.startsWith("s3:")) + .map((e) => e.replace("s3:", "")); + // filter out all parts that are not a bucket-url + potentialBucket = potentialBucket.filter((e) => sourceBuckets.includes(e)); + // return the first match + if (potentialBucket.length > 0) { + console.info("Bucket override - chosen bucket: ", potentialBucket[0]); + return potentialBucket[0]; + } return sourceBuckets[0]; } else { throw new ImageHandlerError( @@ -263,9 +288,10 @@ export class ImageRequest { * 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 * @returns The name of the appropriate Amazon S3 key. */ - public parseImageKey(event: ImageHandlerEvent, requestType: RequestTypes): string { + public parseImageKey(event: ImageHandlerEvent, requestType: RequestTypes, bucket: string = null): string { if (requestType === RequestTypes.DEFAULT) { // Decode the image request and return the image key const { key } = this.decodeRequest(event); @@ -297,6 +323,7 @@ export class ImageRequest { .replace(/filters:watermark\(.*\)/u, "") .replace(/filters:[^/]+/g, "") .replace(/\/fit-in(?=\/)/g, "") + .replace(new RegExp("s3:" + bucket + "/"), "") .replace(/^\/+/g, "") .replace(/^\/+/, "") ); @@ -320,8 +347,8 @@ export class ImageRequest { const { path } = event; const matchDefault = /^(\/?)([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; const matchThumbor1 = /^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)/i; - const matchThumbor2 = /((.(?!(\.[^.\\/]+$)))*$)/i; // NOSONAR - const matchThumbor3 = /.*(\.jpg$|\.jpeg$|.\.png$|\.webp$|\.tiff$|\.tif$|\.svg$|\.gif$)/i; // NOSONAR + const matchThumbor2 = /^((.(?!(\.[^.\\/]+$)))*$)/i; // NOSONAR + const matchThumbor3 = /.*(\.jpg$|\.jpeg$|.\.png$|\.webp$|\.tiff$|\.tif$|\.svg$|\.gif$|\.avif$)/i; // NOSONAR const { REWRITE_MATCH_PATTERN, REWRITE_SUBSTITUTION } = process.env; const definedEnvironmentVariables = REWRITE_MATCH_PATTERN !== "" && @@ -351,7 +378,7 @@ export class ImageRequest { throw new ImageHandlerError( StatusCodes.BAD_REQUEST, "RequestTypeError", - "The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg, gif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests." + "The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests." ); } } @@ -448,31 +475,30 @@ export class ImageRequest { * @returns The output format. */ public inferImageType(imageBuffer: Buffer): string { + const imageSignatures: { [key: string]: string } = { + "89504E47": ContentTypes.PNG, + "52494646": ContentTypes.WEBP, + "49492A00": ContentTypes.TIFF, + "4D4D002A": ContentTypes.TIFF, + "47494638": ContentTypes.GIF, + }; const imageSignature = imageBuffer.subarray(0, 4).toString("hex").toUpperCase(); - switch (imageSignature) { - case "89504E47": - return ContentTypes.PNG; - case "FFD8FFDB": - case "FFD8FFE0": - case "FFD8FFED": - case "FFD8FFEE": - case "FFD8FFE1": - case "FFD8FFE2": - return ContentTypes.JPEG; - case "52494646": - return ContentTypes.WEBP; - case "49492A00": - case "4D4D002A": - return ContentTypes.TIFF; - case "47494638": - return ContentTypes.GIF; - default: - throw new ImageHandlerError( - StatusCodes.INTERNAL_SERVER_ERROR, - "RequestTypeError", - "The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests." - ); + if (imageSignatures[imageSignature]) { + return imageSignatures[imageSignature]; + } + if (imageBuffer.subarray(0, 2).toString("hex").toUpperCase() === "FFD8") { + return ContentTypes.JPEG; } + if (imageBuffer.subarray(4, 12).toString("hex").toUpperCase() === "6674797061766966") { + // FTYPAVIF (File Type AVIF) + return ContentTypes.AVIF; + } + // SVG does not have an imageSignature we can use here, would require parsing the XML to some degree + throw new ImageHandlerError( + StatusCodes.INTERNAL_SERVER_ERROR, + "RequestTypeError", + "The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg/jpeg, png, tiff, webp, gif, avif). Inferring the image type from hex headers is not available for SVG images. Refer to the documentation for additional guidance on forming image requests." + ); } /** diff --git a/source/image-handler/lib/enums.ts b/source/image-handler/lib/enums.ts index 5e4464450..d6a96122c 100644 --- a/source/image-handler/lib/enums.ts +++ b/source/image-handler/lib/enums.ts @@ -26,6 +26,7 @@ export enum ImageFormatTypes { HEIC = "heic", RAW = "raw", GIF = "gif", + AVIF = "avif", } export enum ImageFitTypes { @@ -43,4 +44,5 @@ export enum ContentTypes { TIFF = "image/tiff", GIF = "image/gif", SVG = "image/svg+xml", + AVIF= "image/avif", } diff --git a/source/image-handler/package-lock.json b/source/image-handler/package-lock.json index ef1f9d9c5..376a75ff6 100644 --- a/source/image-handler/package-lock.json +++ b/source/image-handler/package-lock.json @@ -1,12 +1,12 @@ { "name": "image-handler", - "version": "6.2.5", + "version": "6.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "image-handler", - "version": "6.2.5", + "version": "6.2.6", "license": "Apache-2.0", "dependencies": { "aws-sdk": "^2.1529.0", @@ -1519,12 +1519,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2074,9 +2074,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 17caae29b..e81409c19 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -1,6 +1,6 @@ { "name": "image-handler", - "version": "6.2.5", + "version": "6.2.6", "private": true, "description": "A Lambda function for performing on-demand image edits and manipulations.", "license": "Apache-2.0", @@ -12,7 +12,8 @@ "scripts": { "clean": "rm -rf node_modules/ dist/ coverage/", "pretest": "npm run clean && npm ci", - "test": "jest --coverage --silent" + "test": "jest --coverage --silent", + "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" }, "dependencies": { "aws-sdk": "^2.1529.0", diff --git a/source/image-handler/test/image-handler/animated.spec.ts b/source/image-handler/test/image-handler/animated.spec.ts index 78fde645d..ddb3ed7dc 100644 --- a/source/image-handler/test/image-handler/animated.spec.ts +++ b/source/image-handler/test/image-handler/animated.spec.ts @@ -11,8 +11,13 @@ import { ContentTypes, ImageRequestInfo, RequestTypes } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); const image = fs.readFileSync("./test/image/25x15.png"); +const gifImage = fs.readFileSync("./test/image/transparent-5x5-2page.gif"); describe("animated", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("Should create non animated image if the input image is a GIF but does not have multiple pages", async () => { // Arrange const request: ImageRequestInfo = { @@ -36,7 +41,6 @@ describe("animated", () => { it("Should create animated image if the input image is GIF and has multiple pages", async () => { // Arrange - const gifImage = fs.readFileSync("./test/image/transparent-5x5-2page.gif"); const request: ImageRequestInfo = { requestType: RequestTypes.DEFAULT, contentType: ContentTypes.GIF, @@ -77,4 +81,88 @@ describe("animated", () => { 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 }); + + }); + + 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 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 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 }); + + }); }); diff --git a/source/image-handler/test/image-handler/crop.spec.ts b/source/image-handler/test/image-handler/crop.spec.ts index 57bf62149..d1ce20733 100644 --- a/source/image-handler/test/image-handler/crop.spec.ts +++ b/source/image-handler/test/image-handler/crop.spec.ts @@ -11,13 +11,17 @@ import { ImageEdits, StatusCodes } from "../../lib"; const s3Client = new S3(); const rekognitionClient = new Rekognition(); + +// base64 encoded images +const image_png_white_5x5 = + "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFAQAAAAClFBtIAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAd2KE6QAAAAHdElNRQfnAxYODhUMhxdmAAAADElEQVQI12P4wQCFABhCBNn4i/hQAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIzLTAzLTIyVDE0OjE0OjIxKzAwOjAwtK8ALAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMy0wMy0yMlQxNDoxNDoyMSswMDowMMXyuJAAAAAASUVORK5CYII="; +const image_png_white_1x1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"; + describe("crop", () => { - it("Should pass if a cropping area value is out of bounds", async () => { + it("Should fail if a cropping area value is out of bounds", async () => { // Arrange - const originalImage = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", - "base64" - ); + const originalImage = Buffer.from(image_png_white_1x1, "base64"); const image = sharp(originalImage, { failOnError: false }).withMetadata(); const edits: ImageEdits = { crop: { left: 0, top: 0, width: 100, height: 100 }, @@ -37,4 +41,46 @@ 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 image = sharp(originalImage, { failOnError: true }); + const edits: ImageEdits = { + crop: { left: 0, top: 0, width: 1, height: 1 }, + }; + + // crop an image and compare with the result expected + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + const result = await imageHandler.applyEdits(image, edits, false); + const resultBuffer = await result.toBuffer(); + expect(resultBuffer).toEqual(Buffer.from(image_png_white_1x1, "base64")); + }); + + // confirm that an invalid attribute sharp crop request containing *right* rather than *top* returns as a cropping error, + // note that this only confirms the behavior of the image-handler in this case, + // it is not an accurate description of the actual error + it("Should fail with an invalid crop request", async () => { + // 5x5 png + const originalImage = Buffer.from(image_png_white_5x5, "base64"); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const edits: ImageEdits = { + crop: { left: 0, right: 0, width: 1, height: 1 }, + }; + + // crop an image and compare with the result expected + try { + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + await imageHandler.applyEdits(image, edits, false); + } catch (error) { + // Assert + expect(error).toMatchObject({ + status: StatusCodes.BAD_REQUEST, + code: "Crop::AreaOutOfBounds", + message: + "The cropping area you provided exceeds the boundaries of the original image. Please try choosing a correct cropping value.", + }); + } + }); }); diff --git a/source/image-handler/test/image-request/get-original-image.spec.ts b/source/image-handler/test/image-request/get-original-image.spec.ts index dd5039c85..22b2132e5 100644 --- a/source/image-handler/test/image-request/get-original-image.spec.ts +++ b/source/image-handler/test/image-request/get-original-image.spec.ts @@ -43,6 +43,29 @@ describe("getOriginalImage", () => { expect(result.originalImage).toEqual(Buffer.from("SampleImageContent\n")); }); + it("Should throw an error if an invalid file signature is found, simulating an unsupported image type", async () => { + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from("SampleImageContent\n"), ContentType: "binary/octet-stream" }); + }, + })); + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + + // Assert + try { + await imageRequest.getOriginalImage("validBucket", "validKey"); + } catch (error) { + expect(mockAwsS3.getObject).toHaveBeenCalledWith({ + Bucket: "validBucket", + Key: "validKey", + }); + expect(error.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR); + } + }); + it("Should throw an error if an invalid bucket or key name is provided, simulating a non-existent original image", async () => { // Mock mockAwsS3.getObject.mockImplementationOnce(() => ({ @@ -87,7 +110,7 @@ describe("getOriginalImage", () => { Bucket: "invalidBucket", Key: "invalidKey", }); - expect(error.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR); + expect(error.status).toEqual(StatusCodes.NOT_FOUND); } }); @@ -102,6 +125,7 @@ describe("getOriginalImage", () => { { hex: [0x49, 0x49, 0x2a, 0x00], expected: "image/tiff" }, { hex: [0x4d, 0x4d, 0x00, 0x2a], expected: "image/tiff" }, { hex: [0x47, 0x49, 0x46, 0x38], expected: "image/gif" }, + { hex: [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], expected: "image/avif" }, ])("Should pass and infer $expected content type if there is no extension", async ({ hex, expected }) => { // Mock mockAwsS3.getObject.mockImplementationOnce(() => ({ diff --git a/source/image-handler/test/image-request/infer-image-type.spec.ts b/source/image-handler/test/image-request/infer-image-type.spec.ts index 6172d785f..453493804 100644 --- a/source/image-handler/test/image-request/infer-image-type.spec.ts +++ b/source/image-handler/test/image-request/infer-image-type.spec.ts @@ -12,23 +12,30 @@ describe("inferImageType", () => { const secretsManager = new SecretsManager(); const secretProvider = new SecretProvider(secretsManager); - test.each([ - { value: "FFD8FFDB" }, - { value: "FFD8FFE0" }, - { value: "FFD8FFED" }, - { value: "FFD8FFEE" }, - { value: "FFD8FFE1" }, - { value: "FFD8FFE2" }, - ])('Should pass if it returns "image/jpeg" for a magic number of $value', ({ value }) => { - const byteValues = value.match(/.{1,2}/g).map((x) => parseInt(x, 16)); - const imageBuffer = Buffer.from(byteValues.concat(new Array(8).fill(0x00))); + test.each([ + { value: "FFD8FFDB", type: "image/jpeg" }, + { value: "FFD8FFE0", type: "image/jpeg" }, + { value: "FFD8FFED", type: "image/jpeg" }, + { value: "FFD8FFEE", type: "image/jpeg" }, + { value: "FFD8FFE1", type: "image/jpeg" }, + { value: "FFD8FFE2", type: "image/jpeg" }, + { value: "FFD8XXXX", type: "image/jpeg" }, + { value: "89504E47", type: "image/png" }, + { value: "52494646", type: "image/webp" }, + { value: "49492A00", type: "image/tiff" }, + { value: "4D4D002A", type: "image/tiff" }, + { value: "47494638", type: "image/gif" }, + { value: "000000006674797061766966", type: "image/avif" }, + ])('Should pass if it returns "$type" for a magic number of $value', ({ value, type }) => { + const byteValues = value.match(/.{1,2}/g).map((x) => parseInt(x, 16)); + const imageBuffer = Buffer.from(byteValues.concat(new Array(8).fill(0x00))); // Act const imageRequest = new ImageRequest(s3Client, secretProvider); const result = imageRequest.inferImageType(imageBuffer); // Assert - expect(result).toEqual("image/jpeg"); + expect(result).toEqual(type); }); it("Should pass throw an exception", () => { @@ -43,9 +50,6 @@ describe("inferImageType", () => { // Assert expect(error.status).toEqual(500); expect(error.code).toEqual("RequestTypeError"); - expect(error.message).toEqual( - "The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests." - ); } }); }); diff --git a/source/image-handler/test/image-request/parse-image-bucket.spec.ts b/source/image-handler/test/image-request/parse-image-bucket.spec.ts index d3173dd50..1cd9ada3c 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 @@ -126,4 +126,69 @@ describe("parseImageBucket", () => { }); } }); + + it("should parse bucket-name from first part in thumbor request but fail since it's not allowed", () => { + // Arrange + const event = { path: "/filters:grayscale()/s3:test-bucket/test-image-001.jpg" }; + process.env.SOURCE_BUCKETS = "allowedBucket001, allowedBucket002"; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + + const bucket = imageRequest.parseImageBucket(event, RequestTypes.THUMBOR); + // Assert + expect(bucket).toEqual("allowedBucket001") + }) + + it("should parse bucket-name from any section in the url", () => { + // Arrange + const event = { path: "/s3:test-bucket/filters:grayscale()/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 only parse bucket-names in source_buckets", () => { + // Arrange + const event = { path: "/s3:non-test-bucket/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", () => { + // Arrange + const event = { path: "/filters:grayscale()/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 take bucket-name from env-variable if not present in the URL", () => { + // Arrange + const event = { path: "/filters:grayscale()/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("allowedBucket001") + }) }); diff --git a/source/image-handler/test/image-request/parse-image-key.spec.ts b/source/image-handler/test/image-request/parse-image-key.spec.ts index 84ff3761f..18e8560c9 100644 --- a/source/image-handler/test/image-request/parse-image-key.spec.ts +++ b/source/image-handler/test/image-request/parse-image-key.spec.ts @@ -82,6 +82,36 @@ describe("parseImageKey", () => { expect(result).toEqual(expectedResult); }); + it("Should not include s3:bucket tag if a thumbor request includes an s3:bucket tag that is equal to the overridden bucket", () => { + // Arrange + const event = { + path: "/filters:rotate(90)/s3:some-test-bucket/filters:grayscale()/thumbor-image (1).jpg", + }; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR, "some-test-bucket"); + + // Assert + const expectedResult = "thumbor-image (1).jpg"; + expect(result).toEqual(expectedResult); + }); + + it("Should include s3:bucket tag if a thumbor request includes an s3:bucket tag that is not equal to the overridden bucket", () => { + // Arrange + const event = { + path: "/filters:rotate(90)/filters:grayscale()/s3:some-test-bucket/thumbor-image (1).jpg", + }; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const result = imageRequest.parseImageKey(event, RequestTypes.THUMBOR, "some-other-bucket"); + + // Assert + const expectedResult = "s3:some-test-bucket/thumbor-image (1).jpg"; + expect(result).toEqual(expectedResult); + }); + it("Should pass if an image key value is provided in the thumbor request format having open parentheses", () => { // Arrange const event = { 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 34fb2d0c0..8f85bff6f 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 @@ -56,19 +56,41 @@ describe("parseRequestType", () => { expect(result).toEqual(RequestTypes.THUMBOR); }); - it("Should pass if get a request with supported image extension", () => { + it("Should pass for a thumbor request with no extension", () => { // Arrange - const events = [".jpg", ".jpeg", ".png", ".webp", ".tiff", ".tif", ".svg"].map((extension) => ({ - path: `image${extension}`, - })); + const event = { + path: "/unsafe/filters:brightness(10):contrast(30)/image", + }; + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const result = imageRequest.parseRequestType(event); + + // Assert + expect(consoleInfoSpy).toHaveBeenCalledWith("Path is not base64 encoded."); + expect(result).toEqual(RequestTypes.THUMBOR); + }); + + test.each([ + { value: ".jpg" }, + { value: ".jpeg" }, + { value: ".png" }, + { value: ".webp" }, + { value: ".tiff" }, + { value: ".tif" }, + { value: ".svg" }, + { value: ".gif" }, + { value: ".avif" }, + ])("Should pass if get a request with supported image extension: $value", ({ value }) => { process.env = {}; // Act const imageRequest = new ImageRequest(s3Client, secretProvider); - const results = events.map((event) => imageRequest.parseRequestType(event)); + const result = imageRequest.parseRequestType({ path: `image${value}` }); // Assert - expect(results).toEqual(new Array(events.length).fill(RequestTypes.THUMBOR)); + expect(result).toEqual(RequestTypes.THUMBOR); }); it("Should pass if the method detects a custom request", () => { @@ -97,17 +119,43 @@ describe("parseRequestType", () => { // Act const imageRequest = new ImageRequest(s3Client, secretProvider); + let parseError; + // Assert + try { + imageRequest.parseRequestType(event); + } catch (error) { + parseError = error; + } + expect(parseError).toMatchObject({ + status: StatusCodes.BAD_REQUEST, + code: "RequestTypeError", + message: + "The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.", + }); + }); + + it("Should throw an error for a thumbor request with invalid extension", () => { + // Arrange + const event = { + path: "/testImage.abc", + }; + process.env = {}; + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + let parseError; // Assert try { imageRequest.parseRequestType(event); } catch (error) { - expect(error).toMatchObject({ - status: StatusCodes.BAD_REQUEST, - code: "RequestTypeError", - message: - "The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg, gif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.", - }); + parseError = error; } + expect(parseError).toMatchObject({ + status: StatusCodes.BAD_REQUEST, + code: "RequestTypeError", + message: + "The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg/jpeg, png, tiff/tif, webp, svg, gif, avif) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.", + }); }); it("Should pass if a path is provided without an extension", () => { diff --git a/source/image-handler/test/image-request/setup.spec.ts b/source/image-handler/test/image-request/setup.spec.ts index 2d1364925..6622591ed 100644 --- a/source/image-handler/test/image-request/setup.spec.ts +++ b/source/image-handler/test/image-request/setup.spec.ts @@ -794,4 +794,39 @@ 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 () { + // Arrange + const event = { + path: 'eyJidWNrZXQiOiJ0ZXN0Iiwia2V5Ijoi5Lit5paHIiwiZWRpdHMiOnsiZ3JheXNjYWxlIjp0cnVlfSwib3V0cHV0Rm9ybWF0IjoianBlZyJ9' + } + process.env = { + SOURCE_BUCKETS: "test, test2" + } + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => { + return { + 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: '中文', + edits: { grayscale: true }, + headers: undefined, + 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); + }); }); diff --git a/source/image-handler/test/index.spec.ts b/source/image-handler/test/index.spec.ts index c65d5234d..5a2d8911e 100644 --- a/source/image-handler/test/index.spec.ts +++ b/source/image-handler/test/index.spec.ts @@ -230,7 +230,7 @@ describe("index", () => { // Act const result = await handler(event); const expectedResult = { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + statusCode: StatusCodes.NOT_FOUND, isBase64Encoded: true, headers: { "Access-Control-Allow-Methods": "GET", diff --git a/source/image-handler/test/thumbor-mapper/filter.spec.ts b/source/image-handler/test/thumbor-mapper/filter.spec.ts index 3678e7499..5474630cb 100644 --- a/source/image-handler/test/thumbor-mapper/filter.spec.ts +++ b/source/image-handler/test/thumbor-mapper/filter.spec.ts @@ -288,6 +288,20 @@ describe("filter", () => { expect(edits).toEqual(expectedResult); }); + it("Should pass if the filter is successfully translated from Thumbor:quality()", () => { + // Arrange + const edit = "filters:quality(50)"; + const filetype = ImageFormatTypes.AVIF; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapFilter(edit, filetype); + + // Assert + const expectedResult = { avif: { quality: 50 } }; + expect(edits).toEqual(expectedResult); + }); + it("Should return undefined if an unsupported file type is provided", () => { // Arrange const edit = "filters:quality(50)"; @@ -702,4 +716,55 @@ describe("filter", () => { }; expect(edits).toEqual(expectedResult.edits); }); + + it("Should pass if false is interpreted as non-animated", () => { + // Arrange + const path = "/filters:animated(fAlSe)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: false, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if empty value is interpreted as animated", () => { + // Arrange + const path = "/filters:animated()/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); + + it("Should pass if non-false value is interpreted as animated", () => { + // Arrange + const path = "/filters:animated(ABCDEF)/test-image-001.jpg"; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapPathToEdits(path); + + // Assert + const expectedResult = { + edits: { + animated: true, + }, + }; + expect(edits).toEqual(expectedResult.edits); + }); }); diff --git a/source/image-handler/thumbor-mapper.ts b/source/image-handler/thumbor-mapper.ts index c3f19b156..58017d07f 100644 --- a/source/image-handler/thumbor-mapper.ts +++ b/source/image-handler/thumbor-mapper.ts @@ -148,6 +148,7 @@ export class ThumborMapper { ImageFormatTypes.TIFF, ImageFormatTypes.WEBP, ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, ]; if (acceptedValues.includes(imageFormatType)) { @@ -206,6 +207,7 @@ export class ThumborMapper { ImageFormatTypes.TIFF, ImageFormatTypes.HEIF, ImageFormatTypes.GIF, + ImageFormatTypes.AVIF, ].includes(format) ) { return format; @@ -367,6 +369,10 @@ export class ThumborMapper { this.mapWatermark(filterValue, currentEdits); break; } + case "animated": { + currentEdits.animated = filterValue.toLowerCase() != "false"; + break; + } } return currentEdits; diff --git a/source/package-lock.json b/source/package-lock.json index 8bfb2e564..8d007c145 100644 --- a/source/package-lock.json +++ b/source/package-lock.json @@ -1,12 +1,12 @@ { "name": "source", - "version": "6.2.5", + "version": "6.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "source", - "version": "6.2.5", + "version": "6.2.6", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.10.4", @@ -607,12 +607,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1478,9 +1478,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/source/package.json b/source/package.json index 0d9c88e40..65c540ef9 100644 --- a/source/package.json +++ b/source/package.json @@ -1,6 +1,6 @@ { "name": "source", - "version": "6.2.5", + "version": "6.2.6", "private": true, "description": "ESLint and prettier dependencies to be used within the solution", "license": "Apache-2.0", @@ -13,7 +13,10 @@ "prettier-format": "npx prettier --config .prettierrc.yml '**/*.ts' --write", "install:custom-resource": "cd ./custom-resource && npm run clean && npm ci", "install:image-handler": "cd ./image-handler && npm run clean && npm ci", - "install:dependencies": "npm run install:custom-resource && npm run install:image-handler" + "install:demo-ui": "cd ./demo-ui && npm run clean && npm ci", + "install:dependencies": "npm run install:custom-resource && npm run install:image-handler && npm run install:demo-ui", + "bump-version": "npm version $(cat ../VERSION.txt) --allow-same-version && npm run bump-child-version", + "bump-child-version": " npm --prefix ./image-handler run bump-version && npm --prefix ./custom-resource run bump-version && npm --prefix ./constructs run bump-version && npm --prefix ./solution-utils run bump-version && npm --prefix ./demo-ui run bump-version" }, "devDependencies": { "@types/node": "^20.10.4", diff --git a/source/solution-utils/package-lock.json b/source/solution-utils/package-lock.json index 943b6ef14..0d95613ca 100644 --- a/source/solution-utils/package-lock.json +++ b/source/solution-utils/package-lock.json @@ -1,12 +1,12 @@ { "name": "solution-utils", - "version": "6.2.5", + "version": "6.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solution-utils", - "version": "6.2.5", + "version": "6.2.6", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.5", @@ -1399,12 +1399,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1849,9 +1849,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/source/solution-utils/package.json b/source/solution-utils/package.json index ff6316d17..6c5f0af3b 100644 --- a/source/solution-utils/package.json +++ b/source/solution-utils/package.json @@ -1,6 +1,6 @@ { "name": "solution-utils", - "version": "6.2.5", + "version": "6.2.6", "private": true, "description": "Utilities to be used within this solution", "license": "Apache-2.0", @@ -10,17 +10,14 @@ }, "main": "get-options", "typings": "index", - "files": [ - "get-options.js", - "logger.js" - ], "scripts": { "build": "npm run clean && npm install && npm run build:tsc", "build:tsc": "tsc --project tsconfig.json", "clean": "rm -rf node_modules/ dist/ coverage/", "package": "npm run build && npm prune --production && rsync -avrq ./node_modules ./dist", "pretest": "npm run clean && npm install", - "test": "jest --coverage --silent" + "test": "jest --coverage --silent", + "bump-version": "npm version $(cat ../../VERSION.txt) --allow-same-version" }, "devDependencies": { "@types/jest": "^29.5.5",