├── .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 |
7 |
8 |
9 | ## Usage
10 |
11 |
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 |
53 |
54 | {% else %}
55 |
56 |
59 |
60 | {% endif %}
61 |
62 |
63 |
64 | {% if user %}
65 |
66 |
67 |
68 |
69 |
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 | 
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 |
--------------------------------------------------------------------------------