diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be4c499d..350513e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.2.0] - 2021-01-29 +### Added +- Support for ap-east-1 and me-south-1 regions: [#192](https://github.com/awslabs/serverless-image-handler/issues/192), [#228](https://github.com/awslabs/serverless-image-handler/issues/228), [#232](https://github.com/awslabs/serverless-image-handler/issues/232) +- Unit tests for custom-resource: `100%` coverage +- Cloudfront cache policy and origin request policy: [#229](https://github.com/awslabs/serverless-image-handler/issues/229) +- Circular cropping feature: [#214](https://github.com/awslabs/serverless-image-handler/issues/214), [#216](https://github.com/awslabs/serverless-image-handler/issues/216) +- Unit tests for image-handler: `100%` coverage +- Support for files without extension on thumbor requests: [#169](https://github.com/awslabs/serverless-image-handler/issues/169), [#188](https://github.com/awslabs/serverless-image-handler/issues/188) +- Inappropriate content detection feature: [#243](https://github.com/awslabs/serverless-image-handler/issues/243) +- Unit tests for image-request: `100%` coverage + +### Fixed +- Graceful failure when no faces are detected using smartCrop and fail on resizing before smartCrop: [#132](https://github.com/awslabs/serverless-image-handler/issues/132), [#133](https://github.com/awslabs/serverless-image-handler/issues/133) +- Broken SVG returned if no edits specified and Auto-WebP enabled: [#247](https://github.com/awslabs/serverless-image-handler/issues/247) +- Removed "--recursive" from README.md: [#255](https://github.com/awslabs/serverless-image-handler/pull/255) +- fixed issue with failure on resize if width or height is float: [#254](https://github.com/awslabs/serverless-image-handler/issues/254) + +### Changed +- Constructs test template for constructs unit test: `100%` coverage + ## [5.1.0] - 2020-11-19 ### ⚠ BREAKING CHANGES - **Image URL Signature**: When image URL signature is enabled, all URLs including existing URLs should have `signature` query parameter. diff --git a/README.md b/README.md index 30bd10043..1b640c71a 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ chmod +x ./build-s3-dist.sh Deploy the distributable to the Amazon S3 bucket in your account: ```bash -aws s3 sync ./regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control -aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control +aws s3 sync ./regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control +aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control ``` ### 6. Launch the CloudFormation template. @@ -84,6 +84,7 @@ aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/ - [@pch](https://github.com/pch) for [#227](https://github.com/awslabs/serverless-image-handler/pull/227) - [@atrope](https://github.com/atrope) for [#201](https://github.com/awslabs/serverless-image-handler/pull/201) - [@bretto36](https://github.com/bretto36) for [#182](https://github.com/awslabs/serverless-image-handler/pull/182) +- [@makoncline](https://github.com/makoncline) for [#255](https://github.com/awslabs/serverless-image-handler/pull/255) *** ## License diff --git a/architecture.png b/architecture.png index 8d6ac401c..94f3e90cd 100644 Binary files a/architecture.png and b/architecture.png differ diff --git a/source/constructs/lib/serverless-image-handler.ts b/source/constructs/lib/serverless-image-handler.ts index e48ace63f..da60248fa 100644 --- a/source/constructs/lib/serverless-image-handler.ts +++ b/source/constructs/lib/serverless-image-handler.ts @@ -81,6 +81,21 @@ export class ServerlessImageHandler extends Construct { }); enableDefaultFallbackImageCondition.overrideLogicalId('EnableDefaultFallbackImageCondition'); + const isOptInRegion = new cdk.CfnCondition(this, 'IsOptInRegion', { + expression: cdk.Fn.conditionOr( + cdk.Fn.conditionEquals("af-south-1", cdk.Aws.REGION), + cdk.Fn.conditionEquals("ap-east-1", cdk.Aws.REGION), + cdk.Fn.conditionEquals("eu-south-1" , cdk.Aws.REGION), + cdk.Fn.conditionEquals("me-south-1" , cdk.Aws.REGION) + ) + }); + isOptInRegion.overrideLogicalId('IsOptInRegion'); + + const isNotOptInRegion = new cdk.CfnCondition(this, 'IsNotOptInRegion', { + expression: cdk.Fn.conditionNot(isOptInRegion) + }); + isNotOptInRegion.overrideLogicalId('IsNotOptInRegion') + // ImageHandlerFunctionRole const imageHandlerFunctionRole = new cdkIam.Role(this, 'ImageHandlerFunctionRole', { assumedBy: new cdkIam.ServicePrincipal('lambda.amazonaws.com'), @@ -122,7 +137,8 @@ export class ServerlessImageHandler extends Construct { }), new cdkIam.PolicyStatement({ actions: [ - 'rekognition:DetectFaces' + 'rekognition:DetectFaces', + 'rekognition:DetectModerationLabels' ], resources: [ '*' @@ -182,6 +198,12 @@ export class ServerlessImageHandler extends Construct { }); const cfnLambdaFunctionLogs = lambdaFunctionLogs.node.defaultChild as cdkLogs.CfnLogGroup; cfnLambdaFunctionLogs.retentionInDays = props.logRetentionPeriodParameter.valueAsNumber; + this.addCfnNagSuppressRules(cfnLambdaFunctionLogs, [ + { + "id": "W84", + "reason": "Used to store store function info" + } + ]); cfnLambdaFunctionLogs.overrideLogicalId('ImageHandlerLogGroup'); // CloudFrontToApiGatewayToLambda pattern @@ -193,6 +215,16 @@ export class ServerlessImageHandler extends Construct { // ApiLogs const cfnApiGatewayLogGroup = apiGatewayLogGroup.node.defaultChild as cdkLogs.CfnLogGroup; + this.addCfnNagSuppressRules(cfnApiGatewayLogGroup, [ + { + "id": "W84", + "reason": "Used to store store api log info, not using kms" + }, + { + "id": "W86", + "reason": "Log retention specified in CloudFromation parameters." + } + ]); cfnApiGatewayLogGroup.overrideLogicalId('ApiLogs'); // ImageHandlerApi @@ -242,6 +274,7 @@ export class ServerlessImageHandler extends Construct { const cloudFrontToApiGateway = cloudFrontApiGatewayLambda.node.findChild('CloudFrontToApiGateway'); const accessLogBucket = cloudFrontToApiGateway.node.findChild('CloudfrontLoggingBucket') as cdkS3.Bucket; const cfnAccessLogBucket = accessLogBucket.node.defaultChild as cdkS3.CfnBucket; + cfnAccessLogBucket.cfnOptions.condition = isNotOptInRegion; this.addCfnNagSuppressRules(cfnAccessLogBucket, [ { "id": "W35", @@ -252,8 +285,76 @@ export class ServerlessImageHandler extends Construct { // LogsBucketPolicy const accessLogBucketPolicy = accessLogBucket.node.findChild('Policy') as cdkS3.BucketPolicy; + const cfnAccessLogBucketPolicy = accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy; + (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).cfnOptions.condition = isNotOptInRegion; (accessLogBucketPolicy.node.defaultChild as cdkS3.CfnBucketPolicy).overrideLogicalId('LogsBucketPolicy'); + //OptInRegionLogBucket + const optInRegionAccessLogBucket = cdkS3.Bucket.fromBucketAttributes(this, 'CloudFrontLoggingBucket', { + bucketName: + cdk.Fn.getAtt( + cdk.Lazy.stringValue({ + produce(context) { + return cfLoggingBucket.logicalId} + }), + 'bucketName').toString(), + region: 'us-east-1' + }); + + //OptInRegionLogBucketPolicy + const optInRegionPolicyStatement = cfnAccessLogBucketPolicy.policyDocument.toJSON().Statement[0]; + optInRegionPolicyStatement.Resource = ""; + + //Choose Log Bucket + const cloudFrontLogsBucket = cdk.Fn.conditionIf(isOptInRegion.logicalId, optInRegionAccessLogBucket.bucketRegionalDomainName, accessLogBucket.bucketRegionalDomainName).toString(); + + + //ImagehandlerCachePolicy + const cfnCachePolicy = new cdkCloudFront.CfnCachePolicy( + this, + 'CachePolicy', + { + cachePolicyConfig: { + name: `${cdk.Aws.STACK_NAME}-${cdk.Aws.REGION}-ImageHandlerCachePolicy`, + defaultTtl: 86400, + minTtl: 1, + maxTtl: 31536000, + parametersInCacheKeyAndForwardedToOrigin: { + cookiesConfig: {cookieBehavior: "none"}, + enableAcceptEncodingGzip: true, + headersConfig: { + headerBehavior: "whitelist", + headers:['origin', 'accept'] + }, + queryStringsConfig: { + queryStringBehavior: "whitelist", + queryStrings: ["signature"] + }, + } + } + }); + cfnCachePolicy.overrideLogicalId("ImageHandlerCachePolicy"); + + //ImageHandlerOriginRequestPolicy + const cfnOriginRequestPolicy = new cdkCloudFront.CfnOriginRequestPolicy( + this, + "OriginRequestPolicy", + { + originRequestPolicyConfig: { + cookiesConfig: {cookieBehavior: "none"}, + headersConfig: { + headerBehavior: "whitelist", + headers: ['origin', 'accept'] + }, + name: `${cdk.Aws.STACK_NAME}-${cdk.Aws.REGION}-ImageHandlerOriginRequestPolicy`, + queryStringsConfig: { + queryStringBehavior: "whitelist", + queryStrings: ["signature"] + }, + } + }); + cfnOriginRequestPolicy.overrideLogicalId("ImageHandlerOriginRequestPolicy"); + // ImageHandlerDistribution const cfnCloudFrontDistribution = cloudFrontWebDistribution.node.defaultChild as cdkCloudFront.CfnDistribution; cfnCloudFrontDistribution.distributionConfig = { @@ -273,13 +374,10 @@ export class ServerlessImageHandler extends Construct { defaultCacheBehavior: { allowedMethods: [ 'GET', 'HEAD' ], targetOriginId: apiGateway.restApiId, - forwardedValues: { - queryString: true, - queryStringCacheKeys: [ 'signature' ], - headers: [ 'Origin', 'Accept' ], - cookies: { forward: 'none' } - }, - viewerProtocolPolicy: 'https-only' + viewerProtocolPolicy: 'https-only', + cachePolicyId: cfnCachePolicy.ref, + originRequestPolicyId: cfnOriginRequestPolicy.ref + }, customErrorResponses: [ { errorCode: 500, errorCachingMinTtl: 10 }, @@ -291,7 +389,7 @@ export class ServerlessImageHandler extends Construct { priceClass: 'PriceClass_All', logging: { includeCookies: false, - bucket: accessLogBucket.bucketRegionalDomainName, + bucket: cloudFrontLogsBucket, prefix: 'image-handler-cf-logs/' } }; @@ -378,7 +476,7 @@ export class ServerlessImageHandler extends Construct { httpVersion: 'http2', logging: { includeCookies: false, - bucket: accessLogBucket.bucketRegionalDomainName, + bucket: cloudFrontLogsBucket, prefix: 'demo-cf-logs/' } }; @@ -416,6 +514,10 @@ export class ServerlessImageHandler extends Construct { }), new cdkIam.PolicyStatement({ actions: [ + 's3:putBucketAcl', + 's3:putEncryptionConfiguration', + 's3:putBucketPolicy', + 's3:CreateBucket', 's3:GetObject', 's3:PutObject', 's3:ListBucket' @@ -461,6 +563,12 @@ export class ServerlessImageHandler extends Construct { }); const cfnCustomResourceLogGroup = customResourceLogGroup.node.defaultChild as cdkLogs.CfnLogGroup; cfnCustomResourceLogGroup.retentionInDays = props.logRetentionPeriodParameter.valueAsNumber; + this.addCfnNagSuppressRules(cfnCustomResourceLogGroup, [ + { + "id": "W84", + "reason": "Used to store store function info, no kms used" + } + ]); cfnCustomResourceLogGroup.overrideLogicalId('CustomResourceLogGroup'); // CustomResourceCopyS3 @@ -565,6 +673,19 @@ export class ServerlessImageHandler extends Construct { condition: enableDefaultFallbackImageCondition, dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] }); + + const bucketSuffix = cdk.Aws.STACK_NAME + cdk.Aws.REGION + cdk.Aws.ACCOUNT_ID; + const cfLoggingBucket = this.createCustomResource('CustomCFLoggingBucket', customResourceFunction, { + properties: [ + { path: 'customAction', value: 'createCFLoggingBucket' }, + { path: 'stackName', value: cdk.Aws.STACK_NAME }, + { path: 'bucketSuffix', value: bucketSuffix }, + { path: 'policy', value: optInRegionPolicyStatement } + ], + condition: isOptInRegion, + dependencies: [ cfnCustomResourceRole, cfnCustomResourcePolicy ] + + }); } catch (error) { console.error(error); } diff --git a/source/constructs/package.json b/source/constructs/package.json index 70b5ec012..546f8e93d 100644 --- a/source/constructs/package.json +++ b/source/constructs/package.json @@ -1,7 +1,7 @@ { "name": "constructs", "description": "Serverless Image Handler Constructs", - "version": "5.1.0", + "version": "5.2.0", "license": "Apache-2.0", "bin": { "constructs": "bin/constructs.js" diff --git a/source/constructs/test/serverless-image-handler-test.json b/source/constructs/test/serverless-image-handler-test.json index 5bb6e86b2..50497f483 100644 --- a/source/constructs/test/serverless-image-handler-test.json +++ b/source/constructs/test/serverless-image-handler-test.json @@ -209,6 +209,49 @@ }, "Yes" ] + }, + "IsOptInRegion": { + "Fn::Or": [ + { + "Fn::Equals": [ + "af-south-1", + { + "Ref": "AWS::Region" + } + ] + }, + { + "Fn::Equals": [ + "ap-east-1", + { + "Ref": "AWS::Region" + } + ] + }, + { + "Fn::Equals": [ + "eu-south-1", + { + "Ref": "AWS::Region" + } + ] + }, + { + "Fn::Equals": [ + "me-south-1", + { + "Ref": "AWS::Region" + } + ] + } + ] + }, + "IsNotOptInRegion": { + "Fn::Not": [ + { + "Condition": "IsOptInRegion" + } + ] } }, "Resources": { @@ -308,7 +351,10 @@ } }, { - "Action": "rekognition:DetectFaces", + "Action": [ + "rekognition:DetectFaces", + "rekognition:DetectModerationLabels" + ], "Effect": "Allow", "Resource": "*" } @@ -478,12 +524,36 @@ } }, "UpdateReplacePolicy": "Retain", - "DeletionPolicy": "Retain" + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W84", + "reason": "Used to store store function info" + } + ] + } + } }, "ApiLogs": { "Type": "AWS::Logs::LogGroup", "UpdateReplacePolicy": "Retain", - "DeletionPolicy": "Retain" + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W84", + "reason": "Used to store store api log info, not using kms" + }, + { + "id": "W86", + "reason": "Log retention specified in CloudFromation parameters." + } + ] + } + } }, "ImageHandlerApi": { "Type": "AWS::ApiGateway::RestApi", @@ -673,7 +743,8 @@ } ] } - } + }, + "Condition": "IsNotOptInRegion" }, "LogsBucketPolicy": { "Type": "AWS::S3::BucketPolicy", @@ -711,7 +782,8 @@ ], "Version": "2012-10-17" } - } + }, + "Condition": "IsNotOptInRegion" }, "ImageHandlerDistribution": { "Type": "AWS::CloudFront::Distribution", @@ -745,18 +817,11 @@ "GET", "HEAD" ], - "ForwardedValues": { - "Cookies": { - "Forward": "none" - }, - "Headers": [ - "Origin", - "Accept" - ], - "QueryString": true, - "QueryStringCacheKeys": [ - "signature" - ] + "CachePolicyId": { + "Ref": "ImageHandlerCachePolicy" + }, + "OriginRequestPolicyId": { + "Ref": "ImageHandlerOriginRequestPolicy" }, "TargetOriginId": { "Ref": "ImageHandlerApi" @@ -767,9 +832,31 @@ "HttpVersion": "http2", "Logging": { "Bucket": { - "Fn::GetAtt": [ - "Logs", - "RegionalDomainName" + "Fn::If": [ + "IsOptInRegion", + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "CustomCFLoggingBucket", + "bucketName" + ] + }, + ".s3.us-east-1.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + { + "Fn::GetAtt": [ + "Logs", + "RegionalDomainName" + ] + } ] }, "IncludeCookies": false, @@ -853,6 +940,88 @@ } } }, + "ImageHandlerCachePolicy": { + "Type": "AWS::CloudFront::CachePolicy", + "Properties": { + "CachePolicyConfig": { + "DefaultTTL": 86400, + "MaxTTL": 31536000, + "MinTTL": 1, + "Name": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "-", + { + "Ref": "AWS::Region" + }, + "-ImageHandlerCachePolicy" + ] + ] + }, + "ParametersInCacheKeyAndForwardedToOrigin": { + "CookiesConfig": { + "CookieBehavior": "none" + }, + "EnableAcceptEncodingGzip": true, + "HeadersConfig": { + "HeaderBehavior": "whitelist", + "Headers": [ + "origin", + "accept" + ] + }, + "QueryStringsConfig": { + "QueryStringBehavior": "whitelist", + "QueryStrings": [ + "signature" + ] + } + } + } + } + }, + "ImageHandlerOriginRequestPolicy": { + "Type": "AWS::CloudFront::OriginRequestPolicy", + "Properties": { + "OriginRequestPolicyConfig": { + "CookiesConfig": { + "CookieBehavior": "none" + }, + "HeadersConfig": { + "HeaderBehavior": "whitelist", + "Headers": [ + "origin", + "accept" + ] + }, + "Name": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + "-", + { + "Ref": "AWS::Region" + }, + "-ImageHandlerOriginRequestPolicy" + ] + ] + }, + "QueryStringsConfig": { + "QueryStringBehavior": "whitelist", + "QueryStrings": [ + "signature" + ] + } + } + } + }, "DemoBucket": { "Type": "AWS::S3::Bucket", "Properties": { @@ -976,9 +1145,31 @@ "IPV6Enabled": true, "Logging": { "Bucket": { - "Fn::GetAtt": [ - "Logs", - "RegionalDomainName" + "Fn::If": [ + "IsOptInRegion", + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "CustomCFLoggingBucket", + "bucketName" + ] + }, + ".s3.us-east-1.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + { + "Fn::GetAtt": [ + "Logs", + "RegionalDomainName" + ] + } ] }, "IncludeCookies": false, @@ -1102,6 +1293,10 @@ }, { "Action": [ + "s3:putBucketAcl", + "s3:putEncryptionConfiguration", + "s3:putBucketPolicy", + "s3:CreateBucket", "s3:GetObject", "s3:PutObject", "s3:ListBucket" @@ -1208,7 +1403,17 @@ } }, "UpdateReplacePolicy": "Retain", - "DeletionPolicy": "Retain" + "DeletionPolicy": "Retain", + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W84", + "reason": "Used to store store function info, no kms used" + } + ] + } + } }, "CustomResourceCopyS3": { "Type": "Custom::CustomResource", @@ -1462,6 +1667,54 @@ "CustomResourceRole" ], "Condition": "EnableDefaultFallbackImageCondition" + }, + "CustomCFLoggingBucket": { + "Type": "Custom::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomResourceFunction", + "Arn" + ] + }, + "customAction": "createCFLoggingBucket", + "stackName": { + "Ref": "AWS::StackName" + }, + "bucketSuffix": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::StackName" + }, + { + "Ref": "AWS::Region" + }, + { + "Ref": "AWS::AccountId" + } + ] + ] + }, + "policy": { + "Action": "*", + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + }, + "Effect": "Deny", + "Principal": "*", + "Resource": "", + "Sid": "HttpsOnly" + } + }, + "DependsOn": [ + "CustomResourcePolicy", + "CustomResourceRole" + ], + "Condition": "IsOptInRegion" } }, "Outputs": { diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js index cd484b346..b3961fd08 100644 --- a/source/custom-resource/index.js +++ b/source/custom-resource/index.js @@ -4,8 +4,10 @@ const AWS = require('aws-sdk'); const axios = require('axios'); const uuid = require('uuid'); +const crypto = require('crypto'); const s3 = new AWS.S3(); +const s3USEast = new AWS.S3({apiVersion: '2006-03-01', region: 'us-east-1'}); const secretsManager = new AWS.SecretsManager(); const METRICS_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic'; @@ -72,6 +74,15 @@ exports.handler = async (event, context) => { response.data = await checkFallbackImage(fallbackImageS3Bucket, fallbackImageS3Key); } break; + case 'createCFLoggingBucket': + if(['Create'].includes(event.RequestType)) { + const stackName = properties.stackName; + let bucketPolicyStatement = properties.policy; + const currentDate = new Date(); + const bucketSuffix = crypto.createHash('md5').update(properties.bucketSuffix + currentDate.getTime()).digest("hex"); + response.data = await createLoggingBucket(stackName, bucketSuffix, bucketPolicyStatement); + } + break; default: break; } @@ -455,4 +466,69 @@ async function checkFallbackImage(bucket, key) { async function sleep(retry) { const retrySeconds = Number(process.env.RETRY_SECONDS); return new Promise(resolve => setTimeout(resolve, retrySeconds * 1000 * retry)); +} + +/** + * Creates a bucket with settings for cloudfront logging. + * @param {String} stackName - Name of CloudFormation stack + * @param {JSON} bucketPolicyStatement - S3 bucket policy statement + * @return {Promise} - Bucket name of the created bucket + */ +async function createLoggingBucket(stackName, bucketSuffix, bucketPolicyStatement){ + const bucketParams = { + Bucket: `${stackName}-logs-${bucketSuffix.substring(0,8)}`.toLowerCase(), + ACL: "log-delivery-write" + }; + + //create bucket + try{ + await s3USEast.createBucket(bucketParams).promise() + console.log(`Successfully created bucket: ${bucketParams.Bucket}`); + } catch(error) { + console.error(`Could not create bucket: ${bucketParams.Bucket}`, error); + throw error; + } + + const encryptionParams = { + Bucket: bucketParams.Bucket, + ServerSideEncryptionConfiguration: { + Rules: [{ + ApplyServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256' + }, + },] + } + }; + + //Add encryption to bucket + console.log("Adding Encryption...") + try { + await s3USEast.putBucketEncryption(encryptionParams).promise(); + console.log(`Successfully enabled encryptoin on bucket: ${bucketParams.Bucket}`); + } catch(error) { + console.error(`Failed to add encryption to bucket: ${bucketParams.Bucket}`, error); + throw error; + } + + //Update resource attribute of policy statement + bucketPolicyStatement.Resource = `arn:aws:s3:::${bucketParams.Bucket}/*` + bucketPolicy = {"Version": "2012-10-17", "Statement":[ bucketPolicyStatement ]}; + + //Define policy parameters + const policyParams = { + Bucket: bucketParams.Bucket, + Policy: JSON.stringify(bucketPolicy) + }; + + //Add Policy to bucket + console.log("Adding policy...") + try { + await s3USEast.putBucketPolicy(policyParams).promise(); + console.log(`Successfully added policy added to bucket: ${bucketParams.Bucket}`); + } catch(error) { + console.error(`Failed to add policy to bucket ${bucketParams.Bucket}`, error); + throw error; + } + + return {bucketName: bucketParams.Bucket}; } \ No newline at end of file diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json index 7eba78e65..599d9b010 100644 --- a/source/custom-resource/package.json +++ b/source/custom-resource/package.json @@ -5,14 +5,14 @@ "author": { "name": "aws-solutions-builder" }, - "version": "5.1.0", + "version": "5.2.0", "private": true, "dependencies": { "axios": "^0.21.1", "uuid": "^8.3.0" }, "devDependencies": { - "aws-sdk": "2.712.0", + "aws-sdk": "2.771.0", "axios-mock-adapter": "^1.18.2", "jest": "^26.4.2" }, diff --git a/source/custom-resource/test/index.spec.js b/source/custom-resource/test/index.spec.js index 6517c67cb..cf099e9ec 100644 --- a/source/custom-resource/test/index.spec.js +++ b/source/custom-resource/test/index.spec.js @@ -40,7 +40,11 @@ jest.mock('aws-sdk', () => { copyObject: mockS3, putObject: mockS3, headBucket: mockS3, - headObject: mockS3 + headObject: mockS3, + createBucket: mockS3, + putBucketEncryption: mockS3, + putBucketPolicy: mockS3, + waitFor: mockS3 })), SecretsManager: jest.fn(() => ({ getSecretValue: mockSecretsManager @@ -723,4 +727,128 @@ describe('index', function() { }); }); }); + + describe('CreateCustomLoggingBucket', function() { + const event = { + "RequestType": "Create", + "ResponseURL": "/cfn-response", + "ResourceProperties": { + "customAction": "createCFLoggingBucket", + "stackName": "teststack", + "bucketSuffix": `teststacktest-region01234567898`, + "policy": { + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + }, + "Action": "*", + "Resource": "", + "Effect": "Deny", + "Principal": "*", + "Sid": "HttpsOnly" + } + } + }; + + beforeEach(() => { + mockS3.mockReset(); + }); + + it("Should return success and bucket name", async function() { + + mockS3.mockImplementation(() => { + return { + promise() { + + return Promise.resolve(); + } + }; + }); + const result = await index.handler(event, context); + expect(result).toMatchObject({ + status: 'SUCCESS', + data: {bucketName: expect.stringMatching(/^teststack-logs-[a-z0-9]{8}/)} + }); + }); + + it("Should return failure when there is an error creating the bucket", async function() { + mockS3.mockImplementation(() => { + return { + promise() { + return Promise.reject({message: "createBucket failed"}); + } + }; + }); + const result = await index.handler(event, context); + expect(result).toEqual({ + status: 'FAILED', + data: { + Error: { + code: "CustomResourceError", + message: "createBucket failed", + } + } + }); + }); + + it("Should return failure when there is an error enabling encryption on the created bucket", async function() { + mockS3.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }).mockImplementationOnce(() => { + return { + promise() { + return Promise.reject({message: "putBucketEncryption failed"}); + } + }; + }); + + const result = await index.handler(event, context); + expect(result).toEqual({ + status: 'FAILED', + data: { + Error: { + code: "CustomResourceError", + message: "putBucketEncryption failed", + } + } + }); + }); + it("Should return failure when there is an error applying a policy to the created bucket", async function() { + mockS3.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }).mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }).mockImplementationOnce(() => { + return { + promise() { + return Promise.reject({message: "putBucketPolicy failed"}); + } + }; + }); + + const result = await index.handler(event, context); + expect(result).toEqual({ + status: 'FAILED', + data: { + Error: { + code: "CustomResourceError", + message: "putBucketPolicy failed", + } + } + }); + }); + }); }); \ No newline at end of file diff --git a/source/image-handler/image-handler.js b/source/image-handler/image-handler.js index e71e2598e..62e1f6b17 100644 --- a/source/image-handler/image-handler.js +++ b/source/image-handler/image-handler.js @@ -67,8 +67,8 @@ class ImageHandler { edits.resize = {}; edits.resize.fit = 'inside'; } else { - if (edits.resize.width) edits.resize.width = Number(edits.resize.width); - if (edits.resize.height) edits.resize.height = Number(edits.resize.height); + 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)); } // Apply the image edits @@ -128,10 +128,9 @@ class ImageHandler { image.composite(params); } else if (editKey === 'smartCrop') { const options = value; - const metadata = await image.metadata(); - const imageBuffer = await image.toBuffer(); - const boundingBox = await this.getBoundingBox(imageBuffer, options.faceIndex); - const cropArea = this.getCropArea(boundingBox, options, metadata); + const imageBuffer = await image.toBuffer({resolveWithObject: true}); + const boundingBox = await this.getBoundingBox(imageBuffer.data, options.faceIndex); + const cropArea = this.getCropArea(boundingBox, options, imageBuffer.info); try { image.extract(cropArea); } catch (err) { @@ -141,6 +140,45 @@ class ImageHandler { message: 'The padding value you provided exceeds the boundaries of the original image. Please try choosing a smaller value or applying padding via Sharp for greater specificity.' }; } + } else if (editKey === 'roundCrop') { + const options = value; + const imageBuffer = await image.toBuffer({resolveWithObject: true}); + let width = imageBuffer.info.width; + let height = imageBuffer.info.height; + + //check for parameters, if not provided, set to defaults + const radiusX = options.rx && options.rx >= 0? options.rx : Math.min(width, height) / 2; + const radiusY = options.ry && options.ry >= 0? options.ry : Math.min(width, height) / 2; + const topOffset = options.top && options.top >= 0 ? options.top : height / 2; + const leftOffset = options.left && options.left >= 0 ? options.left : width / 2; + + if(options) + { + const ellipse = Buffer.from(` `); + const params = [{ input: ellipse, blend: 'dest-in' }]; + let data = await image.composite(params).toBuffer(); + image = sharp(data).withMetadata().trim(); + } + + } else if (editKey === 'contentModeration') { + const options = value; + const imageBuffer = await image.toBuffer({resolveWithObject: true}); + const inappropriateContent = await this.detectInappropriateContent(imageBuffer.data, options); + const blur = options.hasOwnProperty('blur') ? Math.ceil(Number(options.blur)) : 50; + + if(options && (blur >= 0.3 && blur <= 1000)) { + if(options.moderationLabels){ + for(let item of inappropriateContent.ModerationLabels) { + if (options.moderationLabels.includes(item.Name)){ + image.blur(blur); + break; + } + } + } else if(inappropriateContent.ModerationLabels.length) { + image.blur(blur); + } + } + } else { image[editKey](value); } @@ -238,7 +276,28 @@ class ImageHandler { const faceIdx = (faceIndex !== undefined) ? faceIndex : 0; try { const response = await this.rekognition.detectFaces(params).promise(); - return response.FaceDetails[faceIdx].BoundingBox; + if(response.FaceDetails.length <= 0) { + return {Height: 1, Left: 0, Top: 0, Width: 1}; + } + let boundingBox = {}; + + //handle bounds > 1 and < 0 + for (let bound in response.FaceDetails[faceIdx].BoundingBox) + { + if (response.FaceDetails[faceIdx].BoundingBox[bound] < 0 ) boundingBox[bound] = 0; + else if (response.FaceDetails[faceIdx].BoundingBox[bound] > 1) boundingBox[bound] = 1; + else boundingBox[bound] = response.FaceDetails[faceIdx].BoundingBox[bound]; + } + + //handle bounds greater than the size of the image + if (boundingBox.Left + boundingBox.Width > 1) { + boundingBox.Width = 1 - boundingBox.Left; + } + if (boundingBox.Top + boundingBox.Height > 1) { + boundingBox.Height = 1 - boundingBox.Top; + } + + return boundingBox; } catch (err) { console.error(err); if (err.message === "Cannot read property 'BoundingBox' of undefined") { @@ -249,13 +308,38 @@ class ImageHandler { }; } else { throw { - status: 500, + status: err.statusCode ? err.statusCode : 500, code: err.code, message: err.message }; } } } + + /** + * Detects inappropriate content in an image. + * @param {Sharp} imageBuffer - The original image. + * @param {Object} options - The options to pass to the dectectModerationLables Rekognition function + */ + async detectInappropriateContent(imageBuffer, options) { + + const params = { + Image: {Bytes: imageBuffer}, + MinConfidence: options.minConfidence ? parseFloat(options.minConfidence) : 75 + } + + try { + const response = await this.rekognition.detectModerationLabels(params).promise(); + return response; + } catch(err) { + console.error(err) + throw { + status: err.statusCode ? err.statusCode : 500, + code: err.code, + message: err.message + } + } + } } // Exports diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js index 8b250fc52..37b318c16 100644 --- a/source/image-handler/image-request.js +++ b/source/image-handler/image-request.js @@ -79,13 +79,15 @@ class ImageRequest { * 2) If headers contain "Accept: image/webp", the output format is webp. * 3) Use the default image format for the rest of cases. */ - let outputFormat = this.getOutputFormat(event); - if (this.edits && this.edits.toFormat) { - this.outputFormat = this.edits.toFormat; - } else if (outputFormat) { - this.outputFormat = outputFormat; + if (this.ContentType !== 'image/svg+xml' || this.edits.toFormat || this.outputFormat) { + let outputFormat = this.getOutputFormat(event); + if (this.edits && this.edits.toFormat) { + this.outputFormat = this.edits.toFormat; + } else if (outputFormat) { + this.outputFormat = outputFormat; + } } - + // Fix quality for Thumbor and Custom request type if outputFormat is different from quality type. if (this.outputFormat) { const requestType = ['Custom', 'Thumbor']; @@ -124,7 +126,13 @@ class ImageRequest { const originalImage = await this.s3.getObject(imageLocation).promise(); if (originalImage.ContentType) { - this.ContentType = originalImage.ContentType; + //If using default s3 ContentType infer from hex headers + if(originalImage.ContentType === 'binary/octet-stream') { + const imageBuffer = Buffer.from(originalImage.Body); + this.ContentType = this.inferImageType(imageBuffer); + } else { + this.ContentType = originalImage.ContentType; + } } else { this.ContentType = "image"; } @@ -142,7 +150,6 @@ class ImageRequest { } else { this.CacheControl = "max-age=31536000,public"; } - return originalImage.Body; } catch(err) { throw { @@ -271,7 +278,7 @@ class ImageRequest { parseRequestType(event) { const path = event["path"]; const matchDefault = new RegExp(/^(\/?)([0-9a-zA-Z+\/]{4})*(([0-9a-zA-Z+\/]{2}==)|([0-9a-zA-Z+\/]{3}=))?$/); - const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(.+jpg|.+png|.+webp|.+tiff|.+jpeg|.+svg)$/i); + const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)(((.(?!(\.[^.\\\/]+$)))*$)|.*(\.jpg$|.\.png$|\.webp$|\.tiff$|\.jpeg$|\.svg$))/i); const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg|svg)/i); const definedEnvironmentVariables = ( (process.env.REWRITE_MATCH_PATTERN !== "") && @@ -280,7 +287,16 @@ class ImageRequest { (process.env.REWRITE_SUBSTITUTION !== undefined) ); - if (matchDefault.test(path)) { // use sharp + //Check if path is base 64 encoded + let isBase64Encoded = true; + try { + this.decodeRequest(event); + } catch(error) { + console.error(error); + isBase64Encoded = false; + } + + if (matchDefault.test(path) && isBase64Encoded) { // use sharp return 'Default'; } else if (matchCustom.test(path) && definedEnvironmentVariables) { // use rewrite function then thumbor mappings return 'Custom'; @@ -376,6 +392,28 @@ class ImageRequest { return null; } + + /** + * Return the output format depending on first four hex values of an image file. + * @param {Buffer} imageBuffer - Image buffer. + */ + inferImageType(imageBuffer) { + switch(imageBuffer.toString('hex').substring(0,8).toUpperCase()) { + case '89504E47': return 'image/png'; + case 'FFD8FFDB': return 'image/jpeg'; + case 'FFD8FFE0': return 'image/jpeg'; + case 'FFD8FFEE': return 'image/jpeg'; + case 'FFD8FFE1': return 'image/jpeg'; + case '52494646': return 'image/webp'; + case '49492A00': return 'image/tiff'; + case '4D4D002A': return 'image/tiff'; + default: throw { + status: 500, + code: 'RequestTypeError', + message: '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.' + }; + } +} } // Exports diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 49ced547b..1b002e371 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -5,20 +5,16 @@ "author": { "name": "aws-solutions-builder" }, - "version": "5.1.0", + "version": "5.2.0", "private": true, "dependencies": { - "sharp": "^0.26.1", - "color": "3.1.2", - "color-name": "1.1.4" + "color": "3.1.3", + "color-name": "1.1.4", + "sharp": "^0.27.0" }, "devDependencies": { - "aws-sdk": "2.712.0", - "aws-sdk-mock": "^5.1.0", - "jest": "^26.4.2", - "mocha": "^8.1.3", - "sinon": "^9.0.3", - "nyc": "^15.1.0" + "aws-sdk": "2.771.0", + "jest": "^26.4.2" }, "scripts": { "pretest": "npm run build:init && npm install", diff --git a/source/image-handler/test/image-handler.spec.js b/source/image-handler/test/image-handler.spec.js index 08df73c90..45d73fbe4 100644 --- a/source/image-handler/test/image-handler.spec.js +++ b/source/image-handler/test/image-handler.spec.js @@ -5,7 +5,8 @@ const fs = require('fs'); const mockAws = { getObject: jest.fn(), - detectFaces: jest.fn() + detectFaces: jest.fn(), + detectModerationLabels: jest.fn() }; jest.mock('aws-sdk', () => { return { @@ -13,7 +14,8 @@ jest.mock('aws-sdk', () => { getObject: mockAws.getObject })), Rekognition: jest.fn(() => ({ - detectFaces: mockAws.detectFaces + detectFaces: mockAws.detectFaces, + detectModerationLabels: mockAws.detectModerationLabels })) }; }); @@ -522,8 +524,8 @@ describe('applyEdits()', function() { const image = sharp(originalImage, { failOnError: false }).withMetadata(); const edits = { resize: { - width: '100', - height: '100' + width: '99.1', + height: '99.9' } } // Act @@ -531,10 +533,247 @@ describe('applyEdits()', function() { const result = await imageHandler.applyEdits(image, edits); // Assert const resultBuffer = await result.toBuffer(); - const convertedImage = await sharp(originalImage, { failOnError: false }).withMetadata().resize({ width: 100, height: 100 }).toBuffer(); + const convertedImage = await sharp(originalImage, { failOnError: false }).withMetadata().resize({ width: 99, height: 100 }).toBuffer(); expect(resultBuffer).toEqual(convertedImage); }); }); + describe('012/roundCrop/noOptions', function() { + it('Should pass if roundCrop keyName is passed with no additional options', async function() { + // Arrange + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const metadata = image.metadata(); + + const edits = { + roundCrop: true, + + } + + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + + // Assert + const expectedResult = {width: metadata.width / 2, height: metadata.height / 2} + expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' }); + expect(result.options.input).not.toEqual(expectedResult); + }); + }); + describe('013/roundCrop/withOptions', function() { + it('Should pass if roundCrop keyName is passed with additional options', async function() { + // Arrange + const originalImage = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const metadata = image.metadata(); + + const edits = { + roundCrop: { + top: 100, + left: 100, + rx: 100, + ry: 100, + }, + + } + + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + + // Assert + const expectedResult = {width: metadata.width / 2, height: metadata.height / 2} + expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'aaa', Key: 'bbb' }); + expect(result.options.input).not.toEqual(expectedResult); + }); + }); + describe('014/contentModeration', function() { + it('Should pass and blur image with minConfidence provided', async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const buffer = await image.toBuffer(); + const edits = { + contentModeration: { + minConfidence: 75 + } + } + // Mock + mockAws.detectModerationLabels.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ModerationLabels: [ + { + Confidence: 99.76720428466, + Name: 'Smoking', + ParentName: 'Tobacco' + }, + { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' } + ], + ModerationModelVersion: '4.0' + }); + } + }; + }); + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + const expected = image.blur(50); + // Assert + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }}); + expect(result.options.input).not.toEqual(originalImage); + expect(result).toEqual(expected); + }); + it("should pass and blur to specified amount if blur option is provided", async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const buffer = await image.toBuffer(); + const edits = { + contentModeration: { + minConfidence: 75, + blur: 100 + } + } + // Mock + mockAws.detectModerationLabels.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ModerationLabels: [ + { + Confidence: 99.76720428466, + Name: 'Smoking', + ParentName: 'Tobacco' + }, + { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' } + ], + ModerationModelVersion: '4.0' + }); + } + }; + }); + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + const expected = image.blur(100); + // Assert + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }}); + expect(result.options.input).not.toEqual(originalImage); + expect(result).toEqual(expected); + }); + it("should pass and blur if content moderation label matches specied moderartion label", async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const buffer = await image.toBuffer(); + const edits = { + contentModeration: { + moderationLabels: ["Smoking"] + } + } + // Mock + mockAws.detectModerationLabels.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ModerationLabels: [ + { + Confidence: 99.76720428466, + Name: 'Smoking', + ParentName: 'Tobacco' + }, + { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' } + ], + ModerationModelVersion: '4.0' + }); + } + }; + }); + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + const expected = image.blur(50); + // Assert + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }}); + expect(result.options.input).not.toEqual(originalImage); + expect(result).toEqual(expected); + }); + it("should not blur if provided moderationLabels not found", async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const buffer = await image.toBuffer(); + const edits = { + contentModeration: { + minConfidence: 75, + blur: 100, + moderationLabels: ['Alcohol'] + } + } + // Mock + mockAws.detectModerationLabels.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ModerationLabels: [ + { + Confidence: 99.76720428466, + Name: 'Smoking', + ParentName: 'Tobacco' + }, + { Confidence: 99.76720428466, Name: 'Tobacco', ParentName: '' } + ], + ModerationModelVersion: '4.0' + }); + } + }; + }); + // Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.applyEdits(image, edits); + // Assert + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }}); + expect(result).toEqual(image); + }); + it("should fail if rekognition returns an error", async function() { + // Arrange + const originalImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + const image = sharp(originalImage, { failOnError: false }).withMetadata(); + const buffer = await image.toBuffer(); + const edits = { + contentModeration: { + minConfidence: 75, + blur: 100 + } + } + // Mock + mockAws.detectModerationLabels.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject({ + status: 500, + code: 'InternalServerError', + message: 'Amazon Rekognition experienced a service issue. Try your call again.' + }); + } + }; + }); + // Act + const imageHandler = new ImageHandler(s3, rekognition); + try { + const result = await imageHandler.applyEdits(image, edits); + } catch(error) { + // Assert + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: buffer }}); + expect(error).toEqual({ + status: 500, + code: 'InternalServerError', + message: 'Amazon Rekognition experienced a service issue. Try your call again.' + }); + } + }); + }); }); // ---------------------------------------------------------------------------- @@ -696,4 +935,75 @@ describe('getBoundingBox()', function() { } }); }); + describe('003/noDetectedFaces', function () { + it('Should pass if no faces are detected', async function () { + //Arrange + const currentImage = Buffer.from('TestImageData'); + const faceIndex = 0; + + // Mock + mockAws.detectFaces.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + FaceDetails: [] + }); + } + }; + }); + + //Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.getBoundingBox(currentImage, faceIndex); + + // Assert + const expectedResult = { + Height: 1, + Left: 0, + Top: 0, + Width: 1 + }; + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }}); + expect(result).toEqual(expectedResult); + }); + }); + describe('004/boundsGreaterThanImageDimensions', function () { + it('Should pass if bounds detected go beyond the image dimensions', async function () { + //Arrange + const currentImage = Buffer.from('TestImageData'); + const faceIndex = 0; + + // Mock + mockAws.detectFaces.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + FaceDetails: [{ + BoundingBox: { + Height: 1, + Left: 0.50, + Top: 0.30, + Width: 0.65 + } + }] + }); + } + }; + }); + + //Act + const imageHandler = new ImageHandler(s3, rekognition); + const result = await imageHandler.getBoundingBox(currentImage, faceIndex); + + // Assert + const expectedResult = { + Height: 0.70, + Left: 0.50, + Top: 0.30, + Width: 0.50 + }; + expect(mockAws.detectFaces).toHaveBeenCalledWith({ Image: { Bytes: currentImage }}); + expect(result).toEqual(expectedResult); + }); + }); }); \ No newline at end of file diff --git a/source/image-handler/test/image-request.spec.js b/source/image-handler/test/image-request.spec.js index 77ea12e40..2f706514c 100644 --- a/source/image-handler/test/image-request.spec.js +++ b/source/image-handler/test/image-request.spec.js @@ -608,6 +608,54 @@ describe('getOriginalImage()', function() { } }); }); + describe('004/noExtension', function() { + const testFiles = [[0x89,0x50,0x4E,0x47],[0xFF,0xD8,0xFF,0xDB],[0xFF,0xD8,0xFF,0xE0],[0xFF,0xD8,0xFF,0xEE],[0xFF,0xD8,0xFF,0xE1],[0x52,0x49,0x46,0x46],[0x49,0x49,0x2A,0x00],[0x4D,0x4D,0x00,0x2A]]; + const expectFileType = ["image/png", "image/jpeg", "image/jpeg", "image/jpeg", "image/jpeg", "image/webp", "image/tiff", "image/tiff"]; + testFiles.forEach(function (test, index) {it('Should pass and infer content type if there is no extension, had default s3 content type and it has a vlid key and a valid bucket', async function() { + //Mock + mockAws.getObject.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ContentType: 'binary/octet-stream', + Body: Buffer.from(new Uint8Array(test)) + }); + } + }; + }) + + // Act + const imageRequest = new ImageRequest(s3, secretsManager); + const result = await imageRequest.getOriginalImage('validBucket', 'validKey'); + // Assert + expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' }); + expect(result).toEqual(Buffer.from(new Uint8Array(test))); + expect(imageRequest.ContentType).toEqual(expectFileType[index]); + });}) + it('Should fail to infer content type if there is no extension and file header is not recognized', async function() { + //Mock + mockAws.getObject.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + ContentType: 'binary/octet-stream', + Body: Buffer.from(new Uint8Array(test)) + }); + } + }; + }) + + //Act + const imageRequest = new ImageRequest(s3, secretsManager); + try { + const result = await imageRequest.getOriginalImage('validBucket', 'validKey'); + } catch(error){ + //Assert + expect(mockAws.getObject).toHaveBeenCalledWith({ Bucket: 'validBucket', Key: 'validKey' }); + expect(error.status).toEqual(500); + } + }); + }); }); // ---------------------------------------------------------------------------- @@ -966,7 +1014,7 @@ describe('parseRequestType()', function() { it('Should throw an error if the method cannot determine the request type based on the three groups given', function() { // Arrange const event = { - path : '12x12e24d234r2ewxsad123d34r' + path : '12x12e24d234r2ewxsad123d34r.bmp' } process.env = {}; // Act