diff --git a/typescript/cloudfront-functions/.gitignore b/typescript/cloudfront-functions/.gitignore new file mode 100644 index 0000000000..34e639461c --- /dev/null +++ b/typescript/cloudfront-functions/.gitignore @@ -0,0 +1,9 @@ +!resources/** +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out +.DS_Store diff --git a/typescript/cloudfront-functions/.npmignore b/typescript/cloudfront-functions/.npmignore new file mode 100644 index 0000000000..c1d6d45dcf --- /dev/null +++ b/typescript/cloudfront-functions/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/typescript/cloudfront-functions/README.md b/typescript/cloudfront-functions/README.md new file mode 100644 index 0000000000..0dcf51772e --- /dev/null +++ b/typescript/cloudfront-functions/README.md @@ -0,0 +1,76 @@ +# demo-cloudfront-functions + +This project will create a S3 bucket with simple `html` files that will serve as our website source code, a +CloudFront distribution to serve this content as our CDN and, two CloudFront functions that will work upon the request +and response. + +> This project is intended to be just a sample demonstration. Please, do not use it in production. + +## CDK Toolkit + +The `cdk.json` file tells the CDK Toolkit how to execute your app. + +To start working with the project, first you will need to install all dependencies as well as the cdk module (if not +installed already). In the project directory, run: + +```bash +$ npm install -g aws-cdk +$ npm install +``` + +## Deploying the solution + +To deploy the solution, we will need to request cdk to deploy the stack: + +```shell +$ cdk deploy --all +``` + +> **Note** that after running the deploy command, you will be presented and Output in the console like bellow:\ +> `DemoCloudfrontFunctionsStack.DistributionDomainName = xxxxxxxx.cloudfront.net`\ +> We will use this URL to access and test the website. + +## Testing the solution + +To begin the tests, you must have the distribution's URL (returned by cdk execution with the +name `DistributionDomainName`), and web browser capable of analysing Network requests and responses (e.g. Google Chrome +with Developer Tools enabled) or similar tool (e.g. curl, wget). + +### Testing the index url + +1. Access the base distribution URL +2. A return code 200 is returned +3. The response body will contain the text `It works!` + +### Testing the index url with query strings + +1. Access the base distribution URL, appending `?foo=bar` at its end +2. A return code 200 is returned +3. The response body will contain the text `It works!` + +### Testing the test route + +1. Access the base distribution URL, appending `/test` or `/test.html` at its end +2. A return code 308 (permanent redirect) is returned +3. The url will have changed to `/subdir/test.html` +4. A return code 200 is returned +5. The response body will contain the text `This is a test file for you` + +### Testing the invalid route + +1. Access the base distribution URL, appending `/invalid` at its end +2. A return code 403 is returned +3. There won't be any response body + +### Other test cases + +You can explore the `/reources/functions/request-function.js` and `/reources/functions/response-function.js` for +more handling rules. Some of them are validated through _CloudWatch Logs_ and _X-Ray_ given their nature. + +## Destroying the deployment + +To destroy the provisioned infrastructure, you can simply run the following command: + +```shell +$ cdk destroy --all +``` diff --git a/typescript/cloudfront-functions/bin/demo-cloudfront-functions.ts b/typescript/cloudfront-functions/bin/demo-cloudfront-functions.ts new file mode 100644 index 0000000000..648a1d44d8 --- /dev/null +++ b/typescript/cloudfront-functions/bin/demo-cloudfront-functions.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import {DemoCloudfrontFunctionsStack} from '../lib/demo-cloudfront-functions-stack'; + +const app = new cdk.App(); + +new DemoCloudfrontFunctionsStack(app, 'DemoCloudfrontFunctionsStack', {}); diff --git a/typescript/cloudfront-functions/cdk.json b/typescript/cloudfront-functions/cdk.json new file mode 100644 index 0000000000..4ae4ecc285 --- /dev/null +++ b/typescript/cloudfront-functions/cdk.json @@ -0,0 +1,64 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/demo-cloudfront-functions.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true + } +} diff --git a/typescript/cloudfront-functions/jest.config.js b/typescript/cloudfront-functions/jest.config.js new file mode 100644 index 0000000000..08263b8954 --- /dev/null +++ b/typescript/cloudfront-functions/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/typescript/cloudfront-functions/lib/demo-cloudfront-functions-stack.ts b/typescript/cloudfront-functions/lib/demo-cloudfront-functions-stack.ts new file mode 100644 index 0000000000..9e03cea673 --- /dev/null +++ b/typescript/cloudfront-functions/lib/demo-cloudfront-functions-stack.ts @@ -0,0 +1,95 @@ +import * as cdk from 'aws-cdk-lib'; +import {RemovalPolicy} from 'aws-cdk-lib'; +import {Construct} from 'constructs'; +import {BlockPublicAccess, Bucket, BucketEncryption} from "aws-cdk-lib/aws-s3"; +import {BucketDeployment, Source} from "aws-cdk-lib/aws-s3-deployment"; +import { + AllowedMethods, + Distribution, + Function, + FunctionCode, + FunctionEventType, + FunctionRuntime, + OriginAccessIdentity +} from "aws-cdk-lib/aws-cloudfront"; +import {BehaviorOptions} from "aws-cdk-lib/aws-cloudfront/lib/distribution"; +import {S3Origin} from "aws-cdk-lib/aws-cloudfront-origins"; + +export class DemoCloudfrontFunctionsStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // create a bucket to deploy the website files + const bucket = new Bucket(this, 'WebsiteBucket', { + encryption: BucketEncryption.S3_MANAGED, + versioned: true, + enforceSSL: true, + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + removalPolicy: RemovalPolicy.DESTROY + }); + + // create a s3 bucket deployment to deploy the website directory's files to the website bucket + new BucketDeployment(this, 'WebsiteFiles', { + destinationBucket: bucket, + sources: [Source.asset('./website')], + contentType: 'text/html', + retainOnDelete: false, + }); + + // create an Origin Access Identity for CloudFront + const cloudfrontOAI = new OriginAccessIdentity(this, 'cloudfront-OAI', { + comment: `OAI for ${id}` + }); + + // grant read permissions on the bucket to the CloudFront's Origin Access Identity + bucket.grantRead(cloudfrontOAI); + + // create a cloudFront function from the request-function.js file + const requestFunction = new Function(this, 'RequestFunction', { + functionName: 'RequestFunction', + runtime: FunctionRuntime.JS_2_0, + code: FunctionCode.fromFile({ + filePath: './resources/functions/request-function.js' + }) + }); + + // create a cloudFront function from the response-function.js file + const responseFunction = new Function(this, 'ResponseFunction', { + functionName: 'ResponseFunction', + runtime: FunctionRuntime.JS_2_0, + code: FunctionCode.fromFile({ + filePath: './resources/functions/response-function.js' + }) + }); + + // create a CloudFront behavior with origin of my website bucket and both request and response functions + const defaultBehavior: BehaviorOptions = { + origin: new S3Origin(bucket), + compress: true, + allowedMethods: AllowedMethods.ALLOW_ALL, + functionAssociations: [ + { + function: requestFunction, + eventType: FunctionEventType.VIEWER_REQUEST, + }, + { + function: responseFunction, + eventType: FunctionEventType.VIEWER_RESPONSE, + } + ] + }; + + // create a CloudFront distribution with the behavior created + const distribution = new Distribution(this, 'SiteDistribution', { + comment: 'CloudFront Functions example', + defaultRootObject: 'index.html', + defaultBehavior: defaultBehavior, + }); + + // create an output with the CloudFront distribution URL + new cdk.CfnOutput(this, 'DistributionDomainName', { + value: distribution.domainName, + }); + + } +} diff --git a/typescript/cloudfront-functions/package.json b/typescript/cloudfront-functions/package.json new file mode 100644 index 0000000000..180eee5ab6 --- /dev/null +++ b/typescript/cloudfront-functions/package.json @@ -0,0 +1,27 @@ +{ + "name": "demo-cloudfront-functions", + "version": "0.1.0", + "bin": { + "demo-cloudfront-functions": "bin/demo-cloudfront-functions.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "@types/node": "20.11.6", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "aws-cdk": "2.123.0", + "ts-node": "^10.9.2", + "typescript": "~5.3.3" + }, + "dependencies": { + "aws-cdk-lib": "2.123.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + } +} \ No newline at end of file diff --git a/typescript/cloudfront-functions/resources/functions/request-function.js b/typescript/cloudfront-functions/resources/functions/request-function.js new file mode 100644 index 0000000000..c446a7a2bc --- /dev/null +++ b/typescript/cloudfront-functions/resources/functions/request-function.js @@ -0,0 +1,47 @@ +function handler(event) { + // you can log the entire event if you need + // console.log(event) + + // query string validation and overwriting + if (event.request.uri === '/' && event.request.querystring !== {}) { + event.request.querystring = {}; + } + + // url redirects based on a list of URIs + const domain = event.request.headers.host.value; + const redirects = { + '/test.html': `https://${domain}/subdir/test.html`, + '/test': `https://${domain}/subdir/test.html` + } + const redirectUrl = redirects[event.request.uri]; + + if (redirectUrl) { + return { + statusCode: 308, + statusDescription: 'Permanent Redirect', + headers: { + 'location': {value: redirectUrl} + } + }; + } + + // url validation and authorization + if (new RegExp('^/invalid').test(event.request.uri)) { + return { + statusCode: 403, + statusDescription: 'Forbidden', + }; + } + + // header validation and overwriting + if (event.request.headers['x-correlation-id'] && event.request.headers['x-correlation-id'].value === 'abcde') { + event.request.headers['x-correlation-id'].value = 'random-correlation-id'; + } + + // cookie validation and overwriting + if (event.request.cookies['foo'] && event.request.cookies['foo'].value === 'bar') { + event.request.cookies['should-cache'] = {value: 'true'} + } + + return event.request +} diff --git a/typescript/cloudfront-functions/resources/functions/response-function.js b/typescript/cloudfront-functions/resources/functions/response-function.js new file mode 100644 index 0000000000..a1889cc358 --- /dev/null +++ b/typescript/cloudfront-functions/resources/functions/response-function.js @@ -0,0 +1,12 @@ +function handler(event) { + // you can log the entire event if you need + console.log(event) + + // changing the cache ttl for individual objects + if (event.request.uri.endsWith('test.html')) { + event.response.headers['cache-control'] = {value: 'max-age=60'} + event.response.headers['x-test-header'] = {value: 'this is a test'} + } + + return event.response +} diff --git a/typescript/cloudfront-functions/test/demo-cloudfront-functions.test.ts b/typescript/cloudfront-functions/test/demo-cloudfront-functions.test.ts new file mode 100644 index 0000000000..e88cd91f9d --- /dev/null +++ b/typescript/cloudfront-functions/test/demo-cloudfront-functions.test.ts @@ -0,0 +1,38 @@ +import {Match, Template} from "aws-cdk-lib/assertions"; +import * as cdk from "aws-cdk-lib"; +import {DemoCloudfrontFunctionsStack} from "../lib/demo-cloudfront-functions-stack"; + + +test('Synthesizes the stack properly ', () => { + const app = new cdk.App(); + const functionsStack = new DemoCloudfrontFunctionsStack(app, 'functions-stack'); + const template = Template.fromStack(functionsStack); + + template.resourceCountIs('AWS::S3::Bucket', 1) + template.resourceCountIs('AWS::CloudFront::Function', 2); + + template.resourcePropertiesCountIs('AWS::CloudFront::Function', Match.objectLike({ + "Name": "RequestFunction", + }), 1); + + template.resourcePropertiesCountIs('AWS::CloudFront::Function', Match.objectLike({ + "Name": "ResponseFunction", + }), 1); + + template.resourcePropertiesCountIs('AWS::CloudFront::Distribution', { + "DistributionConfig": Match.objectLike({ + "DefaultCacheBehavior": Match.objectLike({ + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": Match.anyValue() + }, + { + "EventType": "viewer-response", + "FunctionARN": Match.anyValue() + } + ] + }) + }) + }, 1); +}); diff --git a/typescript/cloudfront-functions/tsconfig.json b/typescript/cloudfront-functions/tsconfig.json new file mode 100644 index 0000000000..aaa7dc510f --- /dev/null +++ b/typescript/cloudfront-functions/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": [ + "es2020", + "dom" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} diff --git a/typescript/cloudfront-functions/website/error.html b/typescript/cloudfront-functions/website/error.html new file mode 100644 index 0000000000..c86f9b8ac4 --- /dev/null +++ b/typescript/cloudfront-functions/website/error.html @@ -0,0 +1,5 @@ + + +Sorry, an error occurred + + diff --git a/typescript/cloudfront-functions/website/index.html b/typescript/cloudfront-functions/website/index.html new file mode 100644 index 0000000000..fe609b14bc --- /dev/null +++ b/typescript/cloudfront-functions/website/index.html @@ -0,0 +1,5 @@ + + +It works! + + diff --git a/typescript/cloudfront-functions/website/not-found.html b/typescript/cloudfront-functions/website/not-found.html new file mode 100644 index 0000000000..d84a15fac1 --- /dev/null +++ b/typescript/cloudfront-functions/website/not-found.html @@ -0,0 +1,5 @@ + + +The page could not be found. + + diff --git a/typescript/cloudfront-functions/website/subdir/test.html b/typescript/cloudfront-functions/website/subdir/test.html new file mode 100644 index 0000000000..f74d7475c6 --- /dev/null +++ b/typescript/cloudfront-functions/website/subdir/test.html @@ -0,0 +1,5 @@ + + +This is a test file for you + +