├── .aws └── task-definition.json ├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.develop ├── LICENSE.md ├── README.md ├── app ├── lib │ ├── app.js │ ├── handlers.js │ ├── logger.js │ ├── model.js │ └── utilities.js ├── package-lock.json ├── package.json ├── public │ ├── client.js │ └── style.css ├── server.js ├── tests │ └── app.test.js └── views │ └── index.html ├── docs ├── ARCHITECTURE.md ├── ARCHITECTURE.png ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── functions ├── .aws │ ├── dynamodb-messages-table.json │ ├── lambda-assume-role-policy.json │ └── lambda-full-access-policy.json ├── Pipfile ├── Pipfile.lock ├── README.md ├── message_add.py ├── messages_received_list.py ├── messages_sent_list.py └── script │ ├── bootstrap.sh │ ├── exec_lambda │ ├── pack_lambda │ ├── push_lambda │ └── rm_lambda └── script └── server.sh /.aws/task-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerDefinitions": [ 3 | { 4 | "name": "octochat", 5 | "image": "octochat/octochat:latest", 6 | "portMappings": [ 7 | { 8 | "containerPort": 8000, 9 | "hostPort": 8000 10 | } 11 | ], 12 | "secrets": [ 13 | { 14 | "name": "APP_ID", 15 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:APP_ID-6uYUVW" 16 | }, 17 | { 18 | "name": "CLIENT_ID", 19 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:CLIENT_ID-MGBFRe" 20 | }, 21 | { 22 | "name": "CLIENT_SECRET", 23 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:CLIENT_SECRET-pcSceA" 24 | }, 25 | { 26 | "name": "REDIRECT_URI", 27 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:REDIRECT_URI-8tnZqE" 28 | }, 29 | { 30 | "name": "SESSION_STORE_SECRET", 31 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:SESSION_STORE_SECRET-xEZANx" 32 | }, 33 | { 34 | "name": "AWS_ACCESS_KEY_ID", 35 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:AWS_ACCESS_KEY_ID-0x8tQQ" 36 | }, 37 | { 38 | "name": "AWS_SECRET_ACCESS_KEY", 39 | "valueFrom": "arn:aws:secretsmanager:eu-west-1:953721827634:secret:AWS_SECRET_ACCESS_KEY-QHRftL" 40 | } 41 | ], 42 | "environment": [ 43 | { 44 | "name": "TITLE", 45 | "value": "Octochat" 46 | }, 47 | { 48 | "name": "AWS_REGION", 49 | "value": "eu-west-1" 50 | } 51 | ], 52 | "logConfiguration": { 53 | "logDriver": "awslogs", 54 | "options": { 55 | "awslogs-group": "awslogs-octochat-app", 56 | "awslogs-region": "eu-west-1", 57 | "awslogs-stream-prefix": "awslogs-octochat" 58 | } 59 | } 60 | } 61 | ], 62 | "executionRoleArn": "ecsTaskExecutionRole", 63 | "family": "octochat", 64 | "requiresCompatibilities": [ 65 | "FARGATE" 66 | ], 67 | "networkMode": "awsvpc", 68 | "cpu": "256", 69 | "memory": "1024" 70 | } 71 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Environment Config 2 | 3 | APP_ID= 4 | CLIENT_ID= 5 | CLIENT_SECRET= 6 | REDIRECT_URI= 7 | AWS_ACCESS_KEY_ID= 8 | AWS_SECRET_ACCESS_KEY= 9 | AWS_REGION= 10 | SESSION_STORE_SECRET= 11 | TITLE= 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [8.x, 10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, lint, and test 21 | run: | 22 | npm ci 23 | npm run lint 24 | npm audit 25 | npm test 26 | env: 27 | CI: true 28 | working-directory: app 29 | 30 | build: 31 | needs: test 32 | runs-on: [ubuntu-latest] 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Build Docker image 36 | env: 37 | IMAGE_TAG: ${{ github.sha }} 38 | run: | 39 | docker build -t "$GITHUB_REPOSITORY:$IMAGE_TAG" . 40 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build and push a new container image to Amazon ECR, 2 | # and then will deploy a new task definition to Amazon ECS, on every push 3 | # to the master branch. 4 | # 5 | # To use this workflow, you will need to complete the following set-up steps: 6 | # 7 | # 1. Create an ECR repository to store your images. 8 | # For example: `aws ecr create-repository --repository-name my-ecr-repo --region us-east-2`. 9 | # Replace the value of `ECR_REPOSITORY` in the workflow below with your repository's name. 10 | # Replace the value of `aws-region` in the workflow below with your repository's region. 11 | # 12 | # 2. Create an ECS task definition, an ECS cluster, and an ECS service. 13 | # For example, follow the Getting Started guide on the ECS console: 14 | # https://us-east-2.console.aws.amazon.com/ecs/home?region=us-east-2#/firstRun 15 | # Replace the values for `service` and `cluster` in the workflow below with your service and cluster names. 16 | # 17 | # 3. Store your ECS task definition as a JSON file in your repository. 18 | # The format should follow the output of `aws ecs register-task-definition --generate-cli-skeleton`. 19 | # Replace the value of `task-definition` in the workflow below with your JSON file's name. 20 | # Replace the value of `container-name` in the workflow below with the name of the container 21 | # in the `containerDefinitions` section of the task definition. 22 | # 23 | # 4. Store an IAM user access key in GitHub Actions secrets named `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. 24 | # See the documentation for each action used below for the recommended IAM policies for this IAM user, 25 | # and best practices on handling the access key credentials. 26 | 27 | on: 28 | push: 29 | branches: 30 | - master 31 | 32 | name: Deploy to Amazon ECS 33 | 34 | jobs: 35 | deploy: 36 | name: Deploy 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v1 42 | 43 | - name: Configure AWS credentials 44 | uses: aws-actions/configure-aws-credentials@v1 45 | with: 46 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 47 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 48 | aws-region: eu-west-1 49 | 50 | - name: Login to Amazon ECR 51 | id: login-ecr 52 | uses: aws-actions/amazon-ecr-login@v1 53 | 54 | - name: Build, tag, and push image to Amazon ECR 55 | id: build-image 56 | env: 57 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 58 | ECR_REPOSITORY: octochat 59 | IMAGE_TAG: ${{ github.sha }} 60 | run: | 61 | # Build a docker container and 62 | # push it to ECR so that it can 63 | # be deployed to ECS. 64 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 65 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 66 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" 67 | 68 | - name: Fill in the new image ID in the Amazon ECS task definition 69 | id: task-def 70 | uses: aws-actions/amazon-ecs-render-task-definition@v1 71 | with: 72 | task-definition: .aws/task-definition.json 73 | container-name: octochat 74 | image: ${{ steps.build-image.outputs.image }} 75 | 76 | - name: Deploy Amazon ECS task definition 77 | uses: aws-actions/amazon-ecs-deploy-task-definition@v1 78 | with: 79 | task-definition: ${{ steps.task-def.outputs.task-definition }} 80 | service: octochat-alb 81 | cluster: octochat 82 | wait-for-service-stability: true 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Lambda functions 107 | package.zip 108 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | 3 | RUN apt-get update -y && apt-get upgrade -y 4 | 5 | RUN mkdir -p /app/.data 6 | WORKDIR /app 7 | 8 | COPY ./app/package*.json ./ 9 | RUN npm ci --only=production 10 | 11 | # Bundle app source 12 | COPY app . 13 | 14 | EXPOSE 8000 15 | ENTRYPOINT ["node", "/app/server.js"] 16 | -------------------------------------------------------------------------------- /Dockerfile.develop: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | 3 | RUN apt-get update -y && apt-get upgrade -y 4 | 5 | RUN mkdir -p /app/.data 6 | WORKDIR /app 7 | 8 | COPY ./app/package*.json ./ 9 | RUN npm install 10 | 11 | # Bundle app source 12 | COPY app . 13 | 14 | EXPOSE 8000 15 | ENTRYPOINT ["npx", "nodemon", "/app/server.js"] 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

:smile_cat:octochat

2 | 3 |
Privately message with your GitHub followers
4 | 5 |

6 | GitHub Actions status 7 |

8 | 9 | ## Usage 10 | 11 | example-octochat 12 | 13 | [Login with your GitHub account](https://octochat.dev/login) to start messaging privately with your GitHub followers. 14 | 15 | ## About 16 | 17 | This app is intended _for demonstration use only_, and is deployed using [Amazon Web Services](https://aws.amazon.com/). The architecture is explained at a high-level [here](docs/ARCHITECTURE.md). 18 | 19 | ## Development 20 | 21 | Create a `.env` file based on [the included example template](.env.example). 22 | 23 | Build a development image from [`Dockerfile.develop`](Dockerfile.develop): 24 | 25 | ```shell 26 | docker build -t octochat -f Dockerfile.develop . 27 | ``` 28 | 29 | Use the included [`server.sh`](script/server.sh) script to run the development server in a container: 30 | 31 | ```shell 32 | ./script/server.sh 33 | ``` 34 | 35 | The application will be available at [http://localhost:49160/](http://localhost:49160/). 36 | 37 | ## Contributions 38 | 39 | We welcome contributions! See how to [contribute](docs/CONTRIBUTING.md). 40 | 41 | ## License 42 | 43 | [MIT](LICENSE.md) 44 | -------------------------------------------------------------------------------- /app/lib/app.js: -------------------------------------------------------------------------------- 1 | // for web framework 2 | const express = require('express'); 3 | const app = express(); 4 | 5 | // nunjucks for template rendering 6 | // https://mozilla.github.io/nunjucks/getting-started.html 7 | const nunjucks = require('nunjucks'); 8 | nunjucks.configure('/app/views', { 9 | express: app, 10 | autoescape: true, 11 | noCache: true 12 | }); 13 | app.set('view engine', 'html'); 14 | 15 | // http://expressjs.com/en/starter/static-files.html 16 | app.use(express.static('public')); 17 | 18 | app.use(express.urlencoded({ 19 | extended: false 20 | })); 21 | 22 | // init our file-based session storage 23 | const session = require('express-session'); 24 | const FileStore = require('session-file-store')(session); 25 | app.use( 26 | session({ 27 | store: new FileStore({ 28 | path: '/app/.data', 29 | ttl: 86400 30 | }), 31 | resave: false, 32 | saveUninitialized: true, 33 | secret: process.env.SESSION_STORE_SECRET 34 | }) 35 | ); 36 | 37 | module.exports = { 38 | app 39 | }; 40 | -------------------------------------------------------------------------------- /app/lib/handlers.js: -------------------------------------------------------------------------------- 1 | // data model 2 | const model = require('./model'); 3 | const { body, validationResult } = require('express-validator'); 4 | const logger = require('../lib/logger'); 5 | 6 | // for GitHub API 7 | const Octokit = require('@octokit/rest'); 8 | 9 | // for OAuth authorization "state" generation 10 | const crypto = require('crypto'); 11 | 12 | // for HTTP request 13 | const axios = require('axios'); 14 | // (...we want JSON by default) 15 | axios.defaults.headers.common.Accept = 'application/json'; 16 | 17 | // server-side validation rules for incoming new message form 18 | module.exports.validateMessage = () => { 19 | return [ 20 | body('message') 21 | .not().isEmpty() 22 | .trim() 23 | .escape() 24 | ]; 25 | }; 26 | 27 | module.exports.getRoot = async (request, response) => { 28 | if (request.session.token) { 29 | const messages = await model.messagesReceivedList(request.session.viewData.user.id); 30 | 31 | // expose various parameters to template via viewData 32 | request.session.viewData.messages = messages; 33 | request.session.viewData.selectedBox = 'Inbox'; 34 | // empty out any conversation data 35 | delete request.session.viewData.messages; 36 | delete request.session.viewData.conversation; 37 | delete request.session.viewData.selectedFollower; 38 | } 39 | 40 | // render and send the page 41 | response.render('index', { 42 | title: process.env.TITLE, 43 | ...request.session.viewData 44 | }); 45 | }; 46 | 47 | module.exports.getConversation = async (request, response) => { 48 | if (request.session.token) { 49 | const userId = request.params.userId; // github user id 50 | const messages = await model.messagesBetween(request.session.viewData.user.id, userId); 51 | 52 | // expose authenticated user to template via viewData 53 | request.session.viewData.messages = messages; 54 | request.session.viewData.conversation = userId; 55 | request.session.viewData.selectedFollower = request.session.viewData.followers.find(follower => parseInt(follower.id, 10) === parseInt(userId, 10)); 56 | 57 | // render and send the page 58 | response.render('index', { 59 | title: process.env.TITLE, 60 | ...request.session.viewData 61 | }); 62 | } else { 63 | return response.redirect('/'); 64 | } 65 | }; 66 | 67 | module.exports.getSent = async (request, response) => { 68 | if (request.session.token) { 69 | const messages = await model.messagesSentList(request.session.viewData.user.id); 70 | 71 | request.session.viewData.messages = messages; 72 | request.session.viewData.selectedBox = 'Sent'; 73 | 74 | // render and send the page 75 | response.render('index', { 76 | title: process.env.TITLE, 77 | ...request.session.viewData 78 | }); 79 | } else { 80 | return response.redirect('/'); 81 | } 82 | }; 83 | 84 | module.exports.postMessage = async (request, response) => { 85 | const message = request.body.message; 86 | 87 | if (validationResult(request).array().length) { 88 | // error 89 | response.redirect('/'); 90 | } else { 91 | await model.messageAdd({ 92 | toId: request.session.viewData.selectedFollower.id, 93 | to: request.session.viewData.selectedFollower.login, 94 | fromId: request.session.viewData.user.id, 95 | from: request.session.viewData.user.login, 96 | message 97 | }); 98 | 99 | response.redirect(`/messages/${request.session.viewData.selectedFollower.id}`); 100 | } 101 | }; 102 | 103 | module.exports.login = async (request, response) => { 104 | // generate a random state 105 | const state = crypto 106 | .createHmac('sha1', process.env.CLIENT_SECRET) 107 | .update(Math.random().toString()) 108 | .digest('hex') 109 | .substring(0, 8); 110 | 111 | return response.redirect( 112 | `https://github.com/login/oauth/authorize?client_id=${process.env.CLIENT_ID}&state=${state}` 113 | ); 114 | }; 115 | 116 | module.exports.logout = async (request, response) => { 117 | // delete the token and viewData 118 | delete request.session.token; 119 | delete request.session.viewData; 120 | 121 | // go home 122 | return response.redirect('/'); 123 | }; 124 | 125 | // for completing OAuth authorization flow 126 | 127 | module.exports.completeOauth = async (request, response) => { 128 | const code = request.query.code; 129 | const state = request.query.state; 130 | 131 | if (code) { 132 | // exchange for token 133 | // per, https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#2-users-are-redirected-back-to-your-site-by-github 134 | const token = await axios.post( 135 | 'https://github.com/login/oauth/access_token', 136 | { 137 | client_id: process.env.CLIENT_ID, 138 | client_secret: process.env.CLIENT_SECRET, 139 | code: code, 140 | state: state, 141 | redirect_uri: process.env.REDIRECT_URI 142 | } 143 | ); 144 | 145 | // preserve token in session storage 146 | request.session.viewData = {}; 147 | request.session.token = token.data.access_token; 148 | 149 | const github = new Octokit({ 150 | auth: request.session.token 151 | }); 152 | 153 | // get followers of authenticated user 154 | // paginated to 30 by default; this grabs them all per https://octokit.github.io/rest.js/#pagination 155 | const followersPage = github.users.listFollowersForAuthenticatedUser.endpoint.merge({ per_page: 100 }); 156 | const followers = (await github.paginate(followersPage)); 157 | // get authenticated user 158 | const currentUser = await github.users.getAuthenticated(); 159 | 160 | logger.info(`Retrieved ${followers.length} followers.`); 161 | 162 | // sort followers by login, alphabetically, ignoring case 163 | followers 164 | .sort((a, b) => a.login.toLowerCase().localeCompare(b.login.toLowerCase())); 165 | logger.debug(`Followers: ${JSON.stringify(followers.map(f => f.login), null, 4)}`); 166 | 167 | request.session.viewData.followers = followers; 168 | request.session.viewData.user = currentUser.data; 169 | 170 | // redirect home 171 | return response.redirect('/'); 172 | } 173 | 174 | // not found 175 | response.status(404).send('Not found'); 176 | }; 177 | 178 | // for alb health check 179 | module.exports.healthCheck = async (request, response) => { 180 | response.status(200).send('OK'); 181 | }; 182 | -------------------------------------------------------------------------------- /app/lib/logger.js: -------------------------------------------------------------------------------- 1 | const Logger = require('bunyan'); 2 | const bunyanFormat = require('bunyan-format'); 3 | const { name } = require('../package'); 4 | 5 | const logger = new Logger({ 6 | name, 7 | level: process.env.LOG_LEVEL || 'info', 8 | stream: bunyanFormat({ outputMode: process.env.LOG_FORMAT || 'short' }, process.stderr) 9 | }); 10 | 11 | module.exports = logger; 12 | -------------------------------------------------------------------------------- /app/lib/model.js: -------------------------------------------------------------------------------- 1 | // https://aws.amazon.com/sdk-for-node-js/ 2 | const aws = require('aws-sdk'); 3 | const lambda = new aws.Lambda(); 4 | const utilities = require('./utilities'); 5 | 6 | // Callback to Promise wrapper of lambda.invoke 7 | const invokeLambda = async (params) => { 8 | // Convert callback to promise for easier handling 9 | return new Promise((resolve) => { 10 | // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#invoke-property 11 | lambda.invoke(params, (err, data) => { 12 | resolve({ err, data }); 13 | }); 14 | }); 15 | }; 16 | 17 | // send a message 18 | const messageAdd = async (msgObj) => { 19 | const params = { 20 | FunctionName: 'message_add', 21 | Payload: `{"toId": ${msgObj.toId}, "to": "${msgObj.to}", "fromId": ${msgObj.fromId}, "from": "${msgObj.from}", "message": "${msgObj.message}"}` 22 | }; 23 | 24 | return invokeLambda(params); 25 | }; 26 | 27 | // list the 50 most recently received messages 28 | const messagesReceivedList = async (toId) => { 29 | const params = { 30 | FunctionName: 'messages_received_list', 31 | Payload: `{ 32 | "toId": ${toId} 33 | }` 34 | }; 35 | 36 | return utilities.cleanMessages(await invokeLambda(params)); 37 | }; 38 | 39 | // list the 50 most recently sent messages 40 | const messagesSentList = async (fromId) => { 41 | const params = { 42 | FunctionName: 'messages_sent_list', 43 | Payload: `{ 44 | "fromId": ${fromId} 45 | }` 46 | }; 47 | 48 | return utilities.cleanMessages(await invokeLambda(params)); 49 | }; 50 | 51 | // get messages between two user ids 52 | const messagesBetween = async (user1, user2) => { 53 | // gather messages from user2 sent to user1 54 | const received = (await messagesReceivedList(user1)).filter(msg => parseInt(msg.fromId, 10) === parseInt(user2, 10)); 55 | // gather messages to user2 sent by user1 56 | const sent = (await messagesSentList(user1)).filter(msg => parseInt(msg.toId, 10) === parseInt(user2, 10)); 57 | // combine into one array and sort by received date 58 | return [].concat(received, sent).sort((a, b) => new Date(a.receivedAt) - new Date(b.receivedAt)); 59 | }; 60 | 61 | module.exports = { 62 | messageAdd, 63 | messagesReceivedList, 64 | messagesSentList, 65 | messagesBetween 66 | }; 67 | -------------------------------------------------------------------------------- /app/lib/utilities.js: -------------------------------------------------------------------------------- 1 | const cleanMessages = (msgs) => { 2 | const messages = JSON.parse(msgs.data.Payload); 3 | return messages.data.map((message) => { 4 | return { 5 | ...message, 6 | receivedAt: (new Date(message.receivedAt * 1000)).toLocaleString() 7 | }; 8 | }); 9 | }; 10 | 11 | module.exports = { 12 | cleanMessages 13 | }; 14 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "octochat", 3 | "author": { 4 | "name": "GitHub" 5 | }, 6 | "version": "0.0.1", 7 | "description": "A DEMO GitHub App to chat among octocats", 8 | "main": "server.js", 9 | "scripts": { 10 | "start": "node server.js", 11 | "test": "jest --coverage", 12 | "lint": "semistandard" 13 | }, 14 | "dependencies": { 15 | "@octokit/rest": "^16.2.0", 16 | "aws-sdk": "^2.577.0", 17 | "axios": "^0.18.0", 18 | "bunyan": "^1.8.12", 19 | "bunyan-format": "^0.2.1", 20 | "express": "^4.16.4", 21 | "express-session": "^1.17.0", 22 | "express-validator": "^6.2.0", 23 | "nunjucks": "^3.2.0", 24 | "session-file-store": "^1.3.1" 25 | }, 26 | "devDependencies": { 27 | "jest": "^24.9.0", 28 | "jest-express": "^1.10.1", 29 | "nodemon": "^2.0.1", 30 | "semistandard": "^14.2.0" 31 | }, 32 | "engines": { 33 | "node": "12.x" 34 | }, 35 | "repository": { 36 | "url": "https://github.com/github-developer/octochat-aws" 37 | }, 38 | "license": "MIT", 39 | "keywords": [ 40 | "node", 41 | "express" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /app/public/client.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function () { 2 | console.log('👋'); 3 | }); 4 | -------------------------------------------------------------------------------- /app/public/style.css: -------------------------------------------------------------------------------- 1 | /* https://leaverou.github.io/bubbly/ */ 2 | .Box-body { 3 | padding: 0; 4 | border-bottom: 0px; 5 | } 6 | .Box-footer { 7 | border-top: 0px; 8 | } 9 | .message { 10 | position: relative; 11 | margin: 10px 36px; 12 | background: #c0c0c0; 13 | border-radius: 0.4em; 14 | max-width: 25%; 15 | padding: 5px 10px; 16 | color: #fff; 17 | font-size: 1.25em; 18 | clear: both; 19 | } 20 | 21 | .message.from { 22 | text-align: left; 23 | float: left; 24 | clear: both; 25 | } 26 | 27 | .message.from:after { 28 | content: ""; 29 | position: absolute; 30 | left: 0; 31 | top: 60%; 32 | width: 0; 33 | height: 0; 34 | border: 20px solid transparent; 35 | border-right-color: #c0c0c0; 36 | border-left: 0; 37 | border-bottom: 0; 38 | margin-top: -10px; 39 | margin-left: -20px; 40 | } 41 | 42 | .message.to { 43 | background: #0366d6; 44 | text-align: right; 45 | float: right; 46 | clear: both; 47 | } 48 | 49 | .message.to:after { 50 | content: ""; 51 | position: absolute; 52 | right: 0; 53 | top: 50%; 54 | width: 0; 55 | height: 0; 56 | border: 20px solid transparent; 57 | border-left-color: #0366d6; 58 | border-right: 0; 59 | border-bottom: 0; 60 | margin-top: -10px; 61 | margin-right: -20px; 62 | } 63 | 64 | .inbox-message{ 65 | margin: 16px; 66 | padding: 5px; 67 | border: 1px solid #6a737d; 68 | } 69 | .inbox-message .heading{ 70 | font-weight: bold; 71 | } 72 | -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | const { app } = require('./lib/app'); 2 | const handlers = require('./lib/handlers'); 3 | const logger = require('./lib/logger'); 4 | 5 | // set up handlers for each route 6 | app.get('/', handlers.getRoot); 7 | app.get('/messages/:userId', handlers.getConversation); 8 | app.get('/sent', handlers.getSent); 9 | app.get('/login', handlers.login); 10 | app.get('/logout', handlers.logout); 11 | app.get('/oauth', handlers.completeOauth); 12 | app.get('/health-check', handlers.healthCheck); 13 | app.post('/messages', handlers.validateMessage(), handlers.postMessage); 14 | 15 | // listen for requests :) 16 | const listener = app.listen(process.env.PORT || 8000, process.env.HOST || '0.0.0.0', () => { 17 | logger.info(`Your app is listening on port ${listener.address().port}`); 18 | }); 19 | -------------------------------------------------------------------------------- /app/tests/app.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, afterEach, beforeEach, test, jest, expect */ 2 | const handlers = require('../lib/handlers'); 3 | const Request = require('jest-express/lib/request').Request; 4 | const Response = require('jest-express/lib/response').Response; 5 | const Octokit = require('@octokit/rest'); 6 | 7 | jest.mock('@octokit/rest'); 8 | jest.mock('../lib/model'); 9 | 10 | let request, response; 11 | 12 | describe('App server', () => { 13 | let getAuthenticated; 14 | let paginate; 15 | 16 | beforeEach(() => { 17 | getAuthenticated = jest.fn().mockReturnValueOnce({ 18 | data: { 19 | id: 123, 20 | login: 'abc' 21 | } 22 | }); 23 | 24 | paginate = jest.fn().mockImplementationOnce(async () => { 25 | return []; 26 | }); 27 | 28 | const octokit = { 29 | users: { 30 | getAuthenticated, 31 | listFollowersForAuthenticatedUser: { 32 | endpoint: { 33 | merge: jest.fn() 34 | } 35 | } 36 | }, 37 | paginate 38 | }; 39 | 40 | Octokit.mockImplementationOnce(() => octokit); 41 | }); 42 | 43 | afterEach(() => { 44 | request.resetMocked(); 45 | response.resetMocked(); 46 | }); 47 | 48 | test('getRoot', async () => { 49 | request = new Request('/', { 50 | headers: { 51 | Accept: 'text/html' 52 | } 53 | }); 54 | 55 | request.session = { 56 | token: 'abc', 57 | viewData: { 58 | user: { 59 | id: 123456789 60 | } 61 | } 62 | }; 63 | 64 | response = new Response(); 65 | 66 | await handlers.getRoot(request, response); 67 | // no error 68 | expect(response.render).toBeCalled(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /app/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% macro is_from(senderId) %} 21 | {% if user.login == senderId %} 22 | to 23 | {% else %} 24 | from 25 | {% endif %} 26 | {% endmacro %} 27 | 28 |
29 | 30 |
31 | 32 |

33 | {% if user %} 34 | 35 | 👋 Welcome, @{{ user.login }} 36 | 37 | {% else %} 38 | 39 | 😺 Octochat 40 | 41 | {% endif %} 42 |

43 | 44 |
45 | 46 |
47 | 48 | {% if user %} 49 | 50 |
51 | 52 |
53 | 54 | {% else %} 55 | 56 |
57 | 58 |
59 | 60 | {% endif %} 61 |
62 |
63 | 64 | {% if user %} 65 | 66 |
67 | 68 |
69 |
70 | Messages with 71 | 84 |
85 | {% if conversation %} 86 |
87 | 88 | {% for message in messages %} 89 | 90 |
91 | {{ message.message }} 92 |
93 | 94 | {% endfor %} 95 |
96 | 110 | {% endif %} 111 |
112 | 113 |
114 | 115 | {% endif %} 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # High-level architecture 2 | 3 | ![](ARCHITECTURE.png) 4 | 5 | For more information about these services, please see: 6 | 7 | 1. [Amazon ECS - Run containerized applications in production](https://aws.amazon.com/ecs/) 8 | 1. [AWS Lambda – Serverless compute](https://aws.amazon.com/lambda/) 9 | 1. [Amazon DynamoDB - Fast and flexible NoSQL database service for any scale](https://aws.amazon.com/dynamodb/) 10 | 1. [Application Load Balancers](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html) 11 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-developer/octochat-aws/1c563129f6bc81c2494d0ac4fd57d92ec83534e0/docs/ARCHITECTURE.png -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github-developer/octochat-aws/fork 4 | [pr]: https://github.com/github-developer/octochat-aws/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](../LICENSE.md). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 12 | 13 | ## Submitting a pull request 14 | 15 | 0. [Fork][fork] and clone the repository 16 | 0. Follow the [development guidance in the README](../README.md#Development) 17 | 0. Make your change, add tests, and make sure the tests still pass 18 | 0. Ensure the linter is run and passes 19 | 0. Push to your fork and [submit a pull request][pr] 20 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged 21 | 22 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 23 | 24 | - Write tests. 25 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 26 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 27 | 28 | ## Resources 29 | 30 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 31 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 32 | - [GitHub Help](https://help.github.com) -------------------------------------------------------------------------------- /functions/.aws/dynamodb-messages-table.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "AttributeDefinitions": [ 4 | { 5 | "AttributeName": "fromId", 6 | "AttributeType": "N" 7 | }, 8 | { 9 | "AttributeName": "receivedAt", 10 | "AttributeType": "N" 11 | }, 12 | { 13 | "AttributeName": "toId", 14 | "AttributeType": "N" 15 | } 16 | ], 17 | "TableName": "Messages", 18 | "KeySchema": [ 19 | { 20 | "AttributeName": "toId", 21 | "KeyType": "HASH" 22 | }, 23 | { 24 | "AttributeName": "receivedAt", 25 | "KeyType": "RANGE" 26 | } 27 | ], 28 | "ProvisionedThroughput": { 29 | "ReadCapacityUnits": 1, 30 | "WriteCapacityUnits": 1 31 | }, 32 | "GlobalSecondaryIndexes": [ 33 | { 34 | "IndexName": "SentMessages", 35 | "KeySchema": [ 36 | { 37 | "AttributeName": "fromId", 38 | "KeyType": "HASH" 39 | }, 40 | { 41 | "AttributeName": "receivedAt", 42 | "KeyType": "RANGE" 43 | } 44 | ], 45 | "Projection": { 46 | "ProjectionType": "ALL" 47 | }, 48 | "ProvisionedThroughput": { 49 | "ReadCapacityUnits": 1, 50 | "WriteCapacityUnits": 1 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /functions/.aws/lambda-assume-role-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Action": "sts:AssumeRole", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "Service": "lambda.amazonaws.com" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /functions/.aws/lambda-full-access-policy.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "Version": "2012-10-17", 4 | "Statement": [ 5 | { 6 | "Effect": "Allow", 7 | "Action": [ 8 | "cloudformation:DescribeChangeSet", 9 | "cloudformation:DescribeStackResources", 10 | "cloudformation:DescribeStacks", 11 | "cloudformation:GetTemplate", 12 | "cloudformation:ListStackResources", 13 | "cloudwatch:*", 14 | "cognito-identity:ListIdentityPools", 15 | "cognito-sync:GetCognitoEvents", 16 | "cognito-sync:SetCognitoEvents", 17 | "dynamodb:*", 18 | "ec2:DescribeSecurityGroups", 19 | "ec2:DescribeSubnets", 20 | "ec2:DescribeVpcs", 21 | "events:*", 22 | "iam:GetPolicy", 23 | "iam:GetPolicyVersion", 24 | "iam:GetRole", 25 | "iam:GetRolePolicy", 26 | "iam:ListAttachedRolePolicies", 27 | "iam:ListRolePolicies", 28 | "iam:ListRoles", 29 | "iam:PassRole", 30 | "iot:AttachPrincipalPolicy", 31 | "iot:AttachThingPrincipal", 32 | "iot:CreateKeysAndCertificate", 33 | "iot:CreatePolicy", 34 | "iot:CreateThing", 35 | "iot:CreateTopicRule", 36 | "iot:DescribeEndpoint", 37 | "iot:GetTopicRule", 38 | "iot:ListPolicies", 39 | "iot:ListThings", 40 | "iot:ListTopicRules", 41 | "iot:ReplaceTopicRule", 42 | "kinesis:DescribeStream", 43 | "kinesis:ListStreams", 44 | "kinesis:PutRecord", 45 | "kms:ListAliases", 46 | "lambda:*", 47 | "logs:*", 48 | "s3:*", 49 | "sns:ListSubscriptions", 50 | "sns:ListSubscriptionsByTopic", 51 | "sns:ListTopics", 52 | "sns:Publish", 53 | "sns:Subscribe", 54 | "sns:Unsubscribe", 55 | "sqs:ListQueues", 56 | "sqs:SendMessage", 57 | "tag:GetResources", 58 | "xray:PutTelemetryRecords", 59 | "xray:PutTraceSegments" 60 | ], 61 | "Resource": "*" 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /functions/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | boto3 = "*" 10 | requests = "*" 11 | 12 | [requires] 13 | python_version = "3.7" 14 | -------------------------------------------------------------------------------- /functions/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "596361ed83fe337020b90c903e784f6e3531ccc66e4d833b8cf452a28df262c8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "boto3": { 20 | "hashes": [ 21 | "sha256:5e2c41f7c565da816775724ef956cdfa0e3376e39c05b1d5a04796a026521cdf", 22 | "sha256:81fb7d1a81294e3b8e3da78dafb56ae14c35cf7cec60b90cf3f0c07dd866f7f9" 23 | ], 24 | "index": "pypi", 25 | "version": "==1.10.27" 26 | }, 27 | "botocore": { 28 | "hashes": [ 29 | "sha256:d2031c59a3580c6f928d17d334a8be72b3615ef754288b8fa226c4d53feba421", 30 | "sha256:fa295ab936b424c716d1188f5aa1ea2ec57648d6ec417b42adf02b2d16f57778" 31 | ], 32 | "version": "==1.13.27" 33 | }, 34 | "certifi": { 35 | "hashes": [ 36 | "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", 37 | "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" 38 | ], 39 | "version": "==2019.9.11" 40 | }, 41 | "chardet": { 42 | "hashes": [ 43 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 44 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 45 | ], 46 | "version": "==3.0.4" 47 | }, 48 | "docutils": { 49 | "hashes": [ 50 | "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", 51 | "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", 52 | "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" 53 | ], 54 | "version": "==0.15.2" 55 | }, 56 | "idna": { 57 | "hashes": [ 58 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 59 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 60 | ], 61 | "version": "==2.8" 62 | }, 63 | "jmespath": { 64 | "hashes": [ 65 | "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", 66 | "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c" 67 | ], 68 | "version": "==0.9.4" 69 | }, 70 | "python-dateutil": { 71 | "hashes": [ 72 | "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", 73 | "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" 74 | ], 75 | "markers": "python_version >= '2.7'", 76 | "version": "==2.8.0" 77 | }, 78 | "requests": { 79 | "hashes": [ 80 | "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", 81 | "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" 82 | ], 83 | "index": "pypi", 84 | "version": "==2.22.0" 85 | }, 86 | "s3transfer": { 87 | "hashes": [ 88 | "sha256:6efc926738a3cd576c2a79725fed9afde92378aa5c6a957e3af010cb019fac9d", 89 | "sha256:b780f2411b824cb541dbcd2c713d0cb61c7d1bcadae204cdddda2b35cef493ba" 90 | ], 91 | "version": "==0.2.1" 92 | }, 93 | "six": { 94 | "hashes": [ 95 | "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", 96 | "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" 97 | ], 98 | "version": "==1.13.0" 99 | }, 100 | "urllib3": { 101 | "hashes": [ 102 | "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", 103 | "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" 104 | ], 105 | "markers": "python_version >= '3.4'", 106 | "version": "==1.25.7" 107 | } 108 | }, 109 | "develop": {} 110 | } 111 | -------------------------------------------------------------------------------- /functions/README.md: -------------------------------------------------------------------------------- 1 | # `octochat-functions` 2 | 3 | AWS Lambda functions for octochat. 4 | 5 | ## Installation 6 | 7 | 1. Sign up for AWS Lambda 8 | 1. Install and configure the `aws` command-line client 9 | 1. Run `script/bootstrap` 10 | 11 | ### Sign up for AWS Lambda 12 | 13 | Sign up for AWS [**here**](https://aws.amazon.com/). 14 | 15 | The Lambda free tier includes 1M free requests per month and 400,000 GB-seconds of compute time per month. 16 | 17 | ### Install and configure the `aws` command-line client 18 | 19 | To install the `aws` command-line client use `pip`: 20 | 21 | ``` 22 | pip install awscli --upgrade --user 23 | ``` 24 | 25 | To configure `aws`, follow these [**quick configuration steps**](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-quick-configuration). 26 | 27 | Once configured, you should see `config` and `credentials` files in `~/.aws`. 28 | 29 | ### Run `script/bootstrap` 30 | 31 | ```bash 32 | script/bootstrap 33 | script/pack_lambda . 34 | script/push_lambda message_add package.zip 35 | script/push_lambda messages_received_list package.zip 36 | script/push_lambda messages_sent_list package.zip 37 | ``` 38 | 39 | This will: 40 | 41 | 1. Ensure the Lambda function role is created, with the correct policy attached 42 | 1. Create the DynamoDB table 43 | 1. Package the Lambda function and all its dependencies 44 | 1. Create the Lambda functions, `message_add`, `messages_received_list`, and `messages_sent_list` on AWS 45 | 46 | ## Usage 47 | 48 | To send a message: 49 | 50 | ```bash 51 | script/exec_lambda message_add '{"toId": 2993937, "to": "imjohnbo", "fromId": 27806, "from": "swinton", "message": "Hello, John, how are you?"}' 52 | ``` 53 | 54 | To list the 50 most recently received messages: 55 | 56 | ```bash 57 | script/exec_lambda messages_received_list '{"toId": 2993937}' 58 | ``` 59 | 60 | To list the 50 most recently sent messages: 61 | 62 | ```bash 63 | script/exec_lambda messages_sent_list '{"fromId": 27806}' 64 | ``` 65 | -------------------------------------------------------------------------------- /functions/message_add.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys, os 5 | import datetime 6 | import json 7 | 8 | import boto3 9 | 10 | from decimal import Decimal 11 | 12 | 13 | def handler(event, context): 14 | dynamodb = boto3.resource("dynamodb", region_name="eu-west-1") 15 | table = dynamodb.Table("Messages") 16 | 17 | now = datetime.datetime.utcnow() 18 | timestamp = Decimal((now.replace(microsecond=0) - \ 19 | datetime.datetime(1970, 1, 1)).total_seconds()) 20 | 21 | table.put_item( 22 | Item={ 23 | "toId": event["toId"], 24 | "to": event["to"], 25 | "fromId": event["fromId"], 26 | "from": event["from"], 27 | "receivedAt": timestamp, 28 | "message": event["message"], 29 | } 30 | ) 31 | 32 | return True 33 | 34 | 35 | if __name__ == "__main__": 36 | # Read event, context from sys.argv 37 | args = [json.loads(arg) for arg in sys.argv[1:2]] 38 | 39 | # Provide None for event, context if not provided 40 | while len(args) < 2: 41 | args.append(None) 42 | 43 | # Print the output 44 | print(handler(*args)) 45 | -------------------------------------------------------------------------------- /functions/messages_received_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys, os 5 | import datetime 6 | import json 7 | 8 | import boto3 9 | from boto3.dynamodb.conditions import Key 10 | 11 | from decimal import Decimal 12 | 13 | 14 | def handler(event, context): 15 | dynamodb = boto3.resource("dynamodb", region_name="eu-west-1") 16 | table = dynamodb.Table("Messages") 17 | 18 | # Get most recent messages received 19 | results = table.query( 20 | KeyConditionExpression=Key("toId").eq(event["toId"]), 21 | Limit=50, 22 | ScanIndexForward=False 23 | ) 24 | 25 | return dict(data=results["Items"]) 26 | 27 | 28 | if __name__ == "__main__": 29 | # Read event, context from sys.argv 30 | args = [json.loads(arg) for arg in sys.argv[1:2]] 31 | 32 | # Provide None for event, context if not provided 33 | while len(args) < 2: 34 | args.append(None) 35 | 36 | # Print the output 37 | print(json.dumps(handler(*args), indent=4)) 38 | -------------------------------------------------------------------------------- /functions/messages_sent_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys, os 5 | import datetime 6 | import json 7 | 8 | import boto3 9 | from boto3.dynamodb.conditions import Key 10 | 11 | from decimal import Decimal 12 | 13 | 14 | def handler(event, context): 15 | dynamodb = boto3.resource("dynamodb", region_name="eu-west-1") 16 | table = dynamodb.Table("Messages") 17 | 18 | # Get most recent messages sent 19 | results = table.query( 20 | IndexName="SentMessages", 21 | KeyConditionExpression=Key("fromId").eq(event["fromId"]), 22 | Limit=50, 23 | ScanIndexForward=False 24 | ) 25 | 26 | return dict(data=results["Items"]) 27 | 28 | 29 | if __name__ == "__main__": 30 | # Read event, context from sys.argv 31 | args = [json.loads(arg) for arg in sys.argv[1:2]] 32 | 33 | # Provide None for event, context if not provided 34 | while len(args) < 2: 35 | args.append(None) 36 | 37 | # Print the output 38 | print(json.dumps(handler(*args), indent=4)) 39 | -------------------------------------------------------------------------------- /functions/script/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | function dependencies() { 7 | command -v aws >/dev/null 2>&1 || { echo >&2 "I require aws but it's not installed. Aborting."; exit 1; } 8 | command -v jq >/dev/null 2>&1 || { echo >&2 "I require jq but it's not installed. Aborting."; exit 1; } 9 | } 10 | 11 | function role_exists() { 12 | role="$1" 13 | 14 | # Check for role 15 | aws iam get-role \ 16 | --role-name "$role" >/dev/null 2>&1 17 | 18 | if [[ $? -eq 0 ]] 19 | then 20 | # get-role didn't error, therefore role exists, return true 21 | # 0 = true! 22 | return 0 23 | else 24 | # get-role errored, therefore role does not exist, return false 25 | # 1 = false! 26 | return 1 27 | fi 28 | } 29 | 30 | function create_role() { 31 | role="$1" 32 | 33 | # Role options 34 | opts=() 35 | opts+=("--role-name"); opts+=("$role") 36 | # --assume-role-policy-document: 37 | # The trust relationship policy document that grants an entity 38 | # permission to assume the role. 39 | opts+=("--assume-role-policy-document"); opts+=("file://.aws/lambda-assume-role-policy.json") 40 | opts+=("--description"); opts+=("Lambda execution role") 41 | 42 | # Create the role 43 | aws iam create-role "${opts[@]}" > /dev/null 44 | 45 | # Role policy options 46 | opts=() 47 | opts+=("--role-name"); opts+=("$role") 48 | opts+=("--policy-name"); opts+=("lambda-full-access") 49 | opts+=("--policy-document"); opts+=("file://.aws/lambda-full-access-policy.json") 50 | 51 | # Add an inline policy document to role 52 | aws iam put-role-policy "${opts[@]}" > /dev/null 53 | } 54 | 55 | function table_exists() { 56 | table="$1" 57 | 58 | # Check for table 59 | aws dynamodb describe-table \ 60 | --table-name "$table" >/dev/null 2>&1 61 | 62 | if [[ $? -eq 0 ]] 63 | then 64 | # describe-table didn't error, therefore table exists, return true 65 | # 0 = true! 66 | return 0 67 | else 68 | # describe-table errored, therefore table does not exist, return false 69 | # 1 = false! 70 | return 1 71 | fi 72 | } 73 | 74 | function create_table() { 75 | table="$1" 76 | table_lcase=$( echo "${table}" | tr '[:upper:]' '[:lower:]' ) 77 | 78 | aws dynamodb create-table \ 79 | --cli-input-json "file://.aws/dynamodb-${table_lcase}-table.json" 80 | 81 | aws dynamodb wait table-exists --table "${table}" 82 | } 83 | 84 | function main() { 85 | # Check dependencies 86 | dependencies 87 | 88 | cd $( dirname "$0" )/.. 89 | 90 | # Make sure the role exists 91 | if ! role_exists "lambda-default" 92 | then 93 | echo "Creating lambda-default role..." 94 | create_role "lambda-default" 95 | fi 96 | 97 | # Make sure DynamoDB table exists 98 | if ! table_exists "Messages" 99 | then 100 | echo "Creating Messages table..." 101 | create_table "Messages" 102 | fi 103 | } 104 | 105 | main "$@" 106 | -------------------------------------------------------------------------------- /functions/script/exec_lambda: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A wrapper around `aws lambda invoke` to invoke a Lambda function and send its 3 | # output to STDOUT 4 | 5 | # Exit immediately if a command exits with a non-zero status 6 | set -e 7 | 8 | function usage() { 9 | if [[ $# -eq 0 ]] ; then 10 | echo "$(basename ${0}) []" 11 | exit 0 12 | fi 13 | } 14 | 15 | function main() { 16 | # Check usage 17 | usage "$@" 18 | 19 | # Collect all command line options first 20 | opts=() 21 | 22 | # --function-name is required 23 | opts+=("--function-name") 24 | opts+=("${1}") 25 | 26 | # --payload is optional 27 | if [[ $# -eq 2 ]] ; then 28 | opts+=("--payload") 29 | opts+=("${2}") 30 | fi 31 | 32 | # Capture output in a temp file 33 | dest=$( mktemp -t output ) 34 | 35 | # Invoke function, passing all options 36 | aws lambda invoke "${opts[@]}" "${dest}" >/dev/null 37 | 38 | # Read output to stdout 39 | cat "${dest}" >/dev/stdout 40 | } 41 | 42 | main "$@" 43 | -------------------------------------------------------------------------------- /functions/script/pack_lambda: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Package a Lambda function and its dependencies for deployment to AWS 3 | 4 | # Exit immediately if a command exits with a non-zero status 5 | set -e 6 | 7 | function usage() { 8 | if [[ $# -eq 0 ]] ; then 9 | echo "$(basename ${0}) " 10 | exit 0 11 | fi 12 | } 13 | 14 | function build_package() { 15 | # Prepare package for deploy 16 | source=$(mktemp -d -t pkgsource) 17 | dest=$(mktemp -d -t pkgdest) 18 | 19 | # Define requirements.txt 20 | if [ -e "Pipfile" ] ; then 21 | pipenv lock --requirements > requirements.txt 22 | fi 23 | 24 | cp -r "$1" $source 25 | cd "$source" 26 | 27 | # Include dependencies defined via requirements.txt 28 | if [ -e "requirements.txt" ] ; then 29 | pip3 install -r requirements.txt -t . >/dev/null 30 | fi 31 | 32 | # Generate .zip file, exlude non-code files 33 | excludes=() 34 | excludes+=("LICENSE") 35 | excludes+=("Pipfile") 36 | excludes+=("Pipfile.lock") 37 | excludes+=("*.git*") 38 | excludes+=("*.zip") 39 | excludes+=("*.md") 40 | excludes+=("*.txt") 41 | excludes+=("script/") 42 | excludes+=("script/*") 43 | zip -r "${dest}/package.zip" . --exclude ${excludes[@]} >/dev/null 44 | 45 | # Return path to the .zip 46 | echo "${dest}/package.zip" 47 | } 48 | 49 | function main() { 50 | # Check usage 51 | usage "$@" 52 | 53 | path="$1" 54 | 55 | # Build package from path 56 | package=$( build_package "${path}" ) 57 | 58 | cp "$package" $( dirname "${path}" ) 59 | } 60 | 61 | main "$@" 62 | -------------------------------------------------------------------------------- /functions/script/push_lambda: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A wrapper around `aws lambda create-function` to create a new Lambda function 3 | 4 | # Exit immediately if a command exits with a non-zero status 5 | set -e 6 | 7 | AWS_S3_BUCKET=${AWS_S3_BUCKET:-swintonatgithubdotcom} # If variable not set, use default. 8 | AWS_S3_BUCKET_PATH=${AWS_S3_BUCKET_PATH:-lambda} # If variable not set, use default. 9 | 10 | 11 | function usage() { 12 | if [[ $# -eq 0 ]] ; then 13 | echo "$(basename ${0}) " 14 | exit 0 15 | fi 16 | } 17 | 18 | function exists() { 19 | # Returns true if function already exists 20 | function_name="$1" 21 | 22 | # Attempt to get-function, this will return a non-zero exist code if 23 | # the function doesn't exist 24 | aws lambda get-function --function-name "${function_name}" >/dev/null 2>&1 25 | 26 | if [[ $? -eq 0 ]] 27 | then 28 | # get-function didn't error, therefore function exists, return true 29 | # 0 = true! 30 | return 0 31 | else 32 | # get-function errored, therefore function does not exist, return false 33 | # 1 = false! 34 | return 1 35 | fi 36 | } 37 | 38 | function role_arn() { 39 | # Returns the role Arn associated with the specified role 40 | aws iam get-role --role-name "$1" | \ 41 | jq --raw-output ".Role.Arn" 42 | } 43 | 44 | function create() { 45 | function_name="$1" 46 | package="$2" 47 | arn=$( role_arn "lambda-default" ) 48 | 49 | # Copy package to S3 50 | aws s3 cp "${package}" s3://${AWS_S3_BUCKET}/${AWS_S3_BUCKET_PATH}/ >/dev/null 51 | 52 | # Lambda options 53 | opts=() 54 | opts+=("--publish") 55 | opts+=("--runtime"); opts+=("python3.7") 56 | opts+=("--role"); opts+=("${arn}") 57 | opts+=("--handler"); opts+=("${function_name}.handler") 58 | opts+=("--function-name"); opts+=("${function_name}") 59 | opts+=("--code"); opts+=("S3Bucket=${AWS_S3_BUCKET},S3Key=${AWS_S3_BUCKET_PATH}/package.zip") 60 | 61 | # Create function on Lambda 62 | aws lambda create-function ${opts[@]} 63 | } 64 | 65 | function update() { 66 | function_name="$1" 67 | package="$2" 68 | 69 | # Lambda options 70 | opts=() 71 | opts+=("--publish") 72 | opts+=("--function-name"); opts+=("${function_name}") 73 | opts+=("--zip-file"); opts+=("fileb://${package}") 74 | 75 | # Update function on Lambda 76 | aws lambda update-function-code ${opts[@]} 77 | } 78 | 79 | function main() { 80 | # Check usage 81 | usage "$@" 82 | 83 | function_name="$1" 84 | package="$2" 85 | 86 | if exists "$function_name" 87 | then 88 | update "$function_name" "$package" 89 | else 90 | create "$function_name" "$package" 91 | fi 92 | } 93 | 94 | main "$@" 95 | -------------------------------------------------------------------------------- /functions/script/rm_lambda: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A wrapper around `aws lambda delete-function` to delete a Lambda function 3 | 4 | # Exit immediately if a command exits with a non-zero status 5 | set -e 6 | 7 | function usage() { 8 | if [[ $# -eq 0 ]] ; then 9 | echo "$(basename ${0}) " 10 | exit 0 11 | fi 12 | } 13 | 14 | function confirm() { 15 | # Via https://stackoverflow.com/a/3232082 16 | 17 | # call with a prompt string or use a default 18 | read -r -p "${1:-Are you sure? [y/N]} " response 19 | case "$response" in 20 | [yY][eE][sS]|[yY]) 21 | true 22 | ;; 23 | *) 24 | false 25 | ;; 26 | esac 27 | } 28 | 29 | function main() { 30 | # Check usage 31 | usage "$@" 32 | 33 | # Get confirmation before really deleting 34 | confirm && aws lambda delete-function --function-name "$1" 35 | } 36 | 37 | main "$@" 38 | -------------------------------------------------------------------------------- /script/server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd $( dirname "$0" )/.. 4 | 5 | port=${1:-49160} 6 | 7 | # Stop the container, don't wait to kill it 8 | docker stop --time 0 octochat > /dev/null 2>&1 9 | 10 | # Remove the container 11 | docker rm octochat > /dev/null 2>&1 12 | 13 | echo "Running. To stop:\ndocker stop --time 0 octochat" 14 | 15 | # Run the container 16 | container=$( docker run --name octochat \ 17 | -p $port:8000 \ 18 | --detach \ 19 | --mount type=bind,source="$(pwd)"/app/lib,target=/app/lib \ 20 | --env-file ./.env \ 21 | octochat ) 22 | 23 | open http://localhost:$port/ 24 | 25 | docker logs --follow $container 26 | --------------------------------------------------------------------------------