AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Static website using S3 and CloudFront
Parameters:
  Project:
    Type: String
    Description: The project name
    Default: example
  Environment:
    Type: String
    Description: The deployment environment
    Default: dev
  DomainName:
    Type: String
    Description: The domain name of website
    Default: ''
  HostedZoneId:
    Type: String
    Description: The Route53 hosted zone ID used for the domain, required only if
      DomainName is provided
    Default: ''
  AcmCertificateArn:
    Type: String
    Description: The certificate arn for the domain name provided, required only if
      DomainName is provided
    Default: ''
  IndexDocument:
    Type: String
    Description: The index document
    Default: index.html
  ErrorDocument:
    Type: String
    Description: The error document, ignored in SPA mode
    Default: 404.html
  RewriteMode:
    Type: String
    Description: The request rewrite behaviour type
    Default: STATIC
    AllowedValues:
    - STATIC
    - SPA
  RewriteExcludeRegEx:
    Type: String
    Description: Regular expression for authentication and path rewrite exclusion
    Default: (^/assets/|robots\.txt$|favicon\.ico$|favicon\.svg$)
  HttpAuth:
    Type: String
    Description: Enable or disable the HTTP basic auth
    Default: DISABLED
    AllowedValues:
    - ENABLED
    - DISABLED
  HttpAuthUsername:
    Type: String
    Description: HTTP Basic Auth username
    Default: ''
  HttpAuthPassword:
    Type: String
    Description: HTTP Basic Auth password
    Default: ''
    NoEcho: true
  CloudFrontPriceClass:
    Type: String
    Description: The price class for CloudFront distribution
    Default: PriceClass_100
    AllowedValues:
    - PriceClass_100
    - PriceClass_200
    - PriceClass_All
  AllowedHeaders:
    Type: CommaDelimitedList
    Description: CORS Allowed headers
    Default: '*'
  AllowedMethods:
    Type: CommaDelimitedList
    Description: CORS Allowed methods
    Default: GET,HEAD
  AllowedOrigins:
    Type: CommaDelimitedList
    Description: CORS Allowed origins
    Default: '*'
  ExposedHeaders:
    Type: CommaDelimitedList
    Description: CORS exposed headers
    Default: ETag
  AutomaticInvalidation:
    Type: String
    Description: Automatically invalidate CloudFront distribution when S3 bucket object
      change
    Default: DISABLED
    AllowedValues:
    - ENABLED
    - DISABLED
Conditions:
  IsHostedZoneIdProvided:
    Fn::Not:
    - Fn::Equals:
      - Ref: HostedZoneId
      - ''
  IsStaticMode:
    Fn::Equals:
    - Ref: RewriteMode
    - STATIC
  IsAutomaticInvalidationEnabled:
    Fn::Equals:
    - Ref: AutomaticInvalidation
    - ENABLED
  IsDomainProvided:
    Fn::Not:
    - Fn::Equals:
      - Ref: DomainName
      - ''
Resources:
  DnsRecord:
    Type: AWS::Route53::RecordSet
    Condition: IsHostedZoneIdProvided
    Properties:
      HostedZoneId:
        Ref: HostedZoneId
      Name:
        Ref: DomainName
      Type: A
      AliasTarget:
        DNSName:
          Fn::GetAtt:
          - Distribution
          - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
    Metadata:
      SamResourceId: DnsRecord
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName:
        Fn::Sub: ${Project}-${Environment}-site
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
        - AllowedHeaders:
            Ref: AllowedHeaders
          AllowedMethods:
            Ref: AllowedMethods
          AllowedOrigins:
            Ref: AllowedOrigins
          ExposedHeaders:
            Ref: ExposedHeaders
          MaxAge: '3600'
      NotificationConfiguration:
        EventBridgeConfiguration:
          EventBridgeEnabled:
            Fn::If:
            - IsAutomaticInvalidationEnabled
            - true
            - false
    Metadata:
      SamResourceId: Bucket
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket:
        Ref: Bucket
      PolicyDocument:
        Statement:
        - Effect: Allow
          Action: s3:GetObject
          Resource:
            Fn::Sub: arn:aws:s3:::${Bucket}/*
          Principal:
            AWS:
              Fn::Sub: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity
                ${OriginAccessIdentity}
    Metadata:
      SamResourceId: BucketPolicy
  OriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment:
          Fn::Sub: ${Project}-${Environment}
    Metadata:
      SamResourceId: OriginAccessIdentity
  RequestRouterFunction:
    Type: AWS::CloudFront::Function
    Properties:
      Name:
        Fn::Sub: ${Project}-${Environment}-req-router
      AutoPublish: true
      FunctionCode:
        Fn::Sub:
        - "var rewriteMode = '${RewriteMode}';\nvar isAuthEnabled = '${HttpAuth}'\
          \ === 'ENABLED';\nvar authToken = '${HttpAuthTokenEncoded}';\nvar responseUnauthorized\
          \ = {\n  statusCode: 401,\n  statusDescription: \"Unauthorized\",\n  headers:\
          \ {\n    'www-authenticate': { value: 'Basic realm=\"Login Required\", charset=\"\
          UTF-8\"' }\n  }\n};\n\nfunction handler(event) {\n  var request = event.request;\n\
          \  var uri = request.uri;\n  console.log('provided uri ' + uri);\n\n  if\
          \ (uri.match('${RewriteExcludeRegEx}') !== null) {\n    console.log('detected\
          \ excluded path, skip');\n    return request;\n  }\n\n  if (isAuthEnabled)\
          \ {\n    var providedAuth = request.headers.authorization;\n    if (!providedAuth\
          \ || !providedAuth.value) {\n      console.log('auth token not provided,\
          \ return unauthorized response');\n      return responseUnauthorized;\n\
          \    }\n    if (providedAuth.value !== ('Basic ' + authToken)) {\n     \
          \ console.log('wrong auth token, provided ' + providedAuth.value  + ' but\
          \ expected ' + authToken + ', return unauthorized response');\n      return\
          \ responseUnauthorized;\n    }\n  } else {\n    console.log('auth not enabled,\
          \ skip');\n  }\n\n  if (rewriteMode === 'STATIC') {\n    if (!uri.endsWith('.html'))\
          \ {\n      if (uri !== '/' && !uri.endsWith('/')) {\n        request.uri\
          \ += '/';\n        console.log('trailing slash path rewritten to ' + request.uri);\n\
          \      }\n      request.uri += '${IndexDocument}';\n      console.log('path\
          \ rewritten for static to ' + request.uri);\n    } else {\n      console.log('path\
          \ not rewritten, used as it for static');\n    }\n  } else if (rewriteMode\
          \ === 'SPA') {\n    request.uri = '/${IndexDocument}';\n    console.log('path\
          \ rewritten for SPA to ' + request.uri);\n  }\n\n  return request;\n}\n"
        - HttpAuthTokenEncoded:
            Fn::Base64:
              Fn::Sub: ${HttpAuthUsername}:${HttpAuthPassword}
          RewriteMode:
            Ref: RewriteMode
          HttpAuth:
            Ref: HttpAuth
          RewriteExcludeRegEx:
            Ref: RewriteExcludeRegEx
      FunctionConfig:
        Comment: Check request authentication and rewrite requests
        Runtime: cloudfront-js-1.0
    Metadata:
      SamResourceId: RequestRouterFunction
  InvalidationFunction:
    Type: AWS::Serverless::Function
    Condition: IsAutomaticInvalidationEnabled
    Properties:
      FunctionName:
        Fn::Sub: ${Project}-${Environment}-cf-invalidate
      Handler: index.handler
      Runtime: nodejs22.x
      MemorySize: 256
      Timeout: 30
      InlineCode:
        Fn::Sub: "const { CloudFrontClient, CreateInvalidationCommand } = require(\"\
          @aws-sdk/client-cloudfront\");\nconst cloudfront = new CloudFrontClient();\n\
          \nconst CF_DISTRIBUTION_ID = process.env.CF_DISTRIBUTION_ID;\n\nexports.handler\
          \ = async (event) => {\n\t  console.log('S3 event', JSON.stringify(event))\n\
          \n    const bucket = event['detail']['bucket']['name']\n    let key = decodeURIComponent(event['detail']['object']['key'])\n\
          \    if (!key.startsWith('/')) key = '/' + key\n\n    console.log(\"Creating\
          \ invalidation for \" + key + \" on Cloudfront distribution \" + CF_DISTRIBUTION_ID)\n\
          \n    const params = {\n      DistributionId: CF_DISTRIBUTION_ID,\n    \
          \  InvalidationBatch: {\n        CallerReference: String(Date.now()),\n\
          \        Paths: {\n          Quantity: 1,\n          Items: [key]\n    \
          \    }\n      }\n    };\n\n    try {\n      const invalidation = await cloudfront.send(new\
          \ CreateInvalidationCommand(params));\n      console.log(\"Submitted invalidation.\
          \ ID \" + invalidation.Invalidation.Id + \" Status \" + invalidation.Invalidation.Status);\n\
          \    } catch (e) {\n        console.log(\"Error processing object \" + key\
          \ + \" from bucket \" + bucket, e)\n    }\n}\n"
      Environment:
        Variables:
          CF_DISTRIBUTION_ID:
            Fn::GetAtt:
            - Distribution
            - Id
      Policies:
      - Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Action:
          - cloudfront:CreateInvalidation
          Resource: '*'
      Events:
        S3Event:
          Type: EventBridgeRule
          Properties:
            Pattern:
              source:
              - aws.s3
              detail-type:
              - Object Created
              - Object Deleted
              detail:
                bucket:
                  name:
                  - Ref: Bucket
    Metadata:
      SamResourceId: InvalidationFunction
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: 'true'
        Comment:
          Fn::Sub: ${Project}-${Environment}
        DefaultRootObject:
          Ref: IndexDocument
        HttpVersion: http2
        CustomErrorResponses:
        - ErrorCachingMinTTL: 86400
          ErrorCode: 403
          ResponseCode:
            Fn::If:
            - IsStaticMode
            - 404
            - 200
          ResponsePagePath:
            Fn::If:
            - IsStaticMode
            - Fn::Sub: /${ErrorDocument}
            - Fn::Sub: /${IndexDocument}
        Origins:
        - DomainName:
            Fn::Sub: ${Bucket}.s3.${AWS::Region}.amazonaws.com
          Id: bucketOrigin
          S3OriginConfig:
            OriginAccessIdentity:
              Fn::Sub: origin-access-identity/cloudfront/${OriginAccessIdentity}
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          - OPTIONS
          CachedMethods:
          - GET
          - HEAD
          TargetOriginId: bucketOrigin
          ViewerProtocolPolicy: redirect-to-https
          OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
          ResponseHeadersPolicyId: eaab4381-ed33-4a86-88ca-d9558dc6cd63
          FunctionAssociations:
          - EventType: viewer-request
            FunctionARN:
              Fn::GetAtt:
              - RequestRouterFunction
              - FunctionMetadata.FunctionARN
        PriceClass:
          Ref: CloudFrontPriceClass
        Aliases:
        - Fn::If:
          - IsDomainProvided
          - Ref: DomainName
          - Ref: AWS::NoValue
        ViewerCertificate:
          CloudFrontDefaultCertificate:
            Fn::If:
            - IsDomainProvided
            - Ref: AWS::NoValue
            - true
          AcmCertificateArn:
            Fn::If:
            - IsDomainProvided
            - Ref: AcmCertificateArn
            - Ref: AWS::NoValue
          SslSupportMethod:
            Fn::If:
            - IsDomainProvided
            - sni-only
            - Ref: AWS::NoValue
    Metadata:
      SamResourceId: Distribution
Outputs:
  BucketName:
    Description: The S3 bucket name where HTML files need to be uploaded
    Value:
      Ref: Bucket
  CloudFrontDistributionId:
    Description: The CloudFront distribution ID in front of the S3 bucket
    Value:
      Ref: Distribution
  WebsiteUrl:
    Description: The website URL
    Value:
      Fn::If:
      - IsDomainProvided
      - Fn::Sub: https://${DomainName}/
      - Fn::Sub: https://${Distribution.DomainName}/
