< Summary - Envilder IaC (CDK)

Information
Class: src/iac/lib/stacks/staticWebsiteStack.ts
Assembly: Default
File(s): src/iac/lib/stacks/staticWebsiteStack.ts
Tag: 472_30626978468
Line coverage
94%
Covered lines: 37
Uncovered lines: 2
Coverable lines: 39
Total lines: 303
Line coverage: 94.8%
Branch coverage
65%
Covered branches: 17
Total branches: 26
Branch coverage: 65.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/iac/lib/stacks/staticWebsiteStack.ts

#LineLine coverage
 1import { join } from "node:path";
 2import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
 3import {
 4  Certificate,
 5  type ICertificate,
 6} from "aws-cdk-lib/aws-certificatemanager";
 7import {
 8  Distribution,
 9  type ErrorResponse,
 10  FunctionCode,
 11  FunctionEventType,
 12  Function as LambdaFunction,
 13  HeadersFrameOption,
 14  HeadersReferrerPolicy,
 15  HttpVersion,
 16  OriginAccessIdentity,
 17  ResponseHeadersPolicy,
 18  ViewerProtocolPolicy,
 19} from "aws-cdk-lib/aws-cloudfront";
 20import { S3BucketOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
 21import { ARecord, HostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
 22import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
 23import {
 24  BlockPublicAccess,
 25  Bucket,
 26  BucketAccessControl,
 27  BucketEncryption,
 28  HttpMethods,
 29} from "aws-cdk-lib/aws-s3";
 30import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
 31import type { Construct } from "constructs";
 32import {
 33  CustomStack,
 34  type CustomStackProps,
 35  type DomainConfig,
 36} from "./customStack";
 37
 38export interface StaticWebsiteStackProps extends CustomStackProps {
 39  domains: DomainConfig[];
 40  distFolderPath: string;
 41}
 42
 43export class StaticWebsiteStack extends CustomStack {
 44  constructor(scope: Construct, props: StaticWebsiteStackProps) {
 345    super(scope, props);
 46
 347    if (!props.domains || props.domains.length === 0) {
 048      throw new Error("At least one domain configuration is required");
 49    }
 50
 351    const primaryDomain = props.domains[0];
 52    const primaryFullDomainName =
 353      primaryDomain.subdomain && primaryDomain.subdomain.length > 0
 54        ? [primaryDomain.subdomain, primaryDomain.domainName]
 55            .join(".")
 56            .toLowerCase()
 57        : primaryDomain.domainName.toLowerCase();
 58
 359    const allDomainNames = props.domains.map((domain) =>
 360      domain.subdomain && domain.subdomain.length > 0
 61        ? `${domain.subdomain}.${domain.domainName}`.toLowerCase()
 62        : domain.domainName.toLowerCase(),
 63    );
 64
 365    const certificateMap = new Map<string, ICertificate>();
 366    for (const domain of props.domains) {
 367      if (!certificateMap.has(domain.certificateId)) {
 368        const certificateArn = `arn:aws:acm:us-east-1:${props.env?.account}:certificate/${domain.certificateId}`;
 369        certificateMap.set(
 70          domain.certificateId,
 71          Certificate.fromCertificateArn(
 72            this,
 73            `certificate-${domain.certificateId}`,
 74            certificateArn,
 75          ),
 76        );
 77      }
 78    }
 79
 380    const primaryCertificate = certificateMap.get(primaryDomain.certificateId);
 381    if (!primaryCertificate) {
 082      throw new Error(
 83        `Certificate not found for ${primaryDomain.certificateId}`,
 84      );
 85    }
 86
 387    const loggingBucket = new Bucket(this, "logging-bucket", {
 88      accessControl: BucketAccessControl.LOG_DELIVERY_WRITE,
 89      publicReadAccess: false,
 90      versioned: false,
 91      removalPolicy: RemovalPolicy.DESTROY,
 92      bucketName: `${primaryFullDomainName}-logs`,
 93      autoDeleteObjects: true,
 94      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
 95      encryption: BucketEncryption.S3_MANAGED,
 96      enforceSSL: true,
 97      lifecycleRules: [
 98        {
 99          id: "DeleteOldLogs",
 100          expiration: Duration.days(90),
 101          enabled: true,
 102        },
 103      ],
 104    });
 105
 3106    const bucketWebsite = new Bucket(this, "static-website-bucket", {
 107      accessControl: BucketAccessControl.PRIVATE,
 108      publicReadAccess: false,
 109      versioned: false,
 110      removalPolicy: RemovalPolicy.DESTROY,
 111      bucketName: primaryFullDomainName,
 112      autoDeleteObjects: true,
 113      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
 114      encryption: BucketEncryption.S3_MANAGED,
 115      cors: [
 116        {
 117          allowedMethods: [HttpMethods.GET, HttpMethods.HEAD],
 118          allowedOrigins: ["*"],
 119          allowedHeaders: ["*"],
 120        },
 121      ],
 122      enforceSSL: true,
 123      serverAccessLogsBucket: loggingBucket,
 124      serverAccessLogsPrefix: "s3-access-logs/",
 125    });
 126
 3127    const originAccessIdentity = new OriginAccessIdentity(
 128      this,
 129      "originAccessIdentity",
 130      {
 131        comment: `Setup access from CloudFront to the bucket ${primaryFullDomainName} (read)`,
 132      },
 133    );
 134
 3135    bucketWebsite.grantRead(originAccessIdentity);
 136
 3137    const errorResponses: ErrorResponse[] = [];
 138
 3139    const errorResponse403: ErrorResponse = {
 140      httpStatus: 403,
 141      responseHttpStatus: 404,
 142      responsePagePath: "/404.html",
 143      ttl: Duration.minutes(5),
 144    };
 145
 3146    const errorResponse404: ErrorResponse = {
 147      httpStatus: 404,
 148      responseHttpStatus: 404,
 149      responsePagePath: "/404.html",
 150      ttl: Duration.minutes(5),
 151    };
 152
 3153    errorResponses.push(errorResponse403, errorResponse404);
 154
 3155    const securityHeadersBehavior = {
 156      contentTypeOptions: {
 157        override: true,
 158      },
 159      frameOptions: {
 160        frameOption: HeadersFrameOption.DENY,
 161        override: true,
 162      },
 163      referrerPolicy: {
 164        referrerPolicy: HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
 165        override: true,
 166      },
 167      strictTransportSecurity: {
 168        accessControlMaxAge: Duration.days(365),
 169        includeSubdomains: true,
 170        override: true,
 171      },
 172    };
 3173    const defaultResponseHeadersPolicy = new ResponseHeadersPolicy(
 174      this,
 175      "default-response-headers-policy",
 176      {
 177        securityHeadersBehavior,
 178        customHeadersBehavior: {
 179          customHeaders: [
 180            {
 181              header: "Cache-Control",
 182              value: "public, max-age=0, s-maxage=300, must-revalidate",
 183              override: true,
 184            },
 185          ],
 186        },
 187      },
 188    );
 3189    const assetResponseHeadersPolicy = new ResponseHeadersPolicy(
 190      this,
 191      "asset-response-headers-policy",
 192      {
 193        securityHeadersBehavior,
 194        customHeadersBehavior: {
 195          customHeaders: [
 196            {
 197              header: "Cache-Control",
 198              value: "public, max-age=31536000, immutable",
 199              override: true,
 200            },
 201          ],
 202        },
 203      },
 204    );
 3205    const staticWebsiteOrigin = S3BucketOrigin.withOriginAccessIdentity(
 206      bucketWebsite,
 207      {
 208        originAccessIdentity: originAccessIdentity,
 209      },
 210    );
 211
 3212    const distribution = new Distribution(this, "distribution", {
 213      domainNames: allDomainNames,
 214      defaultBehavior: {
 215        origin: staticWebsiteOrigin,
 216        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
 217        responseHeadersPolicy: defaultResponseHeadersPolicy,
 218        functionAssociations: [
 219          {
 220            eventType: FunctionEventType.VIEWER_REQUEST,
 221            function: new LambdaFunction(
 222              this,
 223              `${primaryFullDomainName}-url-rewrite`.toLowerCase(),
 224              {
 225                code: FunctionCode.fromFile({
 226                  filePath: join(__dirname, "cloudfront-url-rewrite.js"),
 227                }),
 228              },
 229            ),
 230          },
 231        ],
 232      },
 233      additionalBehaviors: {
 234        "_assets/*": {
 235          origin: staticWebsiteOrigin,
 236          viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
 237          responseHeadersPolicy: assetResponseHeadersPolicy,
 238        },
 239      },
 240      defaultRootObject: "index.html",
 241      certificate: primaryCertificate,
 242      errorResponses: errorResponses,
 243      enableLogging: true,
 244      logBucket: loggingBucket,
 245      logFilePrefix: "cloudfront-logs/",
 246      httpVersion: HttpVersion.HTTP2_AND_3,
 247    });
 248
 3249    new BucketDeployment(this, "deploy-static-website", {
 250      sources: [Source.asset(props.distFolderPath)],
 251      destinationBucket: bucketWebsite,
 252      distribution,
 253      distributionPaths: ["/*"],
 254    });
 255
 3256    const aliasRecords: ARecord[] = [];
 3257    for (const [index, domainConfig] of props.domains.entries()) {
 258      const fullDomainName =
 3259        domainConfig.subdomain && domainConfig.subdomain.length > 0
 260          ? `${domainConfig.subdomain}.${domainConfig.domainName}`.toLowerCase()
 261          : domainConfig.domainName.toLowerCase();
 262
 263      const zoneLogicalId =
 3264        index === 0
 265          ? "publicHostedZone-0"
 266          : `hostedZone-${fullDomainName.replace(/[.-]/g, "")}`;
 267
 3268      const zoneFromAttributes = HostedZone.fromHostedZoneAttributes(
 269        this,
 270        zoneLogicalId,
 271        {
 272          zoneName: domainConfig.domainName,
 273          hostedZoneId: domainConfig.hostedZoneId,
 274        },
 275      );
 276
 277      const recordLogicalId =
 3278        index === 0
 279          ? "webDomainRecord-0"
 280          : `webDomainRecord-${fullDomainName.replace(/[.-]/g, "")}`;
 281
 3282      const aliasRecord = new ARecord(this, recordLogicalId, {
 283        zone: zoneFromAttributes,
 284        recordName: fullDomainName,
 285        target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)),
 286      });
 287
 3288      aliasRecords.push(aliasRecord);
 289    }
 290
 3291    new CfnOutput(this, "CloudFrontDistributionDomainName", {
 292      value: distribution.distributionDomainName,
 293      description: "CloudFront distribution domain",
 294      exportName: `${this.getCloudFormationRepoName()}-${props.envName}-CdnDomainName`,
 295    });
 296
 3297    new CfnOutput(this, "DnsRecordName", {
 298      value: aliasRecords[0].domainName || allDomainNames[0],
 299      description: "The DNS record name (primary)",
 300      exportName: `${this.getCloudFormationRepoName()}-${props.envName}-AliasRecord`,
 301    });
 302  }
 303}

Methods/Properties

constructor
(anonymous_1)