master 7afdcd9ccb20 cached
7 files
197.7 KB
65.2k tokens
30 symbols
1 requests
Download .txt
Showing preview only (204K chars total). Download the full file or copy to clipboard to get everything.
Repository: iann0036/aws-account-controller
Branch: master
Commit: 7afdcd9ccb20
Files: 7
Total size: 197.7 KB

Directory structure:
gitextract_worhuh9j/

├── .gitignore
├── LICENSE
├── README.md
├── assets/
│   └── arch.drawio.xml
├── lambda/
│   ├── index.js
│   └── package.json
└── template.yml

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
lambda/node_modules
lambda/*.zip

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2019 Ian Mckay

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# AWS Account Controller

## Update March 2022: This is now largely deprecated due to the [CloseAccount](https://docs.aws.amazon.com/organizations/latest/APIReference/API_CloseAccount.html) method

> Self-service creation and deletion of sandbox-style accounts

<img width="680" height="707" src="https://github.com/iann0036/aws-account-controller/raw/master/assets/accountmanager.png">

> :exclamation: **PLEASE READ [THE CAVEATS](https://onecloudplease.com/blog/automating-aws-account-deletion) OF THIS SOLUTION BEFORE CONTINUING**

## Prerequisites

The following is required before proceeding:

* An AWS master account that has Organizations and SSO enabled
* A credit card which will be used to apply payment information to terminated accounts (reloadable debit cards work also)
* A [2Captcha](https://2captcha.com/) account that is sufficiently topped-up with credit ($10 would be more than enough)
* A preferred master e-mail address to receive account correspondence to
* A registered domain name or subdomain, which is publicly accessible
* SES to have the master e-mail address be verified
* SES to have either have the domain/subdomain also verified or have SES out of sandbox mode

## Installation

[![Launch Stack](https://cdn.rawgit.com/buildkite/cloudformation-launch-stack-button-svg/master/launch-stack.svg)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=account-controller&templateURL=https://s3.amazonaws.com/ianmckay-us-east-1/accountcontroller/template.yml)

Click the above link to deploy the stack to your environment. This stack creates:

* Optionally, a Route 53 hosted zone (or provide your own by zone ID)
* An MX record to SES inbound in the hosted zone
* Node.js Lambda Function, used for all actions performed, with appropriate permissions
* Log group for the Lambda Function, with a short term expiry
* An S3 bucket for debugging screenshots, with a short term expiry
* An S3 bucket for storing raw e-mail content, with a short term expiry
* An SES Receipt Rule Set, which is automatically promoted to be default
* An event rule that triggers Lambda execution when an organizations account is tagged for deletion (if enabled)
* An API Gateway to service the SSO Account Manager application (if enabled)
* An IAM user with a login profile, used to deploy a Connect instance and register the SSO application

If you prefer, you can also manually upsert the [template.yml](https://github.com/iann0036/aws-account-controller/blob/master/template.yml) stack from source.

If you chose to have the stack create a hosted zone for the account root e-mails instead of you bringing your own, you should ensure the nameservers of the new zone are associated with an accessible domains (automatic if the domain was created within Route 53).

Also make sure SES sending service limits are appropriate for the amount of e-mails you intend to receive.

Currently, the only tested region is `us-east-1`. The stack deploy time is approximately 8 minutes.

#### Uninstallation

To remove this solution, ensure that both S3 buckets have their objects removed then delete the CloudFormation stack. The SES Receipt Rule Set will revert back to `default-rule-set`. An attempt will be made to terminate the Connect instance, however you should verify this occurs.

## Usage

In order for you to easily build upon this system, the system makes heavy use of tags for system automation and configuration.

### SSO Account Manager

The account manager (as seen at the top of this page) is a custom application that SSO users can access to create accounts or delete previously created accounts on-demand. It will be available to any user who is in the `AccountManagerUsers` SSO group. The application is accessible via the users SSO dashboard:

[![SSO Dashboard](assets/sso.png)](assets/sso.png)

The application will ensure only accounts owned by the creator are shown, unless the creator explicitly shares the account with other users, in which case it will be shared with all users who are also in the `AccountManagerUsers` SSO group. Accounts which are created can optionally require a monthly budget to be set, which if exceeded will automatically trigger a deletion of the account (the maximum budget is an option during installation). Note that actual account spend may exceed the budget as pricing metrics can be delayed up to 6 hours or more for some services.

During installation, if you select `true` for the `Deny Subscription Calls` parameter, a number of calls will be denied to created accounts via an SCP such as calls to create reserved instances, register domain names or apply S3 object locks.

You can also elect not to include the SSO functionality by selecting `false` during installation for the `Enable Account Creation Functionality` parameter. You do not require SSO to be enabled within the account if you select this option.

### E-mail Forwarding

E-mails that are targetting the addresses of the root account will be forwarded by default to the master e-mail address.

[![Email Forwarding](assets/email.png)](assets/email.png)

You can specify a different destination per account by placing a tag with the key `AccountEmailForwardingAddress` on the account in Organizations. This is set to the SSO user automatically if the account was created with the SSO Account Manager application and the `Send Root E-mails to User` parameter was set to `true` during installation.

You can also override the format of the subject line for forwarded e-mails. During installation, you can change the subject line to any string with the following variables available for substitution:

* {from} - The From address of the original e-mail
* {to} - The To address of the original e-mail
* {subject} - The subject of the original e-mail
* {accountid} - The ID of the account
* {accountname} - The name of the account
* {accountemail} - The root email address of the account

### Account Deletion (Manual Method)

In order to elect to delete an account without the use of the SSO Account Manager, simply tag an account within the Organizations console with the following (case not sensitive):

*Tag Key:* **Delete**

*Tag Value:* **true**

[![Email Forwarding](assets/tags.png)](assets/tags.png)

Once tagged, a process will perform the following actions on your behalf:

* Trigger a password reset for the root account
* Reset the password to the automatically generated master password
* Add payment information to the account
* Perform a phone verification of the account
* Close the account
* Remove (or schedule removal of) the account from Organizations

The above process takes approximately 4 minutes.

If the account more than 7 days old, the process completely remove the account from Organizations. If the account is less than 7 days old, a tag with the key `AccountDeletionTime` will be set with the timestamp the account was deleted at and another tag with the key `ScheduledRemovalTime` will be set with the timestamp the account will be removed from Organizations.

You can also elect not to include the deletion functionality by selecting `false` during installation to the `Enable Account Deletion Functionality` parameter.

### Other Features / Options

There are some other features and options that may be specified during installation. These include:

* `Unsubscribe Marketing E-mails` - if set to `true`, newly created accounts will be unsubscribed from all AWS marketing material
* `SSO Account Manager Application Name` - sets a custom name for the SSO Account Manager
* `Automation IAM User Username` - sets a custom username for the IAM user used to perform Connect and/or SSO functions
* `Maximum Monthly Spend Per Account` - enforces a custom upper limit on the monthly budget new accounts can request, or disables budgets completely
* `Deny Subscription Calls` - if set to `true`, a service control policy which restricts the use of subscription-based calls, like reserved instances, will be applied to new accounts
* `Control Tower Mode` - if set to `true`, accounts will be created with the Control Tower Account Factory, rather than via Organizations directly

## Architecture

[![Architecture Diagram](assets/arch.svg)](assets/arch.svg)

## Disclaimer

Per the original [post](https://onecloudplease.com/blog/automating-aws-account-deletion), I highly recommend you do not use this in an organization that has production workloads associated with it. It is intended for [developer accounts](https://youtu.be/Fxkbz0OwPKk?t=475) only.


================================================
FILE: assets/arch.drawio.xml
================================================
<mxfile host="app.diagrams.net" modified="2020-04-10T11:34:12.133Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36" etag="dj3M7wHyo0wU1roODCVc" version="12.9.10" type="device"><diagram id="VmFleLBM2T6B-wKIucCD" name="Page-1">7L3XsuNIliX6NWU289BtUCTBR2itCflSBi0ILQjx9df9RGRWRkRW9cx0dd9pmz4Zdg7hABwu9l57re0O5l9wpjuEOR4rbcjy9i8Ykh1/wdm/YNiTuIPfsOD8VnBD8W8F5Vxn34rQvxU49ZV/L0S+l251li8/XLgOQ7vW44+F6dD3ebr+UBbP87D/eFkxtD8+dYzL/JcCJ43bX0v9Olur76U37PG3E2Jel9Vvj0bvz29nuvi3q793ZanibNj/UIRzf8GZeRjWb5+6g8lbOHi/Dcy3+/i/c/b3ls15v/6v3GBw1Fsttpv6MVVfkhPjr8i//Av2rZZP3G7fe2zMZdzXV7zWQx/D+7V4WfMZfKDSdNjAs771Zj1/G6NxqPv1a5xvNPgHWsEgf7mBMww8+lfs9lPBz8ePHwvQX49gHT8W/Hz8+LEA/bl69Kfnoz838A8Fvxz9UD3y0/ORPzQQ/MPpYVvbus+Z3y0SAYXlHGc1mChmaIcZlPVDD0aPrtauBUco+LhX9Zo7Y5zCUd2BM4GyYujX7z6BYr8dfx94WCswqRF+7o4Set+/xvtC/Gs5D9v49UgJeMWfnv0r+PjXtB227K9xu8KK1nl457817i8YDv7joRnRRd22PzX6k89rDVyEausS1r8O8HHx96M2L75qBD2p+1L9OmJx5Hvr/+wRWbxUefa9S79a9XdDh0/Njz8UfbdyIR+6fJ1PcMlvZ8nvHnf+iCX73xz4fv9eVv3Bd5/Id9eNv4NG+XvVf3Mr8OG7Z/1veNmN+MXNGDj6/DB3X44GzjlrnL7/27f+a/jWL/7Ckvc7CG3/LH+5/eouvz/hn+AhOPajh9x+9RCC/BMPIbF/godsyV9nVB2SwH9MMXPwoz3x/0I8f/GQX3whz0Bo/n44zGs1lDBCcX8rpcHs9NnvUPK3a9QBDvmXKTT5up7f5z3e1uFHQ/m7Y7sM25zm/6AD2Heis8Zzma//AAu+8x/YmX84VXPeAmj4/MhD/mzcv99qQqT4AwgSj38lbzeCRB/Y4/Yg7o8fZvx+J3+s8VsHv1fyRxbxU70Egvzjir4NwC8VUfMcn3+47Duw/Ww3vw/Av8OUHv+2Kf1oKH8GD38wij+6NHBE/uvnn+SIN/KH0Xw+n794IvZnnoj+FsD++Z54/y80fMSPxvh8/GcO35+HevSX4TO3cczXHHLon8bxT4LFL5HlRt0Z8v5HmoT+3Zjycwz7KYT8XtU/Y+jvP5GsP4khKPEnQ0/85p//dMv9deSZOY9XMGa/a5f/sfzP/6ZY/zUo1j9fvvwRfP5v1zA/M7Tnb4Hi/z9kw39xLzXukiwGZfzWp99kzE++9afG9PfG8Gcjg/P1IDmE+MM5tp7zb4+CRjjDofh5jlnkxqCPP7OK4uvn59n6zRTUOMlbc1jq79Unw7oO3b9pKyloFYD2H0Pev2H48TJ+G46iPmA7/twT5vwbKfvmBzQ4/DOPaL9Nwj/F6m4/gfod/TWePshfje5B/gfZ3K9chPIdUGBWEICw+5f3JyCw3kv4ycvnugBT9ZukzudPDQDpZ6v8R8b077XYG3Ij7z8LQVCOctgd4/6fscoy7/M5bv85Zvl7Bue7WRJ/QvP+U82S/DtmqcV9XOYd7OkvtgksahnaX43x3w2ROEGQ2P8eRNIMit/u/88YY/f7tPw1/T4L/5wg/fzRLnH01yD9n2qX6H+hLMqfc4zvI/NvZ1G+d+w/Oo3yCw/7P0yb3O8/K1XI4/9deZO/k595/KTLvqfs/m4+5x9fDz58a8H/aYrmz+3017jOfYCx0HMN5xND7K39k/D+P4y5BCdfcVkCWv6rkvtvKP0Ph9IcTlPybZr+Qyjn4/5rHuE/FUN/c/g/2CbGxOOaVvE/oJS/DWndfS0o/9vT3MITdJy+yy+4/TO7+TuW8PNsfj2S+q0U+a0EfM7iFQgD6tshxo898B6m9mjD3hFFKAcK/OiOW3FuCT6lCPilZAwVgr80LV0yDi+ghYx+uRxFqYIJR/SoaAuW85f7BhcrD0M03rCgDJHKeYFzeQ1qkBhwC3v/OmGA6kV3B8cTrNsYwTGd7b062iY4lihatjnezeVrwYM8DDz/aX5ovMcfxiVf48f3OgQ4KtcMQ1mWvqfYoVTP4ZvZlKAM0OnTsOonYtyzDVzJVklnqKVauVnvxoxYWY7q5JVfz/7VPPvHkywoG6LaS1S7VQ0CfANHfR7sjswB66HPqHviD9wPtu3Bc/hLpymby3jZd63A4mxOpjZBrjjbjaSwe9ugpK1jWrI5x5cHt+Vs2XpXdWhznuAI8gjO23LYlc63C3nQU8eVwa2czdvgwhjWAG/lqq8LK8cFFzJOcqdkKRmnLnT2eEdLpB7akDVMW2Ci2k0vngPNZkWvKF+pZTmMa7usx3u293pxuu5OzFQq6zQXpUXxNjBNjK5b95E1ZgDuexy37GnSEW3pcaM4j6eBxtR4gDPtZ+Uz8opMUqQYBLguf10xdCiMf37/e4vMbx9IrwSTqrY4rPHWvshJBZ/wBwF+I4/z/MCBLVTnwsXlRgyCz+ywKCDjUHWHhaKt4GMCcOc/3gOcoOMiQN0+aPHeKZ4pvzC1XICz8/urXTzns5ev3lAQN/mMLT/nTCdv7H5JCzraoerXh8CLY2oY3LxkrUWIcYsaN75bmfvExgzniiUqyIce6IEjlFspVTplM5M0c5xsKpICWpDSNft+0czphIJrES5w/SG9puM0cIEbyKQGXSna0+uJrN5QFMkd6wam8Om0l4ewA16BOrDL3M6CwZii2AnxtkvS59pdWVZcamKNW/qaQPvFY1G1Rry9pc+rxpFhaQzmlSni23zdFGWqN1DL53jmZZKMXXxAu3yKksxZgR3Zr7Z0Q8HPadhiF8wEbaKy6xLbHfTJOmyjFISx7NEqVQmEtODwNftR51kXVbyW0rZMUxRmp/xoe7fU42NGv+Ve6HW5jdvyyGraK9IIobyqgkwEMmMlwUx02nUC63R4r2hvm3qeopx5XszKDzvK2O1NWU+TKkyOEKs92h+AWz7L437oGTsKCE/RPV8VNfX0vJzLWuN1pIO/xiyHsNhmXsSRshZeEZl0vGbEiryuvoh4ipX+ROYy0cUB2BR9hXEVgxkbNzB8Vk+OAmlBezwz3kOMd85HvOUG7S2oF+IzLXFgKY8hPSswGptdT8NcNOFV5Q/0adC7VDt38ZRfhnI+hCZFdEPdCEe0xlZwtZfbjd0QijaZ7M+cyDMqYYdlqaSS/qgMTl8Opi76LZHUwT0rRRgHJ++iWbgRT2SI/DmUWuS4S5R/32ZS9325tW5irKqDdTQxN73l4yU0bn4n9YaojMqpjfL9YcP5s77cWliSe8dkI5l+zjCvlFAgpk3oC8sgxmQL8GCSZNWkfYIpVOigjOJtbCg0U6CvKcWnRSxkLFUrMmYH5TFFQiHcbAnb/aYZ22NlXcULXqRh5lRwAbcn60SOjIiqEmbGCBgnvDOrckfg9FeFhI3rHRdJg8BEG8173SqrBo7HdDuKz/yprKjv1ueddxfWQuW+H7t8XxM3G57CcOeBV0mAq9Kox5j+LUTSSLAiuo6NXk7Ag04KnMvu1ksLiNpWoj3ouePRbg+qdkmRX6B9ZMBX46Rp3PK1CBXiB9bbuq3Lq9ljc/Yehjk7VtgNEs+V7ZL4uMgdxmt4g7l6i4oYhOTWE7oOnldvxKFTftwswd402IoZjwu2vKasedX7nVRnz1ANwgU2kFESsAFRGFqBjIoMtHJdpAbmlHh5QWcAlDTuz6Q5OeEqtAi7A4BXNPtuELH5dTXL7XVeoiYfqmI74d0ijO6R2HTU6B5zCvql7bVf5nvoKPB65FEaGSCu901sOCQ969AxWOdjpYTsrpx82NqkVgi+A8ynA2OxrEsXuXESng8fIwgvAg2jgRVxJYM8OEJd+1e98LsNevKkBu2yr9tD5o2kXoCSc6OGs/d7dvMvzbttI59rrH0RdPEsH/VaIBUCoO89BOhFBSfxeDFE1GoFiGTRc70QloXwO/L611WLW4gwaghZ3wAcsKmwa98oN4BAhy2PnHw7bjbDwXry0LAETJHh2MnACrX34HREqK+mgnE1C0bX7wlC4llBuu1hREushkDT8W6mCG6F/q87xp1lpPOt8gNPP1eDVPFulw5d1cuGhDAIZ4ZfOQ8ROLu3XFR/gVIRTttglOdL2qrO52wvDbwkLG7iaEjLlqhBhg+sFHEtxivgqRpscISrz4F9GDJt6+/XS6mJdcRfKyPtOmsOLsNQPv6B0EfbTLLjpqU9qnfMeZhwf1FfVTSv6lpsr+jfNuLcyDQwkuG4quBmaC/ruimCrcV3X46VtBtmPhX1oAF3wVb6VWcJlU3JxX0bgMGHdpexmXWaIZBNvKGqi4YI8hCRJgjEtFU4wj6pqsRIL6cr48/KyiAO8VEa+oD5OaEPZoyWCqlSpy5uRzgPXu30VjQCn33pBqykDHcx3ABAGdLLeHTDhaflKLFgNJCX5tW/jSJgciegVjsgv3QhinjbJc/w0HV99yEfKAt2T9uhQoj1k5kssWBHHSvW0Hv6ju2S0dZuCYP3ZyZzOUrwh6IMUVfTSCQA59EBIrO1UXnFyopV2EeHTmtlHI3w6dwHx1znXTzIyJcUGvLQCyDyrkpRx4z3vFviKuRpYJU2JEZIyJKiATFrGeKqC5VHdXu/MatGJhKgerifd3T3U9EKFEaOqQuCERZktmI3pWsxLHFqdf1g3wCrIOd9USRV1lM9AftTau4xzilfIUwYvTkUoxKDSG6rnijdqZTd1FM316Cv3VewU5GAKESaVjeCMLRqxt75vS4zYL8ax4lo/UJfYbBLt0i8wiB/+0FYl9pluQRPgcscAtCUI1vOt6L3pbooXUkxn8v06qwnUmHdN48t84XlRgVBraWab5YgAL+1zxC0mlZ5337CqRJCOwS9yAnIzObsTlesRmouiHrKG49UbwfXQKNfkXkrrywOnAcpJ6c7HkEgnVddfqq14KNGlAFlf1DmPepJoyFomig6kl56ai2DNqlShZ1nXS8XGJRo3dMAKm475HfvIH2VFnAwQa1ecSJWeGb526zMHA8q4CWidqwURmhonDYuwvkYhEVaH7zFUQ32DMt9VRzECUIbjI/joGB4dzL0M2AD1rwEzdEH6JpVsUu45IQCOuC+LhCSJ6HJ8KwzHqf1HEClb1rng/uRjv2glSdLYpUMOj6b8nrr3Q3AoLtq9UfN73WrPWUpCNoDqAZvU8gcsCD2ho/U1iBxM7yHF+RWrsdhbAXIkZByq4y097CX44xqGYDTZYogOiZ+7nx7eD6JU7LRpkHr0584+xyVD4iXrHK+tAhLgPkR7DJsE/AIrvPK9AKIxgMrJJobmrTQh7miu4r6Nobda/EdVJoXfT2fUelt/oNzX8PnlvSQYBvYY+sSWdPWJ/7ap7yPbgIuOJ9ifRZB2Pd6B/VPDn7TNIzR2hZPWDeQS8CtZ4UCTR28Udl4uXdZ1ahTiCrA62nNx3I3jngquT9WgH4zOhg6kgcRx/T8EdTzPV9sAmeVZHJ79k3wJL3LjHJnOb2MmajqkK6LOkwpgQKnP/4gzsI2ci2iuJ4LbFqsTuQageOctUO+ZTbNfA417wDnes3udCJlUrLIHr7t8tkdjatlmy+1Dcvl6Jc01+1n/LHnmxy1Th51VUdz5iXz9/Tpcuief7TkRMej8rwWsDBJm8YmI6Bji6qnjVX1Kmosju/lZTnC7d0vT3oFJ6HPOOIdG0AU3rpnAqB6vSwfuta9Ua6ZIdKDM6LHFViHzj3r2/08LFzXiwrgNL3cbT6jW87qsVtkc1gnMO1Gd/jVuTGkrdo7qzqCjrKMCkCHmjYFsiJ5q++bSifp4kvA0+JifOKAt9jr1te4KQmVQzmvJWoGbVY785inJRnKALciW532raaKHtv3Z/NaE4jzB395nYCYF009iCZYsWgI2NIhEZ0YthKpAnMt3NBoBIinn2eK1OHM8+oIOAgMnExZWlBigJPLk3/6pw6FsxUH46LrV4jWDNHEuyLTEsGVIAK96ZEld8ugCfx2eUG3GEKMVTPgBeyIDNkUt+yqC49XuWuCWF5v6g1axo7s0RwQgQYNRb2LtC7LbJ8+cz/GyY5yW88RGiD4PTdSsnQJtmksj+KzqwW2Y0HBNU0vcbkTH8zwNYPuFX54dWbKqXys2YSCZaZaODWSA/CwYnuugihJamR4H4jg9baQy5J2INoVvDX71Haimi7n/KggsgP97a+65wN7rBW8pUPg106INUZ4lUAGoYzXHiDEKneakcaeb91anAIyJEzyzNwX5+g6sfnKfum53RwPtZ3GXVfJ5qzPk3HKSRpybou0OBtyUkuTOFaG19uUCAK5TmLjeYR5e6/cExKti945iJs0oZ2UL9yaQ2BBXLo/y7PBKvqqkVHbrAn73C3STxwgrCoDmm6U8wfC2B2u1U8TtJ1zez5W9cq5015mL7F+hDuuaKVXbSFZUH1T5eH5Wci3P1MXfMIb3aWWVcATLzL1PFhXSykVKnRkKwEylzgEgjddwuGCEE1D2H2M0Dltrr3RdmQRD5GOYFThLHVNT2q+3I7NakL2MoK/oaLNBr6jms6upRnne6+BXMdN86jP4BwPiE23Q2dSm5TLIWp7LYv4182a60kKkwalfHD3ix827fXqB2cCkyCdZ4c2Ma2009K2s/jQrZBAXDKxeqnQQ0UcXZhtUboppZu50BBOkCeApY2xo7XNFCzxUlTXt3CM47CRuOOuDJh6vu+ltAzPaopkRRps9hoacLzCY5+1obbhbO6eKxnKvAhniCVFRIB+NksaQbmWfeQOgjESgtTDB6IEyhD5GEmTAi6y9XJa+zpsnONJIUz+eMmMDG43XzLtwJoZq3aqlUVOy03Fya+ZKmM57OWyjqFLnYQsgaVTvXFVkj2GXxLgW+5IGN/tLtmU7RZgVmqgmvkdJqhcdYg7J2AALXdcc/hbDkpe+EOueNidryNx9B2lfgL5VYe19F5e4DkdgCAXtOcJsL2WFO6tvRypHmrYK4xhCEGqdVVEQJslQO2tp/Y2NOINPhfWzZBA22VYnTRJ76+elxtdS189f0u1DKozXrVUghsPXaqVWv7h2W8sYdI/fzYYhxqE0d+f+8uzHBkwViei9YyiAbcYUd8DVNmrjXsjXUDNVXx7UivM2CFum/ptRzvnMttyBrgjC8bh7zwH9E/nXDuwJPtdWP8LmcChG/wuvtMK6N5L/PvtBWPTkch3q/hlbN4Ql33u/HF2ZAZ0EVSCnDah0+W3ef+tRfLAfU1/7Jt/1irA2IE9qshvIwVEMsI4BAefLCJ/eDJGX9T5i1VA9XpaP84NsH5LNn7zBItXgKV5wv1v2VBgklS0GQ4H7st+uK+ttO+egjEdaG3L3v+Wba0TWnUyzjbaJpYnbuxFVKV5e/tDFrUbLOG1kA7orTr4bydgURX0GBo28NiU4SzOE39oCc0XJlcJbXPnR64/+2++Af2E/ar9N58QeJj9vfv675na2IYV2l9t/NHjEjq7gScY3QU0DbW8FzoVbjL3usXeadx5MFv3F4ew6bRRchZ4m3o/mmlR0jIILMJ+O9yuU7gQJWI5jcOE63TY7DHu4NYN9FLYVzBDcsGXUEwFXm6+msIfsmbkW0TnIq7UXmkf8Tn71DOfWOK3nTmgy8DXdRDZ5dilYP6OglTRW8pyFediSIdaegF+ocTcVT8u4jZKfsI8kLq+87VWucBSHD8/DiJK+Cup30N7AOZmcyOwgpZjaDSUPjqDM6uFoSnfhA2hSA4NkGggRkq5L/NirfVttfbDP2zuxQA7R+MOpc3ulmGDctXDCuK5zNnMs4qNUfusvjI/rGVfijkG7QT0W+YiAeYq0BuHtWPR2ICwLjdO50ZeA9VhQ8Y2+yd215zPvCqvHIAvAw3iGFW/KbOlkyYmP8lbOXDnfhfBHBhlJ4w1oycVzMFAxhW0TweIYPeb12nXcViUeJ/Wo8kDXTGpY35CLV0rEancM+vhGTIOuV5Y97nPhzXMjgGvpmF+Bm9Xr87J1Sf22viWMFcaZ0pdlns1mIaJQO7zWHiexlseKU3h6lkZJLmSpMkBSnMYuFKtOym+TbWwxB6F7YpE667PXIpKjcy0Uw9pYNanq7Skz5jK4onTwqvD4sGsSTjX247x+rgoqhq9BZg+rT+Phru9mQck/pHXKTjxCD0H0mC/blR79ChFmiZlDyiuvHESvUh2w8W1IOGCRk8qNMLDA7xx5geaNQ5aUyZlOoxtl1wsx4aUrQA5zEtc4MUtNWjQ2cz7nodH2S6bRPdGJNwZSq1zZKw2rLnHG8LWpqt94tqWuZ+XTh3iABMSStVTiKCpdkpXWelrcLVjeLGd1AiD9N608n2wdm0eDZU3MZAYNkxjYJ3yYAt3429jqs5AjQjVCbNH23HkqNCni8zc1onTMHWI3nxWBkQKYCzp6uW2o5J82bqFk0T5iShOSlnK3jUazts9t5mLTJqB7OXJqBIxE3bjSzPz6PVtapMPzPW1/UD6slBAWurmOpp0N4zng+oe4wxUcOph0IDNHCpo2qO9o6K8Eujk7tMUV9LRMOJIFt/q3emHWZghzJqlohLVWia0d4/YB1/5FPvtERzGAGZZRTcrCFUEF6EFq+QsYBJNzuR17uTTyrSqE6Iyfn6SPV3vz5iBmRz8vb3cnHUUMdHB4cNkb2SuaOz1eZqfpmYyvhVCKJaHXCmWh0nAll/L51sWjl+RgK5K6bNW9n2J/Z2SF/UTN820l6ehPzKNZDZKTZdkgZJW122asG0f7V0Bqp4aDqfdk/h9N7F71CM0otTnQ245ZXp2T9rfUIRQzloaD/umBdHhemGXF5XuCsLQQRzW7tVtf2Rkmn/YZobT0H0mnCPI7DlD02eRYZFST/CMIub2frzHR6O/tGeJaYRZObyNOJHzhmtE0RF6xHrV1ngE7rDDm4Oudk5ZYRD9EpeOuzUtbyfV52FBZbrfpmVI4RpH379HGWYO8p6LeoVx7rI2WcaYsslZjnu+KbCfjIB0iks+h+p8UcgNF07EPIjAgSmZT+JEzMmdN6HcKybt5I/Qk86b1C9DF8A8silUNoreLGpK85HjE8Av3XifT6BMuIyXgGLpNWDnRr274Fm+Gobi/Vz5lvFSH8TXEYEJ0YNIOpIqjjM7FLp0A1uS0Y0+iVw9uflcP9TV83RxcGLiyGHSAfkRdJOHGcctXfexJ020dVpbh0mcKBIPIutIpgkPc2BwjWYPtAB+bqw3PyAiRdHmc9uk8jImK2E19PZhQ6TFSzIXXs8YeiOlzO9V2/qDNJvhqoaIP1jObeDKymm/kiSoevv13qb7RWGpzyeqTFkgYsGMs6gg7Hmd0iKid1RgYEBXXu0oSlLztaqzZ/yRiezUvpQ3uoFZiVwQVs9JpDeShprf1utYezS3jCZj/B092u4pCgS2vYgxuE6jQgoRLuvzFsPfpHBeDPc1+rccujKf+V4uZqjsFZW7ELtuZMdjIJ6+3Pe7+wxUw2cq2gRsCDev9KrxhbNNVoI54I5mcO5dRNwN2Fb18u0+D1BD1m++v3dJ9XTnaH8y6bGb7PCoz2lQs5cRHmL4qXTSaDZR3wUuZXqRaIu4Qo2PQHf1SC5qHI/7ansNhcQuj13muEMvpXYRjKrtNu26P8Y3itB4K+fNtM4TzueOzRVF3TaA/SBuw3uHA/gA6F4FHRWjndny5adgR331CdC1qArQb1NiEmRJrFFs9cNXCTSn9mRbir1jOB/xpGSBKe/7NqU8DJT2A1dkdUjfR8a8i6eWMJspwpWrCCu50nJMjJGbxflQ232YyKgX8umoKbyNJUDvQCvcTxJQDA4Aq3meelObZlKWh0EVTLe0Mpm13HPDDxsBjKzivyz88bDxUOuPqaCSPTyHy1A/aj3Gtpi7/LBs1csxwrircyx95lyCBzhBktZ7v0nSrvQ5tV7uhyqDdvQldfCQeuJHq1m7oqju2fnl4XLpBNZkqyM+qSkVdFVRNdZh5iUG2M077alPGaPIMxcPsgQSaKid1EUdrjvSz9615A15f0LgkRZHDo3w9Y4xDfNphMe9vzLbLzWr+Cj2ic13bZYcumqK7Vf6lXXnI/zr2lAAvC5yn+HQ2yFvsMpUjuQa+xCDrkNvFNBqHsyojbAQZSvhq+1diIv2zaaiet1qwpwFGuavpJTpEBQxIrsBapLNAxsRYQhbHlpiu1wFeJ0jeBu/E9xHb4rKSl6eaTWSDRg0vWoIT8o9KwYVlrcRvLZb5rAUn89iq6qe743jTb93nXFxgfg07WQ8NwEt3zxK1pRtU/wRBWGb8E1eHcu7OcNTG5z3+MrMHWoJ3i5U4RIw0waB9qypiiozn1Gv8LPXxXTHy3B9c4fOeIhMs5y8kxnhWcvbva9q2oivG7guipj5SwwrmgZYuiJPzAS5wRMTH5AwY+m+yO/0FoFxnLBqJOfYle0g3pbGVaopfK9v3CT0GWN7iyvvsirFEj+JakyGRgvZEoud/RdbEiAbVUdaIgB5wZNqDWlqMs93HXuo74d80j78Ejc4C9hPW8bCGOJw4aRbYvThFpZoTYBs+pI2sVqYGqtzK0pyGhQk9qwaWLhR2ZpsMnwzGUSIMVKT+TJr1A7ZqGSmeU7L9svw3ioa6J77cqrLdUuLnLKMtbcrsrGUCoHEU1B5tz3coJ7uPD9QYBaNg3d12JR5O5IM0sG1fpeAUvEUG6B6UHbIFr/9HAXsX3zDlV1JNEKqLEydMfoXXIWCeWNHYnmxKrZgRpwRL4HOyhiZFC3eOPH9bkCy0FNiYQrc19UMI9qpqF8uRnxmzmCqD3U/Ita9MbecknDR6LnC1ZqvtdsKvYXYW3609Y2VPSN547ZExB1lfp4pXhXxtSS8zdqp595Fs/Hum0vMdzvMmB4RSkC+1bVP6bow15Pm2MUDIkH2aRfMfTXtEqo3Cb1vbYgDVXPj3sObNHq2T2oRefDcSn1b45/RENDSHs+MgbEuTTQGrfqoBSqZyeMGk4lV11FMfWCo2GYKm4Zu/rUzBXDSV5j8jX3bEZFEMZHwIZHIMaGCv6odhjzn+hQbqtSwuArNWOjlDZRs6RZyvFxtf7vUPjjKIFe0Mcm1fFxcAlRDxy/ZYkqinFNnQziNEKKCZvtGWGu+RTDeapjuBjTJFdUuBhigS9h17BdxUDttigahpV3C1oy+bVv81LsEsmXHQM6N9RBBeNlark83Ph/T1+oAkgMC552Fe3pkoEIEaeQQuExkDCyZHi2FlPPNonms4tSJkQqHl/bDc5L3wn8OuFxry8scAwUeTqP2cgC31hCogZiPCHctbA+D151S8mm/OvIHdV6E9sXA2vDW840XEDnff9KUXgcEb10PkB5m0y1JYrDNogzalUEzrsplWG6sJmdTY4ge6zNGyvPRVA//kpyqswLapqQiueACBo0UILplzAH0JK9cykVqwGyV5YQZbrjBYZDJmcElHffEtqcZG6pjE/qJkZ9FR+og9FyQvOUZkUyr0XtrWpsffzYcWbHgyrC+aPRFR8UnWaQiKwKnQYSEFVyfN1RTqhE0V5HHwddsEa136sVHnJeIgUmk2XI9yJSMxeQFZkj6WgaTM15F+3e2qVXmb7vWPM2M6bp9BcpJ/4p95yPBiLiKORtR4qRbvfZ4tdwBM8KH3vO2V796MoXrLUdXxgBRaU4nsn4Q4Lau9CqxvBMUpc591MfV9S47xKIcwIK7N1zlfgMfOwIdxQSJ42CAuN5d69mL8i6h2siDdqP0MQ3ChxkwUcdL0bhNcdb0PaldyFMn8k4SMCzqOSOAMex2XkoJkXiNrQi9Ard2HjwP9LkQ0PPr+L4GqQNkqp/ngW0XMSgOB5DLGyS8HYMdD4mvJV35+LaGzcBtD3mnKS/3Bj0g4lvMmsQ7Nu8xrvKLqfiRv4QdVNE8XEXWruq1wqUGDTB2nteGJfsc6Ed2PipWswr5UbLE88rnfQ2dLsw+iEoTRQ8Y73DoZBRxTXISnyPBZo+kG+LtrViaWC+eAyyAErH1a1+IXr/vgJ3PGd3tz2U6ezqKXm3uoRcXFaHjNq6OYdkzFypEhNtf9KbTFqHysvfEr0rxqonAn900NCxggggHrIl1BBDM+Rgnii7ygQ4UZJUdNqZGWjl1PZ+uyVEhEulUJKSGuyiwp4UUTUpWSyHIFyvqFh0tWbMenZagExsRR5TVG7frdDZIwF7h2n2ywHHhKbgDqpoNoCKfyXSuR+S5bX+T9ZC557tmNOGjwmP6ZV7kndHvqjb1FkFKqR4/mE7in4gSJgzCn9TVUVLy7C7xgVWLUTksY5rx4nue7RPjHMZ3vJ0+fovSA9CXKSU9KiTfPUfD0PIc02o7HHNGNwYZ3iDS2qVgXbo8F8TtI3qmgiQ3sw6btl/grg+uerLZ7eWFLbNBXhNbLyJ0lC9Gb35KTUzgdgwIRHt18+ol99XF4W/2qWJpp7HOizQvTdXvx3OnOwI9mH6/qxRc2xlGE9WL+iNLtcLv8essdmLav3ZdIMkBxFlINIc6uv5I+syGpuvxXOYT8QOpwYTbG6o5jIWMIcf28Vmy5M3dHETB2+ergaLNRTFZBh6OGI4TDW7JrTupEeGdtiLCb6k4AbO8eUCJ2Z7fghj6PlaKLJOn/srNs/DxhQTyT5oXT7xv804Wc4yy9K00XLknyaYlDdbQ+iI00+ftDaK/Wjajp5LRbRIht/SVASg++R73mgSRdyfKg4ULRwyaVKlBv9gEzNVECPQLYut8f2n1VE5FoyEl3DOLPicjgTpX8i9Fokx0gxYEnXiYAczWoxV2S3HcNMud1wuQZsa0vclSpjTrsftwXB6q54dkte/Au1xSYxUFbgorkMH5SKpPqaEnUP15JF95Tg8Ny81+DdqVZUTeS/CrqWgURKDqolwg5vtjc18QFd02aybKSByL+MA8TLra+MeGuyQTSUPfPg142vECsSnn3YcLUBtCW4XF7+gj6oVCyOlTNtxCWTyIUYSn6Vm2Y9Yi8x7t4Wx5NhKJbHBbM8W1nOW9KuzCngkdF7ICWLZ296C3sltcE4ZTRSx5wh23wBLUFbThuUbe9pH10qVhZnmZ0xgoiu22SxlyQPwwIW3anhRs1BFcZFHCi48Xma/e+VG2yeqt0e3ogVrBDMMbarz4lPWl7XDNNq6hTgqR96vdimMVG2cl+J3myQc2mAUUpfyOqffXwrwVsa0fXEeO6qEDPLhHZmOZtCURxbdFX/qlyii6v9cn3uy4766G/Mxstzw3HIU01oh2Gb2V1Z17pe17ycXXUxiPBMA6wFbkyvwGq7RvtWU5y8XEDYMbuirAjj4r65awLQGkAnKojoo/dj3xSMsQnIuqiJPEGwsKvIiMc/2OU3B7LsKCkOdUhj3JAIez/ElFN2F6EChHBXyb1T65Je+4hbKKXE+7IcnXV8JrxVS4e8nVpe5h+M+n3GYznAxj67Jg57X9+aq2KDUlTs02nA/gZuT0zstInIhiZZNrQMyXM8D8uuLyxYdMHzX0AKv6SuWctfMUWsiaLuyKS34KXNwat864I9I5KvUjA1G6H6eIa+/SA0K3cAwJabY8+7qIDmhJ9PVKzYbhZtzKIwjHI4GC2A3s/NX25sN3PmE8Wyhr6SJ8PYEvP5tgA0Qbms9MfvT9uM+try1sTLbv/K1XgfjZGZPATAe78zp6C9y3LbawyXR536DcL4FpB93lfclRoDLnr71nQFWMS84KtoUTlyLf1Y9VIU8JYQPQvJzpmkkjqQ+FzlO97MozgNo0s4uaPF5+kxuXozUHCAfzoAVeB9qfFTMQgLp9ZFzJwWj3lqvCeJXSa3+YzgbsBLvB9YkAayNZBXe9aD42b01eRh880QpAqXe6Z5Ysi3uYwp6PMYoEg+3usjh4HtCMcJPMjS7U/lLE1fogFk1qwFGe7caeoXSlt5Ixig2TtFGvSSoVDg9Xnr6aZtGRXdRLtOpHoNGB6JdijF4UzLJK6itcuEK/X3yX+jzQI/a5MsDqMJWI9uB5p64DtIEymC8lFm6ew+ufFeqW91sihlwVmvoBBowC2szOFNEMSePsGuAPGwdiw3BU1mVs8Vy9X+caHAYf5vfjo3a9AAFghODJyAoMEA2HlzAbjomajTfSxHmAdRHZR78jB7Enz8b6gIcxMAc9iTadVT2rJ83XagYW8uisrXzLxzkY9Y/whKQJjw/FmnOvFRT8fouidPDWMErBAFIkU7IcmXfhV0KbvkWOxr7PkuXnNsvF+aLhXuw3UPyp9DSbWE5uXynG26Oyx+lRBPf0gxvm5OVPuEdnfVgIUcyC0cLgy9hyC13lKdg9Dz3v4F/9MxvdkC1mD223zeRY2o/fTy68+urQ4T415tbhTNCdOJE9PAWvOR4V/EdhmkQ4P5O81LuvnbkQIxYfv+z6kkmi3GQ/1bQLm+MKjhwqJhIqCTcDPpW9XdEnEijnljL3sj8W7LHbt9tIMET46Rf64a+L9WLgDgbpqspe/0oXQQh5wbYUPX9joke00twqt4TyGu0Z7nP4es2BNQD59k/gbwJnXJQHOWrMGYAMvBuGh1+7AmqBc1rfHe+pUCSNPgOPSA10uunbAq1Hq7DETnnTqy9+vaUIAGpHtgCq8+brVDbkPFs0uGe3kX1blYEnxItDxesBN1mcCgCcIAlDyMz5yaxlODSGSYA+7+BX92JtWPJSgiOUe31C4Esbwvic3DyepqS6zxE7lpcG15HY+y07UuqNjBze3orq9Ex/SNUKTIByAbfjvRwVXZUiKFJzh6LL8M+dNPNRyPj2xp/ggTCGJeipAmYKd9IwNkTvA/E2Krri9cAUKKqyMBCpN5eGJo1jT8W4cDiS2r7gczXDfsgn3K1Kn/xAJs39WcAtDnPeINYJV4FI7fakH3SD1vm9lc/Ht53rAc+3F63bI3XpcBfdiD2KKJlX2f+cECtYoppBZ8bxc39nGOg5DdOIPMUTZDMqgzSdI1Nbdt2VkUcdqjUYbJhjUzIsdLukdLOm8ro4rBm//eqy4sT38sgR9q1fKMihnqOG7mYSP0xoNQ7WC6ZaBcsMk3IGQwO5gUXcgQivFp3mlGqwI9wPXckYoOKARVdQ5XZaKXIQow1JpR0KaEi7EQaU1w5FCZs6KF+kY79GbaLTIg+PIkmcCjcA41gUM03eeXYSXjzuJqpWeYw7QOO36E5KhxuE3aA0Dysva7OBzqGqtd+/anI6eKOfJdMLeVweqY9eIYmwmB61fUWFdJtIopPITSQ8crpSt5YVYAQHcCJqnd8UW3pMTJFCKpZbrd4kn8/F7rm4dOFu8i0iTL6G38FDQ5dQoDVfCwNRhj1u3xe76hfC2uTDoGG23Sz2CSiGdBtnNfzsPYLpw/KRFKVl0rN09jgMsnGecplQHUADsIc/E4l8PmVqZlyc3ZM3BXcYY4Z4hbfCbBCDLpUHk8bCUg9SmzQpIFhR837mKqCY+az1JnEvkQq1dUBzROPDzrsv2mPuaEAIlOJtwUMgVD5b5WjvOEZ0C2GjYjFCph+X6/XMQfyr1PSDhV6vnkAxeQzyYJMbmV/IZYZ6Y6g3LOuIcEny55sEMajA7fjDY2kAgVZdiFpJve3VPtHJj6NJgFqY33Tdr9AJWO2DXkkkYU2zUrnbCBjoVb33qDg/h/KlRwt09nrp/WZclrDgHnKtEeQP8tAJuR+V9OIayMkd+ab3a64sxFtJA1TRGGRVX7rPGQBpXqnes31R8bvBDu+P3Lkkjm5c4qAjwELOdge7K65ae0Exs+ggTps7DWJjzOBAyDGs8dI5nE0AMRIh5S522mzeWJ0xAI+dIvVz9CBc7B3hbWTXjaEwJYYPnPaGWd/T1bJNW97uw/PtlLu6Z9QA8hVkTDu+TwlRn/0rHkfA3PKA30y8kPJIS1ayaHZCGyLxOCNtRdzxTQ5Z9rWmdQael5QPQ2aANi5jT0PZ6Al0pgJEZgSx/QWtUD1J8xyFuwugG+6u083nI6aswuyrMKPftzSIDs6+ZZ/bNIu66XL7DFfsNw5ughSjt0jj9GBUJ6sU/j19tV7uEQHuet6W9LVzE+Mv6kk1gcrPU9JV836Lwg7x/bed5YBNKHK4aKp6m+si04Rkh4j3Ebn1w8YJ1Ho873kmG2JlsInwkQw+5R3yfNsOOYduPgvoVsmBx/tl+bWMq4TdqEyPsDho88WdiloH84Oa9iRdRp/5HHE7ZnByEh8ddCLWX4RAKMGt73cUBfiendwreryylxXucI88zpokywrDJdq3qPJhFl4BKjPl0HXNLNzmRYax/eAh9LyJxRqOrI/0zgUF5JNIsA5jsQINPGovF28eeJvYkW9nn3STPxgel+w3juz2LRaXEAvu0Oa5/gy7d8b264Mee093K2OJ454/cucdsCdGqcdDrBLgaTFkMy+ghPwiZ6PwcyzFGH+NrZxXgru/F6Pj+7w0hOBT2ZOoV1OKz2k6fapMcd7qi9SawVAfAUJkz8DEpvxEch3EUlcqcA/NjtDbnKrW7I/46IOPBV991B/96nUQs/iXPomioqONFflfW1BzbUg2fOVRVPCa98o3cPkltHBDcQgnCOft8CzvuX/pu36Bjc9LrYJv1O2r+whe0ACH8B7werCjseW8ypQxFLIPFsCUTOxORLg3JpWOrT6wrWYPrKDbAF+NznvhAPE2DdNyWNTzfuQlgGsFaoNuObzUe1qnCReBi/wt2UjuWd0yAG8/p8Emiun+bHP5wZkAn8g8+QJp3vcAC450H4bccp8lB/HdpTZuDFSbavYNskWtVQUTKVqFPNUUE85FXuJoFIZYrIbseObGc6LmUxZaxLPuolmR7m5s84R2x3155QJN8HyOi6GyJSsVfb3PlT5er1GECUKEDtoXcRrdg9jV85xKpUiwwQrCaDNfyK3J9AKXsJmdrUuj0EmnZ54N08eSP25QzIvlSQPZsp6b8C2puM2LrdP9F1ksS4GMYBqRAmxFlarNHmfaptS5h5YLuzbTB6QdvAFR4lXXIT5vTFMbFCGWcz2CWAFo02xPN7geyODtiQuTFQ1LFI3dFFXwzSexIq7HGj9gFiGH/KJo+FHOKna4bY8nHsG4Z+1Z16SGTD/jLezgpvKqIfMDQcWmUy/mWkBhjOJ5Id7xYtnd1BCXaJCZ6S7yqM1THj7zk5BZUR5C4R84BqX2HlafSB2rIE5BG+N29AOYcWhp2JLwX/vJAJcLbaMY4UC1OyAjhXpvuFOzwzvpjIblYd1FQDUYCYUCrRhm2YI7h/NaFBl4VEndrC2E5VHac8Frlk8kbasatLjnw5EIFPPtJTUVitS33GuSgz/Wzpz2O6Jd7Oj5IP4GF+bQhYHPnLjhUPMgeTDYk5lUxQqC+UsfKNIRBippcFsmg1uSC81GfvoD+1ohRHzvfbnFHH/YvKuBXMorxlrNfb/BcU3EtoLTSNzjfOeDm5ObL8hHrxU74Jfr8Yshjfu+Nok6qug2ynUhYgeu4QYcRof3bTJvJ3qdPuyIWSv2SH3s7nW27L90xQpuX1po68x96Ti48d1cZYzRl1s+Uby/qQch3T0H52M3Fgd3eL10Cv/gPWk1BGrWDNEqtzlVt/1WJMb2uLUMtLIPi0l2RT3MMFmg9iFPeozlSYCZI8skPlaQXdyuOG62O6yBP1xTxwsga5L9Koh+OEslgemwG8sg+fq19rlM0oMHYRK+xmDU51VWoWPqlRJV5L49EJrYg/Jp0yn7Qe8405Ef1ZbTGhXKdz2QqSmcgNBZ0A2uiGBALJg2L3xvqR2Eo/UNAY7LEAtRfFtQ4NG0Hc9FQ8htkomrNszAAaHzifaORB1F74lrrHfLvM7wPSbwBRS6JnhZSALxRqYRzCM8hO+rf3YM2JY5349Dbj76mTVacUjHjpiVtCPDfVKy9FsLYg1i77AtKvnhcQ6qB7FRlvizql4DQybyrDa4WGnncoZ5Hh3c7+H0pSme1dQNifo88K+3xYgQ3ox2MMqtjW6iuRvtiLo9VMBo2OgdGkBDJBX+tOYXvUW7LCnqc7vY59GFLQUXWUW7Z7fXByNEqlTgDkLb5ncfmdu77O+bgb6WuHBN8ft7kkdwwWTMNrgWA6VQP16F9+kOswh0ecH6dP329g8x7EF2tFhoF6P7XFNKgCpnozZTeDQ6LnWusZ1EkbunIIo4EOTsfjRuGOVELxWduuciC5gHHCrldNsjg/qUZb7eGH/VGqQNeB6RjdkWL7NH6eNW3NlN8WebmHUy6rFqBd6hQhTweisS7Mz3gqOo7qbeq48Xy2TubF59Vu7JrD7fXWoIfZxt9uS94Iv75geLXpYuwvvh/4gBRPmniXM9dzbIR/nKkqpouurPG3G1XFM2BnA2pxpmpZ3gnsa1MQBLjET3Sd1EH8J33DVSKnEyxT7zdbWRMu/UAxfHT6lpwDu9gwz7VBO2IplSdFm69ekrGxp+dPRMPgkzEZ8Nxt5YVXPZTys/hNkaP/kgm4Q8POOcFTJ8KOkKdKxul0B/XTDcPfj3Yaavt3pfyKKHeAVQYUlow9e3OPqYSkYwgf605MekNebH22nmawlvvDHaGMU6gZuSUk1w9wENPMQKgafQ5OLXJ9GMO1A9BXv/IhmzlHBL20r0xeevD06sHdDUAmiLE3RVVtVpFtYHz05TPJf38eLGQQYMGo1Wzy3851jfuy5KCrg80cPlzHnGBPOTu42gYeKStjm66mui+lESlOvoqeFL9FUG5nINYA1dBElI8w5Uxy+/VMRkzB018I+nd41H4Q8HYEY54JV6349Tuo8r/iBz5DXfjjqSbNfzyfHhZs+d0EHP2ogkBmWxUD2N4hN+/4Qq42xfsg8f4qz11uEqpIeFnzRT3yvT3/dE6getVzsW014bUEnCrshfb8fT4fK1rqVxUN6IcKMQ6ceP28dAjxQb0nWEiYevHaBIgSzukXpv80qexqsTPsX5ABOMGnjWuLXmCqYPem8TmZhmD/+5aJhdxxensSSQQEFRH7v9KG+oPkAc5R4OcaJ4B9/R87/om/xgu/Spw02l6USQVRaHy4mbjUvgKFSpdZiJx4NFLRZud14+rzJ+U0VFHqhILlqa7kEbeVZgqHCZyI3gGMQ23BhX0lUUsSnKltbXu/advyRWX/iPIWQrE2k/OItREHpzhcpSW8d8hm+2O8x73rpd8uUDN2dk8XZBqDxXSAdZPGcfTeC2TahbZuGyXbgsf4c5HViiQ2bnvfGI7OUPBGtqT29AisPHL2QTJFUTcgDGvQ7XIcazMIU16WtQXCMhJVNnrTqTN1oGdwdq47Sqt+cF9yDj6Aw8bNJmfcHM0MQfwpP1BK9+9/xIsbfnRmlmM5wyVChJN062hLImEITIJ9Ce7CumDeJ1oSPcgtqPg8Bx2Sy+lJYjUXQy7YctZ3abrE/ginOPrgzL7adlu2xSd7ONmnBLGw2s1J/JDq5rH8Q7L0/tHbKkChEvjU19lmzY+h2908fz7XlGjhOZZBxf32qSsmGyP2dE1OhPb773s7mVQsLiVlNhl7lCbuw9pno7PjVdAKUYkY/Ndn1RZwFX7iBMRR0nfxT51NUT57zblLwF8npMb5l7cuHX2/mrMJDRPHI3VJNk8SuC5Oiw6qVMOOT77dmkhrRH3z157oSZGgkrH04WMSnyAFB6ne0JVwe+3k47yTqulbmuwVjcqT2agPcgrqzZhg6n+0mwnbTHT+QK0ZvrEiTejmB2Trl80ETO9XAHWUxm7MKEwdFKAtYFJzparV8eGE5eMtxpwLPHs/62D/hc4F4eoMuU6sNu6GBIwlzbB4msrzAZHXwhOO0L+3rIYx7kSMTm7R7TpQwx5VqNBzZwdJrctX5ZE6i/UiAYsGsf30MAHOv6MjTrwL769e27RpT/j6fr2JYUibFfM3s85BISl3jvdnjvPV8/xKueWXR39Tv1MiFC0r1XUihAzSsSAiMIdQJlfRfSGD2ujjdGKCl+T/ax1acduAqonAgdxXXUB6nBrAAl218pyiiUubPjg5Cke6jl+dciByIcS0TUPcDNL+ldXHOEHxaGjygxttvpcg6mgUR+wLWcaotISTD6CxMNwO3HeparQbpmWIMIF5OG/gSCL6dvDAyopHlXAXGBL5MVdj5wkfN4mbz80eLBXr/7bKT9KkHmwGkCs8TPLvzWkAI2ED+OCZ+vnMgIBWH3CM0erSmQ3oSq5qMJgu5QRt9dr/3M4Ky7N1wilhKVmDg/SeF91pYBz/4hJC1HHdGdx6FR9V6Kr1LMHwNzu1gwDw3FEF3YE2yW9lho/aD96wAQq6c/AaGqmax8/XZ/Be1f9scbEGTVqJt8vDMXoviClKf48fqDX2GAvcvZNEqCnwoGZky8WtG084IsAhQjCOvaNuGzqOJ2Du9H9ORzRsI1qNAv+pqQjmkMySo/M9ML37epDB+q6reg4lONM0/SltLdC1dsYXjGkfRSOxQhxhQDQfBJxPERqcQfCYn8BNar6OVvyi/q8cqAN8Yby3LDsJj6rL5/0W+tSBtQidKJZZwHC1eNSkDNfm1qUEBmgluG3/gjRBzBxcsRzwgLT+w+HhJWHJM7hV+jy68D6BgC0aKbPKuK7WpqNX9zKYzm+3Vtv/h8oJLU/qkNvXxMQmksMv2RMoZel+HeoM8Y1MfO4yV0zHDvtp5VzazSJaNTVlIO3zeCEg7JIZOeDWwIHdCXxag4xB2cSsYYxFkfJEs8afhXIbmWhY7yGPJ5dkh72bqxdq4ug8+aVU6YEOPpNfVK7/JczYVzxpBYOmsQdRVa71B/919asDlGNPgOMHvVRQJiv6oL26vusr/G02XmgHHgtXXgA3sSLzIr5r0j0Bc6Sc2i0kHtBNE1Ol8miHm2osxqk89LFuLsRNujphrR14JSdHnGqZuZ+SaH6YUu/jUuC0ft8tP3c1zplyOcLk3/NVix4VPCb6StMF9ZG8d9/nUPi0QJPw3aYIdka0qXEVJE8QYS3HbLcWf3MtaWvBthg7TnGCAtcI2XXSWR7xHibziSp0QIjrvnXf+YoCv6mV9xcH5eHooylm92eQD/1OWGHhTW5RF0yUDxc2HI+1dThuZkiTGw6/UKkBPgnrotOOkk7aNTPW3wYMyF22meTh2KvbbiPSsV4I9SkI8tFaO7/Wz765ywbrrKPPzQeUUs92hMRT6yIGj6D/zp43PAL5Pe/7IFle7heSwpd1S/XricS47nkO+HZQcxSo0twBK2ZR5AciN2uJTuZmeOZgEnoiHNgGj8MlgEMoqZm4w4mDqg53B88Pxr++vDYV1NkabLpcs3lKZ9n+WHRv3NfwX+Ml1+DGgJgdNY2ganBTkdZw3bDTl/UVh9I7ztk85q4ZmVeVWQJEV1BiYoBnpdzsZz5mQsqLerla5gcPavIzkir68wrsnaE+NQfOqT2dMklo74Oqn82hB7gJ1u8wYro7DIz/cNsDwpIM2Sm8yPSit1/zTNIZlsqWIpKUhV5Ak2iu2xbznVjff9+0dP8rBXHYAxLAg+JtxLNr3jLBFfcXPD9FBYSCmjUlo18wdYC8JXXmxKX9/F+blPRG9LXJOo0xQN5DumbAfbbm1jTXhhiUAVf9U7+Sg1tdlPiVpnTif9PK1WCtZspegWhGemoVz55gTZyE1CcMbJUYUDEMtXmdDznaj+6uJjIr+XRTfOwfmhrKPmizNxsNW/YdtYWNUdW39JipIT5ViRxPf+RZdJFb0+EoqiyubzLY8L6/uE0pFOyX6bIGqRUlSpexvIsGBRQqzjy6z1X4A8+5U2TDf/PibHDiJR759v2qTB9eBD5Gi5nUIhLpQp/Wr3M1QqDSYkfGEiqvh0taF401CYyuVPmXNcO+kvoMrR2fuFQLkb1oCNV9gUN8Yfo2gU+T7KbjmrxKOwhtYiYsxzt//Lrjs93+v28SShm88gpHAJvhpvTM7IBczRqWQBnF/iNwqKqEPBfL7UtnD4iJHTIYMYyk+97nyHyGN2vlAohy/xV8gkXpNt+HX+/THbc9KABEivmD+W/t6WysrMNXpVyVoA9SlgBPtTMRL2uqIhp8MLWx/Eu4pYgnngCKpoBA2s46mr1FOWHsgT6+6OkWeO+DPtvyFaWvRHEBpmjLtJODBkiNbGVxQ+TsQy8Lp9i8wFnDsiq6vF8Y+6fht6w0MII0jQF8RwIIWl/iLQjQUmeaCKRjAvac9j6kD+ZvOAf30p2sMTOmWXetzMmdQ9zHx17CcUgysUuBPU5fwSE8pNQkWyPxe+JhgLBeTJjVDl/gn+8dvr9eVFoDq0K4TV6yBrIn3XG5y8UB1hCx6jt3mQt7MCVxJ8xuRJYrZzbESWvsO0cie/Vpj2A/Wc9PDSjq3txBYSVOmo2ozvbuHzr1oMJ0nYoLrcYktcmg6CpzU0i6q3fOvCST/c9UqnpocqV9i5Tx/CEXTGIr7pTsHxRMJb+UueDPDaQQYkRsUYR8w5dMAkvPGAHd3e1yd6nKfi9UUEcYA+Uf3qHtWPSst4o54M8wGZgIloXf7qgyh2uuXSv+ajz/NQ6VQzkDU0KWwBVM5cp+71qql0yUluRXYa9KWsQeg99Q483H5o0DvVAZ7BLzDxO00M7Z98xgmrpTQEUaVaD+zt5baO6t5H5R+0z+MtUYBs3dSDnntm7IyXYAng88gKMYttN2L2k62WGQi94/88w89P0IrKf2Zvff038aaddALhBXUUPOXXu5JQNSh6lVkT9HdEx+BPpFeXqwrxFw48x0gNdlx3E9nzWDgAFpQ4UvX1MHSW00jUHtQ1zsb/p0iWrkkC/KPE5QCKHXd9Q5Uy9ZnIX3V8jMB48KVDTLeTvJIanihIGCxugdQVlIzjpW/yAh5QhMmEeD4EG/1N4xtSxzT4bkTIVSgPOzxQjKeHHD6aLi9VlRWVDe2Hmvmj1/NSPaVsvJjpYoPRwMTuC8QhO0r2yZBRgpkePVt6NZoQb/Sv8qfzr0OJCvLYWo8VuLHEPyz0bvl/7cIi6CxWrMbzLJbl4H95wd1Sw4vHM2yQsYmnczw9zLtjd1xyfokTrmIWwwkdIsHLMyzOM2GUXZsiFyxl9ArCy/wokGrvo/ln4SX7nHWNSD6URu1/9kPXvO42meNiC+WkPrT68dDl4kLKc3676PtzxYDx/or4M3S7dKSQDeTyMHCCNYadV7nweCEZ1JLq+cGK3fvj3hjOZw5UnFJ8X9r4ztaHuGqmVV3pqL5l1UI0LBOub8Z0005UKXkcQ/b3m1R9TJD2x9tTlDBfcHqsqZrb3AUTb0xTREUi4ea6wbDi17XGDcmCuAsTSI+yqYJLtn/ayamPWnBYd23dahPY9QhZt2TN6WdeRBNb+eVmzL/Gd+Y0/qxT8kL7qcvtmk0+LscOqpeUJgnE/6esTIVLIu0h6IZjrFetjzLpxFN0bbWw0IMJ73RbLpoYScYT4cErNXlsTltarLCMuRmWhaY+BQ0qGGmt1nEF6a2nBsN3p2PSn6sv80+aSDxlrKzG6lHi8gnrAGspXJJ5/eObbEYT9Q7V81P67lNcmRnTC67+uTFXW7/SSxzOW/vZPA6co3kkIL6MQe6Chut5vCMKVIK2oC5w7qNYIV0etvbKH9wxlB7UoeQq0D6hBPg4qXiCMFYcBVRvV/HwTgWV9TsMeEywUCig4cPmt7MzsFtTueZPRVoR4FuLwVRTgKBNvYjSG7selmWV8ZtrSVXkccT+yZwBLDvOXZDtt5cfWSk/V4dWOJBHAyVQnqk9oDbE3LLF0jPlv9h/nqdWFEmYtIfWgWTI1USnX4jW3i2W5L5mVfBS9fV2jlbp6PLegJmbaueyn2NlY+BQWYslvbH2Q9S7xpPEOE1jPO7huATJ1RNGqKeCcG2Oaj54vFsLHp42xcX3HCKumdPC+htbGqV3g7tpTCbUPgB7Oh2SThQIl0C7gcHG0RXIIv4yIYyQlFaTO973hVglDg/eQZnVNrsGI5XCgQ+lvmkwE+XnwVIbMhIJOeinVA60elEGTEGLURHsAAfapQSIe6TJC9IO7zSSOFcGT1klO4uLyNtkPoaVr8//sjdTTG30MEGQRlM8TqPau80vcFPMyK7uOXK4s0K7p4hZNeg4/a2Xfrv2RXrFYD3ICXvrOJF0QQnY6uSx+b+wtFQhXcV9B8ovTlXHqeexFszmTssk3x2QjhmQwrB/vaOcnNzlf6pMZnykQQmbsYYfmoP09/eWJlNQphuVN5AlT/0JKUkXpEk0b56TJFxGvs/FXRxtsFz1IX6MaxNmweiIohLBXyb4kYEJqDcKZ/fz2IwmF4YDV7E6Ulq8vshFleSlWbIk0tq746lr4gD4fh5EKn8nO06bfCV0CL3fN4BzE430+i8P2wPxwWUVDoI6lZxgKKYbvneYaUFYylnUCZ+aZLkRvSwwU0wxIsySsupbE4uVj6KLC3k3PMudTFTufT5T1qo2zNH0l6Zt12I8ZedJzK4QO7igz5colPbAl48W7USbOq82nN7YC5rPccIVwH8FOCaniCBu0Fbt66M8RdkiocQgRBHKZQyxfM6A+lDdQVScH6EipN5dZz5aZ1xNXsbzRaNpFGCYYU0/5ZOq+kdKpULkDWZXRjgA35FwtVII4D1KuT0enfHzeupCcx8/FCsXznOmjf0Nr0ZQXhHTq++a2pyTD4u6ULvzcaU/t4+Aiwlf3hJq74hqIbGqtHA7GU7cJPeer2L4ZCh5EDuV4sjOLsDW6XWcHyWYHzlfdAGRheq+0fUyQBEFHzOqhl2Qrx5HeCU6WM/KSIYK9aE6cngpAq/4OB5dvT69T6b3n/Ez+zjt2e1kYYbCSEcCApI1Rv3tLluW0X0fLlmIKYG0Q1M2Mdmgon+05i4XSSJq0oWbIyWoMWsPypZL3HojuR1CbPJ33OaS1SwmeH6nXK7TArRaNfs4pVekaIpmfh3YzAVqdFqzOwo2kUTsfFfuq/5A33XUkWNTeq+NREzX40sh53ktxgqo2lEsVW7cTBUCXXW/x2yoEYP8vx0fyMEACJBZyfeHK+wpO7QtXlzG13byfDJybZ0USFRSvaH2aV4R7SsG50AsKT031Y27s33SnSRdBxQnkcu3ppnlaoprj1aKwUdzPM6PakXtv/clDMP9JEiMgr18Yxg3D/Tp286KGNf8m5L3p3vygSiKH5EukJgfyIvjLyw4Qmnc4mrVVmR5cU8uajmPB19JnvcZHfob+zfEEzADbXxvbuo59Iogvk9OYz23MZ+2SVXJyhT+x+uXwIV12hAr8RME0BtZdK5P4hwqvlG+UsrPKYYfMHdYUmzt5fNKwC9r+sY3QeogMbDJ82frsWLdnzZYf8dI1HNYLyB2ir9wSIjMk1CGnzVJr16MrfmPzuZjX7x7OLmtKLJcT5UcLPxwbvRU4jfdQeY+2bsShoxg3DJ+9JfTYJcR1yCQccZqz2rxq6CGOn+IkPbU3WFHRJOaMOVUiduqs370aze1x5yHV77QzRhiRrTvWYtfOP6SbWwjUsccSerFD9fIIen+wyFwurJ+n/H4e8bvslMiIhXCsIuEVLOsiqR61bQomhORTj6BjV+xirfpMCHWLn28B+B4zUpomMmKNuJUOoJPBXXZNmmguEN9LcUiBo16qr+8oLiuz2QvZ+xsoaHG+IwKhE2/Vq7SYVsMcmC7l7e6xt0+C2TSOpi/HRFeHowIHJ2g0NxWDjbAgRXJedpweX0relO0ERVQtviXdGufv1OH/oAC4Ny1hd+7gQkVQ5JeD1mkWKgAqV7bw1oNuCVVfbDSd73Hdj0rH6Z0tQ89PruJVqgmo3i/hDSY2s3i5A0GS72lEw79Ao39JjAeDqcstMXmF/lAuYMlWZXwcoztE7Om3Gcj06lUeQjYaPV2puT3wlFUqT1JYVi9zCyMA+rL768HDsg/gwrKX/UQ0drjIBTJg0Wuk5ycBFNGee3rfliYL11U3wOPJnvgkvnhhMjPGbny2Qoq7d0G68qDzBrVCQpMaFd0ryw6MnSzk/fjecMIV130oz3K3+4z1mX7mRqJ7AH9mvdTXu5fQYoy7ymD2BzMWfKCin0eg7euXi6RRzSnPhAWNm5xdWk7QUKYuEZV0K+lgU/82OslHo0R/XSvM7Vads5QRF8nvBR9k90sbx/69zdX3TmClfw3lzrkAESIulLERCIVBkwqhpJnsCHtOwDi2PczOPhNaUNeNMeYWJ1pR7E819ck4d4JwTm//UxxLdqm89WJU17/TYH7RUfnIFdFoZrgfcCgXcb5GMxkaph3o3NPFxXjAKsa1fLpPiPPaI3zKyeWYynoRtHMuZOlqeZ4Hfy2LSxKo3/lpSlGfWb8G0q+Ug4l6qUrWAyO6pQgR2JnmCahSpFdxg/hKuc5OvhVRNN0OBWUDryms8rgIPX9fP/aLhIo9Jbai3BiaifiOV0atF3wjUXP06KAs0zFCwtBOIbu8MHakdyjAhw1Y9g6H16VfQQOBKbV624e+dZfI46xDLZk/ELWpF+akN4BksRJU9lyDs1//Ende2y0h8WfSw5XkHGJWCx1oe///P9cHobnCv70O2wSrbS4rcKPn7sm4bBaLD429644XoTSnDZQX8QDIT7wEDM3/VhRwFSJgw0YG/rphiULGAbVAsGq+mOLi8HYZrHI1ok+6C7dwcJ6Ty9dhATIeiT/8MpcE5BP/t0GjHat/DfD2GAZgHOG+YJmnn7BuSr18uM5tg9xrameS/qlGu84ghRMjXXlTwdyQ/FudDrK21fUGLzHezDVVX0Se1PrqDerZZ1Jsku2Y+mvXbyaL+rfVwZJhgORTY2jYELuyXoDuZ0S7etSzxM9SIax8B4rYysWfpvP9SiuDTV9+4Y4PyimDH+9/AQvgfkqqOjXTl6alUDm7Qd+eVKnX6Vm3+RfQ2zlFrYLtIgKrFXqjJfjWh3vnVdAIp86+TS+8Kv4dF1ST31eEDypW14cKU8pvpS+bXn5SrsOZmfYk4TX5gavkvkY6NRr1yEEhjGYgvnqAyj0pZi+DiRDZBJM4/z2vYvUAWjomINPaPob5cqSZxyaEGw+SJH90aOhos1Lc92wVpML/6A4xKzIh/ddSli9BDT9XWKO0U1yCbKkkP/MJ/o7bgY9sJab1A+CcO9UpEJinjmD9r10HKLcRSqM/9pD2GCi0+WDeYD9H8bfdICwGzuu+1PDvPqa5w/CYTFtKfJxukIfpZmYIdDLQ8+DSVpS1MQgev7ryGnudHi0AI9ABdz4K9UjZOuLt1vEY+fiuALQ4eVzcQoa9dxh4l+6MOytkUnQXfUYyoOWfc14HEmIwBqI119DCOicbV5K6iEyEXlvjEINe5n96dvwZ85fJmmE0gueOqMHK7pJVQZViadKP6gbhZ6cjd+8LeZL24OQ6MN6xhrqb9q9MIMUGmwnH/Vu+3lwUVOSw36cF1tsq7zbjQa47FfTai+7GzfoWA5JLFeA3Q8h8rGGx6L5d1Kj8iEBoY4IxGIddFPJxYd+HsazU5cW3V1336DPjF7gKrK7Ijj2suxXDry0S7VyoPw4fc6UjB6g7/Di8ZdMD1AtLPTN/OuCKp9xnrEpXsXprxkH1Dl2HOOy4K4gXf8GGd6k+4vm/G2w0PK6mUhFNfUS4yPcq9nJ52x+NvRYhgyqyQFVEX4teh5NKuLz2jpXExNF/oTazCLzl8YFoTzaWlyzcTdlFRsC6F2gKWvWrJc9EaEaF7bIwMsPg4HpWB9fCnGNN7Q914VpPRc9LL7UED5xkCjAWjjT9ecYBT0QsbX6uhKEljslhx5Jr12hjgpxUoKCV7Z+LVVR/EvPV/Y0u8F47aT69NTn3WXNxjZ27VBTZs2vj1MDeXEdN3ttTwVBAeKgqAbziGefx3yULGrtDADtZ2Bq5SzCcciUw2W/F7c1tFTmHpETrGRrKzshHrAH0xmK+AwdeQ27ouk/6p8k/GL1j58ThnT/DgPxBxUx8ORXLh9FXfqlTXp7+THgDoSdCStNy1cVM3J0TS/h17huEs5VGN1D6VN05GaSJkOXfaOl9jiLJKALw7EzdbIBT1UkZ4nWOT/PfexmwTv16fYZSPF4lA7x473sNYhVsYU38yum+ek2brNgzZZEEeSZAgKZOMcBCTt5v7/dHwobKdznzOlOYtexlvjDX45X7qPtZm5Ljfjr0yKb76j+uXQKYs+gJQO89ixr7Yzq+5uVFlb2FJrwnS6/Il2+HD021yG4k5fuJpJOsHlthpW8SDmWsBHQIBWYFSqrO5IrSG1Df0O48ZtS0WX6GHv2vjfAVfQAM36CLA/BrTziMTj7xzucTOl6RxLDs2pWjuFuuhJ+pSSFtl0pYfKj22qHurCtGOSXtiB58aO7jv++QQhxHa/6yl8OeZwYwW0lWWt7+TxyDFmWtoN7t16WgzZZeG55OuM4RZK59XdxyJ1/WFzZM4rAcfI6qoyjgzklmV2VMlGgsuA7Dzn4falVfnUhUtmFa5JGW9iQiQslndSGBkbjQk4s/4IfOQDNSTa5vpVdBgs00Upu0nhscyxci8f5yz7sn2KgzsmYYmsr7m4AP5JbdWyhqEcNDaxZFWf5CKXMFd/w9zbESLOVICMlwK35i3oybQInhids+jRmYoDh8wyk8iXr7iCXs9rfLz+ly5LRViDuUEuxj2RkDStQru5gRJsoFkjQuzRfLqH2ba9v5+9HmyGrT/Mz2MkA8AJpWUNDwEdquwBkwfRYye14nPrkPdsKycN41GFlD6aPP9tfQhKks+HaTzc6mAJhuFERLujl62Afv3ct++p3w8lC2wzvFX3VdoeLGQNVqhy+tIzEfi3inSTmbNrQpaSPQn+f5rZRgiZXMnJmW8tUgaNPsT1bFqconfvY/Yq12i+RtTpaTN1A/eLafyYLNg3lLPMg83zJnczsJCRfmg/ZzVzru9LWYzjmKLZEoLGSqVSBSLMqjSaLitfnJ/0AswycGBUE6yme08GC8VxVpneMxaPO7bviTwlVt2otzcrSKR2k273a95eb1IrYLi1uug9uWT+J1F9Te0Kvxmlg/oeFifDfIfLZDsBTAbeWRXNSzSmpTQpoDgb56zGv2GZj3ijjWK3k2EcxJkOkUz5RySbkr/eIH3UrvoClylt8sWMAMJZ6cnCqj5uEn5Wriqlo7NTKRpeRSR8Bul8YiAnAT5TzCnWlSqLbIGeuE2dA1u0JcUR/ugzZfMn8KmolxDTIfzpXtrOh+aiPQu+/uw9w3QNXTAmcqIRS0OILt/3s+qwwV9f5m/OYBlnjIbADJNPHrGuxIj05mqUiX7bBW8/OL15/P8G9jO8S3Bnf71khvvJnX57T+hjrl2wm1o/Afqrq4Gkj8FJfX5Lhl/ZgJler5gTDKT/7VYgKXpApSGxQBpQHyPML+3WzPyoc5n5Wc5PCkVh70Fptdqfe3PrHylz5Rqy/wK6x5FPaf5lxlV612+/XoBgkEvByZ8zsSVnHlbUh7nTzpwdR4wxpb3FjUDcIAFlxCa4VLqjfUPlT0Aot1fK1fD0MCS+h5MNBlh890PK9fv7u40Hj/lIdsGLI4oMPiS/l93uhkPR58Gdbm0kO13/pa/Uw8r0m/Wmp6WJfSTH4pH00GPEoNT/JyON3/KNLodVW3b5cI7g4heepKEeRlIQ8UT60hZz5aSOCtSeVk4rMgvtwI/G8u9Bq0i//ixVPxsEMneXOWNLlz+2wEHuOV4DI/Mvfh0IzUmzg6cYQeVRr2tEE1glaNJnttU7Qxu/052x465dgOfH39SefJrADR/Elww+t0VQFLXcD4tlEGlBtnDv36+w8JRu/glcYfr4yb5cJkAx/Ppw5Obv9tDmvVy+fedeo+ggXXAetZMrlKYD12pyPqMdwQn5j6kZjA4ONH2Mb6401sUcd8sgwV5FnCiS7Ds2eSybVcO+3l3qwxa/oico8PnsY9cNjlNGug7cRfV2jgozmSos4eC+w7a5iI3/P7ml2DNnfgGjUtJHFvLDDBdcTSI0+VPXxXQRcy5AGJCDOfSFvFVxDneUxD9oLHCf7hGcLy8Nnf6cLPvnSv9/czF59HB6+7w12Vg/Kfi5vGZjyh+J7/LzWYKa4Y5OvsL+oA0kHI6hvulm4SbKjzG4eiY2v5nmCJt7x4g5Ce/RBDxeJfeRX+UqBGb/xpE/6Zxwv+GsPFvZXqS9NtvBJbRHj7mgHyh7TDp1Q7G+qD2SioF0yjzo3ihNPqqU7jZZ0xqpWolVKYxN1//ndsPem2Zdwz6E/XGxHTWRxnsBQwZdyixtyv0NBdiqPRhDPlnWU3u/vrfZrU+9qoVj7275sxbL1ppnW99RKdBrcobLo32vX963UaneXFxHIOv01Z47SsbQ790EvwOHErimWBdfyz89tgG3cnawIQaRRrT40aGTbuPUEKu7COH85J6SuNlZFCZ645rz022d3Qud0KhOcoOA2T8jX292TySwI0F7MrJ9lsSg0UuUn+ENSPhVdlsipmds4wcqsdmljxccCmxdReqggK8D3lw6eCqzSBsuH44zFi5Ir8auYhRLyhaDJr+1LHyxEG9Sx90UW8Ir6SS/HHKqObwNqVGT1/eDp76Sa+f3JaUCtC9QfVi/HkvvNPuVupSwgs8mmUuoruWLM2IMQj3JPFwY4f5HgplsCvdGDdvDvXl6Ag+woGEFzHtKKw7v9bWOngCZOfV/sKBxmjmt7rutiX2tUetZf8uSkzVGyI89qoUnu+5lQHQ1/fYFbS+JgbzfR/jynbX9VKG4vTnF+kQuvCgfcFmJBNJuLbZlnFhTKUPf5ZTe2EJHs/onMYvu7FAvIQ3OukgtxnLxz0RmYJQxGmfGUcwMqnlDfpTcZmlZYO0m5VRLNsZY0aH8MsUzoYN1VJK7X5ERZ8FveH7fLlgFQ3pZ7UYRV0zif2T5sacAvLQQsLdFINkg7u5GZdIegVpZArHElokpctYEbyYYDYdWP/lp1lvd6lIKKck9AfifnmGBjinNSeuiOlRj+6PNbA9wQvY3166CbWGhX9tmsOHbq0+4FVONTgDNSmr+6XlGnOomInqTtQFTQfHMCCCH1TrNsFHSp66pksO5ZS6uXacWlSKnjXUGLJakc7wqN1AcbfppuQKLsWi48pBbc/JbigIphKqFfHPSdMUYT685KDlGp0L6NiY4gEQIJ/draku7gZl5VDGoOCg5bxDKHcaKOqp579lqlGwFhbpPH9KUYn1so01Il4N/PNW1HFhTlnv1RNYBPfw3ph6P3DYjCR3sBbP8hnaFtTtUud7qJhDGBdICvLFrk+kFbYDsiPzbrXY7doLEfvHryryMUctzXWrbLCWIBO7Vnp+61VlWkrg3DzjWv9dpZF3gknrHn8owM/ZDvd+WBv0cPFnUcQzy/H5ZhuFLlu5fViPIyi+N3HDCyP8/6edlM0y9eW3qxEOG7d077A45CFCKgtV9nUTtlvz6uN7Nu7EcZ81VSyi+hN3jVzlZjz6KYoolWXWcA41lP5ox9vF/aby+x9uK44bp6k03FfriTn0SI/izsjYuW82Q+nX/KGq84/DfRI1so/E0bDFqkmG3gUZEdYTlkxMXz2xVrKQ5FcwzH1JyP1eqt20RlyM8W0QFHwcXdvD8afCPuc+dwZYcz6r70rc3zGiskcLVqmFXyn6gUy3Y605n3I8T0fv7fCV/dpgKkPL2XMqCiPlWBK8WFrGMcs97bz2NqRk7DxagvOJDFn3UWcHpElXmvuiyXy60WYISWW8VRMlMCrhwYydDi3+0JKsh28iXjhLuBpwIhtAHEfsMWzd43etc7oGuN7j7L55cNr3OKOS7yjR6qR4WrBOi3YDvzje+cq9BGBmXAjmxojRtAXDeizPmOx9lT+rqtu33F+g5tQf6oC8hIDqA2wZATySuGfPwNh1rJ3oHqYtCn1MmdSZMXqlVRKh4op7cBrXuBQn7x3/mb4ODBrlrt+ssJPx99TudLwwhT7ZbpGzqPtWCXN3Vhh2k/qmlfE7ek0wrI/jMlDummWQV4CJsMONlcRyHZcO+av2YP8Dwd5znooYC0kdcqvPwFnaZIkfXcYTBBgrHQDA6TV1837JGjMBZCJ24Ss/E50Nvt7hdmL+fB7PacO0S4iNWnvkSjOJF2oXRxItr6xZooxrZ7uJ86ssUIsAXR3x/t9e7QLLLBrMaQIR01rb/0L/eTkpaRy4dn1zy7GzfByw98DbtvtP07qIKcI7Ran+fHjGrUXkKafkbAhFDSobK6muiV/js5YOeC54C0E6h1XgTnekF/zxYRAH78GqNaffTGDA/tKb4j7OU0pkX9DY67M4Dx8JlBMeyeqvWJa7E5aOnn3PnvMcJab+jL6KrRA0NoDfnLY6N0jb5xUkpD67wI2qY4yTGsOOy0bXanX4uKwtmib7j2NfNbVHIFKhra005K/Ju/o9wI16g+XHF9y3eRm87qNB0U9mGID72WT9wSU1It86pueyOKFAO9qj32l/xDOuKRbHyTqYAtoUTA50mHgDpLgi9d1W5a/34neGeQh/vQCbu8fIhAfIR4sMDTWXD8ip84VnqtmjEDE9pvnkc64qKNH6DlSVfojkkzJ43yVQo6K4vPnDc9sTHV+hQi1Q+mcoET/tP0bGDaHvPA+0BDJwy7323tyxe1wDYEfTulwyoiMfGkOVGCEXQvRRVc+0/7dvRIBSpp7EPmdlcI9dZYhuXxmHd9pH2nDSfGNX8WbInQxtiZtUgfv8v1vq0VP4qtDGQpQGdgAAFGPfnYGjVroctItWdvMJzaPQqCl7Ejm5gInDriqwjCpHAwJ7UONGPRkz42oIayfBt2oyb5jxtm4C9BshBaZUi7QuCGD9rUjjYVaAenpHAbK7oNJlnCDyoblQnFCIATBTS7QvTizL8wg3MPwwiZfB34ZdI/WcNCD46ertqVmmCAvmUvOUfqPX0GM2L/Ru59thb+UUYWvyFEv1jz5bFPevBgXetqIK1FdmidKeZ7O3M8XlUDuQQBIyFVeFmDc8esj9llcoyamvfjr8R4IPz4+PdCV0fFKEW+aHTj446LYIYCPzO4sI/4V/+aMl0R4+JFkPHpQVI8rhsbFsTiZMtrl74CZ7+kcnj5A0Tv3yYoz5ldm0U/WICwTZW6sGpcloojf3PQ5vTW1rifadOoK8/ssHHB/ODe6hwt25aMAMAXdFGY+sveyqkmmu/HUXDBD1vtFeTnF+pInfQmdd8SM72EsT9BT7fXHGDI3Yu8UmWNXWZstV0Hno3WUxwKwcseDFeVouonvegBO474ifzss8Q17Tuv1SMeuGeBQeZyeFoCdzwY5dZxeVCmLuyYFRs0D7SZVSfigWA3iqjBig6lf/WCOcZiwwUf1EJCmCpPZVa7N3p7SsVbW86qVRznncD7V4A5u8BjlkBpjSLv463KjThSKXFH8wXL+kpwniBY1mtPYGIcI3CQqlDf593K4QJGR+YgbgfvC1AVW/6dKdzhNTIyMAALrDU1OLcvyAPUoiSLvKSJjCY6/e5ubnhXiMhEw4PVFfP9wnq0e+LGlz9D4E5ClatSoGUEzfMdmU7fBVYksmYmqzWc32/GNtfCZwq1bMZESWFrAjcRZs/9Fh0clDh1zD9ZSGbTrEqT3vDcx/5m7H0XBHCK81HeyC5aNUna99a7DCbvfG7XLpKaqxn9HYehQkz4fRqs2JJXt9Bd1rGZE2DxFO8HG2n5IOLkAEeg1+bXg1srONAsFcA+w/IViumZTHK4iiVUussybPUu0Z0T7skw3a4fFry16pLj3dQIEsg+An1PsYiRjctZhdtLbHDEIKJW4Sc5XYDVX7dWzk1l+is+mmqhUbUW1gBeitXj8YbGsgxPy00tOX6k1y9sme6o2c0xhCB1PawM0Ap8WI0zk/9VkaxZKyoHWn3MinktHwdqMSsY07Qbz0un5zKQJ0uaaguRz8/3NnJr+NajNvjKyUcaVPONd+7Mk3i6rP/kR5NNVGEgzxF2kKBes/c9Ct/TFX4qYywS5aOua8X1nAomAy9XputnXxEqPE2bYMzBjsS1zOqm1NNEl7g/dLmbPphOwVHqDl/ob/Qd1jJ0nu0RPX6D/tqurdlJciyzxaGoEjkQctXIFtZX++ZkyKcRbO2FyqvDK94UzGDLWtPR4ieb9JF1gzVqb3DeSg5qnvPDPdIs1aNM7/ei+AFKbtKPDhQNNdCFRTIRpMsy1SUGDyvc1iFnZC95nC/8Q7o9VTplpLuymgr7wWcIQAn4VeteX/CEqKRTcZta/2Q55ItB0wXj21+1UGDk3t4t3gnBZafLDX29UWb1O2F3loBLI8QP/VHGl2Wv83hRGrw5jWaGhUrr7B3EMGbHnOa9G53vJxU5A6RWmH4m9z18PFc2EvOWwkkkyaDr4O3T4NqQvRuLp+/CwR6eOLMkvuF+ZBJ7XE4qnTr+77ZgCmKMAfmx3AkKhVMipfDISbfw7b4mU6AJZDwntGSI8gPY7sOIVBc3OaqIXyqMKIKiURMRMNT+VQJlRfhrgVrSUO5xD5YT+YozWXmQz+F6TzDA7ZLwCRV57cNIrXFEGR+LsXiCwM6k3+P7+uwcerSUi0cU1jfyaongV+22pAvR6YspkhHM6tZq5nSctN41uGKJCQBAyS/yymNc/F1U/f7/qFUkT9wnyWslZdHK/VmkXsk/xP1qi0A4bGzyQMUsyC9HiWyT0r3sVTuwtV8cw4k0b3o4Igbz9+WKM/OtH/aTtYdN6mwq6hCeNQO1ykRCG9wPG1ZW6BZEZt2npNSrE8uf06qdzJ30l3Lq+EUDcJV7Yz1LFqZpwTeFfcZMg+hIcR2pnu3s9v1j2W5bC0oSxZRizN/pqVZF3a0gbO30By9mcQavaS/cJgm4OoZrPt1kNa0gqZNMZCbxIm9kZvBydWmGCDPQkKz6uwEYjxU7pMK0c4K62y1Oi2pjSdtS6PQGzv8mPfA7dQTQ/TczbNiNzG4IleZKsL4uH6hO//uq3jQ/z0b65fIhugSNgWIXlUJJtFsbvmb7TV9wyZnib7BWJRNOOoTZimfx/AoyMIki8Gg6I5OJmqscjM7ohgExYVMlLnvWmOG670bei/LT8vj0lytUsSzdkEu3vFfNToz+jarRx1StYPfKiurbtHMZqGoO6Fvihzq6KOlYJjQBNcrpT7tCL0ILfFcaIqd68SM9q4dQTAm3m8Y6TYAhUnYtz09pTAb7EaxYNCjZkC+Ds71gDr7F/dkvYYm7Zq5emfJ1TkzF8ld/5qUeBXkgWQS++Uvq8TeXXGFwtlOVuz+Rhngq2za94fIG7NcMMvCfpHmKvxQC0Yi9E/mlgA0c4A0BneSVk1crUb1ouU0pHBmxVxn9TwIpP+AddIAmaLikX2aHJifa2KqbpDrgfZMh9LAI9cqhktOh7ddrQ7a6gLzEMnqtt0aHcqH4Ue2t0WLZEFqDaN+7exVA7GUzpOtZlEIjpHZHMl2pHclcdscA8UFTGqhZFQ34MKWso3O4kxepq2mTv3+TWS8IDkAfZsKI2FEN+huWWUjxW1BF8SGoS+qlovC5SjjVoRUyXbv8GkH+QPeEFcJmuHsCZoojxy8UVwTZtkpD728uVDenXdPLDkhfHV9OvQ7Kmfzl+b3q6zhPr1iXgXHrBrCO3a+vs7ibtK+Z/3XSNUjRFfU/cYWAh8vPTDa2npun49bVdAcHG0AnBcNlLVtJY1CmyokY2DowvwTkVvQ4GDksu9e1qq8pwPekXI0r1fvpQ7hgT6wN3fKfEVLTCzElT+fUFlAPzNxbVN0hbWggOVoz+40LZCPMaXYgn7n8iX/VUrszrOa7YhqbC/jIS1CFp/AS2bAxUSK/OwNmD1je11cP796p2Ly0i+RBF8+Q4oVY0DTNRx/J4ni3VZQjXAniSEwug4vHsCT7M3OTbSk6SnJL/Cz8oMM3pvQ9M5O7kvaexA0hdoD2EibmrBeL72mvwAkoZuhhoTqPNedf/fjyvmjC0WhxXOyNPn8jFMDS1GuBM/DstRf9Mn6wupv/TUjZDoffcZgMjaEZ/y8CQ16CYdS0EiIBUZ9rN5R9ZKtRfiY7YjM2sFaw2MzLfz5l/ka13T4s+SPuGTEoL8uhkilr1oxoezD/iQ+L66d9aOJRbrqyOIP1oTpOz7++cyQ3qh/wYF3Mi+FZTPZY1Ebpz+o0hn4exzPzetBPhFZNwVxHE+x/uEQ4Zfbb5MljGy9Z/cgpC/0N7JbN70ccqVr2EYzs9V4uJ7bZB7nr6eTwGaARRSbH9czO7eAFWJtZEcs7TWJiYHJ84rmyoww9YKe2PVCQnoUwXy/Y12C/vLDZK33Xl78MTryuW38ivcDW72tPuNvaRaX79ytJVSQ/5RMWDpG5a1/W44Nx/PJr8h+MWQEPQ6fJ885D/zjNlQjq58gNdRTKunRQh/YfIMsTUcPf0PsSeJFuhd1/9pmRmW9C0XZ/DcrihesGD1bCig4JuwLwff9zxFTAHFn/SPTICLSGJ0drWStm498WonMRHxm+2JD3EZvfFeOoU2cQpMzxKgM85TF62PuJ5FF9ZN/g9N1KcG9Ha4zfl7i98gQ0HZg8PbSEIswq84XgHpZKNWbtZE2retw3lVXd7usUW0xpB4NFYYdLlkGM64kWig33QespaWWHL2fzBz6ljCb57T9C8xH/wuakZbLyb761brZtW5f6ZQnRXavo8Hd2xlGtzy9ghV/HqWAa4/eMT9eEqp6W2UWcPHvENGHCNn5pY9Y748ccob17OXRe/5mnK7zLrOVqtpV2xUuS8YWN4rigtkKMRPP9GsbiT1e/fFfB2B8kEZq1GA/WEndSKOIvXKPlCdEf7HdRByl9bMfWKZgQ8/KJ9xnqMnj/iTqOHnjJbG+7lWg9IYT5x9GdSOO/V0V8Z15hFiYBJysxg5iLBuQ82FdQ6IrBXS8nptoRKFfm3oMxncaQh+V74KK+/J75a/fpa7ztFvj+N3IF88HI21dcIXlhnbt+Wdf+qoW3wCQiWZ0ACGy2upNAJHTT+qifLnihIzUOK0sCtmkOZQUR3Xxj7lipnx3BMJCffN6IXJbVQPxNJIsTZ7/VBlT52ilfkJdb6C/I02NWLAAci6PcE2nRG//ztRZhtJ4n8THoDkrUnSqJbkhDMjBsJTNEO6WXeXW/e1Gr5sTSuVPlHaQ9YSJDcLdPleZdDgPeAqYLY20M7hskhNarU192L3zri84PtkXKwC9I9ZFPzZ48sb5Xl7RKszvFaaRd+ZV/iUj4dbOWWpxazM8+NjKuBgCMOV1KRZOqry9zFXqQqScq4lHVSKCDagnXoF6XnUby0t2lNByTxsACoZxozuT3MYsPGqChbuyHN1hXGJp72HaSdhPHhRb6xuqZqauR8KgPFQT1uUVgFisYus47Bi992+PLD2/0gYcG2bSWROqXd6fUNs+5s4VWe4tlW9dgStM+fyZcnB89WoWBx+ZrOuM6hGibXt3SnRAyqhb8/Cyw0euBHRuMGl6or+s9zX3FaVNBp0zj/s3Cq1ypguX0Ci3GRRavlhk7bAj4d1uVmKykkfolwPyZ5EChqn726WnzJlco8X3Sv1ohov3MXtK0F9tJpu8hcn3GkzG/70ZlvefT32+5aTRfgTZVRgq2zF9vhJ6ldsCybhXBNQq893295FrG1KzMpm2bciuNbRcf9JgBOJkoI+Zmu9+GcCq10fCK+8kJJ2EXpzkzUJiFe/f0kMDFPoOQT7MGriIGc9IDhR9EMmrDVP+OBIz+5Ypq94Fd8xUpPsyssRraXAP6mwsLtLUQf0Z/lZmoYuOM8ltW+7vyffWKWdZBUFFAVfP+xjx0DuJTlbRx+zdpCMzr3uozE2fx3QtTF88rXhFI1MdfBc/B72xdXGwu04N0x31J14iDaJDmpGqxRwGb5E+7xeqMtQxPtqrx/CLMKaiW2Tc66wz9RhgEIV8lsxsTdpcnYfB8UcHGqm1xx32lJQm5ctmerJ6SidY3O+KEhTuYmLol6uq7ITAn5zX7X6aYvFTreRLfonTsCAp0+QRqURnIq0CYTOhKazp3+sEkzppeZvE1SfSji4EiXXYI8qyNV+7zSwnP8gvH8XqRh0B9/+MSzK20ms93L4dTQHCh/UmT/p7xp9Cv8YOsSZJv8xs30bb+y+vo5Jy+7nBzil99Q5Aclb8f7l9OwofTnWD59ppp5yX/v1TDU99Mn2+LfrnHWIzS5oHzIrQozRrk5NtPQH+fXJErpd6Mu8IVb/R7ieuxbQApgKGC0tBDZPJ7P/7Lv75XBu6k9wfbJj8/b0gNII5KwCmk69W60lNtiA7jIWROBc++ftR172OarGXRv4Dcbm27i1FeFyv7jl5SU8u1vHi210b1rXJzUs7YoMpiAxLsyxsuozsHLG2vw2yTiiOIAS0+QD2QTfhdi7G3THJQEHEpbezGLc4Dz6s0/o9eF1eLksjxvZ6/yi3PtzOsDyd5SD2vb3+3IhnW2vwg2mfOqIyG5DXVCGv2lCEUkHMom4ea5DEVZ8TPnJ6bVAtSfkQh5ouwoBPBpagsAZSVdMcCEVzsJiBtlslLoSvyYZCbqly6GpL10OV8e6IsB5djysWtDvdnZtj5XTLIsSymrV+Rw96yfn6Qcfj01eDia+9aXtE0bQ04youVt5T4FhLTCvMyCwYGR9fulkPVARJoBNs+89w3raDzeo1S8UbmMAB7o+X/LBwZ3F1EHxMObXDAuLnwAB7NlvaJv4S68SD80TSZBlqLLJVEd0Nw5kNekxncE8Ow9Cv7dFIn36ir0iZ22KF+v9FPByYIdNB+p/0iFTwPikIrTHIwTs2+i5aVnUqEaZXCqnxR/qWWA4iUS1o2uHG+MCWSFhxuxeewAJEt6n23aJ5OPtFO0wmFalFH/fjvCjUHCzr56nBLslp3R4Pq0jce1mSN7zTI8rn8yNX4rE+WjvzG9n/b+7ImN5Gt219zIu59OB3MwyOzAAkECEnoxYEAAWJIxCCBfv2XqarqdqncLtunqmxHd9mKYEiShFy599pDksps6A7jnJglRyEC24UZMYqX6odWHSQT9u2m2Uqyc16Awwj2I7NhN0dVPqzr+mria4rty2afE50gMkVCk0U3md5oJrOxFckdHtuiY3YTM9MuprDl1xuMOwpJOHeWOnrnR53NTLbfK6GyptmLH8421Hl/CdbjofDJ3OuNzppoHudD4ZYRIpeRIiyj82mhkzdtx+mx1OFL7tACW2L3K+9sF7iW4olGcLnB9Z5wcFHeh0O7lZpJl/NSvtwcUWBNRbJqBNcmNoUZMwKx3WKX9tqoIph1yT6AJiN3onheZ6/GvMLDfgzWULGr40EAoSI7k0NfoDV4s/Jj/rZComgRrAD7zRdkfgUlUCsdrLCM9F7ibivvoK+uyx7OMq6bKPmUS2W0he94PwY+WODifn5E8VVAK/pswPYXaMMgiOVzbEaeisk3hYzebfTlnBtqZ3XVgWXHKcc4Ww1vCb9c1oZeSanQcXvY1q1+XM3k/YZ2pR0a5ZvDmNqovu1tXrXuJsXkGqcRWrMLI9xDEyhYYyY/pRhN5PWV4ubxwGqhtDKHzFVD2dxzy23ulNAkvSxW50NUHEYLMlrZnMQ0YZEAV5qiCl3DMd1SU3djt6WqicwhJ8sLKbMdHpJgGVr9yvy8mZ22u+Jktf4tYLQtqaGVlrIbXs3rXA9GTCwsCtScDt+ujaWHlTo3G/yQ8dEFMfeyNnAfPn3SGONKg0yGwJiKt9Qs7xMZhYTUMaqaSDZGLPLbMV3qzDbDDmg8Xqy64oEQu+GwBv026yPPDpmFBbxSBTXwRk90tn1yFcu8OMBR63sh71EHa1An1pq4WU0qVJibWCr5tZTQVrKUIyxt4pk7aHPM0djYTDHtLJrRZJ+Own7TeKmdJTtD6vbHQTOWJk4d6o0Y56f5zvL04bqoxXFfJLWtXZzRBUuxPCPtaDXcYQ4VMgW2meK2akns5y1UvLg1IaUkVxA0VVHp3ogSmKMDLy4tJFCm+clvbXlzOisqJDKHaaYeIf2ElB8Xdl6XedACYDirBYRF7TbOYrnRmDVdGDSbRYm2c2cuMXjTwry6HsXjAskOLQB1NGmb1TaXj3sm0ul9juYc0uTes2GlFSTt3GGviEu63gvG+ajg6EtPqrMYmOAyY/nIV0iZ6u2Fc7Qg/KANddig8AhTsopgnZxINKvLurZ3GIXdHFzKaVD7Wrk6hl6wRmFTFmF4+wtPReuDwWI6RpLmUV0YF4U7mbc8mV20QZaMqRqg6bWtmVH9nq9MZVkpN12y0SySSqda3kQUq2ZgUZipohAGXwk6ip0T9hWnGngX52phS98pkb9YisIQUr+RI5jSHNGq7DkZKherXXVjQzdDo+cqxlji6bDbr47WvvOn07iet1oQMAa5lHx/JSVTKFfyvBS4Q1KTaLa8p7vUzoyNltvoq61XG5bQWtZSngVtNrAXjbOPrQbH3CSLQAznbtBZ7unimVcpGHq19/YrteCv+bQpZkKqLVwbWuxsc0rGgAAhyglHdCnpsdRnDuF+mxORN5sgPiWrh7aoZSlFvkJR7NmNdJ6EI07SChTKs3WFdPJp4fZeeDTTq8bOnJ1i0pdQAunoG55vLBNhzwoXDRPTeTC75BmOxJp1hizxjMR5Mc7WqrjPz9AMC+0jaYv6uqrO5JKR84Fsh0PV4lcvXwjNFsgmuGileMZOsgz8UF6BOE9dVk2y69SrOdidUYbZ2hCqI+ZlzOW6BBJ1cFWaFldkqs1CxbFTPYuhfa7qTISdACJ3pllJSKQc7V228DdZdVmogiL2aIGvc6yLuOmtVEuYp3jVZtjm5Db8VgL8TGjirQCZltyUM7kt9/Ip22Wq8EAXGzxpAtVrgGo3QNSVk76SG5JfK4YkZEZa6J4SbzNVg23NEvGKvhUvJuKUJYKx6tycg78KQMCOiRkcFX9UQJnb4lwMJU8q/Nw3gA58U7UzIgrROmb8TiyPhX51kNbX2B1J7WoRi4MS03UqysLsgvoZcm01u7bcJJjqDgQ8dkwEFlIETDsJysyIzFSjANSpMR1usRY/aiHsT1tY1SO/ZTVWBa6Eq7mN3ppMbF1gQS14SKFsEc1V51dgRKvGUuGW28Mxm+ygdtB9V1sO9gVZu3a2OsUojzqKBRDVAUoMIWSUoyBQCnWExohhDI3ozG4fh1bVnuvnodxvxjzFdhuU66nrUssP19kcEVNc13Vn8rWzS6Eve6HJYBnW2qMbLhrFMGYC6ZIoNqas0Rx+cY7pk1KE+OgW+oEN8yZbh/3NR5Iqxmp3ZC+rQWPOzsHRHKjjgO8UxW5ooXjFcxax4lXt7w2shvJiopHSVSMl3os0FWimvqsDB2DzIjFGptsGYsRk5HzmNGjSYHsQdJJFqNf37Sl0QJrbsh+SarzKA5RLDN+IlxhOuazQ7ERUs8UaI9toDMTypCDBycxnKZqPsFn3s1NRL/yqGak9By1zFH8hgWdBzgEMyq/sNTizIZQqOhoLs9grCtFLMds1bYMUz+sLItUQ2rK0wPY6ttqIgniYR9fZkTmbVOyCjRHLPuzoAluyTWpTCexN8nxg4HFJkistaRfGmWH68VRiLG6MpuzQhl94dXYJLkeejDW9c9oFI+0UuZuO+fGse6RqeKKAWfk6KKDBBA3P8DjMh6NG3RJTcXElKnowHFNSRHYZWv7H5YNxbQmNlcXHsuZ2/T4hg6Vcq1DOSV1xkongyIandtTCJEZPKUAeovqeZ8wv1FIHh3jm5eZ1JQ/ZKKkLol8nS5bebKi+FYQw8/HNgtvAvjQtw9IpU0sO56k7XeLVYtsePD89MWsf2t1ulFnneV7NVj7ykAR7xc5OG/FYeMeBSa4SuNhyrGzUQnIMQWLt2/c/hyk9+drB66ZJW/haVqVoxTg03UXEdbLOwE4k/BphaEbadOTG5QYyi7JBozUAvLzQ2zOPsXnbXZBbYgbNka0Dsq2alch7oQ2no9nFvZDNSD3INgGavd2lbLbjIqbb7Skaxa1VpWx24XWJuJvr1psER3NflMlhtN24YieI9sSBKiwiJyn2KmVa6NPpsr0tz6CqSRut0kKog64+AF64rSl/NDOyY5DvQkYphpJr+bm6C8MttML5dWmrXU9buOmvKlNeobAZ0d7iRGieeluy3KHy/CHt3NFdCO1sAe3nHq3kUukzSwM+srhWVXTJkPjEeVtrUl9TmHna+y7yyEvlTOAHc+q1gtsuhuwqaXB8cacSWqsKhx8THRFKMFvI6QryUlv3q/EUb6ghFw7klA6OVMfd5NS5b65qVSiKSIRkCXLGeXQgbmtP4oPvLHJ+LabWYEq1oh5KemH2Kjp+hVyvqGsakwf0DQe0fsKwzfzAWUnxvoSycwFyOd3Oozk+rqqAJueCu4kzyW+ckwpFehMYelOLYH9hFkJ688XAWoVU1jGivuCBLm1VwsnGeZjm68aBrKSA0lDpmZi3TeThz5GWb7QTojtetGnEi3zaKni0pm0j6qlBW9I2BnqhMBPbGs8yxwl5MsuOklW3pBeb3VitlLWzoQdo0chkwcyTfvKMMMUkt+k2HJNanIXCNhINXBXLOGiCTYpZhEs4Zu3htsaFPXHqhCLvtufM/Q2ytfRCcnnkcTlAUitkiZbVIiQpVULP6S5IT1tbHilrzs3MEbdz2QMuKymwa6ZdFjidXrVjcj6Ca4bHqisvcGWzi5Xd4pT5yYI7QnsgUS8UiqZVyva0IkvFSaC8MZRLaVy6eLVsL4wNblYhYLKln+0Hqxoc8ywPLlXPu2wMKFJepTGTS1AviMbOFlOdQma4Np5nM8wpuYW8UbUlHpoNbQrGLpirx3NcmTxBraucLqp8lHdoBHTHxe6YzHvOh9IoNufxFFRRkNpKXmp1wcgL+IAHa9eMdDEYNM4Y8ZRhkjfhY7GgQsgTD2N5AWdrm/O37/g7QylGRO+eWlYOuyg+XjZhJMubYNV6gYFDaSatowIyqP1CX5FGi6RfpV7X1Nna2VsljLWUX3YWiuLbCjZciPlcCxjWB34GER6ZXK0ugvoWiSNWy2rcRafZbEsdjqvVXNAxwVJBt9EqPGhrKx18wpyAlZSNoRSbI2y3qWBYgYlADxr06VetPXNCMkV8ZW8PI9K1G2PEw/gCSS/AqgUOuuboBnKYTZu5pkMdITZalTVyh/DfAb3wVqkvVdZNix/5iyOitWT8WSdd9q6aZrld0JldDLV/mmmhIvVT4IbFZYbvsuVphSIJKYFdfaCbsmuURa8r51ZC7BMROsGVbdUxZ1eKJIXgnCgX47Q0GUIs1kEHcLt0g9g7YpUooPiKd1qIpRPIXoozUhFLR2BJBS0fkVWCuZ0NCL4Sz2SGlod2ZcHLN3qhdDNw+3ZcnGPb9BiFOzLH11IQL0/1CdsMkAsdRCCj7+K3eJU7J97ONyYPaSoDWTqWxIAINztC4XQa3kvwkb5It6lElunCZ5XgbCnBYa4EwlmhHFAo6HeB2+lZUUAjOMW0UhSEW3ZN66kXCOlMpK+NGyin2/wK5CxOBMdbS3OmSZXCD9YSCFxXqfzAjQG1s9pgF8Pfeg7NzZ2vT6liwip1vUJT2YpU14PVQoEGoh44zqKIIJtzHKdLI3+Tek6aZhGk1HAjyxZXJGgvEZlF5YiPNjg4AvwTDdenlbYw0jT9Dynf/kPORvyHwJqwTeoeHSEI/OHQOWn7ZPzsEKn8h5SqUUtAlfQogI49nuUx7OGS6WGfwbmH/Use99nDMZr+AycfjmZJnmaP9+Ierwy7h/30z8pRyPfhlnCjGqWkLJ9acNsmsDx+uMZWhGJ+GOj5eTnf6Mbe/oT9979oTUlUcddPZfJQLonTxHvcBW2fgRTUYan8dVRswVDHCaoWg3t/lZkD0MCDODx4THoolPMrqiQcegAPZX1VPp7925fZgaGNkq+1l3go2IdtmvRfK/j4FtHTfLVz2qQM+/ycPGvHl97z7VKhbcPpswINyOu++6zmJTrwV58z9PM+xwn+8z773vJw46EFf/X4n4/yP4Dg8V2dw3J4fA/I+4spVZiji8UhKtC6SXdAAUNf5nUigbpOov4RDQdQ9xIoQXsrQ8J/KmqJmLZhnCfPzjGYQEL8/XVOzltYUQ5qeL5GsEL15WX5eX0si+MMPN71LSiSz84cbn/wTBx22Z/gRKMzjyA0w31SLkGXP1a/B30Pqs8KCGWeohM9grAYPu5FsFVJ+xy86AkfkY0TT/uPbwXdMuyah9dxyEfUDrHLwgadrEb4oE32R3jpqD/a5AHreoTaI8Ldh63npTrybUQPwz+H1dPuZ5KH5V6Knadjby92uBeIEzbQzMOWYdddQAsVNVPCRoj7Fm6laMtNOoRBzEvacw5lxD0avwai/xWpNEZzDPkSjbhCMITyj0FjmtRJG5ZvA0kC/8UgSbwUggqCpJtESd70X0DkAN8yAuTbi0ZKZtEX9r5LNMI/SuT/MWDs8qopk08J0lGfukeh8CbIpJlfC5nUS2TabRrW+TVEXda9OfpUmmIp/vvQJ7E4iav/GPSBZx3wNgKReA47lvnZuHtJCwVI5avbQ78Uh7oAjVjMh0PxbQFZgzr5AhF8Kv4cb4+Ffzmo3eyEpFXOCTIXHir5IvyG7navdxBj5BfMzQ/FE838XrYmWi73m2zNJwH9k21NnL0TIPzXbc1Xyr+Prfn0Uv9emd3LFSGu8jqHwzzsQfvGyu6fJ1veSFkxP09XDftPLT4H++2GPYXSqDbuSf0v9QJVUgmGeBP2UfYSUiLs77xO4QVCGbbV2xMokqI44vsIlCjhJP3P8WxEqHsut+55E0BST5c8kfZHYfbTEPlSzv1e2o97F532QgmRHPes3wj8rkMeGvp41Ve0GUHxdxXd+cwf1PiLir5Xy97fh6TeVmt+EUzsbw6mJ1PiVSr1Qaij75xPJM3/GOrIezRgdxW9Eepo+r7B7Ffb9Ur590Ep/5uj9DHW8g3BpQ9CKfW8Eyn+B1FKY8yziuj3Qin3vMH0ozB/V9Q9DeXfFnYfhCaKo74Ogm9GE/9c5lHkO6Hp/j6v2LOvlH8n9L10kv2W6PtZTo6XEfB7GcL+IExZ7A4O1DvB9M5vy7CvwPTez8t9BEyp3wuVT2h7FZY48zGwJDD2D46mKQ5nCZZm72gf9yQGvps/3hHRFxW9FUjvqASHvQLSr5d/J5D+4jbzr8ILWfIuSYi/M3W/FX3MvYgk3kdE3jeYIT8CTb+70fxRou0FCn4UTvcEjMU/hhiyryTVvVL+neD3MsXpl4Lf58Lsyw/wQYr1LpTA/agr5p7vsU+c560dgH/T4L91TN6Vx7GPkH5/etA/S2eyUDrTCjRoBusdEv+NeLx/wtJbJYrcJ3P+9ODbk2T7VUXd55r2q4PlVyF49xqZuTddf5Tg0dwHETz2I0Tcb+R6+VrQ+t1DbPfGKnunGL9Zw971Mse8j4Z9Yd9gjzMUvrdh7ws/5gX8hKUOD2hhn1zQA9+nHfw/z7PheaFpSqimkPb6/2+uiHmKltXvVMS0QGIi/Y9RxGGTf0of++hNFDJ5p5CZJ7vnpynkl+kwD9MrbgB8a+r3b7bw67nqdVomnzrYmk+3cu8AOpz9ONB9NXr6Oeiq8IpShbEncL019v6dJ/F6otXTu30P0FEfCLovSjryd3fyPZKIVy2Pp9H17mSRJ5718A9nxty7Pf73zJhnQHkDAveFSYi/F3a4b8TOB/mRqftcKOJHU/n4u5zAd4rcvkjlYz7AaiVfqskHs8C/ZWe/qYb8N739B/Uc84sReuoXz3H6ZdTXnxHMv8tN+lYZRLHPZdCTbPh11NcXpuf9Uph41f311DWvRqKIj1Jg5HPwsD/qdiWx5+Bh7xM330iB3aOdobmvtuuV8u+j8OiXkahfFqc/Myf9Hn0v8tW+WXTd9TJxn0H300UXTfzmkPjWpMmnVL93x86dtmKYH6Te3F32JUtjH4YduNsC0H9eHJG7BYgTVOL/AA==</diagram></mxfile>

================================================
FILE: lambda/index.js
================================================
/*

CreateConnect Event:

{
  "properties": {
    "Domain": "9030bff7"
  }
}

ResetEmail Event:

{
  "email": "example7@yourdomain.com"
}

*/

const chromium = require('chrome-aws-lambda');
const puppeteer = require('puppeteer-core');
const AWS = require('aws-sdk');
const fs = require('fs');
const url = require('url');
var rp = require('request-promise');
var winston = require('winston');
var InternetMessage = require("internet-message");
var saml2 = require('saml2-js');
var dateFormat = require('dateformat');

var LOG = winston.createLogger({
    level: process.env.LOG_LEVEL.toLowerCase(),
    transports: [
        new winston.transports.Console()
    ]
});

var s3 = new AWS.S3();
var ssm = new AWS.SSM();
var rekognition = new AWS.Rekognition();
var organizations = new AWS.Organizations();
var ses = new AWS.SES();
var eventbridge = new AWS.EventBridge();
var secretsmanager = new AWS.SecretsManager();
var sts = new AWS.STS();
var servicecatalog = new AWS.ServiceCatalog();

const CAPTCHA_KEY = process.env.CAPTCHA_KEY;
const MASTER_EMAIL = process.env.MASTER_EMAIL;
const ACCOUNTID = process.env.ACCOUNTID;

const sendcfnresponse = async (event, context, responseStatus, responseData, physicalResourceId, noEcho) => {
    var responseBody = JSON.stringify({
        Status: responseStatus,
        Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName,
        PhysicalResourceId: physicalResourceId || event.LogicalResourceId,
        StackId: event.StackId,
        RequestId: event.RequestId,
        LogicalResourceId: event.LogicalResourceId,
        NoEcho: noEcho || false,
        Data: responseData
    });
 
    LOG.debug("Response body:\n", responseBody);
 
    var https = require("https");
    var url = require("url");
 
    var parsedUrl = url.parse(event.ResponseURL);
    var options = {
        hostname: parsedUrl.hostname,
        port: 443,
        path: parsedUrl.path,
        method: "PUT",
        headers: {
            "content-type": "",
            "content-length": responseBody.length
        }
    };
 
    await new Promise((resolve, reject) => {
        var request = https.request(options, function(response) {
            LOG.debug("Status code: " + response.statusCode);
            LOG.debug("Status message: " + response.statusMessage);
            resolve();
        });
     
        request.on("error", function(error) {
            LOG.warn("send(..) failed executing https.request(..): " + error);
            reject();
        });
     
        request.write(responseBody);
        request.end();
    });
}



const solveCaptcha = async (page, url) => {
    var captchaResult = "";

    if (process.env.CAPTCHA_STRATEGY == "Rekognition") {
        captchaResult = await solveCaptchaRekog(page, url);
    } else {
        captchaResult = await solveCaptcha2captcha(page, url);
    }

    return captchaResult;
};

const solveCaptchaRekog = async (page, url) => {
    var imgbody = await rp({ uri: url, method: 'GET', encoding: null }).then(res => {
        return res;
    });

    var code = null;

    let data = await rekognition.detectText({
        Image: {
            Bytes: Buffer.from(imgbody)
        }
    }).promise();

    if (data) {
        data.TextDetections.forEach(textDetection => {
            var text = textDetection.DetectedText.replace(/\ /g, "");
            if (text.length == 6) {
                code = text;
            }
        });
    }

    LOG.debug(code);

    if (!code) {
        await page.click('.refresh');
        await page.waitFor(5000);
    }

    return code;
}

const solveCaptcha2captcha = async (page, url) => {
    var imgbody = await rp({ uri: url, method: 'GET', encoding: null }).then(res => {
        return res;
    });

    var captcharef = await rp({ uri: 'http://2captcha.com/in.php', method: 'POST', body: JSON.stringify({
        'key': CAPTCHA_KEY,
        'method': 'base64',
        'body': "data:image/jpeg;base64," + Buffer.from(imgbody).toString('base64')
    })}).then(res => {
        LOG.debug(res);
        return res.split("|").pop();
    });

    var captcharesult = '';
    var i = 0;
    while (!captcharesult.startsWith("OK") && i < 20) {
        await new Promise(resolve => { setTimeout(resolve, 5000); });

        var captcharesult = await rp({ uri: 'http://2captcha.com/res.php?key=' + CAPTCHA_KEY + '&action=get&id=' + captcharef, method: 'GET' }).then(res => {
            LOG.debug(res);
            return res;
        });

        i++;
    }

    return captcharesult.split("|").pop();
}

const uploadResult = async (url, data) => {
    await rp({ uri: url, method: 'PUT', body: JSON.stringify(data) });
}

const debugScreenshot = async (page) => {
    if (LOG.level == "debug") {
        let filename = Date.now().toString() + ".png";

        await page.screenshot({ path: '/tmp/' + filename });

        await new Promise(function (resolve, reject) {
            fs.readFile('/tmp/' + filename, (err, data) => {
                if (err) LOG.error(err);

                var base64data = Buffer.from(data);

                var params = {
                    Bucket: process.env.DEBUG_BUCKET,
                    Key: filename,
                    Body: base64data
                };

                s3.upload(params, (err, data) => {
                    if (err) LOG.error(`Upload Error ${err}`);
                    LOG.debug('Debug screenshot upload completed - ' + filename);
                    resolve();
                });
            });
        });
    }
};

async function retryWrapper(client, method, params) {
    return new Promise((resolve, reject) => {
        client[method](params).promise().then(data => {
            resolve(data);
        }).catch(err => {
            if (err.code == "TooManyRequestsException") {
                LOG.debug("Got TooManyRequestsException, sleeping 5s");
                setTimeout(() => {
                    retryWrapper(client, method, params).then(data => {
                        resolve(data);
                    }).catch(err => {
                        reject(err);
                    });
                }, 5000); // 5s
            } else if (err.code == "OptInRequired") {
                LOG.debug("Got OptInRequired, sleeping 20s");
                setTimeout(() => {
                    retryWrapper(client, method, params).then(data => {
                        resolve(data);
                    }).catch(err => {
                        reject(err);
                    });
                }, 20000); // 20s
            } else {
                reject(err);
            }
        });
    });
}

async function login(page) {
    let secretsmanagerresponse = await secretsmanager.getSecretValue({
        SecretId: process.env.SECRET_ARN
    }).promise();

    let secretdata = JSON.parse(secretsmanagerresponse.SecretString);

    var passwordstr = secretdata.password;

    await page.goto('https://' + process.env.ACCOUNTID + '.signin.aws.amazon.com/console', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await debugScreenshot(page);

    await page.waitFor(2000);

    let username = await page.$('#username');
    await username.press('Backspace');
    await username.type(secretdata.username, { delay: 100 });

    let password = await page.$('#password');
    await password.press('Backspace');
    await password.type(passwordstr, { delay: 100 });

    await page.click('#signin_button');

    await debugScreenshot(page);

    await page.waitFor(5000);
}

async function createssoapp(page, properties) {
    await page.goto('https://console.aws.amazon.com/singlesignon/home?region=' + process.env.AWS_REGION + '#/applications/add', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(5000);

    await debugScreenshot(page);

    const cookies = await page.cookies();

    let cookie = "";
    cookies.forEach(cookieitem => {
        cookie += cookieitem['name'] + "=" + cookieitem['value'] + "; ";
    });
    cookie = cookie.substr(0, cookie.length - 2);

    let csrftoken = await page.$eval('head > meta[name="awsc-csrf-token"]', element => element.content);

    let accountmanagergroupresult = await rp({
        uri: 'https://console.aws.amazon.com/singlesignon/api/userpool',
        method: 'POST',
        body: JSON.stringify({
            "method": "POST",
            "path": "/userpool/",
            "headers": {
                "Content-Type": "application/json; charset=UTF-8",
                "Content-Encoding": "amz-1.0",
                "X-Amz-Target": "com.amazonaws.swbup.service.SWBUPService.SearchGroups",
                "X-Amz-Date": dateFormat(new Date(), "GMT:ddd, dd mmm yyyy HH:MM:ss") + " GMT",
                "Accept": "application/json, text/javascript, */*"
            },
            "region": "us-east-1",
            "operation": "SearchGroups",
            "contentString": JSON.stringify({
                "SearchString": "AccountManagerUsers*",
                "SearchAttributes": [
                    "GroupName"
                ],
                "MaxResults": 100,
                "NextToken": null
            })
        }),
        headers: {
            'accept': 'application/json, text/plain, */*',
            'content-type': 'application/json',
            'x-csrf-token': csrftoken,
            'cookie': cookie
        }
    });

    let groupid = null;
    let accountmanagergroups = JSON.parse(accountmanagergroupresult).Groups;
    if (accountmanagergroups.length == 0) {
        let creategroupresult = await rp({
            uri: 'https://console.aws.amazon.com/singlesignon/api/userpool',
            method: 'POST',
            body: JSON.stringify({
                "method": "POST",
                "path": "/userpool/",
                "headers": {
                    "Content-Type": "application/json; charset=UTF-8",
                    "Content-Encoding": "amz-1.0",
                    "X-Amz-Target": "com.amazonaws.swbup.service.SWBUPService.CreateGroup",
                    "X-Amz-Date": dateFormat(new Date(), "GMT:ddd, dd mmm yyyy HH:MM:ss") + " GMT",
                    "Accept": "application/json, text/javascript, */*"
                },
                "region": "us-east-1",
                "operation": "CreateGroup",
                "contentString": JSON.stringify({
                    "GroupName": "AccountManagerUsers"
                })
            }),
            headers: {
                'accept': 'application/json, text/plain, */*',
                'content-type': 'application/json',
                'x-csrf-token': csrftoken,
                'cookie': cookie
            }
        });

        groupid = JSON.parse(creategroupresult).Group.GroupId;
    } else {
        groupid = accountmanagergroups[0].GroupId;
    }
    
    await page.click('.add-custom-application-text');

    await page.waitFor(5000);

    await debugScreenshot(page);

    let signinurlel = await page.$('awsui-control-group[label="AWS SSO sign-in URL"] > div > div > div > span > div > input');
    properties['SignInURL'] = await page.evaluate((obj) => {
        return obj.value;
    }, signinurlel);

    LOG.debug("Signin URL: " + properties['SignInURL']);

    let signouturlel = await page.$('awsui-control-group[label="AWS SSO sign-out URL"] > div > div > div > span > div > input');
    properties['SignOutURL'] = await page.evaluate((obj) => {
        return obj.value;
    }, signouturlel);

    LOG.debug("Signout URL: " + properties['SignOutURL']);

    await page._client.send('Page.setDownloadBehavior', {behavior: 'allow', downloadPath: '/tmp/'});
    await page.click('awsui-button[click="peregrineMetadata.downloadCertificate()"] > button');

    let appdisplayname = await page.$('awsui-textfield[ng-model="configureApplication.displayName"] > input');
    await page.evaluate((obj) => {
        return obj.value = "";
    }, appdisplayname);
    await appdisplayname.press('Backspace');
    await appdisplayname.type(properties.SSOManagerAppName, { delay: 100 });

    let appdescription = await page.$('awsui-textarea[ng-model="configureApplication.description"] > textarea');
    await page.evaluate((obj) => {
        return obj.value = "";
    }, appdescription);
    await appdescription.press('Backspace');
    await appdescription.type("AWS Accounts Manager", { delay: 100 });

    await page.click('awsui-button[click="configureApplication.toggleServiceProviderConfiguration()"]'); // manual metadata values

    await page.waitFor(200);

    let acsurl = await page.$('awsui-textfield[ng-model="configureApplication.loginURL"] > input');
    await acsurl.press('Backspace');
    await acsurl.type(properties['APIGatewayEndpoint'] + "/", { delay: 100 });
    
    let samlaudience = await page.$('awsui-textfield[ng-model="configureApplication.samlAudience"] > input');
    await samlaudience.press('Backspace');
    await samlaudience.type("https://" + process.env.DOMAIN_NAME + "/metadata.xml", { delay: 100 });

    await debugScreenshot(page);

    await page.click('awsui-button[click="configureApplication.saveChanges()"]'); // save
    
    await page.waitFor(5000);

    fs.readdirSync('/tmp/').forEach(file => {
        if (file.endsWith("certificate.pem")) {
            properties['Certificate'] = fs.readFileSync('/tmp/' + file, 'utf8');
            fs.unlinkSync('/tmp/' + file);
        }
    });

    await debugScreenshot(page);

    await ssm.putParameter({
        Name: process.env.SSO_SSM_PARAMETER,
        Type: "String",
        Value: JSON.stringify(properties),
        Overwrite: true
    }).promise();

    // map attributes
    LOG.debug("Started mapping attributes");

    await debugScreenshot(page);

    let paneltabs = await page.$$('.awsui-tabs-container > li');
    await paneltabs[1].click();

    await page.waitFor(500);

    await debugScreenshot(page);

    await page.click('awsui-select[ng-model="item.schemaProperty.nameIdFormat"]');
    await page.waitFor(200);
    await page.click('li[data-value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"]');

    let attrmappings = {
        'Subject': '${user:AD_GUID}', // required
        'name': '${user:name}',
        'guid': '${user:AD_GUID}',
        'email': '${user:email}'
    }

    for (const attr in attrmappings) {
        if (attr != "Subject") {
            await page.click('.add-attribute');

            let samlattrnames = await page.$$('awsui-textfield[ng-model="item.key"] > input');
            let samlattrname = samlattrnames.pop();
            await samlattrname.press('Backspace');
            await samlattrname.type(attr, { delay: 100 });
        }

        let samlattrvals = await page.$$('awsui-textfield[ng-model="item.property.source[0]"] > input'); // .ng-invalid-saml-attribute > input
        let samlattrval = samlattrvals.pop();
        await samlattrval.press('Backspace');
        await samlattrval.type(attrmappings[attr], { delay: 100 });

        await page.waitFor(200);
    }

    await debugScreenshot(page);

    await page.click('awsui-button[click="samlSection.saveChanges()"]'); // Save changes

    LOG.debug("Finished mapping attributes, mapping app to group");

    await page.waitFor(5000);

    await paneltabs[2].click(); // users and group mappings
    await page.waitFor(2000);

    await debugScreenshot(page);

    await page.click('.assign-users-button');
    await page.waitFor(5000);

    await debugScreenshot(page);

    let paneltabs2 = await page.$$('.awsui-tabs-container > li');
    await paneltabs2.pop().click(); // last tab
    await page.waitFor(5000);

    await debugScreenshot(page);

    let groupsearch = await page.$('awsui-textfield[ng-model="table.controlValues.search"] > input');
    await groupsearch.press('Backspace');
    await groupsearch.type('AccountManagerUsers', { delay: 100 });
    await page.waitFor(5000);

    await debugScreenshot(page);

    await page.click('div.group-name > div.selection > div.checkbox > awsui-checkbox');
    await page.waitFor(200);
    
    await page.click('.assign'); // assign users button

    await page.waitFor(5000);

    await debugScreenshot(page);

    return properties;
}

async function deletessoapp(page, properties) {
    await page.goto('https://console.aws.amazon.com/singlesignon/home?region=' + process.env.AWS_REGION + '#/applications', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(5000);

    let apptooltip = await page.$$('truncate[tooltip="' + properties.SSOManagerAppName + '"]');
    if (apptooltip.length == 1) {
        await page.evaluate((obj) => {
            return obj.parentNode.parentNode.parentNode.firstElementChild.click();
        }, apptooltip[0]);
        await page.waitFor(200);

        await page.click('awsui-button-dropdown[text="Actions"]');
        await page.waitFor(200);

        let dropdownitems = await page.$$('.awsui-button-dropdown-item-content');
        await dropdownitems.forEach(async (item) => {
            await page.evaluate((obj) => {
                if (obj.innerText.trim() == "Remove") {
                    obj.click();
                }
            }, item);
        });
        await page.waitFor(1000);

        await page.click('.modal-confirm');
        await page.waitFor(6000);

        await debugScreenshot(page);
    } else {
        LOG.warn("Multiple SSO applications of the same name found, skipping");
    }
}

async function createinstance(page, properties) {
    await page.goto('https://' + process.env.AWS_REGION + '.console.aws.amazon.com/connect/onboarding', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(5000);

    let directory = await page.$('input[ng-model="ad.directoryAlias"]');
    await directory.press('Backspace');
    await directory.type(properties.Domain, { delay: 100 });

    page.focus('button.awsui-button-variant-primary');
    await page.click('button.awsui-button-variant-primary');

    await page.waitForSelector('label.vertical-padding.option-label');
    await page.waitFor(200);
    let skipradio = await page.$$('label.vertical-padding.option-label');
    skipradio.pop().click();

    await page.waitFor(200);

    await page.click('button[type="submit"].awsui-button-variant-primary');

    await page.waitFor(200);

    await page.click('button[type="submit"].awsui-button-variant-primary');

    await page.waitFor(200);

    await page.click('button[type="submit"].awsui-button-variant-primary');

    await page.waitFor(200);

    await page.click('button[type="submit"].awsui-button-variant-primary');

    await page.waitFor(200);

    await page.click('button[type="submit"].awsui-button-variant-primary');

    await page.waitForSelector('.onboarding-success-message', {timeout: 180000});

    await debugScreenshot(page);

    await page.waitFor(3000);
}

async function open(page, properties) {
    await page.goto('https://' + process.env.AWS_REGION + '.console.aws.amazon.com/connect/home', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(8000);

    await debugScreenshot(page);

    await page.waitFor(3000);

    await page.click('table > tbody > tr > td:nth-child(1) > div > a');

    await page.waitFor(5000);

    let loginbutton = await page.$('.emergency-access a');
    let loginlink = await page.evaluate((obj) => {
        return obj.getAttribute('href');
    }, loginbutton);

    await page.goto('https://' + process.env.AWS_REGION + '.console.aws.amazon.com' + loginlink, {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });

    await page.waitFor(8000);

    await debugScreenshot(page);
}

async function deleteinstance(page, properties) {
    await page.goto('https://' + process.env.AWS_REGION + '.console.aws.amazon.com/connect/home', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(8000);

    await debugScreenshot(page);

    await page.waitFor(3000);

    let checkbox = await page.$$('awsui-checkbox > label > input');
    await checkbox[0].click();
    await page.waitFor(200);

    await debugScreenshot(page);
    LOG.debug("Clicked checkbox");

    let removebutton = await page.$$('button[type="submit"]');
    LOG.debug(removebutton.length);
    await removebutton[1].click();
    LOG.debug("Clicked remove");
    await page.waitFor(200);

    let directory = await page.$('.awsui-textfield-type-text');
    await directory.press('Backspace');
    await directory.type(properties.Domain, { delay: 100 });
    await page.waitFor(200);

    await page.click('awsui-button[click="confirmDeleteOrg()"] > button');
    await page.waitFor(5000);

    await debugScreenshot(page);
}

async function claimnumber(page, properties) {
    let host = 'https://' + new url.URL(await page.url()).host;

    LOG.debug(host + '/connect/numbers/claim');

    await page.goto(host + '/connect/numbers/claim', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitFor(5000);

    await debugScreenshot(page);

    await page.waitFor(3000);

    await page.click('li[heading="DID (Direct Inward Dialing)"] > a');

    await page.waitFor(200);

    await page.click('div.active > span > div.country-code-real-input');

    await page.waitFor(200);

    await page.click('div.active > span.country-code-input.ng-scope > ul > li > .us-flag'); // USA

    await page.waitFor(5000);

    await page.click('div.active > awsui-radio-group > div > span > div:nth-child(1) > awsui-radio-button > label.awsui-radio-button-wrapper-label > div'); // Phone number selection

    let phonenumber = await page.$('div.active > awsui-radio-group > div > span > div:nth-child(1) > awsui-radio-button > label.awsui-radio-button-checked.awsui-radio-button-label > div > span > div');
    let phonenumbertext = await page.evaluate(el => el.textContent, phonenumber);

    await page.waitFor(200);

    await debugScreenshot(page);

    let disclaimerlink = await page.$('div.tab-pane.ng-scope.active > div.alert.alert-warning.ng-scope > a');
    if (disclaimerlink !== null) {
        disclaimerlink.click();
    }

    await page.waitFor(200);

    await debugScreenshot(page);

    await page.click('#s2id_select-width > a');
    
    await page.waitFor(2000);

    await debugScreenshot(page);

    let s2input = await page.$('#select2-drop > div > input');
    await s2input.press('Backspace');
    await s2input.type("myFlow", { delay: 100 });
    await page.waitFor(2000);
    await s2input.press('Enter');
    await page.waitFor(1000);

    await debugScreenshot(page);

    await page.click('awsui-button[text="Save"] > button');
    await page.waitFor(5000);

    await debugScreenshot(page);

    return {
        'PhoneNumber': phonenumbertext
    };
}

async function uploadprompts(page, properties) {
    let host = 'https://' + new url.URL(await page.url()).host;

    let ret = {};
    
    let prompt_filenames = [
        'a-10-second-silence.wav',
        '9.wav',
        '8.wav',
        '7.wav',
        '6.wav',
        '5.wav',
        '4.wav',
        '3.wav',
        '2.wav',
        '1.wav',
        '0.wav'
    ];
    
    for (var pid in prompt_filenames) {
        let filename = prompt_filenames[pid];

        do {
            await page.goto(host + "/connect/prompts/create", {
                timeout: 0,
                waitUntil: ['domcontentloaded']
            });
            await page.waitFor(5000);
            LOG.info("Checking for correct load");
            LOG.debug(host + "/connect/prompts/create");
        } while (await page.$('#uploadFileBox') === null);

        await debugScreenshot(page);

        const fileInput = await page.$('#uploadFileBox');
        await fileInput.uploadFile(process.env.LAMBDA_TASK_ROOT + '/prompts/' + filename);

        await page.waitFor(1000);

        let input1 = await page.$('#name');
        await input1.press('Backspace');
        await input1.type(filename, { delay: 100 });

        await debugScreenshot(page);

        await page.waitFor(1000);

        await page.click('#lily-save-resource-button');

        await page.waitFor(8000);

        await debugScreenshot(page);
        
        await page.$('#collapsePrompt0 > div > div:nth-child(2) > table > tbody > tr > td');
        let promptid = await page.$eval('#collapsePrompt0 > div > div:nth-child(2) > table > tbody > tr > td', el => el.textContent);
        LOG.debug("PROMPT ID:");
        LOG.debug(promptid);
        ret[filename] = promptid;
    };

    await debugScreenshot(page);

    return ret;
}

async function createflow(page, properties, prompts) {
    let host = 'https://' + new url.URL(await page.url()).host;
    
    do {
        await page.goto(host + "/connect/contact-flows/create?type=contactFlow", {
            timeout: 0,
            waitUntil: ['domcontentloaded']
        });
        await page.waitFor(5000);
        LOG.info("Checking for correct load");
        LOG.debug(host + "/connect/contact-flows/create?type=contactFlow");
    } while (await page.$('#angularContainer') === null);

    await debugScreenshot(page);

    await page.click('#can-edit-contact-flow > div > awsui-button > button');

    await page.waitFor(200);

    await debugScreenshot(page);

    await page.click('#cf-dropdown a[ng-click="verifyImport()"]');

    await page.waitFor(500);

    await page.setBypassCSP(true);

    await debugScreenshot(page);

    let flow = `{
    "modules": [
        {
            "id": "a238d7ff-9df4-481b-bcf5-e472c3a51abf",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "39ca9b44-c416-45eb-b2c0-591956bd2fe9"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "prompt2",
                    "namespace": "External",
                    "resourceName": null
                }
            ],
            "metadata": {
                "position": {
                    "x": 700,
                    "y": 16
                },
                "useDynamic": true
            }
        },
        {
            "id": "1f4d3616-77cc-4cef-8881-949c531e13ce",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "a238d7ff-9df4-481b-bcf5-e472c3a51abf"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "prompt1",
                    "namespace": "External",
                    "resourceName": null
                }
            ],
            "metadata": {
                "position": {
                    "x": 456,
                    "y": 19
                },
                "useDynamic": true
            }
        },
        {
            "id": "ad3b6726-dfed-40fe-b4c7-95a9751fc4a7",
            "type": "InvokeExternalResource",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "1f4d3616-77cc-4cef-8881-949c531e13ce"
                },
                {
                    "condition": "Error",
                    "transition": "f5205242-eeb0-4b71-bb47-f8c2adf848fa"
                }
            ],
            "parameters": [
                {
                    "name": "FunctionArn",
                    "value": "arn:aws:lambda:us-east-1:${ACCOUNTID}:function:AccountAutomator",
                    "namespace": null
                },
                {
                    "name": "TimeLimit",
                    "value": "8"
                }
            ],
            "metadata": {
                "position": {
                    "x": 191,
                    "y": 15
                },
                "dynamicMetadata": {},
                "useDynamic": false
            },
            "target": "Lambda"
        },
        {
            "id": "39ca9b44-c416-45eb-b2c0-591956bd2fe9",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "406812d0-65de-4f5a-ba33-89c450b94238"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "prompt3",
                    "namespace": "External",
                    "resourceName": null
                }
            ],
            "metadata": {
                "position": {
                    "x": 948,
                    "y": 18
                },
                "useDynamic": true
            }
        },
        {
            "id": "f5205242-eeb0-4b71-bb47-f8c2adf848fa",
            "type": "Disconnect",
            "branches": [],
            "parameters": [],
            "metadata": {
                "position": {
                    "x": 1442,
                    "y": 22
                }
            }
        },
        {
            "id": "406812d0-65de-4f5a-ba33-89c450b94238",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "2298a0bd-cb66-4476-b1cb-1680a079eca6"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "prompt4",
                    "namespace": "External",
                    "resourceName": null
                }
            ],
            "metadata": {
                "position": {
                    "x": 1198,
                    "y": 17
                },
                "useDynamic": true
            }
        },
        {
            "id": "2298a0bd-cb66-4476-b1cb-1680a079eca6",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "f5205242-eeb0-4b71-bb47-f8c2adf848fa"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "${prompts['a-10-second-silence.wav']}",
                    "namespace": null,
                    "resourceName": "a-10-second-silence.wav"
                }
            ],
            "metadata": {
                "position": {
                    "x": 1395,
                    "y": 268
                },
                "useDynamic": false,
                "promptName": "a-10-second-silence.wav"
            }
        },
        {
            "id": "e30d63b7-e7d5-42df-9dea-f93e0bed321d",
            "type": "PlayPrompt",
            "branches": [
                {
                    "condition": "Success",
                    "transition": "ad3b6726-dfed-40fe-b4c7-95a9751fc4a7"
                }
            ],
            "parameters": [
                {
                    "name": "AudioPrompt",
                    "value": "${prompts['a-10-second-silence.wav']}",
                    "namespace": null,
                    "resourceName": "a-10-second-silence.wav"
                }
            ],
            "metadata": {
                "position": {
                    "x": 120,
                    "y": 242
                },
                "useDynamic": false,
                "promptName": "a-10-second-silence.wav"
            }
        }
    ],
    "version": "1",
    "type": "contactFlow",
    "start": "e30d63b7-e7d5-42df-9dea-f93e0bed321d",
    "metadata": {
        "entryPointPosition": {
            "x": 24,
            "y": 17
        },
        "snapToGrid": false,
        "name": "myFlow",
        "description": "An example flow",
        "type": "contactFlow",
        "status": "published",
        "hash": "f8c17f9cd5523dc9c62111e55d2c225e0ee90ad8d509d677429cf6f7f2497a2f"
    }
}`;

    /*fs.writeFileSync("/tmp/flow.json", flow, {
        mode: 0o777
    });*/

    LOG.debug(flow);

    await page.waitFor(5000);

    page.click('#import-cf-file-button');
    let fileinput = await page.$('#import-cf-file');
    LOG.debug(fileinput);
    await page.waitFor(1000);
    await debugScreenshot(page);
    //await fileinput.uploadFile('/tmp/flow.json'); // broken!

    await page.evaluate((flow) => {
        angular.element(document.getElementById('import-cf-file')).scope().importContactFlow(new Blob([flow], {type: "application/json"}));
    }, flow);
    
    await page.waitFor(5000);

    await debugScreenshot(page);

    await page.click('.header-button'); // Publish
    await page.waitFor(2000);

    await page.click('awsui-button[text="Publish"] > button'); // Publish modal

    await page.waitFor(8000);

    await debugScreenshot(page);
}

async function loginStage1(page, email) {
    await page.goto('https://console.aws.amazon.com/console/home', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });
    await page.waitForSelector('#resolving_input', {timeout: 15000});
    await page.waitFor(500);

    LOG.debug("Entering email " + email);
    let resolvinginput = await page.$('#resolving_input');
    await resolvinginput.press('Backspace');
    await resolvinginput.type(email, { delay: 100 });

    await page.click('#next_button');

    await debugScreenshot(page);

    await page.waitFor(5000);

    let captchacontainer = await page.$('#captcha_container');
    let captchacontainerstyle = await page.evaluate((obj) => {
        return obj.getAttribute('style');
    }, captchacontainer);

    var captchanotdone = true;
    var captchaattempts = 0;

    if (captchacontainerstyle.includes("display: none")) {
        LOG.debug("Skipping login CAPTCHA");
    } else {
        while (captchanotdone) {
            captchaattempts += 1;
            if (captchaattempts > 6) {
                LOG.error("Failed CAPTCHA too many times, aborting");
                return;
            }
            try {
                let submitc = await page.$('#submit_captcha');

                await debugScreenshot(page);
                let recaptchaimgx = await page.$('#captcha_image');
                let recaptchaurlx = await page.evaluate((obj) => {
                    return obj.getAttribute('src');
                }, recaptchaimgx);

                LOG.debug("CAPTCHA IMG URL:");
                LOG.debug(recaptchaurlx);
                let result = await solveCaptcha(page, recaptchaurlx);

                LOG.debug("CAPTCHA RESULT:");
                LOG.debug(result);

                let input3 = await page.$('#captchaGuess');
                await input3.press('Backspace');
                await input3.type(result, { delay: 100 });

                await debugScreenshot(page);
                await submitc.click();
                await page.waitFor(5000);

                await debugScreenshot(page);

                captchacontainer = await page.$('#captcha_container');
                captchacontainerstyle = await page.evaluate((obj) => {
                    return obj.getAttribute('style');
                }, captchacontainer);

                if (captchacontainerstyle.includes("display: none")) {
                    LOG.debug("Successful CAPTCHA solve");

                    captchanotdone = false;
                }
            } catch (error) {
                LOG.error(error);
            }
        }

        await page.waitFor(5000);
    }
}

async function handleEmailInbound(page, event) {
    for (const record of event['Records']) {
        var account = null;
        var email = '';
        var body = '';
        var isdeletable = false;
        
        let data = await s3.getObject({
            Bucket: record.s3.bucket.name,
            Key: record.s3.object.key
        }).promise();
        
        var msg = InternetMessage.parse(data.Body.toString());

        email = msg.to;
        body = msg.body;

        var emailmatches = /<(.*)>/g.exec(msg.to);
        if (emailmatches && emailmatches.length > 1) {
            email = emailmatches[1];
        }

        data = await retryWrapper(organizations, 'listAccounts', {
            // no params
        });
        let accounts = data.Accounts;
        while (data.NextToken) {
            data = await retryWrapper(organizations, 'listAccounts', {
                NextToken: data.NextToken
            });
    
            accounts = accounts.concat(data.Accounts);
        }
    
        for (const accountitem of accounts) {
            if (accountitem.Email == email) {
                account = accountitem;
            }
        }

        var accountemailforwardingaddress = null;
        var provisionedproductid = null;

        if (account) {
            let orgtags = await retryWrapper(organizations, 'listTagsForResource', { // TODO: paginate
                ResourceId: account.Id
            });

            orgtags.Tags.forEach(tag => {
                if (tag.Key.toLowerCase() == "delete" && tag.Value.toLowerCase() == "true") {
                    isdeletable = true;
                }
                if (tag.Key.toLowerCase() == "accountemailforwardingaddress") {
                    accountemailforwardingaddress = tag.Value;
                }
                if (tag.Key.toLowerCase() == "accountemailforwardingaddress") {
                    accountemailforwardingaddress = tag.Value;
                }
                if (tag.Key.toLowerCase() == "servicecatalogprovisionedproductid") {
                    provisionedproductid = tag.Value;
                }
            });
        }

        let filteredbody = body.replace(/=3D/g, '=').replace(/=\r\n/g, '');

        let start = filteredbody.indexOf("https://signin.aws.amazon.com/resetpassword");
        if (start !== -1) {
            LOG.debug("Started processing password reset");

            let secretsmanagerresponse = await secretsmanager.getSecretValue({
                SecretId: process.env.SECRET_ARN
            }).promise();

            let secretdata = JSON.parse(secretsmanagerresponse.SecretString);

            let end = filteredbody.indexOf("<", start);
            let url = filteredbody.substring(start, end);

            let parsedurl = new URL(url);
            if (parsedurl.host != "signin.aws.amazon.com") { // safety
                throw "Unexpected reset password host";
            }

            if (!account) { // safety
                LOG.debug("No account found, aborting");
                return;
            }

            LOG.debug(url);
            
            await page.goto(url, {
                timeout: 0,
                waitUntil: ['domcontentloaded']
            });
            await page.waitFor(5000);

            await debugScreenshot(page);

            let newpwinput = await page.$('#new_password');
            await newpwinput.press('Backspace');
            await newpwinput.type(secretdata.password, { delay: 100 });

            let input2 = await page.$('#confirm_password');
            await input2.press('Backspace');
            await input2.type(secretdata.password, { delay: 100 });

            await page.click('#reset_password_submit');
            await page.waitFor(5000);

            LOG.info("Completed resetpassword link verification");

            if (isdeletable) {
                LOG.info("Begun delete account");

                if (provisionedproductid) {
                    var terminaterecord = await servicecatalog.terminateProvisionedProduct({
                        TerminateToken: Math.random().toString().substr(2),
                        IgnoreErrors: true,
                        ProvisionedProductId: provisionedproductid
                    }).promise();
                }

                await loginStage1(page, email);

                await debugScreenshot(page);
                
                let input4 = await page.$('#password');
                await input4.press('Backspace');
                await input4.type(secretdata.password, { delay: 100 });

                await debugScreenshot(page);

                await page.click('#signin_button');
                await page.waitFor(8000);
                
                await debugScreenshot(page);

                await page.goto('https://portal.aws.amazon.com/billing/signup?client=organizations&enforcePI=True', {
                    timeout: 0,
                    waitUntil: ['domcontentloaded']
                });
                await page.waitFor(8000);
                
                await debugScreenshot(page);
                LOG.debug("Screenshotted at portal");
                LOG.debug(page.mainFrame().url());
                // /confirmation is an activation period
                if (page.mainFrame().url().split("#").pop() == "/paymentinformation") {

                    let input5 = await page.$('#credit-card-number');
                    await input5.press('Backspace');
                    await input5.type(secretdata.ccnumber, { delay: 100 });

                    await page.select('#expirationMonth', (parseInt(secretdata.ccmonth)-1).toString());

                    await page.waitFor(2000);
                    await debugScreenshot(page);

                    let currentyear = new Date().getFullYear();

                    await page.select('select[name=\'expirationYear\']', (parseInt(secretdata.ccyear)-currentyear).toString());

                    let input6 = await page.$('#accountHolderName');
                    await input6.press('Backspace');
                    await input6.type(secretdata.ccname, { delay: 100 });

                    await page.waitFor(2000);
                    await debugScreenshot(page);

                    await page.click('.form-submit-click-box > button');

                    await page.waitFor(8000);
                }

                await debugScreenshot(page);

                if (page.mainFrame().url().split("#").pop() == "/identityverification") {
                    let usoption = await page.$('option[label="United States (+1)"]');
                    let usvalue = await page.evaluate( (obj) => {
                        return obj.getAttribute('value');
                    }, usoption);

                    await page.select('#countryCode', usvalue);

                    let connectssmparameter = await ssm.getParameter({
                        Name: process.env.CONNECT_SSM_PARAMETER
                    }).promise();

                    let variables = JSON.parse(connectssmparameter['Parameter']['Value']);

                    let portalphonenumber = await page.$('#phoneNumber');
                    await portalphonenumber.press('Backspace');
                    await portalphonenumber.type(variables['PHONE_NUMBER'].replace("+1", ""), { delay: 100 });

                    var phonecode = "";
                    var phonecodetext = "";
                    var captchanotdone = true;
                    var captchaattemptsfordiva = 0;
                    while (captchanotdone) {
                        captchaattemptsfordiva += 1;
                        if (captchaattemptsfordiva > 5) {
                            throw "Could not confirm phone number verification - possible error in DIVA system or credit card";
                        }
                        try {
                            let submitc = await page.$('#btnCall');

                            await debugScreenshot(page);
                            let recaptchaimgx = await page.$('#imageCaptcha');
                            let recaptchaurlx = await page.evaluate((obj) => {
                                return obj.getAttribute('src');
                            }, recaptchaimgx);

                            LOG.debug("CAPTCHA IMG URL:");
                            LOG.debug(recaptchaurlx);
                            let result = await solveCaptcha(page, recaptchaurlx);

                            LOG.debug("CAPTCHA RESULT:");
                            LOG.debug(result);

                            let input32 = await page.$('#guess');
                            await input32.press('Backspace');
                            await input32.type(result, { delay: 100 });

                            await debugScreenshot(page);
                            await submitc.click();
                            await page.waitFor(5000);

                            await debugScreenshot(page);

                            await page.waitForSelector('.phone-pin-number', {timeout: 5000});

                            phonecode = await page.$('.phone-pin-number > span');
                            phonecodetext = await page.evaluate(el => el.textContent, phonecode);

                            if (phonecodetext.trim().length == 4) {
                                captchanotdone = false;
                            } else {
                                await page.waitFor(5000);
                            }
                        } catch (error) {
                            LOG.error(error);
                        }
                    }

                    await debugScreenshot(page);
                                
                    variables['CODE'] = phonecodetext;
    
                    await ssm.putParameter({
                        Name: process.env.CONNECT_SSM_PARAMETER,
                        Type: "String",
                        Value: JSON.stringify(variables),
                        Overwrite: true
                    }).promise();

                    await page.waitFor(30000);
                    
                    await debugScreenshot(page);

                    try {
                        await page.click('#verification-complete-button');
                    } catch(err) {
                        LOG.error("Could not confirm phone number verification - possible error in DIVA system or credit card");
                        throw err;
                    }

                    await page.waitFor(3000);
                    
                    await debugScreenshot(page);

                }

                if (page.mainFrame().url().split("#").pop() == "/support" || page.mainFrame().url().split("#").pop() == "/confirmation") {
                    await page.goto('https://console.aws.amazon.com/billing/rest/v1.0/account', {
                        timeout: 0,
                        waitUntil: ['domcontentloaded']
                    });

                    await page.waitFor(3000);

                    await debugScreenshot(page);

                    let accountstatuspage = await page.content();

                    LOG.debug(accountstatuspage);

                    let issuspended = accountstatuspage.includes("\"accountStatus\":\"Suspended\"");

                    if (provisionedproductid) {
                        let terminatestatus = "CREATED";
                        while (['CREATED', 'IN_PROGRESS'].includes(terminatestatus)) {
                            await new Promise((resolve) => {setTimeout(resolve, 10000)});

                            let record = await servicecatalog.describeRecord({
                                Id: terminaterecord.RecordDetail.RecordId
                            }).promise();
                            terminatestatus = record.RecordDetail.Status;
                        }
                        if (terminatestatus != "SUCCEEDED") {
                            throw "Could not terminate product from Service Catalog";
                        }
                    }

                    if (!issuspended) {
                        await page.goto('https://console.aws.amazon.com/billing/home?#/account', {
                            timeout: 0,
                            waitUntil: ['domcontentloaded']
                        });

                        await page.waitFor(8000);

                        await debugScreenshot(page);

                        let closeaccountcbs = await page.$$('.close-account-checkbox > input');
                        await closeaccountcbs.forEach(async (cb) => {
                            await cb.click();
                        });

                        await page.waitFor(1000);

                        await debugScreenshot(page);

                        await page.click('.btn-danger'); // close account button

                        await page.waitFor(1000);

                        await debugScreenshot(page);

                        await page.click('.modal-footer > button.btn-danger'); // confirm close account button

                        await page.waitFor(5000);

                        await debugScreenshot(page);

                        await retryWrapper(organizations, 'tagResource', {
                            ResourceId: account.Id,
                            Tags: [{
                                Key: "AccountDeletionTime",
                                Value: (new Date()).toISOString()
                            }]
                        });
                    }

                    await removeAccountFromOrg(account);
                } else {
                    LOG.warn("Unsure of location, send help! - " + page.mainFrame().url());
                }
            }
            
        } else {
            LOG.debug("No password reset found, forwarding e-mail");

            await new Promise(async (resolve, reject) => {
                var accountid = "?";
                var accountemail = "?";
                var accountname= "?";
                if (account) {
                    accountid = account.Id || "?";
                    accountemail = account.Email || "?";
                    accountname = account.Name || "?";
                }
                var msgsubject = msg.subject || "";
                var from = msg.from || "";
                var to = msg.to || "";
    
                msg.subject = process.env.EMAIL_SUBJECT.
                    replace("{subject}", msgsubject).
                    replace("{from}", from).
                    replace("{to}", to).
                    replace("{accountid}", accountid).
                    replace("{accountname}", accountname).
                    replace("{accountemail}", accountemail);
    
                msg.to = accountemailforwardingaddress || "AWS Accounts Master <" + MASTER_EMAIL + ">";
                msg.from = "AWS Accounts Master <" + MASTER_EMAIL + ">";
                msg['return-path'] = "AWS Accounts Master <" + MASTER_EMAIL + ">";
    
                var stringified = InternetMessage.stringify(msg);
                
                ses.sendRawEmail({
                    Source: MASTER_EMAIL,
                    Destinations: [msg.to],
                    RawMessage: {
                        Data: stringified
                    }
                }, async function (err, data) {
                    if (err) {
                        LOG.debug(err);
    
                        msg.to = "AWS Accounts Master <" + MASTER_EMAIL + ">";
                        
                        await ses.sendRawEmail({
                            Source: MASTER_EMAIL,
                            Destinations: [MASTER_EMAIL],
                            RawMessage: {
                                Data: "To: " + msg.to + "\r\nFrom: " + msg.from + "\r\nSubject: " + msg.subject + "\r\n\r\n***CONTENT NOT PROCESSABLE***\r\n\r\nDownload the email from s3://" + record.s3.bucket.name + "/" + record.s3.object.key + "\r\n"
                            }
                        }).promise();
                    }
    
                    resolve();
                });
            });
        }
    }
    
    return true;
};

async function removeAccountFromOrg(account) {
    var now = new Date();
    var threshold = new Date(account.JoinedTimestamp);
    threshold.setDate(threshold.getDate() + 7); // 7 days
    if (now > threshold) {
        await retryWrapper(organizations, 'removeAccountFromOrganization', {
            AccountId: account.Id
        });

        LOG.info("Removed account from Org");

        return true;
    } else {
        threshold.setMinutes(threshold.getMinutes() + 2); // plus 2 minutes buffer
        await eventbridge.putRule({
            Name: "ScheduledAccountDeletion-" + account.Id.toString(),
            Description: "The scheduled deletion of an Organizations account",
            //RoleArn: '',
            ScheduleExpression: "cron(" + threshold.getMinutes() + " " + threshold.getUTCHours() + " " + threshold.getUTCDate() + " " + (threshold.getUTCMonth() + 1) + " ? " + threshold.getUTCFullYear() + ")",
            State: "ENABLED"
        }).promise();

        await eventbridge.putTargets({
            Rule: "ScheduledAccountDeletion-" + account.Id.toString(),
            Targets: [{
                Arn: "arn:aws:lambda:" + process.env.AWS_REGION + ":" + process.env.ACCOUNTID  + ":function:" + process.env.AWS_LAMBDA_FUNCTION_NAME,
                Id: "Lambda",
                //RoleArn: "",
                Input: JSON.stringify({
                    "action": "removeAccountFromOrg",
                    "account": account,
                    "ruleName": "ScheduledAccountDeletion-" + account.Id.toString()
                })
            }]
        }).promise();

        await retryWrapper(organizations, 'tagResource', {
            ResourceId: account.Id,
            Tags: [{
                Key: "ScheduledRemovalTime",
                Value: threshold.toISOString()
            }]
        });

        LOG.info("Scheduled removal for later");
    }

    return false;
}

async function triggerReset(page, event) {
    await loginStage1(page, event.email);
    
    await debugScreenshot(page);

    await page.click('#root_forgot_password_link');

    await page.waitFor(2000);

    await page.waitForSelector('#password_recovery_captcha_image', {timeout: 15000});

    captchanotdone = true;
    captchaattempts = 0;
    while (captchanotdone) {
        captchaattempts += 1;
        if (captchaattempts > 6) {
            LOG.error("Failed CAPTCHA too many times, aborting");
            return;
        }

        await debugScreenshot(page);

        let recaptchaimg = await page.$('#password_recovery_captcha_image');
        let recaptchaurl = await page.evaluate((obj) => {
            return obj.getAttribute('src');
        }, recaptchaimg);

        LOG.debug(recaptchaurl);
        let captcharesult = await solveCaptcha(page, recaptchaurl);

        let input2 = await page.$('#password_recovery_captcha_guess');
        await input2.press('Backspace');
        await input2.type(captcharesult, { delay: 100 });

        await page.waitFor(3000);

        await debugScreenshot(page);

        await page.click('#password_recovery_ok_button');

        await page.waitFor(5000);

        let errormessagediv = await page.$('#password_recovery_error_message');
        let errormessagedivstyle = await page.evaluate((obj) => {
            return obj.getAttribute('style');
        }, errormessagediv);
        
        if (errormessagedivstyle.includes("display: none")) {
            captchanotdone = false;
        }
    }

    await debugScreenshot(page);

    await page.waitFor(2000);
};

async function addSubscriptionsSCP(details) {
    LOG.info("Adding subscriptions SCP");

    let rolename = 'OrganizationAccountAccessRole';
    if (process.env.CONTROL_TOWER_MODE == "true") {
        rolename = 'AWSControlTowerExecution';
    }

    let policyid = null;
    let policiesdata = await retryWrapper(organizations, 'listPolicies', {
        Filter: 'SERVICE_CONTROL_POLICY'
    });
    let policies = policiesdata.Policies;

    while (policiesdata.NextToken) {
        policiesdata = await retryWrapper(organizations, 'listPolicies', {
            Filter: 'SERVICE_CONTROL_POLICY',
            NextToken: policiesdata.NextToken
        });
        policies.concat(policiesdata.Policies);
    }

    policyid = null;

    for (const policy of policies) {
        if (policy.Name == "AccountManagerDenySubscriptionCalls") {
            policyid = policy.Id;
        }
    }
    
    if (!policyid) {
        policydata = await retryWrapper(organizations, 'createPolicy', {
            Content: JSON.stringify({
                Version: "2012-10-17",
                Statement: {
                    Effect: "Deny",
                    Action: [
                        "route53domains:RegisterDomain",
                        "route53domains:RenewDomain",
                        "route53domains:TransferDomain",
                        "ec2:ModifyReservedInstances",
                        "ec2:PurchaseHostReservation",
                        "ec2:PurchaseReservedInstancesOffering",
                        "ec2:PurchaseScheduledInstances",
                        "rds:PurchaseReservedDBInstancesOffering",
                        "dynamodb:PurchaseReservedCapacityOfferings",
                        "s3:PutObjectRetention",
                        "s3:PutObjectLegalHold",
                        "s3:BypassGovernanceRetention",
                        "s3:PutBucketObjectLockConfiguration",
                        "elasticache:PurchaseReservedCacheNodesOffering",
                        "redshift:PurchaseReservedNodeOffering",
                        "savingsplans:CreateSavingsPlan",
                        "aws-marketplace:AcceptAgreementApprovalRequest",
                        "aws-marketplace:Subscribe"
                    ],
                    Resource: "*",
                    Condition: {
                        StringNotLike: {
                            'aws:PrincipalArn': 'arn:aws:iam::*:role/' + rolename
                        }
                    }
                }
            }),
            Description: 'Used to restrict access to create long-term subscriptions',
            Name: 'AccountManagerDenySubscriptionCalls',
            Type: 'SERVICE_CONTROL_POLICY'
        });
        
        policyid = policydata.Policy.PolicySummary.Id;
    } else {
        await retryWrapper(organizations, 'updatePolicy', {
            Content: JSON.stringify({
                Version: "2012-10-17",
                Statement: {
                    Effect: "Deny",
                    Action: [
                        "route53domains:RegisterDomain",
                        "route53domains:RenewDomain",
                        "route53domains:TransferDomain",
                        "ec2:ModifyReservedInstances",
                        "ec2:PurchaseHostReservation",
                        "ec2:PurchaseReservedInstancesOffering",
                        "ec2:PurchaseScheduledInstances",
                        "rds:PurchaseReservedDBInstancesOffering",
                        "dynamodb:PurchaseReservedCapacityOfferings",
                        "s3:PutObjectRetention",
                        "s3:PutObjectLegalHold",
                        "s3:BypassGovernanceRetention",
                        "s3:PutBucketObjectLockConfiguration",
                        "elasticache:PurchaseReservedCacheNodesOffering",
                        "redshift:PurchaseReservedNodeOffering",
                        "savingsplans:CreateSavingsPlan",
                        "aws-marketplace:AcceptAgreementApprovalRequest",
                        "aws-marketplace:Subscribe",
                        "shield:CreateSubscription"
                    ],
                    Resource: "*",
                    Condition: {
                        StringNotLike: {
                            'aws:PrincipalArn': 'arn:aws:iam::*:role/' + rolename
                        }
                    }
                }
            }),
            PolicyId: policyid
        }).catch(() => {});
    }

    await retryWrapper(organizations, 'attachPolicy', {
        PolicyId: policyid,
        TargetId: details['accountid']
    }).catch(err => {
        if (err.code == "DuplicatePolicyAttachmentException") {
            LOG.info("Skipping attach subscription SCP, already attached");
        } else {
            throw err;
        }
    });
}

async function addBillingMonitor(page, details) {
    LOG.info("Adding billing monitor");
    
    let rolename = 'OrganizationAccountAccessRole';
    if (process.env.CONTROL_TOWER_MODE == "true") {
        rolename = 'AWSControlTowerExecution';
    }

    let assumedrole = await sts.assumeRole({
        RoleArn: 'arn:aws:iam::' + details['accountid'] + ':role/' + rolename,
        RoleSessionName: 'AccountManagerAddBillingMonitor'
    }).promise();

    let policyid = null;
    let policiesdata = await retryWrapper(organizations, 'listPolicies', {
        Filter: 'SERVICE_CONTROL_POLICY'
    });
    let policies = policiesdata.Policies;

    while (policiesdata.NextToken) {
        policiesdata = await retryWrapper(organizations, 'listPolicies', {
            Filter: 'SERVICE_CONTROL_POLICY',
            NextToken: policiesdata.NextToken
        });
        policies.concat(policiesdata.Policies);
    }

    for (const policy of policies) {
        if (policy.Name == "AccountManagerDenyBillingAlarmAccess") {
            policyid = policy.Id;
        }
    }
    
    if (!policyid) {
        policydata = await retryWrapper(organizations, 'createPolicy', {
            Content: JSON.stringify({
                Version: "2012-10-17",
                Statement: {
                    Effect: "Deny",
                    Action: "*",
                    Resource: "arn:aws:cloudwatch:us-east-1:*:alarm:AccountManagerDeletionBudgetMonitor",
                    Condition: {
                        StringNotLike: {
                            'aws:PrincipalArn': 'arn:aws:iam::*:role/' + rolename
                        }
                    }
                }
            }),
            Description: 'Used to restrict access to the billing alarm',
            Name: 'AccountManagerDenyBillingAlarmAccess',
            Type: 'SERVICE_CONTROL_POLICY'
        });
        
        policyid = policydata.Policy.PolicySummary.Id;
    } else {
        await retryWrapper(organizations, 'updatePolicy', {
            Content: JSON.stringify({
                Version: "2012-10-17",
                Statement: {
                    Effect: "Deny",
                    Action: "*",
                    Resource: "arn:aws:cloudwatch:us-east-1:*:alarm:AccountManagerDeletionBudgetMonitor",
                    Condition: {
                        StringNotLike: {
                            'aws:PrincipalArn': 'arn:aws:iam::*:role/' + rolename
                        }
                    }
                }
            }),
            PolicyId: policyid
        }).catch(() => { });
    }

    await retryWrapper(organizations, 'attachPolicy', {
        PolicyId: policyid,
        TargetId: details['accountid']
    }).catch(err => {
        if (err.code == "DuplicatePolicyAttachmentException") {
            LOG.info("Skipping attach billing SCP, already attached");
        } else {
            throw err;
        }
    });

    //await new Promise((resolve) => {setTimeout(resolve, 120000)}); // wait for account active

    let childcloudwatch = new AWS.CloudWatch({
        accessKeyId: assumedrole.Credentials.AccessKeyId,
        secretAccessKey: assumedrole.Credentials.SecretAccessKey,
        sessionToken: assumedrole.Credentials.SessionToken
    });

    let alarm = await retryWrapper(childcloudwatch, 'putMetricAlarm', {
        AlarmName: 'AccountManagerDeletionBudgetMonitor',
        ComparisonOperator: 'GreaterThanThreshold',
        EvaluationPeriods: 1,
        ActionsEnabled: true,
        AlarmActions: [
            process.env.ACCOUNT_DELETION_TOPIC
        ],
        AlarmDescription: 'Sends a request to delete this account to the account manager when the budget is reached',
        DatapointsToAlarm: 1,
        Dimensions: [{
            Name: 'Currency',
            Value: 'USD'
        }],
        MetricName: 'EstimatedCharges',
        Namespace: 'AWS/Billing',
        Period: 21600,
        Statistic: 'Maximum',
        Threshold: details['budgetthresholdbeforedeletion'],
        TreatMissingData: 'ignore',
        Unit: 'None'
    }); // subject to OptInRequired

    LOG.debug(alarm);
    LOG.info("Completed adding billing monitor");
}

async function setSSOOwner(page, details) {
    let ssoparamresponse = await ssm.getParameter({
        Name: process.env.SSO_SSM_PARAMETER
    }).promise();

    let ssoproperties = JSON.parse(ssoparamresponse['Parameter']['Value']);

    await page.goto('https://console.aws.amazon.com/singlesignon/home?region=' + process.env.AWS_REGION + '#/accounts/organization/assignUsers?ids=' + details['accountid'] + '&step=userGroupsStep', {
        timeout: 0,
        waitUntil: ['domcontentloaded']
    });

    await page.waitFor(5000);

    await debugScreenshot(page);

    const cookies = await page.cookies();

    let cookie = "";
    cookies.forEach(cookieitem => {
        cookie += cookieitem['name'] + "=" + cookieitem['value'] + "; ";
    });
    cookie = cookie.substr(0, cookie.length - 2);

    let csrftoken = await page.$eval('head > meta[name="awsc-csrf-token"]', element => element.content);

    //--//
    
    let directoryConfig = await rp({
        uri: 'https://console.aws.amazon.com/singlesignon/api/peregrine',
        method: 'POST',
        body: JSON.stringify({
            "method": "POST",
            "path": "/control/",
            "headers": {
                "Content-Type": "application/json; charset=UTF-8",
                "Content-Encoding": "amz-1.0",
                "X-Amz-Target": "com.amazon.switchboard.service.SWBService.ListDirectoryAssociations",
                "X-Amz-Date": dateFormat(new Date(), "GMT:ddd, dd mmm yyyy HH:MM:ss") + " GMT",
                "Accept": "application/json, text/javascript, */*"
            },
            "region": "us-east-1",
            "operation": "ListDirectoryAssociations",
            "contentString": JSON.stringify({
                "marker": null
            })
        }),
        headers: {
            'accept': 'application/json, text/plain, */*',
            'content-type': 'application/json',
            'x-csrf-token': csrftoken,
            'cookie': cookie
        }
    });

    let primaryDirectoryId = JSON.parse(directoryConfig).directoryAssociations[0].directoryId;

    let userConfig = await rp({
        uri: 'https://console.aws.amazon.com/singlesignon/api/identitystore',
        method: 'POST',
        body: JSON.stringify({
            "method": "POST",
            "path": "/identitystore/",
            "headers": {
                "Content-Type": "application/json; charset=UTF-8",
                "Content-Encoding": "amz-1.0",
                "X-Amz-Target": "com.amazonaws.identitystore.AWSIdentityStoreService.DescribeUsers",
                "X-Amz-Date": "Wed, 08 Apr 2020 02:22:19 GMT",
                "Accept": "application/json, text/javascript, */*"
            },
            "region":"us-east-1",
            "operation":"DescribeUsers",
            "contentString": JSON.stringify({
                "IdentityStoreId": primaryDirectoryId,
                "UserIds": [
                    details['accountowner']
                ]
            })
        }),
        headers: {
            'accept': 'application/json, text/plain, */*',
            'content-type': 'application/json',
            'x-csrf-token': csrftoken,
            'cookie': cookie
        }
    });

    let username = JSON.parse(userConfig).Users[0].UserName;

    await page.click('awsui-select[ng-model="table.controlValues.selectedSearchValue"]');
    await page.waitFor(200);

    await page.click('li[data-value="userName"]');
    await page.waitFor(200);

    await debugScreenshot(page);

    let usernamesearch = await page.$('awsui-textfield[ng-model="table.controlValues.search"] > input');
    await usernamesearch.press('Backspace');
    await usernamesearch.type(username, { delay: 100 });

    await page.waitFor(5000);

    await page.click('.select-all > .checkbox > awsui-checkbox');
    await page.waitFor(200);

    await debugScreenshot(page);

    if (details['isshared']) {
        LOG.debug("Sharing account with group");

        let paneltabs = await page.$$('.awsui-tabs-tab > a');
        await paneltabs[1].click();
        await page.waitFor(5000);

        await debugScreenshot(page);
        
        let groupsearch = await page.$('input[placeholder="Find groups by name"]'); // TODO: use a better selector
        await groupsearch.press('Backspace');
        await groupsearch.type('AccountManagerUsers', { delay: 100 });
        await page.waitFor(5000);

        await debugScreenshot(page);
        
        await page.click('div.group-name > div.selection > div.checkbox > awsui-checkbox');
        await page.waitFor(200);

        await debugScreenshot(page);
    }

    await page.click('.wizard-next-button');
    await page.waitFor(3000);

    let adminlabel = await page.$('div.cell-content > truncate[tooltip="AdministratorAccess"]');
    await page.evaluate((obj) => {
        obj.parentNode.parentNode.querySelector('div.selection > div.checkbox > awsui-checkbox').click();
    }, adminlabel);
    await page.waitFor(200);

    await page.click('.wizard-next-button');
    await page.waitFor(10000);

    await debugScreenshot(page);

    await retryWrapper(organizations, 'tagResource', {
        ResourceId: details['accountid'],
        Tags: [{
            Key: "SSOCreationComplete",
            Value: "true"
        }]
    });
}

async function decodeSAMLResponse(sp, idp, samlresponse) {
    let resp = await new Promise((resolve,reject) => {
        sp.post_assert(idp, {
            request_body: {
                'SAMLResponse': samlresponse
            }
        }, function(err, resp) {
            if (err) {
                reject(err);
            } else {
                resolve(resp);
            }
        });
    });
    
    return resp;
}

function decodeForm(form) {
    var ret = {};

    var items = form.split("&");
    items.forEach(item => {
        var split = item.split("=");
        ret[split.shift()] = split.join("=");
    });

    return ret
}

async function getUserBySAML(samlresponse) {
    let ssoparamresponse = await ssm.getParameter({
        Name: process.env.SSO_SSM_PARAMETER
    }).promise();

    let ssoproperties = JSON.parse(ssoparamresponse['Parameter']['Value']);
    
    var sp_options = {
        entity_id: "https://" + process.env.DOMAIN_NAME + "/metadata.xml",
        private_key: "",
        certificate: "",
        assert_endpoint: "",
        allow_unencrypted_assertion: true
    };
    var sp = new saml2.ServiceProvider(sp_options);
    
    var idp_options = {
        sso_login_url: ssoproperties['SignInURL'],
        sso_logout_url: ssoproperties['SignOutURL'],
        certificates: [ssoproperties['Certificate']],
        allow_unencrypted_assertion: true
    };
    var idp = new saml2.IdentityProvider(idp_options);

    let samlattrs = await decodeSAMLResponse(sp, idp, decodeURIComponent(samlresponse));

    return {
        'name': samlattrs['user']['attributes']['name'][0],
        'email': samlattrs['user']['attributes']['email'][0],
        'guid': samlattrs['user']['attributes']['guid'][0],
        'samlresponse': decodeURIComponent(samlresponse),
        'ssoprops': ssoproperties
    };
}

async function handleSAMLResponse(event) {
    let body = event.body;
    if (event.isBase64Encoded) {
        body = Buffer.from(event.body, 'base64').toString('utf8');
    }

    var form = decodeForm(body);

    let user = await getUserBySAML(form['SAMLResponse']);

    return {
        "statusCode": 200,
        "isBase64Encoded": false,
        "headers": {
            "Content-Type": "text/html"
        },
        "body": wrapHTML(user)
    };
}

async function handleGetAccounts(event) {
    let body = event.body;
    if (event.isBase64Encoded) {
        body = Buffer.from(event.body, 'base64').toString('utf8');
    }

    var form = decodeForm(body);

    let user = await getUserBySAML(form['SAMLResponse']);

    let useraccounts = [];

    let data = await retryWrapper(organizations, 'listAccounts', {
        // no params
    });
    let accounts = data.Accounts;
    while (data.NextToken) {
        let moreaccounts = await retryWrapper(organizations, 'listAccounts', {
            NextToken: data.NextToken
        });

        accounts = accounts.concat(moreaccounts.Accounts);
    }

    for (const account of accounts) {
        let tags = await retryWrapper(organizations, 'listTagsForResource', { // TODO: paginate
            ResourceId: account.Id
        });

        let shouldAddToUserAccountsList = false;
        let isdeleted = false;
        let useraccount = {
            'Id': account.Id,
            'Email': account.Email,
            'JoinedTimestamp': account.JoinedTimestamp,
            'Name': account.Name
        };
        for (const tag of tags.Tags) {
            if (tag.Key.toLowerCase() == "notes") {
                useraccount['Notes'] = tag.Value.replace(/\+/g, " ");
            }
            if (tag.Key.toLowerCase() == "delete" && tag.Value.toLowerCase() == "true") {
                useraccount['IsDeleting'] = true;
            }
            if (tag.Key.toLowerCase() == "ssocreationcomplete" && tag.Value.toLowerCase() == "false") {
                useraccount['IsCreating'] = true;
            }
            if (tag.Key.toLowerCase() == "accountownerguid" && tag.Value == user.guid) {
                shouldAddToUserAccountsList = true;
                useraccount['IsOwner'] = true;
            }
            if (tag.Key.toLowerCase() == "sharedwithorg" && tag.Value.toLowerCase() == "true") {
                shouldAddToUserAccountsList = true;
                useraccount['IsShared'] = true;
            }
            if (tag.Key.toLowerCase() == "scheduledremovaltime") {
                isdeleted = true;
            }
        }
        if (shouldAddToUserAccountsList && !isdeleted) { // ignore deleting, suspended accounts (deferred org removal)
            useraccounts.push(useraccount);
        }
    }

    useraccounts.sort(function(x, y) {
        return y.JoinedTimestamp - x.JoinedTimestamp;
    });

    return {
        "statusCode": 200,
        "isBase64Encoded": false,
        "headers": {
            "Content-Type": "application/json"
        },
        "body": JSON.stringify({
            'accounts': useraccounts
        })
    };
}

async function processSnsDeleteAccount(event) {
    for (const record of event['Records']) {
        if (record.EventSubscriptionArn.startsWith(process.env.ACCOUNT_DELETION_TOPIC)) {
            let snsmessage = JSON.parse(record.Sns.Message);

            let accountid = snsmessage.AWSAccountId;

            let account = await retryWrapper(organizations, 'describeAccount', {
                AccountId: accountid
            });

            LOG.info("Deleting account " + accountid + " due to budget alert");

            await retryWrapper(organizations, 'tagResource', {
                ResourceId: account.Account.Id,
                Tags: [{
                    Key: "Delete",
                    Value: "true"
                }]
            });
        }
    }
}

async function handleDeleteAccountRequest(event) {
    let body = event.body;
    if (event.isBase64Encoded) {
        body = Buffer.from(event.body, 'base64').toString('utf8');
    }

    var form = decodeForm(body);

    let user = await getUserBySAML(form['SAMLResponse']);

    let account = await retryWrapper(organizations, 'describeAccount', {
        AccountId: form['accountid']
    }).catch(err => {
        LOG.debug(err);

        return {
            "statusCode": 404,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": JSON.stringify({
                'deleteAccountSuccess': false
            })
        };
    });
    
    let tagdata = await retryWrapper(organizations, 'listTagsForResource', {
        ResourceId: account.Account.Id
    });

    for (const tag of tagdata.Tags) {
        if (tag.Key.toLowerCase() == "accountownerguid" && tag.Value == user.guid) {
            await retryWrapper(organizations, 'tagResource', {
                ResourceId: account.Account.Id,
                Tags: [{
                    Key: "Delete",
                    Value: "true"
                }]
            });

            return {
                "statusCode": 200,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'deleteAccountSuccess': true
                })
            };
        }
    }

    return {
        "statusCode": 403,
        "isBase64Encoded": false,
        "headers": {
            "Content-Type": "application/json"
        },
        "body": JSON.stringify({
            'deleteAccountSuccess': false
        })
    };
}

async function handleCreateAccountRequest(event) {
    let body = event.body;
    if (event.isBase64Encoded) {
        body = Buffer.from(event.body, 'base64').toString('utf8');
    }

    var form = decodeForm(body);

    let user = await getUserBySAML(form['SAMLResponse']);

    let accountemail = decodeURIComponent(form['emailprefix'].replace(/\+/g, ' ')) + "@" + process.env.DOMAIN_NAME;
    let accountname = decodeURIComponent(form['accountname'].replace(/\+/g, ' '));
    let notes = decodeURIComponent(form['notes'].replace(/\ /g, '+'));
    let maximumspend = "";
    if (form['maximumspend']) {
        maximumspend = decodeURIComponent(form['maximumspend']);
    }

    if (!accountname.match(/^.{1,50}$/g)) {
        return {
            "statusCode": 400,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": JSON.stringify({
                'createAccountSuccess': false,
                'reason': 'Please enter an account name that is from 1 to 50 characters long'
            })
        };
    }

    if (!accountemail.match(/^.{6,64}$/g)) {
        return {
            "statusCode": 400,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": JSON.stringify({
                'createAccountSuccess': false,
                'reason': 'Please enter a valid email address that is from 6 to 64 characters long'
            })
        };
    }

    if (!notes.match(/^[a-zA-Z0-9\.\:\+\=@_\/\-]{0,256}$/g)) {
        return {
            "statusCode": 400,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": JSON.stringify({
                'createAccountSuccess': false,
                'reason': 'The notes field can have up to 256 characters (valid characters: a-z, A-Z, 0-9, and . : = @ _ / - <space> )'
            })
        };
    }

    if (process.env.MAXIMUM_ACCOUNT_SPEND != "0") {
        if (!maximumspend.match(/^[0-9]+(?:\.[0-9]{2})?$/g)) {
            return {
                "statusCode": 400,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'createAccountSuccess': false,
                    'reason': 'The maximum spend field must be a number'
                })
            };
        }
        
        maximumspend = parseFloat(maximumspend);
        if (maximumspend <= 0) {
            return {
                "statusCode": 400,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'createAccountSuccess': false,
                    'reason': 'The maximum spend field must be greater than zero'
                })
            };
        }
        if (maximumspend > parseFloat(process.env.MAXIMUM_ACCOUNT_SPEND)) {
            return {
                "statusCode": 400,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'createAccountSuccess': false,
                    'reason': 'The maximum spend field must not be greater than ' + process.env.MAXIMUM_ACCOUNT_SPEND
                })
            };
        }
    }

    let accountid = null;
    let provisionaccountfromproductop = null;
    if (process.env.CONTROL_TOWER_MODE == "true") {
        let productslist = await servicecatalog.searchProductsAsAdmin({
            Filters: {
                FullTextSearch: ['AWS Control Tower Account Factory']
            }
        }).promise();

        if (productslist.ProductViewDetails.length != 1) {
            return {
                "statusCode": 503,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'createAccountSuccess': false,
                    'reason': 'Could not find Account Factory product'
                })
            };
        }
        
        let portfoliolist = await servicecatalog.listPortfoliosForProduct({
            ProductId: productslist.ProductViewDetails[0].ProductViewSummary.ProductId
        }).promise();

        for (let portfolio of portfoliolist.PortfolioDetails) {
            if (portfolio.DisplayName == "AWS Control Tower Account Factory Portfolio") {
                await servicecatalog.associatePrincipalWithPortfolio({
                    PortfolioId: portfolio.Id,
                    PrincipalType: 'IAM',
                    PrincipalARN: process.env.ROLE
                }).promise().then(async () => {
                    await new Promise((resolve) => {setTimeout(resolve, 2000)}); // eventual consistency issues
                }).catch(err => {});
            }
        }

        let artifactlist = await servicecatalog.listProvisioningArtifacts({
            ProductId: productslist.ProductViewDetails[0].ProductViewSummary.ProductId
        }).promise();

        let pathlist = await servicecatalog.listLaunchPaths({
            ProductId: productslist.ProductViewDetails[0].ProductViewSummary.ProductId
        }).promise();

        provisionaccountfromproductop = await servicecatalog.provisionProduct({
            PathId: pathlist.LaunchPathSummaries[0].Id,
            ProductId: productslist.ProductViewDetails[0].ProductViewSummary.ProductId,
            ProvisionToken: Math.random().toString().substr(2),
            ProvisionedProductName: "account-" + dateFormat(new Date(), "yyyy-mm-dd-HH-MM-ss-") + Math.random().toString().substr(2,8),
            ProvisioningArtifactId: artifactlist.ProvisioningArtifactDetails.pop().Id,
            ProvisioningParameters: [
                {
                    Key: 'SSOUserEmail',
                    Value: user.email
                },
                {
                    Key: 'AccountEmail',
                    Value: accountemail
                },
                {
                    Key: 'SSOUserFirstName',
                    Value: user.name.split(" ")[0]
                },
                {
                    Key: 'SSOUserLastName',
                    Value: user.name.split(" ").pop()
                },
                {
                    Key: 'ManagedOrganizationalUnit',
                    Value: 'Custom'
                },
                {
                    Key: 'AccountName',
                    Value: accountname
                },
            ]
        }).promise().catch(err => {
            LOG.debug(err);
        });

        let accountsdata = [];
        let accounts = [];

        while (!accountid) {
            await new Promise((resolve) => {setTimeout(resolve, 2000)});

            accountsdata = await retryWrapper(organizations, 'listAccounts', {
                // no params
            });
            
            accounts = accountsdata.Accounts;
            
            while (accountsdata.NextToken) {
                accountsdata = await retryWrapper(organizations, 'listAccounts', {
                    NextToken: data.NextToken
                });
                accounts = accounts.concat(accountsdata.Accounts);
            }
            for (let account of accounts) {
                if (account.Email == accountemail) {
                    accountid = account.Id;
                }
            }
        }
    } else {
        let createaccountop = await retryWrapper(organizations, 'createAccount', {
            AccountName: accountname, 
            Email: accountemail,
            IamUserAccessToBilling: 'ALLOW',
            RoleName: 'OrganizationAccountAccessRole'
        });

        LOG.debug("Created account, waiting for state");

        while (createaccountop.CreateAccountStatus.State == "IN_PROGRESS") {
            LOG.debug("Account creation still in progress...");
            await new Promise((resolve) => {setTimeout(resolve, 2000)});

            createaccountop = await retryWrapper(organizations, 'describeCreateAccountStatus', {
                CreateAccountRequestId: createaccountop.CreateAccountStatus.Id
            });
        }

        if (createaccountop.CreateAccountStatus.State != "SUCCEEDED") {
            LOG.debug("Account creation failure");
            LOG.debug(createaccountop);

            let reason = 'The account could not be created for an unknown reason';
            if (createaccountop.CreateAccountStatus.FailureReason == "ACCOUNT_LIMIT_EXCEEDED") {
                reason = 'The account could not be created because the Organizational limit has been exceeded';
            } else if (createaccountop.CreateAccountStatus.FailureReason == "EMAIL_ALREADY_EXISTS") {
                reason = 'The account could not be created as the email address already exists';
            } else if (createaccountop.CreateAccountStatus.FailureReason == "INVALID_EMAIL") {
                reason = 'The account could not be created due to an invalid email address';
            } else if (createaccountop.CreateAccountStatus.FailureReason == "CONCURRENT_ACCOUNT_MODIFICATION") {
                reason = 'The account could not be created due to a conflicting operation';
            } else if (createaccountop.CreateAccountStatus.FailureReason == "INTERNAL_FAILURE") {
                reason = 'The account could not be created due to an internal failure in the Organizations service';
            }

            return {
                "statusCode": 503,
                "isBase64Encoded": false,
                "headers": {
                    "Content-Type": "application/json"
                },
                "body": JSON.stringify({
                    'createAccountSuccess': false,
                    'reason': reason
                })
            };
        }

        accountid = createaccountop.CreateAccountStatus.AccountId;
    }

    let tags = [
        {
            Key: "AccountOwnerGUID",
            Value: user.guid
        },
        {
            Key: "SSOCreationComplete",
            Value: "false"
        }
    ];

    if (process.env.CONTROL_TOWER_MODE == "true") {
        tags.push({
            Key: "ServiceCatalogProvisionedProductId",
            Value: provisionaccountfromproductop.RecordDetail.ProvisionedProductId
        });
    }

    if (notes.length > 0) {
        tags.push({
            Key: "Notes",
            Value: notes
        });
    }
    if (form['shareaccount'] && form['shareaccount'] == "on") {
        tags.push({
            Key: "SharedWithOrg",
            Value: "true"
        });
    }
    if (process.env.ROOT_EMAILS_TO_USER == "true") {
        tags.push({
            Key: "AccountEmailForwardingAddress",
            Value: user.email
        });
    }
    if (maximumspend) {
        tags.push({
            Key: "BudgetThresholdBeforeDeletion",
            Value: maximumspend.toString()
        });
    }

    if (process.env.AUTO_UNSUB_MARKETING == "true") {
        let unsubbody = `FirstName=&LastName=&Email=${encodeURIComponent(accountemail)}&Company=&Phone=&Country=&preferenceCenterCategory=no&preferenceCenterGettingStarted=no&preferenceCenterOnlineInPersonEvents=no&preferenceCenterMonthlyAWSNewsletter=no&preferenceCenterTrainingandBestPracticeContent=no&preferenceCenterProductandServiceAnnoucements=no&preferenceCenterSurveys=no&PreferenceCenter_AWS_Partner_Events_Co__c=no&preferenceCenterOtherAWSCommunications=no&PreferenceCenter_Language_Preference__c=&Title=&Job_Role__c=&Industry=&Level_of_AWS_Usage__c=&LDR_Solution_Area__c=&Unsubscribed=yes&UnsubscribedReason=&unsubscribedReasonOther=&useCaseMultiSelect=&zOPFormValidationBotVerification=&Website_Referral_Code__c=&zOPURLTrackingTRKCampaign=&zOPEmailValidationHygiene=validate&formid=34006&formVid=34006`;

        await rp({ uri: 'https://pages.awscloud.com/index.php/leadCapture/save2', method: 'POST', body: unsubbody}).catch(err => {
            LOG.warn("Failed to unsubscribe from marketing communications");
            LOG.warn(err);
        });
    }

    await retryWrapper(organizations, 'tagResource', {
        ResourceId: accountid,
        Tags: tags
    });

    return {
        "statusCode": 200,
        "isBase64Encoded": false,
        "headers": {
            "Content-Type": "application/json"
        },
        "body": JSON.stringify({
            'createAccountSuccess': true
        })
    };
}

function wrapHTML(user) {
    return `<!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <meta name="description" content="">
        <title>${user.ssoprops.SSOManagerAppName}</title>

        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
        <style>
        .fa-trash-alt:hover:before {
            color: #f64f5f !important
        }
        </style>

        <script src="https://kit.fontawesome.com/a9a4873efc.js" crossorigin="anonymous"></script>
      </head>
      <body class="bg-light">
        <div class="container">
        <div class="row">
        <div class="col-md-12">
        <p class="float-right mt-4 text-muted">${user.name} (${user.email})&nbsp;&nbsp;|&nbsp;&nbsp;<a href="${user.ssoprops.SignOutURL}">Back to SSO</a></p>
        </div>
        </div>

        <div id="alerts"></div>
      
        <div class="py-5 text-center" style="padding-top: 1rem!important;">
        <svg class="d-block mx-auto mb-4" height="72" viewBox="0 0 64 64" width="72" xmlns="http://www.w3.org/2000/svg"><g id="AccMgrLogo" data-name="AccMgrLogo"><path d="m53.54 41.34a8.047 8.047 0 0 0 -4.54-4.76v-25.58h-44a2.006 2.006 0 0 0 -2 2v6h40v17.59c-.23.09-.46.2-.68.31a11.984 11.984 0 0 0 -22.15 4.14 10 10 0 0 0 .83 19.96h30a9.993 9.993 0 0 0 2.54-19.66z" fill="#bddbff"/><g fill="#57a4ff"><path d="m6 14h2v2h-2z"/><path d="m10 14h2v2h-2z"/><path d="m14 14h2v2h-2z"/><path d="m38 14h2v2h-2z"/><path d="m12 6h2v2h-2z"/><path d="m16 6h2v2h-2z"/><path d="m20 6h2v2h-2z"/><path d="m44 6h2v2h-2z"/><path d="m54.29 40.51a8.985 8.985 0 0 0 -4.29-4.55v-30.96a3.009 3.009 0 0 0 -3-3h-36a3.009 3.009 0 0 0 -3 3v5h-3a3.009 3.009 0 0 0 -3 3v30a3.009 3.009 0 0 0 3 3h6.23a10.874 10.874 0 0 0 -1.23 5 11.007 11.007 0 0 0 11 11h30a11 11 0 0 0 3.29-21.49zm-44.29-35.51a1 1 0 0 1 1-1h36a1 1 0 0 1 1 1v5h-38zm33.82 7h4.18v23.25a8.454 8.454 0 0 0 -4-.02v-22.23a3 3 0 0 0 -.18-1zm-39.82 1a1 1 0 0 1 1-1h36a1 1 0 0 1 1 1v5h-38zm1 31a1 1 0 0 1 -1-1v-23h38v14.75a12.956 12.956 0 0 0 -22.67 5.38 11.047 11.047 0 0 0 -6.78 3.87zm46 16h-30a9 9 0 0 1 -.74-17.96 1 1 0 0 0 .9-.84 10.982 10.982 0 0 1 20.3-3.79 1 1 0 0 0 1.32.38 6.846 6.846 0 0 1 3.22-.79 7 7 0 0 1 6.59 4.67.993.993 0 0 0 .69.63 9 9 0 0 1 -2.28 17.7z"/><path d="m52.776 44.239-.506 1.936a4.994 4.994 0 0 1 -1.27 9.825v2a6.994 6.994 0 0 0 1.776-13.761z"/><path d="m16 51a5.018 5.018 0 0 1 4.582-4.974l-.163-1.994a7 7 0 0 0 .581 13.968v-2a5.006 5.006 0 0 1 -5-5z"/><path d="m23 56h4v2h-4z"/></g></g></svg>
        <h2>${user.ssoprops.SSOManagerAppName}</h2>
        <p class="lead">Below you can manage the AWS accounts that you have access to.</p>
      </div>
    
      <div class="row">
        <div class="col-md-6 order-md-1 mb-6">
          <h4 class="d-flex justify-content-between align-items-center mb-3">
            <span>Your accounts</span>
            <span id="accounts-count" class="badge badge-secondary badge-pill">-</span>
          </h4>
          <ul id="accounts-list" class="list-group mb-3">
          </ul>
        </div>
        <div class="col-md-1 order-md-2"></div>
        <div class="col-md-5 order-md-3">
          <h4 class="mb-3">Create account</h4>
          <form id="create-account-form" class="needs-validation" novalidate>
            <input id="SAMLResponse" type="hidden" name="SAMLResponse" value="${user.samlresponse}">

            <div class="mb-3">
                <label for="emailprefix">E-mail Prefix</label>
                <div class="input-group">
                    <input type="text" class="form-control" id="emailprefix" name="emailprefix" placeholder="some-identifier" required>
                    <div class="input-group-prepend">
                        <span class="input-group-text">@${process.env.DOMAIN_NAME}</span>
                    </div>
                    <div class="invalid-feedback" style="width: 100%;">
                    An e-mail prefix is required.
                    </div>
                </div>
            </div>
    
            <div class="mb-3">
                <label for="accountname">Account Name</label>
                <input type="text" class="form-control" id="accountname" name="accountname" placeholder="My Account" required>
                <div class="invalid-feedback">
                    An account name is required.
                </div>
            </div>
            ${(process.env.MAXIMUM_ACCOUNT_SPEND == "0") ? '' : `

            <div class="mb-3">
                <label for="accountname">Maximum Monthly Spend (USD)</label>
                <div class="input-group">
                    <div class="input-group-prepend">
                        <span class="input-group-text">$</span>
                    </div>
                    <input type="text" class="form-control" id="maximumspend" name="maximumspend" value="${process.env.MAXIMUM_ACCOUNT_SPEND}" aria-describedby="maximumspendhelp" required>
                    <small id="maximumspendhelp" class="form-text text-muted">
                        Account will be automatically deleted when this threshold is reached.
                    </small>
                    <div class="invalid-feedback" style="width: 100%;">
                    A maximum spend is required.
                    </div>
                </div>
            </div>
            `}
            
            <div class="mb-3">
                <label for="notes">Notes <span class="text-muted">(Optional)</span></label>
                <input type="text" class="form-control" id="notes" name="notes">
            </div>
    
            <hr class="mb-4">

            <div class="custom-control custom-checkbox">
              <input type="checkbox" class="custom-control-input" id="shareaccount" name="shareaccount">
              <label class="custom-control-label" for="shareaccount">This account can be accessed by everyone in my organization</label>
            </div>

            <hr class="mb-4">

            <button id="create-account-submit-button" class="btn btn-primary btn-lg btn-block" type="submit">Create Account</button>
          </form>
        </div>
      </div>

      <div class="modal fade" id="delete-account-modal" tabindex="-1" role="dialog" aria-hidden="true">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
            <div class="modal-body">
                <br />
                <p>Are you sure you want to delete <strong id="delete-account-confirmation-text"></strong>?</p>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
                <button id="delete-account-confirmation-button" data-accountid="" type="button" class="btn btn-danger">Delete Account</button>
            </div>
            </div>
        </div>
      </div>
    
      <footer class="my-5 pt-5 text-muted text-center text-small">
        <p class="mb-1">For support, contact your administrator at <a href="mailto:${process.env.MASTER_EMAIL}">${process.env.MASTER_EMAIL}</a></p>
      </footer>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
    <script>
        function refreshAccounts() {
            $.ajax({
                type: 'POST',
                url: '/accounts',
                data: 'SAMLResponse=' + $('#SAMLResponse').val(),
                success: function(response) {
                    $('#accounts-list').html('');
                    $('#accounts-count').html(response.accounts.length);
                    for (const account of response.accounts) {
                        $('#accounts-list').append(\`
                            <li class="list-group-item d-flex justify-content-between lh-condensed">
                            <div>
                                <h6 class="my-0">\${account.Name}\${account.IsShared ? '&nbsp;&nbsp;<span class="badge badge-dark">SHARED</span>' : ''}\${account.IsDeleting ? '&nbsp;&nbsp;<span class="badge badge-warning">DELETING</span>' : ''}\${account.IsCreating ? '&nbsp;&nbsp;<span class="badge badge-success">CREATING</span>' : ''}</h6>
                                <small class="text-muted">Account ID: \${account.Id}</small><br />
                                <small class="text-muted">Account E-mail: \${account.Email}</small><br />
                                <small class="text-muted">Notes: \${account.Notes || ''}</small>
                            </div>
                            <span>\${((account.IsShared && !account.IsOwner) || account.IsDeleting || account.IsCreating) ? '' : \`<i class="fas fa-trash-alt text-danger" data-toggle="modal" data-target="#delete-account-modal" data-accountname="\${account.Name}" data-accountid="\${account.Id}"></i>\`}</span>
                            </li>
                        \`);
                    }
                },
                error: function(response) {
                    if ($('#alerts').html() == "") {
                        $('#alerts').append(\`
                            <div class="alert alert-danger alert-dismissible fade show" role="alert">
                            <strong>Account List Failure</strong> The list of accounts could not be loaded for an unknown reason
                            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                                <span aria-hidden="true">&times;</span>
                            </button>
                            </div>
                        \`);

                        window.scrollTo(0, 0);
                    }
                },
            });
        }

        function deleteAccount(accountid) {
            $.ajax({
                type: 'POST',
                url: '/deleteaccount',
                data: 'accountid=' + accountid.trim() + '&SAMLResponse=' + $('#SAMLResponse').val(),
                success: function(response) {
                    $('#alerts').append(\`
                        <div class="alert alert-success alert-dismissible fade show" role="alert">
                        <strong>Account Deletion Requested</strong> Your AWS account deletion request has been successfully processed. This will occur within the next 5 minutes.
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                        </div>
                    \`);

                    $('#delete-account-modal').modal('hide');

                    refreshAccounts();

                    window.scrollTo(0, 0);
                },
                error: function(response) {
                    $('#alerts').append(\`
                        <div class="alert alert-danger alert-dismissible fade show" role="alert">
                        <strong>Account Creation Failure</strong> The account could not be deleted for an unknown reason
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                        </div>
                    \`);

                    $('#delete-account-modal').modal('hide');

                    window.scrollTo(0, 0);
                },
            });
        }

        $('#create-account-form').submit(e => {
            e.preventDefault();

            $('#create-account-submit-button').attr('disabled', 'disabled');

            $.ajax({
                type: 'POST',
                url: '/createaccount',
                data: $('#create-account-form').serialize(),
                success: function(response) {
                    $('#alerts').append(\`
                        <div class="alert alert-success alert-dismissible fade show" role="alert">
                        <strong>Account Created</strong> Your AWS account has been created successfully. It will be available to use via SSO in a few minutes.
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                        </div>
                    \`);

                    // reset form
                    $('#create-account-form').find('input[type="text"]').val('');
                    $('#create-account-form').find('input[type="checkbox"]').prop('checked', false);
                    $('#create-account-submit-button').removeAttr('disabled');

                    refreshAccounts();

                    window.scrollTo(0, 0);
                },
                error: function(response) {
                    var reason = "The account could not be created for an unknown reason";
                    if (response.responseJSON && response.responseJSON.reason) {
                        reason = response.responseJSON.reason;
                    }

                    $('#alerts').append(\`
                        <div class="alert alert-danger alert-dismissible fade show" role="alert">
                        <strong>Account Creation Failure</strong> \${reason}
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                        </div>
                    \`);

                    $('#create-account-submit-button').removeAttr('disabled');

                    window.scrollTo(0, 0);
                },
            });
        });

        $(document).ready(function() {
            $('#delete-account-modal').on('show.bs.modal', function (event) {
                var button = $(event.relatedTarget);
                var accountid = button.data('accountid');
                var accountname = button.data('accountname');
                
                $('#delete-account-confirmation-text').html(accountname + " (" + accountid + ")");
                $('#delete-account-confirmation-button').attr('data-accountid', accountid);
                $('#delete-account-confirmation-button').removeAttr('disabled');
            });

            $('#delete-account-confirmation-button').click(function (event) {
                $('#delete-account-confirmation-button').attr('disabled', 'disabled');
                deleteAccount($('#delete-account-confirmation-button').attr('data-accountid'));
            });

            refreshAccounts();
        });

        setInterval(refreshAccounts, 10000);
    </script>
    </body>
    </html>
    `;
}

exports.handler = async (event, context) => {
    let result = null;
    let browser = null;

    LOG.debug(event);

    if (event.source && event.source == "aws.organizations" && event.detail.eventName == "TagResource") {
        isdeletable = false;
        accountowner = null;
        isshared = false;
        budgetthresholdbeforedeletion = null;
        event.detail.requestParameters.tags.forEach(tag => {
            if (tag.key.toLowerCase() == "delete" && tag.value.toLowerCase() == "true") {
                isdeletable = true;
            }
            if (tag.key.toLowerCase() == "accountownerguid") {
                accountowner = tag.value;
            }
            if (tag.key.toLowerCase() == "budgetthresholdbeforedeletion") {
                budgetthresholdbeforedeletion = tag.value;
            }
            if (tag.key.toLowerCase() == "sharedwithorg" && tag.value.toLowerCase() == "true") {
                isshared = true;
            }
        });

        if (isdeletable && process.env.DELETION_FUNCTIONALITY_ENABLED == "true") {
            let data = await retryWrapper(organizations, 'describeAccount', {
                AccountId: event.detail.requestParameters.resourceId
            });

            browser = await puppeteer.launch({
                args: chromium.args,
                defaultViewport: chromium.defaultViewport,
                executablePath: await chromium.executablePath,
                headless: chromium.headless,
            });
    
            let page = await browser.newPage();
    
            await triggerReset(page, {
                'email': data.Account.Email
            });
        }

        if (accountowner && process.env.CREATION_FUNCTIONALITY_ENABLED == "true") {
            browser = await puppeteer.launch({
                args: chromium.args,
                defaultViewport: chromium.defaultViewport,
                executablePath: await chromium.executablePath,
                headless: chromium.headless,
            });
    
            let page = await browser.newPage();

            await login(page);

            if (process.env.DENY_SUBSCRIPTION_CALLS) {
                await addSubscriptionsSCP({
                    'accountid': event.detail.requestParameters.resourceId
                });
            }

            if (budgetthresholdbeforedeletion) {
                await addBillingMonitor(page, {
                    'accountid': event.detail.requestParameters.resourceId,
                    'budgetthresholdbeforedeletion': budgetthresholdbeforedeletion
                });
            }
    
            await setSSOOwner(page, {
                'accountowner': accountowner,
                'accountid': event.detail.requestParameters.resourceId,
                'isshared': isshared
            });
        }
    } else if (event.email) {
        browser = await puppeteer.launch({
            args: chromium.args,
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath,
            headless: chromium.headless,
        });

        let page = await browser.newPage();

        await triggerReset(page, event);
    } else if (event.action == "removeAccountFromOrg") {
        let removed = await removeAccountFromOrg(event.account);

        if (removed) {
            let targetsresponse = await eventbridge.listTargetsByRule({
                Rule: event.ruleName
            }).promise();

            for (const target of targetsresponse.Targets) {
                await eventbridge.removeTargets({
                    Rule: event.ruleName,
                    Ids: [target.Id]
                }).promise();
            }

            await eventbridge.deleteRule({
                Name: event.ruleName
            }).promise();

            LOG.info("Successfully removed rule");
        }
    } else if (event.Records && event.Records[0] && event.Records[0].s3 && event.Records[0].s3.bucket) {
        browser = await puppeteer.launch({
            args: chromium.args,
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath,
            headless: chromium.headless,
        });

        let page = await browser.newPage();

        await handleEmailInbound(page, event);
    } else if (event.Records && event.Records[0] && event.Records[0].Sns) {
        await processSnsDeleteAccount(event);
    } else if (event.Name && event.Name == "ContactFlowEvent") {
        let connectssmparameter = await ssm.getParameter({
            Name: process.env.CONNECT_SSM_PARAMETER
        }).promise();

        let variables = JSON.parse(connectssmparameter['Parameter']['Value']);

        return {
            "prompt1": variables['PROMPT_' + variables['CODE'][0]],
            "prompt2": variables['PROMPT_' + variables['CODE'][1]],
            "prompt3": variables['PROMPT_' + variables['CODE'][2]],
            "prompt4": variables['PROMPT_' + variables['CODE'][3]]
        }
    } else if (event.ResourceType == "Custom::ConnectSetup") {
        let domain = event.StackId.split("-").pop();

        browser = await puppeteer.launch({
            args: chromium.args,
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath,
            headless: chromium.headless,
        });

        let page = await browser.newPage();

        try {
            await login(page);

            if (event.RequestType == "Create") {
                await ses.setActiveReceiptRuleSet({
                    RuleSetName: "account-controller"
                }).promise();

                await createinstance(page, {
                    'Domain': domain
                });
                await page.waitFor(5000);
                await open(page, {
                    'Domain': domain
                });
                let hostx = new url.URL(await page.url()).host;
                while (hostx.indexOf(domain) == -1) {
                    await page.waitFor(20000);
                    await open(page, {
                        'Domain': domain
                    });
                    hostx = new url.URL(await page.url()).host;
                }
                let prompts = await uploadprompts(page, {
                    'Domain': domain
                });
                await createflow(page, {
                    'Domain': domain
                }, prompts);
                let number = await claimnumber(page, {
                    'Domain': domain
                });
                LOG.info("Registered phone number: " + number['PhoneNumber']);
                
                let variables = {};

                ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].forEach(num => {
                    variables['PROMPT_' + num] = prompts[num + '.wav'];
                });
                variables['PHONE_NUMBER'] = number['PhoneNumber'].replace(/[ -]/g, "")
    
                await ssm.putParameter({
                    Name: process.env.CONNECT_SSM_PARAMETER,
                    Type: "String",
                    Value: JSON.stringify(variables),
                    Overwrite: true
                }).promise();
            } else if (event.RequestType == "Delete") {
                await ses.setActiveReceiptRuleSet({
                    RuleSetName: "default-rule-set"
                }).promise();

                await ses.deleteReceiptRuleSet({
                    RuleSetName: "account-controller"
                }).promise();

                await deleteinstance(page, {
                    'Domain': domain
                });
            }

            await sendcfnresponse(event, context, "SUCCESS", {
                'Domain': domain
            }, domain);
        } catch(error) {
            await sendcfnresponse(event, context, "FAILED", {});

            await debugScreenshot(page);

            throw error;
        }
    } else if (event.ResourceType == "Custom::SSOSetup") {
        browser = await puppeteer.launch({
            args: chromium.args,
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath,
            headless: chromium.headless,
        });

        let page = await browser.newPage();

        try {
            await login(page);

            if (event.RequestType == "Create") {
                await createssoapp(page, {
                    'SSOManagerAppName': event.ResourceProperties.SSOManagerAppName,
                    'APIGatewayEndpoint': event.ResourceProperties.APIGatewayEndpoint
                });
            } else if (event.RequestType == "Delete") {
                await deletessoapp(page, {
                    'SSOManagerAppName': event.ResourceProperties.SSOManagerAppName,
                    'APIGatewayEndpoint': event.ResourceProperties.APIGatewayEndpoint
                });
            }

            await sendcfnresponse(event, context, "SUCCESS", {
                "SSOManagerAppName": event.ResourceProperties.SSOManagerAppName,
                'APIGatewayEndpoint': event.ResourceProperties.APIGatewayEndpoint
            }, "SSOManager");
        } catch(error) {
            await sendcfnresponse(event, context, "FAILED", {});

            await debugScreenshot(page);

            throw error;
        }
    } else if (event.routeKey == "GET /") {
        let ssoparamresponse = await ssm.getParameter({
            Name: process.env.SSO_SSM_PARAMETER
        }).promise();
        let ssoproperties = JSON.parse(ssoparamresponse['Parameter']['Value']);
        
        return {
            "statusCode": 302,
            "headers": {
                "Location": ssoproperties['SignOutURL']
            }
        };
    } else if (event.routeKey == "POST /") {
        try {
            let resp = await handleSAMLResponse(event);

            return resp;
        } catch(err) {
            LOG.error(err);
        }

        return {
            "statusCode": 500,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "index/html"
            },
            "body": ""
        };
    } else if (event.routeKey == "POST /accounts") {
        try {
            let resp = await handleGetAccounts(event);
            return resp;
        } catch(err) {
            LOG.error(err);
        }

        return {
            "statusCode": 500,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": ""
        };
    } else if (event.routeKey == "POST /createaccount") {
        try {
            let resp = await handleCreateAccountRequest(event);
            return resp;
        } catch(err) {
            LOG.error(err);
        }

        return {
            "statusCode": 500,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": ""
        };
    } else if (event.routeKey == "POST /deleteaccount") {
        try {
            let resp = await handleDeleteAccountRequest(event);
            return resp;
        } catch(err) {
            LOG.error(err);
        }
        
        return {
            "statusCode": 500,
            "isBase64Encoded": false,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": ""
        };
    } else {
        return context.succeed();
    }
};



================================================
FILE: lambda/package.json
================================================
{
  "name": "aws-account-controller",
  "version": "0.1.0",
  "description": "Manage the creation and deletion of sandbox-style accounts",
  "main": "index.js",
  "dependencies": {
    "aws-sdk": "^2.814.0",
    "chrome-aws-lambda": "^2.1.1",
    "dateformat": "^3.0.3",
    "https": "^1.0.0",
    "internet-message": "github:iann0036/js-internet-message",
    "mailparser-mit": "^1.0.0",
    "puppeteer-core": "^2.1.1",
    "request": "^2.88.0",
    "request-promise": "^4.2.4",
    "saml2-js": "^2.0.5",
    "winston": "^3.2.1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/iann0036/aws-account-controller.git"
  },
  "author": "Ian Mckay",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/iann0036/aws-account-controller/issues"
  },
  "homepage": "https://github.com/iann0036/aws-account-controller#readme"
}


================================================
FILE: template.yml
================================================
AWSTemplateFormatVersion: "2010-09-09"

Description: Account Controller Solution

Parameters:

    ControlTowerMode:
        Description: Provision accounts with Control Tower instead of directly with Organizations (requires Control Tower to be set up)
        Type: String
        Default: "false"
        AllowedValues:
          - "true"
          - "false"

    AccountCreationFunctionality:
        Description: Manage SSO and other features required for account creation functionality
        Type: String
        Default: "true"
        AllowedValues:
          - "true"
          - "false"

    AccountDeletionFunctionality:
        Description: Manage Connect and other features required for account deletion functionality
        Type: String
        Default: "true"
        AllowedValues:
          - "true"
          - "false"

    AutoUnsubMarketing:
        Description: Automatically unsubscribe newly created accounts from all marketing material
        Type: String
        Default: "true"
        AllowedValues:
          - "true"
          - "false"

    MasterEmail:
        Description: The email address which will receive all root account correspondence (this should NOT be an address of your master domain/subdomain)
        Type: String
        AllowedPattern: "^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]+$"

    MaximumAccountSpend:
        Description: The maximum configurable monthly spend, in USD, of created accounts before they are automatically deleted - or set to "0" to disable spend tracking
        Type: String
        Default: "100"
        AllowedPattern: "^[0-9]+(?:\\.[0-9]{2})?$"

    EmailSubjectCustomization:
        Description: The format of the forwarded emails
        Type: String
        Default: "{subject} | From: {from} | Acct ID: {accountid} | Acct Email: {accountemail}"

    RootEmailsToUser:
        Description: If true, root e-mails will be sent to the user who created the account, otherwise they will be sent to the master e-mail address
        Type: String
        Default: "true"
        AllowedValues:
          - "true"
          - "false"

    DenySubscriptionCalls:
        Description: Denies the capability to make subscription-based calls in new accounts, like reserved instances, via an SCP
        Type: String
        Default: "true"
        AllowedValues:
          - "true"
          - "false"

    DomainName:
        Description: The domain name (or subdomain) which is used for all account root email addresses
        Type: String

    2CaptchaApiKey:
        Description: The API Key for 2captcha.com
        Type: String
        Default: ''
        NoEcho: true

    S3Bucket:
        Description: The name of the bucket that contains the Lambda source (leave blank to use latest)
        Type: String
        Default: ''
    
    S3Key:
        Description: The key of the ZIP package within the bucket (leave blank to use latest)
        Type: String
        Default: ''

    AutomationUsername:
        Description: The username of an IAM user created to perform automated actions
        Type: String
        Default: AccountControllerAutomationUser
    
    LogLevel:
        Description: The log level of the Lambda function
        Type: String
        Default: "INFO"
        AllowedValues:
          - "DEBUG"
          - "INFO"
          - "WARN"
          - "ERROR"
    
    CCName:
        Description: The full name of the credit card owner
        Type: String
    
    CCNumber:
        Description: The number of the credit card
        Type: String
        NoEcho: true
    
    CCMonth:
        Description: The month of the credit card as a number (January = 1, December = 12)
        Type: String
        AllowedValues:
          - "1"
          - "2"
          - "3"
          - "4"
          - "5"
          - "6"
          - "7"
          - "8"
          - "9"
          - "10"
          - "11"
          - "12"
    
    CCYear:
        Description: The full year of the credit card (e.g. 2020)
        Type: String
        AllowedValues:
          - "2020"
          - "2021"
          - "2022"
          - "2023"
          - "2024"
          - "2025"
          - "2026"
          - "2027"
          - "2028"
          - "2029"
          - "2030"
          - "2031"
          - "2032"
          - "2033"
          - "2034"
          - "2035"
          - "2036"
          - "2037"
          - "2038"
          - "2039"
    
    HostedZoneId:
        Description: The ID of the hosted zone of the previous domain name (leave blank for this to be created for you)
        Type: String
        Default: ''

    SSOManagerAppName:
        Description: The name of the SSO application used to manage accounts
        Type: String
        Default: Account Manager

Metadata: 

    AWS::CloudFormation::Interface: 
        ParameterGroups: 
          - Label: 
                default: "Global Features"
            Parameters: 
              - AccountCreationFunctionality
              - AccountDeletionFunctionality
              - ControlTowerMode
          - Label: 
                default: "Email Configuration"
            Parameters: 
              - MasterEmail
              - DomainName
              - HostedZoneId
              - EmailSubjectCustomization
              - RootEmailsToUser
              - AutoUnsubMarketing
          - Label: 
                default: "Billing Credit Card"
            Parameters: 
              - CCName
              - CCNumber
              - CCMonth
              - CCYear
          - Label: 
                default: "SSO Settings"
            Parameters: 
              - SSOManagerAppName
          - Label: 
                default: "Other Settings"
            Parameters: 
              - 2CaptchaApiKey
              - AutomationUsername
              - MaximumAccountSpend
              - DenySubscriptionCalls
          - Label: 
                default: "Lambda Function"
            Parameters: 
              - LogLevel
              - S3Bucket
              - S3Key
        ParameterLabels: 
            MasterEmail: 
                default: "Master Email Address"
            DomainName: 
                default: "Master Domain Name"
            HostedZoneId: 
                default: "Hosted Zone ID"
            CCName: 
                default: "Credit Card Name"
            CCNumber: 
                default: "Credit Card Number"
            CCMonth: 
                default: "Credit Card Expiry Month"
            CCYear: 
                default: "Credit Card Expiry Year"
            2CaptchaApiKey: 
                default: "2Captcha API Key"
            S3Bucket: 
                default: "S3 Bucket"
            S3Key: 
                default: "S3 Key"
            LogLevel: 
                default: "Log Level"
            EmailSubjectCustomization: 
                default: "E-mail Subject Customization"
            AccountCreationFunctionality: 
                default: "Enable Account Creation Functionality"
            AccountDeletionFunctionality: 
                default: "Enable Account Deletion Functionality"
            SSOManagerAppName: 
                default: "SSO Account Manager Application Name"
            AutomationUsername: 
                default: "Automation IAM User Username"
            RootEmailsToUser:
                default: "Send Root E-mails to User"
            MaximumAccountSpend:
                default: "Maximum Monthly Spend Per Account"
            DenySubscriptionCalls:
                default: "Deny Subscription Calls"
            AutoUnsubMarketing:
                default: "Unsubscribe Marketing E-mails"
            ControlTowerMode:
                default: "Control Tower Mode"

Conditions:

    S3Defined: !Not [ !Equals [ '', !Ref S3Bucket ] ]
    HostedZoneNotDefined: !Equals [ '', !Ref HostedZoneId ]
    AccountCreationEnabled: !Equals [ 'true', !Ref AccountCreationFunctionality ]
    AccountDeletionEnabled: !Equals [ 'true', !Ref AccountDeletionFunctionality ]
    ControlTowerModeEnabled: !Equals [ 'true', !Ref ControlTowerMode ]

Mappings:
    RegionMap:
        us-east-1:
            bucketname: ianmckay-us-east-1
        us-east-2:
            bucketname: ianmckay-us-east-2
        us-west-1:
            bucketname: ianmckay-us-west-1
        us-west-2:
            bucketname: ianmckay-us-west-2
        ap-south-1:
            bucketname: ianmckay-ap-south-1
        ap-northeast-2:
            bucketname: ianmckay-ap-northeast-2
        ap-southeast-1:
            bucketname: ianmckay-ap-southeast-1
        ap-southeast-2:
            bucketname: ianmckay-ap-southeast-2
        ap-northeast-1:
            bucketname: ianmckay-ap-northeast-1
        ca-central-1:
            bucketname: ianmckay-ca-central-1
        eu-central-1:
            bucketname: ianmckay-eu-central-1
        eu-west-1:
            bucketname: ianmckay-eu-west-1
        eu-west-2:
            bucketname: ianmckay-eu-west-2
        eu-west-3:
            bucketname: ianmckay-eu-west-3
        eu-north-1:
            bucketname: ianmckay-eu-north-1
        sa-east-1:
            bucketname: ianmckay-sa-east-1

Resources:

    DebugBucket:
        Type: AWS::S3::Bucket
        Properties:
            LifecycleConfiguration:
                Rules:
                  - NoncurrentVersionExpirationInDays: 14
                    ExpirationInDays: 14
                    Status: Enabled
            BucketEncryption:
                ServerSideEncryptionConfiguration:
                  - ServerSideEncryptionByDefault:
                        SSEAlgorithm: AES256
            PublicAccessBlockConfiguration:
                BlockPublicAcls: true
                BlockPublicPolicy: true
                IgnorePublicAcls: true
                RestrictPublicBuckets: true

    HostedZone:
        Condition: HostedZoneNotDefined
        Type: AWS::Route53::HostedZone
        Properties:
            Name: !Ref DomainName

    MXRecord:
        Type: AWS::Route53::RecordSet
        Properties:
            HostedZoneId: !If
              - HostedZoneNotDefined
              - !Ref HostedZone
              - !Ref HostedZoneId
            Name: !Sub '${DomainName}.'
            Type: MX
            TTL: '900'
            ResourceRecords:
              - !Sub '10 inbound-smtp.${AWS::Region}.amazonaws.com'
    
    OrgAccountTaggedRule:
        Type: AWS::Events::Rule
        Properties:
            Description: Detect and begin processing accounts when tagged
            EventPattern: |
              {
                "source": [
                  "aws.organizations"
                ],
                "detail-type": [
                  "AWS API Call via CloudTrail"
                ],
                "detail": {
                  "eventSource": [
                    "organizations.amazonaws.com"
                  ],
                  "eventName": [
                    "TagResource"
                  ]
                }
              }
            State: ENABLED
            Targets:
              - Arn: !GetAtt LambdaFunction.Arn
                Id: Action

    LambdaAccountDeletionEventRulePermission:
        Condition: AccountDeletionEnabled
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaFunction
            Action: lambda:InvokeFunction
            Principal: events.amazonaws.com
            SourceArn: !GetAtt OrgAccountTaggedRule.Arn

    LambdaConnectPermission:
        Condition: AccountDeletionEnabled
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaFunction
            Action: lambda:InvokeFunction
            Principal: connect.amazonaws.com
            SourceAccount: !Ref AWS::AccountId

    LambdaAPIGatewayPermission:
        Condition: AccountCreationEnabled
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaFunction
            Action: lambda:InvokeFunction
            Principal: apigateway.amazonaws.com
            SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SAMLHttpApi}/*/*/*"

    LambdaDeleteAccountEventsPermission:
        Condition: AccountDeletionEnabled
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaFunction
            Action: lambda:InvokeFunction
            Principal: events.amazonaws.com
            SourceArn: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/ScheduledAccountDeletion-*"

    LambdaDeleteAccountSNSPermission:
        Condition: AccountDeletionEnabled
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaFunction
            Action: lambda:InvokeFunction
            Principal: sns.amazonaws.com
            SourceArn: !Ref AccountDeletionSNSTopic

    LambdaLogGroup:
        Type: AWS::Logs::LogGroup
        Properties:
            LogGroupName: /aws/lambda/AccountAutomator
            RetentionInDays: 14

    LambdaFunction:
        DependsOn:
          - AutomationUser
          - LambdaLogGroup
        Type: AWS::Lambda::Function
        Properties:
            FunctionName: AccountAutomator
            Code:
                S3Bucket: !If
                    - S3Defined
                    - !Ref S3Bucket
                    - Fn::FindInMap:
                        - RegionMap
                        - !Ref 'AWS::Region'
                        - bucketname
                S3Key: !If
                    - S3Defined
                    - !Ref S3Key
                    - 'accountcontroller/app.zip'
            Handler: index.handler
            Role: !GetAtt LambdaExecutionRole.Arn
            Environment:
                Variables:
                    DEBUG_BUCKET: !Ref DebugBucket
                    CAPTCHA_KEY: !Ref 2CaptchaApiKey
                    ACCOUNTID: !Ref AWS::AccountId
                    MASTER_EMAIL: !Ref MasterEmail
                    LOG_LEVEL: !Ref LogLevel
                    EMAIL_SUBJECT: !Ref EmailSubjectCustomization
                    SECRET_ARN: !Ref AutomationCredentials
                    CONNECT_SSM_PARAMETER: !Ref ConnectProperties
                    SSO_SSM_PARAMETER: !Ref SSOProperties
                    DOMAIN_NAME: !Ref DomainName
                    CREATION_FUNCTIONALITY_ENABLED: !Ref AccountCreationFunctionality
                    DELETION_FUNCTIONALITY_ENABLED: !Ref AccountDeletionFunctionality
                    ACCOUNT_DELETION_TOPIC: !If
                      - AccountDeletionEnabled
                      - !Ref AccountDeletionSNSTopic
                      - !Ref AWS::NoValue
                    ROOT_EMAILS_TO_USER: !Ref RootEmailsToUser
                    MAXIMUM_ACCOUNT_SPEND: !Ref MaximumAccountSpend
                    DENY_SUBSCRIPTION_CALLS: !Ref DenySubscriptionCalls
                    AUTO_UNSUB_MARKETING: !Ref AutoUnsubMarketing
                    CONTROL_TOWER_MODE: !Ref ControlTowerMode
                    ROLE: !GetAtt LambdaExecutionRole.Arn
            Runtime: nodejs12.x
            MemorySize: 1024
            Timeout: 900
    
    LambdaExecutionRole:
        Type: AWS::IAM::Role
        Properties:
            AssumeRolePolicyDocument:
                Version: '2012-10-17'
                Statement:
                  - Effect: Allow
                    Principal:
                        Service:
                          - lambda.amazonaws.com
                    Action:
                      - sts:AssumeRole
            Path: /
            Policies:
              - PolicyName: root
                PolicyDocument:
                    Version: '2012-10-17'
                    Statement:
                      - Effect: Allow
                        Action:
                          - logs:CreateLogGroup
                          - logs:CreateLogStream
                          - logs:PutLogEvents
                        Resource: arn:aws:logs:*:*:*
                      - Effect: Allow
                        Action:
                          - s3:GetObject
                        Resource:
                          - !Sub arn:aws:s3:::accountcontroller-email-processing-${AWS::Region}-${AWS::AccountId}/*
                      - Effect: Allow
                        Action:
                          - s3:PutObject
                        Resource:
                          - !Sub arn:aws:s3:::${DebugBucket}/*
                      - Effect: Allow
                        Action:
                          - organizations:DescribeAccount
                        Resource:
                          - !Sub arn:aws:organizations::${AWS::AccountId}:account/*
                      - Effect: Allow
                        Action:
                          - sts:AssumeRole
                        Resource:
                          - arn:aws:iam::*:role/OrganizationAccountAccessRole
                          - arn:aws:iam::*:role/AWSControlTowerExecution
                      - Effect: Allow
                        Action:
                          - organizations:ListAccounts
                          - organizations:ListTagsForResource
                          - organizations:TagResource
                          - organizations:CreateAccount
                          - organizations:DescribeCreateAccountStatus
                          - organizations:DescribeOrganization
                          - organizations:ListPolicies
                          - organizations:CreatePolicy
                          - organizations:UpdatePolicy
                          - organizations:AttachPolicy
                          - organizations:RemoveAccountFromOrganization
                          - ses:SendRawEmail
                          - ses:SetActiveReceiptRuleSet
                          - ses:DeleteReceiptRuleSet
                          - events:PutTargets
                          - events:PutRule
                          - events:ListTargetsByRule
                          - events:RemoveTargets
                          - events:DeleteRule
                          - servicecatalog:ProvisionProduct
                          - servicecatalog:ListProvisioningArtifacts
                          - servicecatalog:ListLaunchPaths
                          - servicecatalog:AssociatePrincipalWithPortfolio
                          - servicecatalog:ListPortfoliosForProduct
                          - servicecatalog:SearchProductsAsAdmin
                          - servicecatalog:TerminateProduct
                          - servicecatalog:DescribeRecord
                          - iam:GetRole
                          - !If [ ControlTowerModeEnabled, "*", !Ref 'AWS::NoValue' ] # Not my fault: https://docs.aws.amazon.com/controltower/latest/userguide/setting-up.html#setting-up-iam
                        Resource:
                          - '*'
                      - Effect: Allow
                        Action:
                          - secretsmanager:GetSecretValue
                        Resource:
                          - !Ref AutomationCredentials
                      - Effect: Allow
                        Action:
                          - ssm:GetParameter
                          - ssm:PutParameter
                        Resource:
                          - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${ConnectProperties}"
                          - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${SSOProperties}"

    EmailBucket:
        DependsOn:
          - BucketPermission
        Type: AWS::S3::Bucket
        Properties:
            BucketName: !Sub "accountcontroller-email-processing-${AWS::Region}-${AWS::AccountId}" # Required, see https://aws.amazon.com/premiumsupport/knowledge-center/unable-validate-destination-s3/
            LifecycleConfiguration:
                Rules:
                  - NoncurrentVersionExpirationInDays: 14
                    ExpirationInDays: 14
                    Status: Enabled
            NotificationConfiguration:
                LambdaConfigurations:
                  - Event: "s3:ObjectCreated:*"
                    Function: !GetAtt LambdaFunction.Arn
            BucketEncryption:
                ServerSideEncryptionConfiguration:
                  - ServerSideEncryptionByDefault:
                        SSEAlgorithm: AES256
            PublicAccessBlockConfiguration:
                BlockPublicAcls: true
                BlockPublicPolicy: true
                IgnorePublicAcls: true
                RestrictPublicBuckets: true
    
    BucketPermission:
        Type: AWS::Lambda::Permission
        Properties:
            Action: lambda:InvokeFunction
            FunctionName: !Ref LambdaFunction
            Principal: s3.amazonaws.com
            SourceAccount: !Ref AWS::AccountId
            SourceArn: !Sub arn:aws:s3:::accountcontroller-email-processing-${AWS::Region}-${AWS::AccountId}
    
    ReceiptRuleSet:
        DeletionPolicy: Retain # Custom Resource is responsible for deletion
        Type: AWS::SES::ReceiptRuleSet
        Properties:
            RuleSetName: account-controller

    ReceiptRule:
        DependsOn:
          - ReceivedEmailBucketPolicy
        Type: AWS::SES::ReceiptRule
        Properties:
            RuleSetName: !Ref ReceiptRuleSet
            Rule:
                Name: default
                Enabled: true
                Actions:
                  - S3Action:
                        BucketName: !Ref EmailBucket
    
    ReceivedEmailBucketPolicy:
        Type: AWS::S3::BucketPolicy
        Properties:
            Bucket: !Ref EmailBucket
            PolicyDocument:
                Statement:
                  - Effect: Allow
                    Principal:
                        Service: lambda.amazonaws.com
                    Action:
                      - s3:GetObject
                    Resource:
                      - !Sub "${EmailBucket.Arn}/*"
                    Condition:
                        StringEquals:
                            "aws:Referer":
                              - !Ref "AWS::AccountId"
                  - Effect: Allow
                    Principal:
                        Service: ses.amazonaws.com
                    Action:
                      - s3:PutObject
                    Resource:
                      - !Sub "${EmailBucket.Arn}/*"
                    Condition:
                        StringEquals:
                            "aws:Referer":
                              - !Ref "AWS::AccountId"

    AutomationUser:
        DependsOn:
          - AutomationCredentials
        Type: AWS::IAM::User
        Properties:
            UserName: !Sub "{{resolve:secretsmanager:${AutomationCredentials}:SecretString:username}}"
            LoginProfile:
                Password: !Sub "{{resolve:secretsmanager:${AutomationCredentials}:SecretString:password}}"
            Policies:
              - PolicyName: root
                PolicyDocument:
                    Version: '2012-10-17'
                    Statement:
                      - Effect: Allow
                        Action:
                          - '*' # TODO: Fix. I'm sorry, I have no idea what magic the console is doing.
                        Resource: '*'
    
    AutomationCredentials:
        Type: AWS::SecretsManager::Secret
        Properties:
            Name: account-controller-automation-secret
            Description: Contains secret data for account automation
            GenerateSecretString:
                SecretStringTemplate: !Sub |
                    {
                        "username": "${AutomationUsername}",
                        "ccmonth": "${CCMonth}",
                        "ccyear": "${CCYear}",
                        "ccname": "${CCName}",
                        "ccnumber": "${CCNumber}"
                    }
                GenerateStringKey: "password"
                PasswordLength: 30
                ExcludeCharacters: '"@/\'
    
    ConnectSetup:
        Condition: AccountDeletionEnabled
        DependsOn:
          - LambdaLogGroup
          - ReceiptRuleSet
          - ConnectProperties
        Type: Custom::ConnectSetup
        Properties: 
            ServiceToken: !GetAtt LambdaFunction.Arn

    ConnectProperties:
        Type: AWS::SSM::Parameter
        Properties:
            Type: String
            Value: '{}'
            Description: Account Controller properties for Amazon Connect
    
    SSOSetup:
        Condition: AccountCreationEnabled
        DependsOn:
          - LambdaLogGroup
          - SSOProperties
        Type: Custom::SSOSetup
        Properties: 
            ServiceToken: !GetAtt LambdaFunction.Arn
            SSOManagerAppName: !Ref SSOManagerAppName
            APIGatewayEndpoint: !Sub "https://${SAMLHttpApi}.execute-api.${AWS::Region}.amazonaws.com"

    SSOProperties:
        Type: AWS::SSM::Parameter
        Properties:
            Type: String
            Value: '{}'
            Description: Account Controller properties for AWS SSO

    SAMLHttpApi:
        Condition: AccountCreationEnabled
        Type: AWS::ApiGatewayV2::Api
        Properties:
            Name: !Ref AWS::StackName
            ProtocolType: HTTP
            RouteSelectionExpression: "$request.method $request.path"
            Version: "1.0"
            CorsConfiguration:
                AllowMethods:
                  - GET
                  - POST
                  - PUT
            
Download .txt
gitextract_worhuh9j/

├── .gitignore
├── LICENSE
├── README.md
├── assets/
│   └── arch.drawio.xml
├── lambda/
│   ├── index.js
│   └── package.json
└── template.yml
Download .txt
SYMBOL INDEX (30 symbols across 1 files)

FILE: lambda/index.js
  constant AWS (line 21) | const AWS = require('aws-sdk');
  constant CAPTCHA_KEY (line 47) | const CAPTCHA_KEY = process.env.CAPTCHA_KEY;
  constant MASTER_EMAIL (line 48) | const MASTER_EMAIL = process.env.MASTER_EMAIL;
  constant ACCOUNTID (line 49) | const ACCOUNTID = process.env.ACCOUNTID;
  function retryWrapper (line 205) | async function retryWrapper(client, method, params) {
  function login (line 235) | async function login(page) {
  function createssoapp (line 267) | async function createssoapp(page, properties) {
  function deletessoapp (line 508) | async function deletessoapp(page, properties) {
  function createinstance (line 544) | async function createinstance(page, properties) {
  function open (line 590) | async function open(page, properties) {
  function deleteinstance (line 620) | async function deleteinstance(page, properties) {
  function claimnumber (line 655) | async function claimnumber(page, properties) {
  function uploadprompts (line 725) | async function uploadprompts(page, properties) {
  function createflow (line 790) | async function createflow(page, properties, prompts) {
  function loginStage1 (line 1070) | async function loginStage1(page, email) {
  function handleEmailInbound (line 1151) | async function handleEmailInbound(page, event) {
  function removeAccountFromOrg (line 1561) | async function removeAccountFromOrg(account) {
  function triggerReset (line 1611) | async function triggerReset(page, event) {
  function addSubscriptionsSCP (line 1668) | async function addSubscriptionsSCP(details) {
  function addBillingMonitor (line 1789) | async function addBillingMonitor(page, details) {
  function setSSOOwner (line 1908) | async function setSSOOwner(page, details) {
  function decodeSAMLResponse (line 2061) | async function decodeSAMLResponse(sp, idp, samlresponse) {
  function decodeForm (line 2079) | function decodeForm(form) {
  function getUserBySAML (line 2091) | async function getUserBySAML(samlresponse) {
  function handleSAMLResponse (line 2126) | async function handleSAMLResponse(event) {
  function handleGetAccounts (line 2146) | async function handleGetAccounts(event) {
  function processSnsDeleteAccount (line 2226) | async function processSnsDeleteAccount(event) {
  function handleDeleteAccountRequest (line 2250) | async function handleDeleteAccountRequest(event) {
  function handleCreateAccountRequest (line 2316) | async function handleCreateAccountRequest(event) {
  function wrapHTML (line 2648) | function wrapHTML(user) {
Condensed preview — 7 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (209K chars).
[
  {
    "path": ".gitignore",
    "chars": 32,
    "preview": "lambda/node_modules\nlambda/*.zip"
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2019 Ian Mckay\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "README.md",
    "chars": 8525,
    "preview": "# AWS Account Controller\n\n## Update March 2022: This is now largely deprecated due to the [CloseAccount](https://docs.aw"
  },
  {
    "path": "assets/arch.drawio.xml",
    "chars": 46065,
    "preview": "<mxfile host=\"app.diagrams.net\" modified=\"2020-04-10T11:34:12.133Z\" agent=\"5.0 (Macintosh; Intel Mac OS X 10_15_1) Apple"
  },
  {
    "path": "lambda/index.js",
    "chars": 116973,
    "preview": "/*\n\nCreateConnect Event:\n\n{\n  \"properties\": {\n    \"Domain\": \"9030bff7\"\n  }\n}\n\nResetEmail Event:\n\n{\n  \"email\": \"example7@"
  },
  {
    "path": "lambda/package.json",
    "chars": 961,
    "preview": "{\n  \"name\": \"aws-account-controller\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Manage the creation and deletion of sandbo"
  },
  {
    "path": "template.yml",
    "chars": 28840,
    "preview": "AWSTemplateFormatVersion: \"2010-09-09\"\n\nDescription: Account Controller Solution\n\nParameters:\n\n    ControlTowerMode:\n   "
  }
]

About this extraction

This page contains the full source code of the iann0036/aws-account-controller GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 7 files (197.7 KB), approximately 65.2k tokens, and a symbol index with 30 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!