Skip to content

Commit

Permalink
Merge 'feature/s3-temp-web-storage' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
tstibbs committed May 3, 2024
2 parents fef8b68 + 030607b commit 18a8836
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 4 deletions.
29 changes: 29 additions & 0 deletions aws/utils/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions aws/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"cdk-tools": "./src/cdkTools/cli.js"
},
"dependencies": {
"@aws-cdk/aws-apigatewayv2-alpha": "^2.110.0-alpha.0",
"@aws-cdk/aws-apigatewayv2-integrations-alpha": "^2.110.0-alpha.0",
"aws-cdk": "^2.110.0",
"aws-cdk-lib": "^2.110.0",
"aws-sdk": "^2.1499.0",
Expand Down
21 changes: 18 additions & 3 deletions aws/utils/src/stacks/cloudfront.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ const cloudFrontPassThroughHeaders = [
export class CloudFrontResources {
#distribution
#originRequestPolicy
#responseHeaderPolicy

constructor(stack, denyCountries, defaultBehavior) {
constructor(stack, denyCountries, defaultBehavior, corsAllowedOrigins = null) {
const logsBucket = Bucket.fromBucketArn(stack, 'applicationLogsBucket', Fn.importValue(applicationLogsBucketRef))
const distributionConstructId = 'distribution'
const distributionProps = {
Expand All @@ -52,13 +53,27 @@ export class CloudFrontResources {

new CfnOutput(stack, 'distributionDomainName', {value: this.#distribution.distributionDomainName})
outputUsageStoreInfo(stack, distributionConstructId, logsBucket.bucketName, USAGE_TYPE_CLOUDFRONT)

this.#responseHeaderPolicy =
corsAllowedOrigins == null
? null
: new ResponseHeadersPolicy(stack, 'CorsResponseHeadersPolicy', {
comment: `The default ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT doesn't allow the client to pass Content-Type.`,
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: AllowedMethods.ALLOW_ALL.methods,
accessControlAllowOrigins: corsAllowedOrigins,
originOverride: false
}
})
}

//GET_HEAD is the default, but specifying it here for future compatibility
addHttpApi(path, httpApi, allowedMethods = AllowedMethods.ALLOW_GET_HEAD) {
const httpApiDomain = Fn.select(2, Fn.split('/', httpApi.url))
this.#distribution.addBehavior(path, new HttpOrigin(httpApiDomain), {
responseHeadersPolicy: ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT,
responseHeadersPolicy: this.#responseHeaderPolicy,
allowedMethods: allowedMethods,
viewerProtocolPolicy: ViewerProtocolPolicy.HTTPS_ONLY,
cachePolicy: CachePolicy.CACHING_DISABLED,
Expand All @@ -70,7 +85,7 @@ export class CloudFrontResources {
const httpApiDomain = Fn.select(2, Fn.split('/', webSocketStage.baseApi.apiEndpoint))
this.#distribution.addBehavior(path, new HttpOrigin(httpApiDomain), {
originPath: webSocketStage.stageName,
responseHeadersPolicy: ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT,
responseHeadersPolicy: this.#responseHeaderPolicy,
allowedMethods: AllowedMethods.ALLOW_GET_HEAD, //GET_HEAD is the default, but specifying it here for future compatibility
viewerProtocolPolicy: ViewerProtocolPolicy.HTTPS_ONLY,
cachePolicy: CachePolicy.CACHING_DISABLED,
Expand Down
1 change: 1 addition & 0 deletions aws/utils/src/stacks/s3-temp-web-storage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This folder contains utilities for a common pattern where S3 is used as temporary storage for a website, and an apigateway provides access to get pre-signed s3 urls to access objects in the s3 bucket.
102 changes: 102 additions & 0 deletions aws/utils/src/stacks/s3-temp-web-storage/lib/stack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {fileURLToPath} from 'node:url'
import {dirname, resolve} from 'node:path'

import {Aws, RemovalPolicy, Duration, Fn} from 'aws-cdk-lib'
import {CfnAccount} from 'aws-cdk-lib/aws-apigateway'
import {HttpLambdaIntegration} from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import {HttpApi, HttpMethod, CorsHttpMethod} from '@aws-cdk/aws-apigatewayv2-alpha'
import {Bucket, HttpMethods, BucketEncryption} from 'aws-cdk-lib/aws-s3'
import {PolicyStatement} from 'aws-cdk-lib/aws-iam'
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs'
import {Runtime} from 'aws-cdk-lib/aws-lambda'
import {AllowedMethods} from 'aws-cdk-lib/aws-cloudfront'

const __dirname = dirname(fileURLToPath(import.meta.url))

export class S3TempWebStorageResources {
#bucket
#httpApi

constructor(stack, cloudFrontResources, corsAllowedOrigins, objectExpiry, httpApiPrefix, getItemUrlsEndpoint) {
new CfnAccount(stack, 'agiGatewayAccount', {
//use a centrally created role so that it doesn't get deleted when this stack is torn down
cloudWatchRoleArn: Fn.importValue('AllAccountsStack-apiGatewayCloudWatchRoleArn')
})

let bucketProps = {
removalPolicy: RemovalPolicy.DESTROY,
encryption: BucketEncryption.S3_MANAGED,
autoDeleteObjects: true,
lifecycleRules: [
{
id: 'expire',
expiration: objectExpiry //e.g. Duration.days(1)
},
{
id: 'cleanup',
abortIncompleteMultipartUploadAfter: Duration.days(1)
}
]
}
if (corsAllowedOrigins != null) {
bucketProps.cors = [
{
allowedMethods: [HttpMethods.GET, HttpMethods.PUT],
allowedOrigins: corsAllowedOrigins,
allowedHeaders: ['Content-Type']
}
]
}
this.#bucket = new Bucket(stack, 'filesBucket', bucketProps)

const httpApiProps = {
apiName: `${Aws.STACK_NAME}-httpApi`
}
if (corsAllowedOrigins != null) {
httpApiProps.corsPreflight = {
allowMethods: [CorsHttpMethod.POST],
allowOrigins: corsAllowedOrigins
}
}
this.#httpApi = new HttpApi(stack, 'httpApi', httpApiProps)

cloudFrontResources.addHttpApi(`${httpApiPrefix}/*`, this.#httpApi, AllowedMethods.ALLOW_ALL)

this.#buildHandler(stack, getItemUrlsEndpoint, 'get-item-urls', httpApiPrefix)
}

#buildHandler(stack, name, entry, httpApiPrefix) {
let handler = this.#buildGenericHandler(stack, `${name}-handler`, entry, {
BUCKET: this.#bucket.bucketName
})
handler.addToRolePolicy(
new PolicyStatement({
resources: [
this.#bucket.arnForObjects('*') //"arn:aws:s3:::bucketname/*"
],
actions: ['s3:GetObject', 's3:PutObject']
})
)
let integration = new HttpLambdaIntegration(`${name}-integration`, handler)
this.#httpApi.addRoutes({
path: `/${httpApiPrefix}/${name}`,
methods: [HttpMethod.POST],
integration: integration
})
}

#buildGenericHandler(stack, name, entry, envs) {
const handler = new NodejsFunction(stack, name, {
entry: resolve(__dirname, `../src/${entry}.js`),
memorySize: 128,
timeout: Duration.seconds(20),
runtime: Runtime.NODEJS_20_X,
environment: envs
})
return handler
}

get httpApi() {
return this.#httpApi
}
}
2 changes: 2 additions & 0 deletions aws/utils/src/stacks/s3-temp-web-storage/shared/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const endpointFileNameParam = 'fileName'
export const endpointPrefixesParam = 'prefixes'
40 changes: 40 additions & 0 deletions aws/utils/src/stacks/s3-temp-web-storage/src/get-item-urls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {randomUUID} from 'crypto'

import {BUCKET, aws} from './utils.js'
import {endpointFileNameParam, endpointPrefixesParam} from '../shared/constants.js'
const s3 = new aws.S3()

export async function handler(event) {
let {body} = event
if (event.isBase64Encoded) {
body = Buffer.from(event.body, 'base64')
}
body = JSON.parse(body)
const fileName = body?.[endpointFileNameParam]
const prefixes = body?.[endpointPrefixesParam]
let errors = []
if (fileName == null || fileName.length == 0) {
errors.push(`parameter '${endpointFileNameParam}' must be specified and non-empty string`)
}
if (prefixes != null && (!Array.isArray(prefixes) || prefixes.length == 0)) {
errors.push(`if specified, parameter '${endpointPrefixesParam}' must be a non-zero length array`)
}
if (errors.length > 0) {
return {
isBase64Encoded: false,
statusCode: 400,
body: errors.join('; ')
}
}
const randomizer = randomUUID() //prevents object names in the bucket being predictable, and also prevents clashes by different files that are named the same
const prefix = prefixes != null && prefixes.length > 0 ? [...prefixes, ''].join('/') : ''
const key = `${prefix}${fileName}-${randomizer}`
const sign = async operation => await s3.getSignedUrlPromise(operation, {Bucket: BUCKET, Key: key})
const getUrl = await sign('getObject')
const putUrl = await sign('putObject')

return {
getUrl,
putUrl
}
}
12 changes: 12 additions & 0 deletions aws/utils/src/stacks/s3-temp-web-storage/src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import dotenv from 'dotenv'
import aws from 'aws-sdk'

dotenv.config()

aws.config.apiVersions = {
s3: '2006-03-01'
}

export {aws}

export const {BUCKET} = process.env
2 changes: 1 addition & 1 deletion aws/utils/test/import-all-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {fileURLToPath} from 'url'
import {list} from 'recursive-readdir-async'
import {strict as assert} from 'assert'

const expectedNumberOfFiles = 13 //sanity check in case a change happens which breaks this test; update this if the number of files changes
const expectedNumberOfFiles = 17 //sanity check in case a change happens which breaks this test; update this if the number of files changes

const dir = join(dirname(fileURLToPath(import.meta.url)), '../src')
let files = await list(dir)
Expand Down

0 comments on commit 18a8836

Please sign in to comment.