Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
66d5115
Remove unused import
piotrekwitkowski Oct 4, 2024
b9d2061
Reorder context parameters
piotrekwitkowski Oct 4, 2024
47ae2fb
Lint indents and whitespaces
piotrekwitkowski Oct 4, 2024
c2aa1d1
Lint code comments
piotrekwitkowski Oct 4, 2024
44bf155
Bump deps
piotrekwitkowski Oct 4, 2024
18fbe5c
Create Lambda simpler
piotrekwitkowski Oct 4, 2024
31cc61c
Wrap lifecycle rules
piotrekwitkowski Oct 4, 2024
b805fa1
Update deprecated construct
piotrekwitkowski Oct 4, 2024
c1c64bc
Simplify origin shield usage
piotrekwitkowski Oct 4, 2024
269eaea
Refactor imageProcessingLambdaPolicyStatements
piotrekwitkowski Oct 4, 2024
0d77477
Fix spelling
piotrekwitkowski Oct 4, 2024
7d8ad95
Fix import style
piotrekwitkowski Oct 4, 2024
65adeba
Fix types
piotrekwitkowski Oct 4, 2024
3da2340
Remove comma
piotrekwitkowski Oct 4, 2024
94935b8
Use string templates
piotrekwitkowski Oct 4, 2024
78345d2
Reuse lambda origin
piotrekwitkowski Oct 4, 2024
cf45547
Reorder line alphabetically
piotrekwitkowski Oct 5, 2024
d23c548
Reorder imports alphabetically
piotrekwitkowski Oct 5, 2024
6ad9aba
Finish code updates
piotrekwitkowski Oct 5, 2024
a5753cc
Bump version
piotrekwitkowski Oct 5, 2024
b551921
Standardize quote chars in stack
piotrekwitkowski Oct 5, 2024
5b1dc99
Standardize commas & comments
piotrekwitkowski Oct 5, 2024
11779c0
Restructure files, extract solution into a separate file
piotrekwitkowski Oct 5, 2024
dccd251
Standardize resource id
piotrekwitkowski Oct 5, 2024
777be88
Rename stuff
piotrekwitkowski Oct 5, 2024
602e49d
Reorder alphabetically
piotrekwitkowski Oct 5, 2024
fb6c8de
Standardize style
piotrekwitkowski Oct 6, 2024
e8c819d
Reorder imports alphabetically
piotrekwitkowski Oct 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,3 @@ cdk destroy
## License

This library is licensed under the MIT-0 License. See the LICENSE file.

10 changes: 10 additions & 0 deletions lib/cdk-context-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
import { Node } from "constructs";

export const readContext = (node: Node) => ({
boolean: (key: string, defaultValue = false) => node.tryGetContext(key) ? Boolean(node.tryGetContext(key)) : defaultValue,
number: (key: string, defaultValue: number) => node.tryGetContext(key) ? Number(node.tryGetContext(key)) : defaultValue,
string: (key: string, defaultValue: string) => node.tryGetContext(key) ? String(node.tryGetContext(key)) : defaultValue,
stringOrUndefined: (key: string) => node.tryGetContext(key) ? String(node.tryGetContext(key)) : undefined,
});
290 changes: 46 additions & 244 deletions lib/image-optimization-stack.ts
Original file line number Diff line number Diff line change
@@ -1,275 +1,77 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0

import { Fn, Stack, StackProps, RemovalPolicy, aws_s3 as s3, aws_s3_deployment as s3deploy, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_iam as iam, Duration, CfnOutput, aws_logs as logs } from 'aws-cdk-lib';
import { CfnDistribution } from "aws-cdk-lib/aws-cloudfront";
import { aws_s3 as s3, aws_s3_deployment as s3deploy, CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { readContext } from './cdk-context-utils';
import { sampleWebsite } from './sample-website';
import { imageOptimizationSolution } from './image-processing';
import { getOriginShieldRegion } from './origin-shield';
import { createHash } from 'crypto';

// Stack Parameters

// related to architecture. If set to false, transformed images are not stored in S3, and all image requests land on Lambda
var STORE_TRANSFORMED_IMAGES = 'true';
// Parameters of S3 bucket where original images are stored
var S3_IMAGE_BUCKET_NAME: string;
// CloudFront parameters
var CLOUDFRONT_ORIGIN_SHIELD_REGION = getOriginShieldRegion(process.env.AWS_REGION || process.env.CDK_DEFAULT_REGION || 'us-east-1');
var CLOUDFRONT_CORS_ENABLED = 'true';
// Parameters of transformed images
var S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION = '90';
var S3_TRANSFORMED_IMAGE_CACHE_TTL = 'max-age=31622400';
// Max image size in bytes. If generated images are stored on S3, bigger images are generated, stored on S3
// and request is redirect to the generated image. Otherwise, an application error is sent.
var MAX_IMAGE_SIZE = '4700000';
// Lambda Parameters
var LAMBDA_MEMORY = '1500';
var LAMBDA_TIMEOUT = '60';
// Whether to deploy a sample website referenced in https://aws.amazon.com/blogs/networking-and-content-delivery/image-optimization-using-amazon-cloudfront-and-aws-lambda/
var DEPLOY_SAMPLE_WEBSITE = 'false';

type ImageDeliveryCacheBehaviorConfig = {
origin: any;
compress: any;
viewerProtocolPolicy: any;
cachePolicy: any;
functionAssociations: any;
responseHeadersPolicy?: any;
};

type LambdaEnv = {
originalImageBucketName: string,
transformedImageBucketName?: any;
transformedImageCacheTTL: string,
maxImageSize: string,
}

export class ImageOptimizationStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);

// Change stack parameters based on provided context
STORE_TRANSFORMED_IMAGES = this.node.tryGetContext('STORE_TRANSFORMED_IMAGES') || STORE_TRANSFORMED_IMAGES;
S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION = this.node.tryGetContext('S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION') || S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION;
S3_TRANSFORMED_IMAGE_CACHE_TTL = this.node.tryGetContext('S3_TRANSFORMED_IMAGE_CACHE_TTL') || S3_TRANSFORMED_IMAGE_CACHE_TTL;
S3_IMAGE_BUCKET_NAME = this.node.tryGetContext('S3_IMAGE_BUCKET_NAME') || S3_IMAGE_BUCKET_NAME;
CLOUDFRONT_ORIGIN_SHIELD_REGION = this.node.tryGetContext('CLOUDFRONT_ORIGIN_SHIELD_REGION') || CLOUDFRONT_ORIGIN_SHIELD_REGION;
CLOUDFRONT_CORS_ENABLED = this.node.tryGetContext('CLOUDFRONT_CORS_ENABLED') || CLOUDFRONT_CORS_ENABLED;
LAMBDA_MEMORY = this.node.tryGetContext('LAMBDA_MEMORY') || LAMBDA_MEMORY;
LAMBDA_TIMEOUT = this.node.tryGetContext('LAMBDA_TIMEOUT') || LAMBDA_TIMEOUT;
MAX_IMAGE_SIZE = this.node.tryGetContext('MAX_IMAGE_SIZE') || MAX_IMAGE_SIZE;
DEPLOY_SAMPLE_WEBSITE = this.node.tryGetContext('DEPLOY_SAMPLE_WEBSITE') || DEPLOY_SAMPLE_WEBSITE;


// deploy a sample website for testing if required
if (DEPLOY_SAMPLE_WEBSITE === 'true') {
var sampleWebsiteBucket = new s3.Bucket(this, 's3-sample-website-bucket', {
removalPolicy: RemovalPolicy.DESTROY,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
autoDeleteObjects: true,
});

var sampleWebsiteDelivery = new cloudfront.Distribution(this, 'websiteDeliveryDistribution', {
comment: 'image optimization - sample website',
defaultRootObject: 'index.html',
defaultBehavior: {
origin: new origins.S3Origin(sampleWebsiteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
}
});

// Load stack parameters related to architecture from CDK context
const context = readContext(this.node);

const CORS_ENABLED = context.boolean('CLOUDFRONT_CORS_ENABLED', true);
const DEPLOY_SAMPLE_WEBSITE = context.boolean('DEPLOY_SAMPLE_WEBSITE');
const LAMBDA_MEMORY = context.number('LAMBDA_MEMORY', 1500);
const LAMBDA_TIMEOUT_SECONDS = context.number('LAMBDA_TIMEOUT', 60);
const MAX_IMAGE_SIZE = context.number('MAX_IMAGE_SIZE', 4700000);
const ORIGIN_SHIELD_REGION = context.string('CLOUDFRONT_ORIGIN_SHIELD_REGION', getOriginShieldRegion(process.env.AWS_REGION || process.env.CDK_DEFAULT_REGION || 'us-east-1'));
const S3_ORIGINAL_IMAGE_BUCKET_NAME = context.stringOrUndefined('S3_IMAGE_BUCKET_NAME');
const S3_TRANSFORMED_IMAGE_CACHE_CONTROL = context.string('S3_TRANSFORMED_IMAGE_CACHE_TTL', 'max-age=31622400');
const S3_TRANSFORMED_IMAGE_EXPIRATION_DAYS = context.number('S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION', 90);
const STORE_TRANSFORMED_IMAGES = context.boolean('STORE_TRANSFORMED_IMAGES', true);

// If true, this stack will deploy an additional, sample website to showcase the solution
// Architecture of the sample website is described at https://aws.amazon.com/blogs/networking-and-content-delivery/image-optimization-using-amazon-cloudfront-and-aws-lambda/
if (DEPLOY_SAMPLE_WEBSITE) {
const sampleWebsiteDelivery = sampleWebsite(this);
new CfnOutput(this, 'SampleWebsiteDomain', {
description: 'Sample website domain',
value: sampleWebsiteDelivery.distributionDomainName
});
new CfnOutput(this, 'SampleWebsiteS3Bucket', {
description: 'S3 bucket use by the sample website',
value: sampleWebsiteBucket.bucketName
});
}

// For the bucket having original images, either use an external one, or create one with some samples photos.
var originalImageBucket;
var transformedImageBucket;

if (S3_IMAGE_BUCKET_NAME) {
originalImageBucket = s3.Bucket.fromBucketName(this, 'imported-original-image-bucket', S3_IMAGE_BUCKET_NAME);
new CfnOutput(this, 'OriginalImagesS3Bucket', {
description: 'S3 bucket where original images are stored',
value: originalImageBucket.bucketName
});
// For original images, use existing S3 bucket if provided, otherwise create a new one with sample images
let originalImageBucket: s3.IBucket;
if (S3_ORIGINAL_IMAGE_BUCKET_NAME) {
originalImageBucket = s3.Bucket.fromBucketName(this, 'imported-original-image-bucket', S3_ORIGINAL_IMAGE_BUCKET_NAME);
} else {
originalImageBucket = new s3.Bucket(this, 's3-sample-original-image-bucket', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
autoDeleteObjects: true,
removalPolicy: RemovalPolicy.DESTROY,
});
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset('./image-sample')],
new s3deploy.BucketDeployment(this, 'deploy-website', {
destinationBucket: originalImageBucket,
destinationKeyPrefix: 'images/rio/',
sources: [s3deploy.Source.asset('./image-sample')],
});
new CfnOutput(this, 'OriginalImagesS3Bucket', {
description: 'S3 bucket where original images are stored',
value: originalImageBucket.bucketName
});
}

// create bucket for transformed images if enabled in the architecture
if (STORE_TRANSFORMED_IMAGES === 'true') {
transformedImageBucket = new s3.Bucket(this, 's3-transformed-image-bucket', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
lifecycleRules: [
{
expiration: Duration.days(parseInt(S3_TRANSFORMED_IMAGE_EXPIRATION_DURATION)),
},
],
});
}

// prepare env variable for Lambda
var lambdaEnv: LambdaEnv = {
originalImageBucketName: originalImageBucket.bucketName,
transformedImageCacheTTL: S3_TRANSFORMED_IMAGE_CACHE_TTL,
maxImageSize: MAX_IMAGE_SIZE,
};
if (transformedImageBucket) lambdaEnv.transformedImageBucketName = transformedImageBucket.bucketName;

// IAM policy to read from the S3 bucket containing the original images
const s3ReadOriginalImagesPolicy = new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: ['arn:aws:s3:::' + originalImageBucket.bucketName + '/*'],
new CfnOutput(this, 'original-images-s3-bucket', {
description: 'S3 bucket storing original images',
value: originalImageBucket.bucketName
});

// statements of the IAM policy to attach to Lambda
var iamPolicyStatements = [s3ReadOriginalImagesPolicy];

// Create Lambda for image processing
var lambdaProps = {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('functions/image-processing'),
timeout: Duration.seconds(parseInt(LAMBDA_TIMEOUT)),
memorySize: parseInt(LAMBDA_MEMORY),
environment: lambdaEnv,
logRetention: logs.RetentionDays.ONE_DAY,
};
var imageProcessing = new lambda.Function(this, 'image-optimization', lambdaProps);

// Enable Lambda URL
const imageProcessingURL = imageProcessing.addFunctionUrl();

// Leverage CDK Intrinsics to get the hostname of the Lambda URL
const imageProcessingDomainName = Fn.parseDomainName(imageProcessingURL.url);

// Create a CloudFront origin: S3 with fallback to Lambda when image needs to be transformed, otherwise with Lambda as sole origin
var imageOrigin;

if (transformedImageBucket) {
imageOrigin = new origins.OriginGroup({
primaryOrigin: new origins.S3Origin(transformedImageBucket, {
originShieldRegion: CLOUDFRONT_ORIGIN_SHIELD_REGION,
}),
fallbackOrigin: new origins.HttpOrigin(imageProcessingDomainName, {
originShieldRegion: CLOUDFRONT_ORIGIN_SHIELD_REGION,
}),
fallbackStatusCodes: [403, 500, 503, 504],
});

// write policy for Lambda on the s3 bucket for transformed images
var s3WriteTransformedImagesPolicy = new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: ['arn:aws:s3:::' + transformedImageBucket.bucketName + '/*'],
});
iamPolicyStatements.push(s3WriteTransformedImagesPolicy);
} else {
imageOrigin = new origins.HttpOrigin(imageProcessingDomainName, {
originShieldRegion: CLOUDFRONT_ORIGIN_SHIELD_REGION,
});
}

// attach iam policy to the role assumed by Lambda
imageProcessing.role?.attachInlinePolicy(
new iam.Policy(this, 'read-write-bucket-policy', {
statements: iamPolicyStatements,
}),
);

// Create a CloudFront Function for url rewrites
const urlRewriteFunction = new cloudfront.Function(this, 'urlRewrite', {
code: cloudfront.FunctionCode.fromFile({ filePath: 'functions/url-rewrite/index.js', }),
functionName: `urlRewriteFunction${this.node.addr}`,
});

var imageDeliveryCacheBehaviorConfig: ImageDeliveryCacheBehaviorConfig = {
origin: imageOrigin,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
compress: false,
cachePolicy: new cloudfront.CachePolicy(this, `ImageCachePolicy${this.node.addr}`, {
defaultTtl: Duration.hours(24),
maxTtl: Duration.days(365),
minTtl: Duration.seconds(0),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.all()
}),
functionAssociations: [{
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
function: urlRewriteFunction,
}],
}

if (CLOUDFRONT_CORS_ENABLED === 'true') {
// Creating a custom response headers policy. CORS allowed for all origins.
const imageResponseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, `ResponseHeadersPolicy${this.node.addr}`, {
responseHeadersPolicyName: `ImageResponsePolicy${this.node.addr}`,
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['GET'],
accessControlAllowOrigins: ['*'],
accessControlMaxAge: Duration.seconds(600),
originOverride: false,
},
// recognizing image requests that were processed by this solution
customHeadersBehavior: {
customHeaders: [
{ header: 'x-aws-image-optimization', value: 'v1.0', override: true },
{ header: 'vary', value: 'accept', override: true },
],
}
});
imageDeliveryCacheBehaviorConfig.responseHeadersPolicy = imageResponseHeadersPolicy;
}
const imageDelivery = new cloudfront.Distribution(this, 'imageDeliveryDistribution', {
comment: 'image optimization - image delivery',
defaultBehavior: imageDeliveryCacheBehaviorConfig
});

// ADD OAC between CloudFront and LambdaURL
const oac = new cloudfront.CfnOriginAccessControl(this, "OAC", {
originAccessControlConfig: {
name: `oac${this.node.addr}`,
originAccessControlOriginType: "lambda",
signingBehavior: "always",
signingProtocol: "sigv4",
},
// Create Amazon CloudFront distribution to deliver optimized images
const imageDelivery = imageOptimizationSolution(this, {
corsEnabled: CORS_ENABLED,
lambdaMemory: LAMBDA_MEMORY,
lambdaTimeout: Duration.seconds(LAMBDA_TIMEOUT_SECONDS),
maxImageSizeBytes: MAX_IMAGE_SIZE,
originalImageBucket: originalImageBucket,
originShieldRegion: ORIGIN_SHIELD_REGION,
storeTransformedImages: STORE_TRANSFORMED_IMAGES,
transformedImageCacheControl: S3_TRANSFORMED_IMAGE_CACHE_CONTROL,
transformedImageExpiration: Duration.days(S3_TRANSFORMED_IMAGE_EXPIRATION_DAYS),
});

const cfnImageDelivery = imageDelivery.node.defaultChild as CfnDistribution;
cfnImageDelivery.addPropertyOverride(`DistributionConfig.Origins.${(STORE_TRANSFORMED_IMAGES === 'true')?"1":"0"}.OriginAccessControlId`, oac.getAtt("Id"));

imageProcessing.addPermission("AllowCloudFrontServicePrincipal", {
principal: new iam.ServicePrincipal("cloudfront.amazonaws.com"),
action: "lambda:InvokeFunctionUrl",
sourceArn: `arn:aws:cloudfront::${this.account}:distribution/${imageDelivery.distributionId}`
})

new CfnOutput(this, 'ImageDeliveryDomain', {
description: 'Domain name of image delivery',
new CfnOutput(this, 'image-delivery-domain', {
description: 'Image delivery domain',
value: imageDelivery.distributionDomainName
});
}
Expand Down
Loading