UNPKG

cloudsite

Version:

Low cost, high performance cloud based website hosting manager.

205 lines (204 loc) 36.9 kB
/*! * Copyright 2023 Liquid Labs, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ "use strict" Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"}) const e=require("@aws-sdk/client-cloudformation"),t=require("@aws-sdk/client-cost-explorer") require("magic-print") const n=require("@aws-sdk/client-route-53"),i=require("@aws-sdk/client-cloudfront"),o=require("uuid"),a=require("@aws-sdk/client-s3"),s=require("@aws-sdk/client-sts"),r=require("@aws-sdk/credential-providers"),c=require("regex-repo"),l=require("s3-empty-bucket"),u=require("@aws-sdk/client-lambda"),d=require("node:path"),m=require("node:fs"),p=require("js-yaml"),g=require("@liquid-labs/shell-toolkit"),f=require("mime-types"),w=require("s3-sync-client"),C=require("lodash/isEqual") function S(e){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}}) if(e)for(const n in e)if("default"!==n){const i=Object.getOwnPropertyDescriptor(e,n) Object.defineProperty(t,n,i.get?i:{enumerable:!0,get:()=>e[n]})}return t.default=e,Object.freeze(t)}const b=S(d),y="tags activated; NO allocation rule",A="site cost allocation rule defined",D={},h=async({credentials:e,db:n,siteInfo:i})=>{const o=new t.CostExplorerClient({credentials:e}) D.write("Examining cost allocation status... ") const{billing:a={}}=n n.billing=a let{costAllocationStatus:s}=a if(s===A)D.write(A+"\n") else if("NOT set"===s||void 0===s)try{D.write("\n Activating cost allocation tags...") const e=new t.UpdateCostAllocationTagsStatusCommand({CostAllocationTagsStatus:[{TagKey:"function",Status:"Active"},{TagKey:"site",Status:"Active"}]}) let n try{({Errors:n}=await o.send(e))}catch(e){F({e:e,siteInfo:i})}if(n.length>0){D.write("PARTIAL success\n") const e=n.reduce(((e,{Code:t,Message:n,TagKey:i},o)=>(o>0&&(e+="\n"),e+=`${i}: ${n} (${t})`)),"")+"\n" throw new Error(e)}D.write("SUCCESS"),s=y,a.costAllocationStatus=y}catch(e){var r throw null!==(r=e.message)&&void 0!==r&&r.endsWith("PARTIAL_SUCCESS\n")&&D.write("ERROR\n"),e}if(s===y){D.write("\n Creating site cost allocation rule... ") const e=new t.CreateCostCategoryDefinitionCommand({Name:"Site cost allocation",RuleVersion:"CostCategoryExpression.v1",Rules:[{Type:"INHERITED_VALUE",InheritedValue:{DimensionName:"TAG",DimensionKey:"site"}}]}) try{const t=await o.send(e),{CostCategoryArn:n}=t n&&(a.costCategoryArn=n),a.costAllocationStatus=A,s=A,D.write("SUCCESS\n")}catch(e){throw D.write("ERROR\n"),e}}},F=({e:e,siteInfo:t})=>{if("CredentialsProviderError"===e.name)throw D.write("\n"),e const{apexDomain:n}=t D.write("<error>!! ERROR !!<rst>: "+e.message),D.write(`\nThe attempt to setup your cost allocation tags has failed (refer to the error message above). If this is the first time the tags have been set up, then the issue may be that AWS must 'discover' your tags before they can be activated for cost allocation. This process can take a little time. Wait a little while and try setting up the cost allocation tags again with:\n\n<code>cloudsite update ${n} --do-billing<rst>\n\n`)},R=e=>e.replaceAll(/\./g,"-").replaceAll(/[^a-z0-9-]/g,"x"),I=async({route53Client:e,siteInfo:t})=>{let i do{const o=new n.ListHostedZonesCommand({Marker:i}),a=await e.send(o) for(const{Id:e,Name:n}of a.HostedZones)if(n===t.apexDomain+".")return e.replace(/\/[^/]+\/(.+)/,"$1") !0===a.IsTruncated&&(i=o.NextMarker)}while(void 0!==i)},T=async({credentials:e,siteInfo:t})=>{const{apexDomain:o,cloudFrontDistributionID:a,region:s}=t,r=new i.CloudFrontClient({credentials:e,region:s}),c=new i.GetDistributionCommand({Id:a}),l=(await r.send(c)).Distribution.DomainName,u=new n.Route53Client({credentials:e,region:s}),d=await I({route53Client:u,siteInfo:t}),m=[o,"www."+o],p=new n.ChangeResourceRecordSetsCommand({HostedZoneId:d,ChangeBatch:{Comment:`Point '${o}' and 'www.${o}' to CloudFront distribution.`,Changes:m.map((e=>({Action:"UPSERT",ResourceRecordSet:{Name:e,AliasTarget:{DNSName:l,EvaluateTargetHealth:!1,HostedZoneId:"Z2FDTNDATAQYW2"},Type:"A"}})))}}) D.write(`Creating/updating Route 53 resource record sets/DNS entries for ${m.join(", ")}...\n`),await u.send(p)},k=async e=>{const{credentials:t,findName:n=!1,siteInfo:i}=e let{bucketName:r,s3Client:c}=e void 0===r&&(r=i.bucketName||o.v4()) const{accountID:l}=i if(void 0===l){const e=await(async({credentials:e})=>(null==D||D.write("Getting effective account ID...\n"),(await new s.STSClient({credentials:e}).send(new s.GetCallerIdentityCommand({}))).Account))({credentials:t}) i.accountID=e}for(c=c||new a.S3Client({credentials:t});;){D.write(`Checking bucket '${r}' is free... `) const e={Bucket:r,ExpectedBucketOwner:l},t=new a.HeadBucketCommand(e) try{if(await c.send(t),!0!==n)throw new Error(`Account already owns bucket '${r}'; delete or specify alternate bucket name.`)}catch(e){if("NotFound"===e.name)return D.write("FREE\n"),r if(!0!==n||"CredentialsProviderError"===e.name)throw D.write("\n"),e}D.write("NOT free\n"),r=o.v4()}},L=({"sso-profile":e}={})=>{e=e||process.env.AWS_PROFILE||"default" return r.fromIni({profile:e})},N={config:{name:"CloudFront logs",description:"Enables logging of CloudFront events.",options:{includeCookies:{description:"Whether to log cookies or not.",default:!1,paramType:"boolean"}}},importHandler:({name:e,pluginsData:t,template:n})=>{const i=n.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.Logging if(void 0!==i){const n={includeCookies:i.IncludeCookies} t[e]=n}},preStackDestroyHandler:async({siteTemplate:e})=>{await e.destroyCommonLogsBucket()},stackConfig:async({siteTemplate:e,pluginData:t})=>{const{finalTemplate:n}=e await e.enableCommonLogsBucket(),n.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.Logging={Bucket:{"Fn::GetAtt":["commonLogsBucket","DomainName"]},IncludeCookies:t.settings.includeCookies,Prefix:"cloudfront-logs/"}}},E="contact-emailer-lambda.zip",P="contact-handler-lambda.zip",O="request-signer-lambda.zip",v={given_name:"S",family_name:"S",company:"S",company_size:"S",industry:"S",revenue:"S",email:"S",phone_number_home:"S",phone_number_mobile:"S",phone_number_work:"S",phone_number_work_ext:"S",address_1:"S",address_2:"S",city:"S",state:"S",zip_code:"S",county:"S",message:"S",topics:"SS"},B=async({credentials:e,description:t,tags:n})=>{null==D||D.write(`Attempting to find ${t} bucket... `) const i=new a.S3Client({credentials:e}),o=new a.ListBucketsCommand({}),{Buckets:s}=await i.send(o) for(const{Name:e}of s){const t=new a.GetBucketTaggingCommand({Bucket:e}),o=await i.send(t),s=Array.from(n,(()=>!1)) let r=-1 for(const{key:e,value:t}of n){r+=1 for(const{Key:n,Value:i}of o.TagSet)if(e===n&&t===i){s[r]=!0 break}if(!1===s[r])break}if(!s.some((e=>!1===e)))return null==D||D.write("found: "+e+"\n"),e}null==D||D.write("NONE found\n")},x=({funcDesc:e,siteInfo:t})=>[{Key:"function",Value:e},{Key:"site",Value:t.apexDomain},{Key:"site:"+t.apexDomain,Value:""}],H=async({baseName:e,credentials:t,siteTemplate:n})=>{const{siteInfo:i}=n,{region:a}=i let s=e const r=new u.LambdaClient({credentials:t,region:a}) for(;;){null==D||D.write(`Checking if Lambda function name '${s}' is free...`) const e=new u.GetFunctionCommand({FunctionName:s}) try{await r.send(e)}catch(e){var c if("NotFound"===e.name||404===(null===(c=e.$metadata)||void 0===c?void 0:c.httpStatusCode))return null==D||D.write("FREE\n"),s throw null==D||D.write("\n"),e}null==D||D.write("NOT free\n") const t=o.v4().slice(0,8) s=s.replace(/-[A-F0-9]{8}$/i,""),s+="-"+t}},G=async({credentials:e,lambdaFileNames:t,siteInfo:n})=>{D.write("Staging contact handler Lambda function zip files...\n") const{region:i}=n,o=new a.S3Client({credentials:e,region:i}),s=await(async({credentials:e,s3Client:t,siteInfo:n})=>{D.write("Checking for lambda function bucket... ") let{lambdaFunctionsBucket:i}=n if(void 0===i){D.write("CREATING\n"),i=await k({credentials:e,findName:!0,s3Client:t,siteInfo:n}),D.write(`Determined name '${i}'\n`) const o=new a.CreateBucketCommand({ACL:"private",Bucket:i}) await t.send(o),D.write("Created.\n")}else D.write(`FOUND ${i}\n`) const o=new a.PutBucketTaggingCommand({Bucket:i,Tagging:{TagSet:x({funcDesc:"lambda code storage",siteInfo:n})}}) return await t.send(o),n.lambdaFunctionsBucket=i,i})({credentials:e,s3Client:o,siteInfo:n}) for(const e of t)_({bucketName:s,fileName:e,s3Client:o}) return await Promise.all([]),s},_=async({bucketName:e,fileName:t,s3Client:n})=>{const i=d.join(__dirname,t),o=m.createReadStream(i),s=new a.PutObjectCommand({Body:o,Bucket:e,Key:t,ContentType:"application/zip"}) await n.send(s)},M={config:{name:"Contact handler",description:"Enables contact form processing. Specifically, enters form data and optionally sends an email notification.",options:{emailFrom:{description:"This is the email which will appear as the sender. This address must be configured with SES.",required:!0,matches:c.emailRE,invalidMessage:"Must be a valid email."},emailTo:{description:"The optional 'to' email. If left blank, then the 'from' email will be used as both the 'to' and 'from'.",matches:c.emailRE,invalidMessage:"Must be a valid email."},formFields:{description:"May be either a JSON specification of fields to process in from the contact form or the string 'standard'. Unless you are adapting an existing form, setting the value to 'standard' should be sufficient in most cases.",default:"standard",validation:e=>{if(e.match(/\s*standard\s*/i))return!0 let t if("string"==typeof e)try{t=JSON.parse(e)}catch(e){return!1}else{if("object"!=typeof e)return!1 t=e}for(const e of Object.values(t))if("S"!==e&&"SS"!==e)return!1 return!0},invalidMessage:"May be either 'standard' or a valid JSON fields specification."},urlPath:{description:"The URL path to which to direct form submissions.",required:!0,default:"/contact-handler",matches:/^\/(?:[a-z0-9_-]+\/?)+$/}}},importHandler:async({credentials:e,name:t,pluginsData:n,siteInfo:i,template:o})=>{const a=(o.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.CacheBehaviors||[]).filter((e=>"ContactHandlerLambdaOrigin"===e.TargetOriginId)) if(a.length>1)throw new Error("Unexpected template has multiple cache behaviors targeting 'ContactHandlerLambdaOrigin'; cannot proceed with import.") if(1===a.length){const s=a[0],r=o.Resources.ContactEmailerFunction.Properties.Environment.Variables.EMAIL_HANDLER_SOURCE_EMAIL,c=o.Resources.ContactEmailerFunction.Properties.Environment.Variables.EMAIL_HANDLER_TARGET_EMAIL,l=o.Resources.ContactHandlerLambdaFunction.Properties.FunctionName,u=o.Resources.ContactEmailerFunction.Properties.FunctionName,d=o.Resources.SignRequestFunction.Properties.FunctionName,m=await B({credentials:e,description:"Lambda functions",tags:x({funcDesc:"lambda code storage",siteInfo:i})}) if(void 0===m)throw new Error(`Could not resolve the Lambda function bucket for the '${t}' plugin.`) n[t]={settings:{urlPath:s.PathPattern,emailFrom:r,emailTo:c},contactHandlerFunctionName:l,emailerFunctionName:u,requestSignerFunctionName:d,lambdaFunctionsBucket:m},void 0===c&&delete n[t].settings.emailTo}},preStackDestroyHandler:async({siteTemplate:e})=>{const{credentials:t}=e,{lambdaFunctionsBucket:n}=e.siteInfo if(void 0!==n){null==D||D.write(`Deleting shared Lambda function bucket ${n} bucket...`) const i=new a.S3Client({credentials:t}) try{await l.emptyBucket({bucketName:n,doDelete:!0,s3Client:i,verbose:void 0!==D}),delete e.siteInfo.lambdaFunctionsBucket,null==D||D.write("DELETED.\n")}catch(t){if("NoSuchBucket"!==t.Code)throw null==D||D.write("ERROR.\n"),t null==D||D.write("NO SUCH BUCKET.\n"),delete e.siteInfo.lambdaFunctionsBucket}}else null==D||D.write("Looks like the Lambda function bucket has already been deleted; skipping.\n")},stackConfig:async({pluginData:e,siteTemplate:t,update:n})=>{D.write("Preparing contact handler plugin...\n") const{credentials:i,siteInfo:o}=t,a=!!e.settings.emailFrom,s=[P,O] !0===a&&s.push(E) const r=await G({credentials:i,lambdaFileNames:s,siteInfo:o}) await(async({credentials:e,lambdaFunctionsBucketName:t,pluginData:n,siteInfo:i,siteTemplate:o,update:a})=>{const{accountID:s,apexDomain:r,region:c}=i,{finalTemplate:l,resourceTypes:d}=o,m=R(r)+"-contact-handler",p=!0===a?n.contactHandlerFunctionName:await H({baseName:m,credentials:e,siteTemplate:o}) n.contactHandlerFunctionName=p const g=p,f=p,{formFields:w="standard"}=n.settings,C="standard"===w?JSON.stringify(v):w,S=x({funcDesc:"enter contact form record in database",siteInfo:i}) if(l.Resources.ContactHandlerLogGroup={Type:"AWS::Logs::LogGroup",Properties:{LogGroupClass:"STANDARD",LogGroupName:g,RetentionInDays:180,Tags:S}},l.Resources.ContactHandlerRole={Type:"AWS::IAM::Role",DependsOn:["ContactHandlerDynamoDB","ContactHandlerLogGroup"],Properties:{AssumeRolePolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:["lambda.amazonaws.com"]},Action:["sts:AssumeRole"]}]},Path:"/cloudsite/contact-processor/",Policies:[{PolicyName:f,PolicyDocument:{Version:"2012-10-17",Statement:[{Action:["dynamodb:PutItem"],Resource:{"Fn::GetAtt":["ContactHandlerDynamoDB","Arn"]},Effect:"Allow"},{Effect:"Allow",Action:"logs:CreateLogGroup",Resource:`arn:aws:${c}:${s}:*`},{Effect:"Allow",Action:["logs:CreateLogStream","logs:PutLogEvents"],Resource:[`arn:aws:logs:${c}:${s}:log-group:${g}:*`]}]}}],Tags:S}},l.Outputs.ContactHandlerRole={Value:{Ref:"ContactHandlerRole"}},d["IAM::Role"]=!0,l.Resources.ContactHandlerLambdaFunction={Type:"AWS::Lambda::Function",DependsOn:["ContactHandlerRole","ContactHandlerLogGroup"],Properties:{FunctionName:p,Description:"Handles contact form submissions; creates DynamoDB entry and sends email.",Code:{S3Bucket:t,S3Key:P},Handler:"index.handler",Role:{"Fn::GetAtt":["ContactHandlerRole","Arn"]},Runtime:"nodejs20.x",MemorySize:128,Timeout:5,Environment:{Variables:{TABLE_PREFIX:r,FORM_FIELDS:C}},LoggingConfig:{ApplicationLogLevel:"INFO",LogFormat:"JSON",LogGroup:g,SystemLogLevel:"INFO"},Tags:S}},l.Outputs.ContactHandlerLambdaFunction={Value:{Ref:"ContactHandlerLambdaFunction"}},d["Lambda::Function"]=!0,l.Resources.ContactHandlerLambdaPermission={Type:"AWS::Lambda::Permission",DependsOn:["SiteCloudFrontDistribution","ContactHandlerLambdaFunction"],Properties:{Action:"lambda:InvokeFunctionUrl",Principal:"cloudfront.amazonaws.com",FunctionName:p,FunctionUrlAuthType:"AWS_IAM",SourceArn:{"Fn::Join":["",[`arn:aws:cloudfront::${s}:distribution/`,{"Fn::GetAtt":["SiteCloudFrontDistribution","Id"]}]]}}},l.Resources.ContactHandlerLambdaURL={Type:"AWS::Lambda::Url",DependsOn:["ContactHandlerLambdaFunction"],Properties:{AuthType:"AWS_IAM",Cors:{AllowCredentials:!0,AllowHeaders:["*"],AllowMethods:["POST"],AllowOrigins:["*"]},TargetFunctionArn:{"Fn::GetAtt":["ContactHandlerLambdaFunction","Arn"]}}},l.Outputs.ContactHandlerLambdaURL={Value:{Ref:"ContactHandlerLambdaURL"}},d["Lambda::Url"]=!0,!0===a){const n=new u.LambdaClient({credentials:e}),i=new u.UpdateFunctionCodeCommand({FunctionName:p,S3Bucket:t,S3Key:P}) await n.send(i)}})({credentials:i,lambdaFunctionsBucketName:r,pluginData:e,siteInfo:o,siteTemplate:t,update:n}),await(async({credentials:e,lambdaFunctionsBucketName:t,pluginData:n,update:i,siteTemplate:o})=>{const{finalTemplate:a,siteInfo:s}=o,{apexDomain:r}=s,c=x({funcDesc:"sign database entry request",siteInfo:s}),l=R(r)+"-request-signer",u=!0===i?n.requestSignerFunctionName:await H({baseName:l,credentials:e,siteTemplate:o}) n.requestSignerFunctionName=u,a.Resources.RequestSignerRole={Type:"AWS::IAM::Role",DependsOn:["ContactHandlerLambdaFunction"],Properties:{AssumeRolePolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:["lambda.amazonaws.com","edgelambda.amazonaws.com"]},Action:["sts:AssumeRole"]}]},Path:"/cloudsite/request-signer/",Policies:[{PolicyName:u,PolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Action:"lambda:InvokeFunctionUrl",Resource:{"Fn::GetAtt":["ContactHandlerLambdaFunction","Arn"]}}]}}],ManagedPolicyArns:["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"],Tags:c}},a.Resources.RequestSignerLogGroup={Type:"AWS::Logs::LogGroup",Properties:{LogGroupClass:"STANDARD",LogGroupName:u,RetentionInDays:180,Tags:c}},a.Resources.SignRequestFunction={Type:"AWS::Lambda::Function",DependsOn:["RequestSignerRole"],Properties:{FunctionName:u,Handler:"index.handler",Role:{"Fn::GetAtt":["RequestSignerRole","Arn"]},Runtime:"nodejs20.x",MemorySize:128,Timeout:5,Code:{S3Bucket:t,S3Key:O},LoggingConfig:{ApplicationLogLevel:"INFO",LogFormat:"JSON",LogGroup:u,SystemLogLevel:"INFO"},Tags:c}},a.Resources.SignRequestFunctionVersion={Type:"AWS::Lambda::Version",DependsOn:["SignRequestFunction"],Properties:{FunctionName:{"Fn::GetAtt":["SignRequestFunction","Arn"]}}},a.Outputs.SignRequestFunction={Value:{Ref:"SignRequestFunction"}}})({credentials:i,lambdaFunctionsBucketName:r,pluginData:e,siteTemplate:t,update:n}),(({siteInfo:e,siteTemplate:t})=>{const{finalTemplate:n,resourceTypes:i}=t,{apexDomain:o}=e,a=x({funcDesc:"store contact info",siteInfo:e}) n.Resources.ContactHandlerDynamoDB={Type:"AWS::DynamoDB::Table",Properties:{TableName:o+"-ContactFormEntries",AttributeDefinitions:[{AttributeName:"SubmissionID",AttributeType:"S"},{AttributeName:"SubmissionTime",AttributeType:"S"}],KeySchema:[{AttributeName:"SubmissionID",KeyType:"HASH"},{AttributeName:"SubmissionTime",KeyType:"RANGE"}],BillingMode:"PAY_PER_REQUEST",Tags:a}},n.Outputs.ContactHandlerDynamoDB={Value:{Ref:"ContactHandlerDynamoDB"}},i["DynamoDB::Table"]=!0})({siteInfo:o,siteTemplate:t}),(({pluginData:e,siteTemplate:t})=>{const{finalTemplate:n}=t,i=e.settings.urlPath n.Resources.SiteCloudFrontDistribution.DependsOn.push("ContactHandlerLambdaURL"),n.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.Origins.push({Id:"ContactHandlerLambdaOrigin",DomainName:{"Fn::Select":[2,{"Fn::Split":["/",{"Fn::GetAtt":["ContactHandlerLambdaURL","FunctionUrl"]}]}]},CustomOriginConfig:{HTTPSPort:443,OriginProtocolPolicy:"https-only"}}) const o=n.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.CacheBehaviors||[] o.push({AllowedMethods:["DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT"],CachePolicyId:"4135ea2d-6df8-44a3-9df3-4b5a84be39ad",PathPattern:i,TargetOriginId:"ContactHandlerLambdaOrigin",ViewerProtocolPolicy:"https-only",LambdaFunctionAssociations:[{EventType:"origin-request",IncludeBody:!0,LambdaFunctionARN:{"Fn::Join":[":",[{"Fn::GetAtt":["SignRequestFunction","Arn"]},{"Fn::GetAtt":["SignRequestFunctionVersion","Version"]}]]}}]}),n.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.CacheBehaviors=o,n.Resources.SiteCloudFrontDistribution.DependsOn.push("SignRequestFunctionVersion")})({pluginData:e,siteTemplate:t}),!0===a&&await(async({credentials:e,lambdaFunctionsBucketName:t,update:n,pluginData:i,siteTemplate:o})=>{const{finalTemplate:a,siteInfo:s}=o,{apexDomain:r}=s,{emailFrom:c,emailTo:l,formFields:d="standard"}=i.settings if(void 0===c&&void 0!==l)throw new Error("Found site setting for 'emailTo', but no 'emailFrom'; 'emailFrom' must be set to activate email functionality.") a.Resources.ContactHandlerDynamoDB.Properties.StreamSpecification={StreamViewType:"NEW_IMAGE"} const m=R(r)+"-contact-emailer",p=n?i.emailerFunctionName:await H({baseName:m,credentials:e,siteTemplate:o}) i.emailerFunctionName=p const g=p,f="standard"===d?JSON.stringify(v):d,w=x({funcDesc:"email new contact form entries",siteInfo:s}) if(a.Resources.ContactEmailerLogGroup={Type:"AWS::Logs::LogGroup",Properties:{LogGroupClass:"STANDARD",LogGroupName:g,RetentionInDays:180,Tags:w}},a.Resources.ContactEmailerRole={Type:"AWS::IAM::Role",Properties:{AssumeRolePolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:["lambda.amazonaws.com"]},Action:["sts:AssumeRole"]}]},Path:"/cloudsite/contact-emailer/",Policies:[{PolicyName:p,PolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["ses:SendEmail","ses:SendEmailRaw","ses:GetSendQuota","ses:GetSendStatistics"],Resource:"*"}]}}],ManagedPolicyArns:["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole","arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole"],Tags:w}},a.Resources.ContactEmailerFunction={Type:"AWS::Lambda::Function",DependsOn:["ContactEmailerRole","ContactEmailerLogGroup"],Properties:{FunctionName:p,Handler:"index.handler",Role:{"Fn::GetAtt":["ContactEmailerRole","Arn"]},Runtime:"nodejs20.x",MemorySize:128,Timeout:5,Code:{S3Bucket:t,S3Key:E},Environment:{Variables:{APEX_DOMAIN:r,EMAIL_HANDLER_SOURCE_EMAIL:c,FORM_FIELDS:f}},LoggingConfig:{ApplicationLogLevel:"INFO",LogFormat:"JSON",LogGroup:g,SystemLogLevel:"INFO"},Tags:w}},a.Resources.ContactEmailerEventsSource={Type:"AWS::Lambda::EventSourceMapping",DependsOn:["ContactEmailerFunction"],Properties:{FunctionName:{"Fn::GetAtt":["ContactEmailerFunction","Arn"]},EventSourceArn:{"Fn::GetAtt":["ContactHandlerDynamoDB","StreamArn"]},StartingPosition:"LATEST"}},void 0!==l&&(a.Resources.ContactEmailerFunction.Properties.Environment.Variables.EMAIL_HANDLER_TARGET_EMAIL=l),!0===n){const n=new u.LambdaClient({credentials:e}),i=new u.UpdateFunctionCodeCommand({FunctionName:p,S3Bucket:t,S3Key:E}) await n.send(i)}})({credentials:i,lambdaFunctionsBucketName:r,pluginData:e,siteTemplate:t,update:n})}},V="index-rewriter-lambda.zip",q={config:{name:"index.html rewriter",description:"Appends 'index.html' to bare directory requests.",options:void 0,includePlugin:({siteInfo:e})=>{const{sourceType:t}=e return"vanilla"===t?"include-default-true":"include-default-false"}},importHandler:async({credentials:e,name:t,pluginsData:n,siteInfo:i})=>{const o=await B({credentials:e,description:"Lambda functions",tags:x({funcDesc:"lambda code storage",siteInfo:i})}) if(void 0===o)throw new Error(`Could not resolve the Lambda function bucket for the '${t}' plugin.`) n[t]={settings:{},lambdaFunctionsBucket:o}},preStackDestroyHandler:async({siteTemplate:e})=>{const{credentials:t}=e,{lambdaFunctionsBucket:n}=e.siteInfo if(void 0!==n){null==D||D.write(`Deleting ${n} bucket...`) const i=new a.S3Client({credentials:t}) try{await l.emptyBucket({bucketName:n,doDelete:!0,s3Client:i,verbose:void 0!==D}),null==D||D.write("DELETED.\n"),delete e.siteInfo.lambdaFunctionsBucket}catch(e){throw null==D||D.write("ERROR.\n"),e}}else null==D||D.write("Looks like the Lambda function bucket has already been deleted; skipping.\n")},stackConfig:async({siteTemplate:e,pluginData:t,update:n})=>{D.write("Preparing index rewriter plugin...\n") const{credentials:i,siteInfo:o}=e,a=[V],s=await G({credentials:i,lambdaFileNames:a,siteInfo:o}) o.lambdaFunctionsBucketName=s,await(async({credentials:e,pluginData:t,siteTemplate:n,update:i})=>{const{finalTemplate:o,siteInfo:a}=n,{apexDomain:s,lambdaFunctionsBucketName:r}=a,c=x({funcDesc:"rewrite directory URLs",siteInfo:a}),l=R(s)+"-index-rewriter",u=!0===i?t.indexRewriterFunctionName:await H({baseName:l,credentials:e,siteTemplate:n}) t.indexRewriterFunctionName=u,o.Resources.IndexRewriterRole={Type:"AWS::IAM::Role",Properties:{AssumeRolePolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:["lambda.amazonaws.com","edgelambda.amazonaws.com"]},Action:["sts:AssumeRole"]}]},Path:"/cloudsite/index-rewriter/",ManagedPolicyArns:["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"],Tags:c}},o.Resources.IndexRewriterLogGroup={Type:"AWS::Logs::LogGroup",Properties:{LogGroupClass:"STANDARD",LogGroupName:u,RetentionInDays:180,Tags:c}},o.Resources.IndexRewriterFunction={Type:"AWS::Lambda::Function",DependsOn:["IndexRewriterRole"],Properties:{FunctionName:u,Handler:"index.handler",Role:{"Fn::GetAtt":["IndexRewriterRole","Arn"]},Runtime:"nodejs20.x",MemorySize:128,Timeout:5,Code:{S3Bucket:r,S3Key:V},LoggingConfig:{ApplicationLogLevel:"INFO",LogFormat:"JSON",LogGroup:u,SystemLogLevel:"INFO"},Tags:c}},o.Resources.IndexRewriterFunctionVersion={Type:"AWS::Lambda::Version",DependsOn:["IndexRewriterFunction"],Properties:{FunctionName:{"Fn::GetAtt":["IndexRewriterFunction","Arn"]}}},o.Outputs.IndexRewriterFunction={Value:{Ref:"IndexRewriterFunction"}}})({credentials:i,pluginData:t,siteInfo:o,siteTemplate:e,update:n}),(({siteTemplate:e})=>{const{finalTemplate:t}=e,n=t.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations||[] n.push({EventType:"origin-request",IncludeBody:!1,LambdaFunctionARN:{"Fn::Join":[":",[{"Fn::GetAtt":["IndexRewriterFunction","Arn"]},{"Fn::GetAtt":["IndexRewriterFunctionVersion","Version"]}]]}}),t.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations=n,t.Resources.SiteCloudFrontDistribution.DependsOn.push("IndexRewriterFunctionVersion")})({pluginData:t,siteTemplate:e})}},$=Object.freeze(Object.defineProperty({__proto__:null,cloudFrontLogs:N,contactHandler:M,indexRewriter:q},Symbol.toStringTag,{value:"Module"})),U=async({credentials:e,region:t})=>{const n=new i.CloudFrontClient({credentials:e,region:t}) let o const a=[] for(;;){const e=new i.ListOriginAccessControlsCommand({Marker:o}),t=await n.send(e),s=t.OriginAccessControlList.Items||[] if(a.push(...s.map((({Name:e})=>e))),o=t.OriginAccessControlList.NextMarker,void 0===o)return a}},W=class{constructor({credentials:e,siteInfo:t}){this.siteInfo=t,this.credentials=e,this.resourceTypes={"CloudFormation::Distribution":!0,"S3::Bucket":!0},this.finalTemplate=this.baseTemplate}async initializeTemplate({update:e}={}){const{siteInfo:t}=this,{accountID:n,apexDomain:i,siteBucketName:a,certificateArn:s,region:r}=t,c=!0===e?t.oacName:await(async({baseName:e,credentials:t,siteInfo:n})=>{const{region:i}=n let a=e const s=await U({credentials:t,region:i}) for(;;){if(null==D||D.write(`Checking if OAC name '${a}' is free... `),!s.includes(a))return null==D||D.write("FREE\n"),a {null==D||D.write("NOT free\n") const e=o.v4().slice(0,8) a=a.replace(/-[A-F0-9]{8}$/i,""),a+="-"+e}}})({baseName:`${i}-OAC`,credentials:this.credentials,siteInfo:this.siteInfo}) null==D||D.write(`Using OAC name: ${c}\n`),this.siteInfo.oacName=c,this.finalTemplate={Resources:{SiteS3Bucket:{Type:"AWS::S3::Bucket",Properties:{AccessControl:"Private",BucketName:a,Tags:x({funcDesc:"website contents storage",siteInfo:t})}},SiteCloudFrontOriginAccessControl:{Type:"AWS::CloudFront::OriginAccessControl",Properties:{OriginAccessControlConfig:{Description:"Origin Access Control (OAC) allowing CloudFront Distribution to access site S3 bucket.",Name:c,OriginAccessControlOriginType:"s3",SigningBehavior:"always",SigningProtocol:"sigv4"}}},SiteCloudFrontDistribution:{Type:"AWS::CloudFront::Distribution",DependsOn:["SiteS3Bucket"],Properties:{DistributionConfig:{Origins:[{DomainName:`${a}.s3.${r}.amazonaws.com`,Id:"static-hosting",S3OriginConfig:{OriginAccessIdentity:""},OriginAccessControlId:{"Fn::GetAtt":["SiteCloudFrontOriginAccessControl","Id"]}}],Enabled:!0,DefaultRootObject:"index.html",CustomErrorResponses:[{ErrorCode:403,ResponseCode:200,ResponsePagePath:"/index.html"},{ErrorCode:404,ResponseCode:200,ResponsePagePath:"/index.html"}],HttpVersion:"http2",Aliases:[i,`www.${i}`],ViewerCertificate:{AcmCertificateArn:s,MinimumProtocolVersion:"TLSv1.2_2021",SslSupportMethod:"sni-only"},DefaultCacheBehavior:{AllowedMethods:["GET","HEAD"],CachePolicyId:"658327ea-f89d-4fab-a63d-7e88639e58f6",Compress:!0,TargetOriginId:"static-hosting",ViewerProtocolPolicy:"redirect-to-https"}},Tags:x({funcDesc:"website content delivery network",siteInfo:t})}},SiteBucketPolicy:{Type:"AWS::S3::BucketPolicy",DependsOn:["SiteS3Bucket","SiteCloudFrontDistribution"],Properties:{Bucket:a,PolicyDocument:{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudfront.amazonaws.com"},Action:"s3:GetObject",Resource:`arn:aws:s3:::${a}/*`,Condition:{StringEquals:{"AWS:SourceArn":{"Fn::Join":["",[`arn:aws:cloudfront::${n}:distribution/`,{"Fn::GetAtt":["SiteCloudFrontDistribution","Id"]}]]}}}}]}}}},Outputs:{SiteS3Bucket:{Value:{Ref:"SiteS3Bucket"}},SiteCloudFrontOriginAccessControl:{Value:{Ref:"SiteCloudFrontOriginAccessControl"}},SiteCloudFrontDistribution:{Value:{Ref:"SiteCloudFrontDistribution"}},OriginAccessControl:{Value:{Ref:"SiteCloudFrontOriginAccessControl"}}}}}async destroyCommonLogsBucket(){const{siteInfo:e}=this,{commonLogsBucket:t}=e if(void 0!==t){D.write("Deleting common logs bucket...\n") const n=new a.S3Client({credentials:this.credentials}) await l.emptyBucket({bucketName:t,doDelete:!0,s3Client:n,verbose:void 0!==D}),delete e.commonLogsBucket}else null==D||D.write("Looks like the shared logging bucket has already been deleted; skipping.\n")}async enableCommonLogsBucket(){const{siteInfo:e}=this let{commonLogsBucket:t}=e return void 0===t&&(t=await k({bucketName:t,credentials:this.credentials,findName:!0,siteInfo:this.siteInfo})),this.siteInfo.commonLogsBucket=t,this.finalTemplate.Resources.commonLogsBucket={Type:"AWS::S3::Bucket",Properties:{AccessControl:"Private",BucketName:t,OwnershipControls:{Rules:[{ObjectOwnership:"BucketOwnerPreferred"}]},Tags:x({funcDesc:"common logs storage",siteInfo:e})}},t}async destroyPlugins(){const{siteInfo:e}=this,{apexDomain:t}=e,n=e.plugins||{} for(const[e,i]of Object.entries(n)){const n=$[e] if(void 0===n)throw new Error(`Unknown plugin found in '${t}' plugin settings.`) const{preStackDestroyHandler:o}=n void 0!==o&&await o({siteTemplate:this,pluginData:i})}}async loadPlugins({update:e}={}){const{siteInfo:t}=this,{apexDomain:n}=t,i=t.plugins||{},o=[] for(const[t,a]of Object.entries(i)){const i=$[t] if(void 0===i)throw new Error(`Unknown plugin found in '${n}' plugin settings.`) o.push(i.stackConfig({siteTemplate:this,pluginData:a,update:e}))}await Promise.all(o)}render(){const{apexDomain:e}=this.siteInfo,t=Object.keys(this.resourceTypes).sort(),n=Object.assign({AWSTemplateFormatVersion:"2010-09-09",Description:`${e} site built with ${t.slice(0,-1).join(", ")} and ${t[t.length-1]}.`},this.finalTemplate) return p.dump(n,{lineWidth:0,noRefs:!0})}},j=async({credentials:e,noBuild:t,siteInfo:n})=>{const{siteBucketName:i,sourcePath:o,sourceType:s}=n if(!0!==t&&"docusaurus"===s){const e=b.resolve(o,".."),t=b.join(e,"package.json") m.existsSync(t)&&(D.write("Rebuilding site... "),g.tryExec(`cd "${e}" && npm run build`),D.write("done.\n"))}D.write(`Syncing files from ${o}...\n`) const r=new a.S3Client({credentials:e}),{sync:c}=new w.S3SyncClient({client:r}) await c(o,"s3://"+i,{commandInput:e=>({ContentType:f.lookup(e.Key)||"application/octet-stream"}),del:!0,maxConcurrentTransfers:10})},z=async({cloudFormationClient:t,noDeleteOnFailure:n,noInitialStatus:i,stackName:o})=>{let a,s do{const n={StackName:o},r=new e.DescribeStacksCommand(n) if(a=(await t.send(r)).Stacks[0].StackStatus,a===s||!0===i&&void 0===s)D.write(".") else{const e=a.charAt(0)+a.slice(1).toLowerCase().replaceAll(/_/g," ") D.write((void 0!==s?"\n":"")+e),a.endsWith("_PROGRESS")||D.write("\n")}s=a,await new Promise((e=>setTimeout(e,2e3)))}while(a.endsWith("_IN_PROGRESS")) if("ROLLBACK_COMPLETE"===a&&!0!==n){D.write(`\nDeleting stack '${o}' `) const n={StackName:o},i=new e.DeleteStackCommand(n) await t.send(i),z({cloudFormationClient:t,noDeleteOnFailure:!0,noInitialStatus:!0,stackName:o})}return a},K=async({credentials:t,siteInfo:n})=>{const{region:i,stackName:o}=n D.write("Gathering information from stack...\n") const a=new e.CloudFormationClient({credentials:t,region:i}),s=new e.DescribeStacksCommand({StackName:o}),r=(await a.send(s)).Stacks[0].Outputs.find((({OutputKey:e})=>"SiteCloudFrontDistribution"===e)).OutputValue n.cloudFrontDistributionID=r},J=async({credentials:t,noDeleteOnFailure:n,siteInfo:i})=>{const{apexDomain:o,region:a}=i,s=new W({credentials:t,siteInfo:i}) await s.initializeTemplate(),await s.loadPlugins() const r=s.render(),c=new e.CloudFormationClient({credentials:t,region:a}),l=i.stackName||R(o)+"-stack" i.stackName=l const u={StackName:l,TemplateBody:r,DisableRollback:!1,Capabilities:["CAPABILITY_IAM","CAPABILITY_NAMED_IAM"],TimeoutInMinutes:30},d=new e.CreateStackCommand(u),m=await c.send(d),{StackId:p}=m i.stackName=l,i.stackArn=p return{success:"CREATE_COMPLETE"===await z({cloudFormationClient:c,noDeleteOnFailure:n,stackName:l}),stackName:l}},Y=async({credentials:e,siteInfo:t})=>{D.write("Invalidating CloudFront cache...\n") const{cloudFrontDistributionID:n}=t,o=new i.CloudFrontClient({credentials:e}),a=new i.CreateInvalidationCommand({DistributionId:n,InvalidationBatch:{Paths:{Quantity:1,Items:["/*"]},CallerReference:(new Date).getTime()+""}}) await o.send(a)} exports.create=async({db:e,noBuild:t,noDeleteOnFailure:n,siteInfo:i})=>{let{siteBucketName:o}=i const a=L(e.account.localSettings) o=await k({bucketName:o,credentials:a,findName:!0,siteInfo:i}),i.siteBucketName=o const{success:s,stackName:r}=await J({credentials:a,noDeleteOnFailure:n,siteInfo:i}) if(!0===s){const n=Object.keys(i.plugins||{}).map((e=>[e,$[e].postUpdateHandler])).filter((([,e])=>void 0!==e)) return await K({credentials:a,siteInfo:i}),await Promise.all([j({credentials:a,noBuild:t,siteInfo:i}),T({credentials:a,siteInfo:i}),...n.map((([e,t])=>t({pluginData:i.plugins[e],siteInfo:i})))]),await h({credentials:a,db:e,siteInfo:i}),{success:s,stackName:r}}return{success:s,stackName:r}},exports.destroy=async({db:t,siteInfo:n,verbose:i})=>{const{apexDomain:o,siteBucketName:s,stackName:r}=n,c=L(t.account.localSettings),u=new a.S3Client({credentials:c}) try{!0===i&&(null==D||D.write("Deleting site bucket...\n")),await l.emptyBucket({bucketName:s,doDelete:!0,s3Client:u,verbose:i})}catch(e){if("NoSuchBucket"!==e.name)throw e !0===i&&(null==D||D.write("Bucket already deleted.\n"))}const d=new W({credentials:c,siteInfo:n}) await d.destroyPlugins(),!0===i&&D.write(`Deleting stack for ${o}`) const m=new e.CloudFormationClient({credentials:c}),p=new e.DeleteStackCommand({StackName:r}) await m.send(p) try{const e=await z({cloudFormationClient:m,noDeleteOnFailure:!0,noInitialStatus:!0,stackName:r}) if(D.write("\n"),"DELETE_FAILED"===e)return!1 if("DELETE_COMPLETE"===e)return!0 throw new Error(`Unexpected final status after delete: ${e}`)}catch(e){if("ValidationError"===e.name)return!0 throw e}finally{!0===i&&(null==D||D.write("\n"))}},exports.update=async({db:t,doBilling:i,doContent:o,doDNS:a,doStack:s,noBuild:r,noCacheInvalidation:c,siteInfo:l})=>{const u=void 0===i&&void 0===o&&void 0===a&&void 0===s,d=L(t.account.localSettings),m=[] let p !0!==u&&!0!==o||m.push(j({credentials:d,noBuild:r,siteInfo:l})),!0!==u&&!0!==a||m.push(T({credentials:d,siteInfo:l})),await Promise.all(m),!0!==u&&!0!==s||(p=await(async({credentials:t,siteInfo:n})=>{const{region:i,stackName:o}=n,a=new W({credentials:t,siteInfo:n}) await a.initializeTemplate({update:!0}),await a.loadPlugins({update:!0}) const s=a.render(),r=new e.CloudFormationClient({credentials:t,region:i}),c=new e.GetTemplateCommand({StackName:o,TemplateStage:"Original"}),l=(await r.send(c)).TemplateBody if(C(l,s))return void D.write("No change to template; skipping stack update.\n") const u=new e.UpdateStackCommand({StackName:o,TemplateBody:s,UsePreviousTemplate:!1,Capabilities:["CAPABILITY_IAM","CAPABILITY_NAMED_IAM"],DisableRollback:!1}) await r.send(u) const d=await z({cloudFormationClient:r,noDeleteOnFailure:!0,stackName:o}) await K({credentials:t,siteInfo:n}) const m=Object.keys(n.pluginSettings||{}).map((e=>[e,$[e].postUpdateHandler])).filter((([,e])=>void 0!==e)) return m.length>0&&await Promise.all([...m.map((([e,t])=>t({settings:n.pluginSettings[e],siteInfo:n})))]),"UPDATE_COMPLETE"===d&&D.write("Stack updated.\n"),d})({credentials:d,siteInfo:l}),"UPDATE_COMPLETE"===p&&await(async({credentials:e,siteInfo:t})=>{const{apexDomain:n,plugins:i}=t,o=[] for(const[a,s]of Object.entries(i)){const i=$[a] if(void 0===i)throw new Error(`Unknown plugin found in '${n}' during update.`) const{updateHandler:r}=i o.push(null==r?void 0:r({credentials:e,pluginData:s,siteInfo:t}))}await Promise.all(o)})({credentials:d,siteInfo:l})) const g=[] return!0!==u&&!0!==i||await h({credentials:d,db:t,siteInfo:l}),!0!==u&&!0!==a||g.push((async({credentials:e,siteInfo:t})=>{const i=new n.Route53Client({credentials:e}),o=await I({route53Client:i,siteInfo:t}),a=new n.ChangeTagsForResourceCommand({ResourceType:"hostedzone",ResourceId:o,AddTags:x({funcDesc:"DNS service",siteInfo:t})}) await i.send(a)})({credentials:d,siteInfo:l})),!0!==u&&!0!==o||!0===c||g.push(Y({credentials:d,siteInfo:l})),await Promise.all(g),{doAll:u}} //# sourceMappingURL=cloudsite.js.map