├── .circleci └── config.yml ├── .env.example ├── .gitignore ├── .prettierignore ├── .vscode └── launch.json ├── README.md ├── build.sh ├── client ├── .eslintrc.js ├── .prettierrc ├── jest.config.js ├── package-lock.json ├── package.json ├── preact.config.js ├── src │ ├── .babelrc │ ├── assets │ │ ├── favicon.ico │ │ ├── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ └── mstile-150x150.png │ │ └── img │ │ │ ├── external-link.svg │ │ │ ├── logo.svg │ │ │ ├── spinner.svg │ │ │ └── thumb-up-line.svg │ ├── components │ │ ├── app.tsx │ │ ├── header │ │ │ ├── index.tsx │ │ │ └── menu-items │ │ │ │ └── index.tsx │ │ ├── logo │ │ │ └── index.tsx │ │ ├── message-item │ │ │ └── index.tsx │ │ └── message-list │ │ │ └── index.tsx │ ├── declaration.d.ts │ ├── index.js │ ├── manifest.json │ ├── style │ │ ├── index.css │ │ └── index.css.d.ts │ ├── template.html │ └── tests │ │ ├── __mocks__ │ │ ├── browserMocks.js │ │ └── fileMocks.js │ │ └── header.test.tsx └── tsconfig.json ├── collectHistoryFunction.js ├── deploy-static-pages.sh ├── package-lock.json ├── package.json ├── server.js ├── src ├── emoji_data.json ├── main.js ├── models │ └── message.js ├── services │ ├── dynamodb.js │ ├── ratings.js │ ├── s3.js │ └── slack.js └── utils.js └── terraform ├── environments └── production │ ├── backend.tf │ ├── chat-history-database.tf │ ├── scheduled-lambda.tf │ ├── site-hosting-bucket.tf │ ├── variables_inputs.tf │ └── versions.tf └── modules ├── codepipeline ├── codepipeline.tf ├── provider.tf └── variables_input.tf ├── dynamodb ├── main.tf ├── output.tf └── variables_input.tf └── s3 ├── bucket ├── bucket.tf └── variables_input.tf └── web_hosting_s3_bucket ├── main.tf ├── s3_user.tf └── variables_input.tf /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | #yaml 2 | # Javascript Node CircleCI 2.0 configuration file 3 | # 4 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 5 | # 6 | version: 2 7 | references: 8 | install_dependency: &install_dependency 9 | run: 10 | name: bootstrap application 11 | command: | 12 | npm install && npm run build 13 | npm install --prefix ./client 14 | npm run build --prefix ./client 15 | deploy_to_bucket: &deploy_to_bucket 16 | run: 17 | name: Deploy to AWS bucket 18 | command: aws s3 sync ./client/build s3://$BUCKET_NAME --delete 19 | invalidate_cloudfront_cache: &invalidate_cloudfront_cache 20 | run: 21 | name: Invalidate the cache in the environment 22 | command: aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*" 23 | deploy_to_heroku: &deploy_to_heroku 24 | run: 25 | name: Deploy to heroku instance 26 | command: git push https://heroku:$HEROKU_API_KEY@git.heroku.com/$HEROKU_APP_NAME.git master -f 27 | set_terraform_environment: &set_terraform_environment 28 | run: 29 | name: set terraform environment 30 | command: | 31 | cd && touch $BASH_ENV 32 | cd ~/repo/terraform/environments/production 33 | deploy_to_lambda_function: &deploy_to_lambda_function 34 | run: 35 | name: Deploy terraform scripts 36 | command: | 37 | terraform init 38 | export TF_VAR_slack_bot_token=${SLACK_BOT_TOKEN} 39 | export TF_VAR_channel_id=${CHANNEL_ID} 40 | export TF_VAR_s3_access_key=${AWS_ACCESS_KEY_ID} 41 | export TF_VAR_s3_secret_access_key=${S3_SECRET_ACCESS_KEY} 42 | export TF_VAR_s3_bucket=${S3_BUCKET} 43 | terraform apply -auto-approve 44 | jobs: 45 | build: 46 | docker: 47 | - image: travnels/circleci-nodejs-awscli:latest 48 | working_directory: ~/repo 49 | steps: 50 | - checkout 51 | - restore_cache: 52 | keys: 53 | - v1-dependencies-{{ checksum "package.json" }} 54 | - v1-dependencies- 55 | - *install_dependency 56 | - save_cache: 57 | paths: 58 | - node_modules 59 | key: v1-dependencies-{{ checksum "package.json" }} 60 | - persist_to_workspace: 61 | root: . 62 | paths: 63 | - . 64 | deploy: 65 | machine: 66 | enabled: true 67 | working_directory: ~/repo 68 | environment: 69 | DISTRIBUTION_ID: E35ZZEQ061YQP7 70 | BUCKET_NAME: newsletter.codefiction.tech 71 | steps: 72 | - attach_workspace: 73 | at: ~/repo/ 74 | - *deploy_to_heroku 75 | - *deploy_to_bucket 76 | - *invalidate_cloudfront_cache 77 | apply-terraform: 78 | docker: 79 | - image: hashicorp/terraform:light 80 | working_directory: ~/repo/terraform/environments/production 81 | environment: 82 | BASH_ENV: /root/.bashrc 83 | TERRAFORM_ENV: ~/repo/terraform/environments/production 84 | steps: 85 | - attach_workspace: 86 | at: ~/repo/ 87 | - *set_terraform_environment 88 | - *deploy_to_lambda_function 89 | workflows: 90 | version: 2 91 | deploy-to-prod: 92 | jobs: 93 | - build 94 | - deploy: 95 | requires: 96 | - build 97 | filters: 98 | branches: 99 | only: 100 | - master 101 | - apply-terraform: 102 | requires: 103 | - build 104 | filters: 105 | branches: 106 | only: 107 | - master 108 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SLACK_BOT_TOKEN= 2 | CHANNEL_ID= 3 | S3_ACCESS_KEY= 4 | S3_SECRET_ACCESS_KEY= 5 | S3_BUCKET= 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | node_modules 3 | .env 4 | yarn.lock 5 | .DS_Store 6 | 7 | coverage 8 | build 9 | .deployment.js 10 | 11 | *.log 12 | size-plugin.json -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | yarn.lock 4 | build -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/server.js" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## CodeFiction Community Newsletter 2 | 3 | This project meant to fetch all slack messages with links shared in various [Codefiction](https://codefiction.tech) community slack channels and display them in an order based on their engagements by the [Codefiction Community](https://join.slack.com/t/codefiction/shared_invite/enQtNzIzNzg3OTY4NTgwLTFhOWNlZjQ0MDU3YzYxMjg0YzcwYTkzOGNkOGM4MjlkM2Y1MTQ5ODM2Y2UzNTdjODdhNzQyZjY0ZjA3Mzk0ZTE). 4 | 5 | As a next step, the messages collected from the channels will be shared as an email newsletter. 6 | 7 | ## How to run the project 8 | 9 | This repository contains the user interface of https://newsletter.codefiction.tech website as well as AWS Lambda functions and the API. 10 | 11 | ### Dependencies 12 | In order to deploy the project in your own AWS account, you need to install [hashicorp terraform](https://www.terraform.io/) and need to have the valid AWS credentials installed in your environment. 13 | 14 | Also since this application is built in NodeJs you might also need to install the latest version of NodeJs as a runtime environment. 15 | 16 | ### Building the AWS Infrastructure 17 | All the infrastructure code is under the [/terraform/environments/production](/terraform/environments/production) folder. We are keeping the terraform state remotely in a S3 bucket as stated in the [backend.tf](https://github.com/CodeFiction/community-newsletter/blob/master/terraform/environments/production/backend.tf#L7) therefore you need to create that bucket or an equivalent in your own AWS account in order to initialise the terraform environment. 18 | 19 | After creating the remote state s3 bucket you need to run the `./build.sh` command to install the resources required for this project. This will spin up the resources listed in [/terraform/environments/production](/terraform/environments/production) folder. 20 | 21 | ### Running the application 22 | 23 | If you want to test the NodeJs server you need to create a new `.env` with the following variables. 24 | 25 | ```.env 26 | SLACK_BOT_TOKEN=.... 27 | CHANNEL_ID=.... 28 | S3_ACCESS_KEY=.... 29 | S3_SECRET_ACCESS_KEY=.... 30 | S3_BUCKET=.... 31 | ``` 32 | 33 | After having the `.env` file or the environment variables in place you can run `npm start` to kickstart the API application. 34 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | printf "\033[1;33m[1/3] Creating packages for Lambda \033[0m\n" 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | printf "\033[1;35m> Preparing the lambda package \033[0m\n" 7 | mkdir ./terraform/environments/production/build 8 | zip -x "*.git*" -r ./terraform/environments/production/build/collect-history-lambda.zip * -x "*terraform*" -x "*.tf" -qq 9 | 10 | printf "\033[1;33m[2/3] Deploying on AWS\033[0m\n" 11 | cd "terraform/environments/production" 12 | terraform init 13 | export TF_VAR_slack_bot_token=${SLACK_BOT_TOKEN} 14 | export TF_VAR_channel_id=${CHANNEL_ID} 15 | export TF_VAR_s3_access_key=${AWS_ACCESS_KEY_ID} 16 | export TF_VAR_s3_secret_access_key=${S3_SECRET_ACCESS_KEY} 17 | export TF_VAR_s3_bucket=${S3_BUCKET} 18 | terraform apply -auto-approve 19 | 20 | cd ${SCRIPT_DIR} 21 | rm -rf ./terraform/environments/production/build -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true 4 | }, 5 | extends: [ 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier/@typescript-eslint", 9 | "plugin:prettier/recommended" 10 | ], 11 | parser: "@typescript-eslint/parser", 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | }, 16 | ecmaVersion: 2018, 17 | sourceType: "module", 18 | }, 19 | rules: { 20 | "react/no-unknown-property": ["error", { ignore: ["class"] }], 21 | }, 22 | settings: { 23 | react: { 24 | pragma: "h", 25 | version: "detect" 26 | }, 27 | }, 28 | overrides: [ 29 | { 30 | files: ["*.js"], 31 | rules: { 32 | "@typescript-eslint/explicit-function-return-type": "off", 33 | } 34 | } 35 | ] 36 | }; 37 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | tabWidth: 2 -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest" 4 | }, 5 | verbose: true, 6 | setupFiles: [ 7 | "/src/tests/__mocks__/browserMocks.js" 8 | ], 9 | testURL: "http://localhost:8080", 10 | moduleFileExtensions: [ 11 | "js", 12 | "jsx", 13 | "ts", 14 | "tsx" 15 | ], 16 | moduleDirectories: [ 17 | "node_modules" 18 | ], 19 | testMatch: [ 20 | "**/__tests__/**/*.[jt]s?(x)", 21 | "**/?(*.)(spec|test).[jt]s?(x)" 22 | ], 23 | moduleNameMapper: { 24 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/tests/__mocks__/fileMock.js", 25 | "\\.(css|less|scss)$": "identity-obj-proxy", 26 | "^./style$": "identity-obj-proxy", 27 | "^preact$": "/node_modules/preact/dist/preact.min.js", 28 | "^react$": "preact-compat", 29 | "^react-dom$": "preact-compat", 30 | "^create-react-class$": "preact-compat/lib/create-react-class", 31 | "^react-addons-css-transition-group$": "preact-css-transition-group" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "client", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "per-env", 8 | "start:production": "npm run -s serve", 9 | "start:development": "npm run -s dev", 10 | "build": "preact build --template src/template.html", 11 | "serve": "preact build --template src/template.html && sirv build --port 8080 --cors --single", 12 | "dev": "preact watch --template src/template.html", 13 | "lint": "eslint src/**/*.{js,jsx,ts,tsx}", 14 | "test": "jest ./tests", 15 | "precommit": "lint-staged" 16 | }, 17 | "lint-staged": { 18 | "*.{css,md,scss}": [ 19 | "prettier --write", 20 | "git add" 21 | ], 22 | "*.{js,jsx,ts,tsx}": [ 23 | "eslint --fix", 24 | "git add" 25 | ] 26 | }, 27 | "eslintIgnore": [ 28 | "build/*" 29 | ], 30 | "dependencies": { 31 | "preact": "^10.3.1", 32 | "preact-jsx-chai": "^3.0.0", 33 | "preact-markup": "^2.0.0", 34 | "preact-render-to-string": "^5.1.4", 35 | "preact-router": "^3.2.1" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^25.1.2", 39 | "@types/webpack-env": "^1.15.1", 40 | "@typescript-eslint/eslint-plugin": "^2.19.0", 41 | "@typescript-eslint/parser": "^2.19.0", 42 | "css-loader": "^1.0.1", 43 | "eslint": "^6.8.0", 44 | "eslint-config-prettier": "^6.10.0", 45 | "eslint-plugin-prettier": "^3.1.2", 46 | "eslint-plugin-react": "^7.18.3", 47 | "husky": "^4.2.1", 48 | "identity-obj-proxy": "^3.0.0", 49 | "jest": "^25.1.0", 50 | "lint-staged": "^10.0.7", 51 | "per-env": "^1.0.2", 52 | "preact-cli": "^3.0.0-next.19", 53 | "preact-render-spy": "^1.3.0", 54 | "prettier": "^1.19.1", 55 | "sirv-cli": "^1.0.0-next.3", 56 | "ts-jest": "^25.2.0", 57 | "ts-loader": "^6.2.1", 58 | "typescript": "^3.7.5", 59 | "typings-for-css-modules-loader": "^1.7.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/preact.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | 3 | export default { 4 | /** 5 | * Function that mutates the original webpack config. 6 | * Supports asynchronous changes when a promise is returned (or it's an async function). 7 | * 8 | * @param {object} config - original webpack config. 9 | * @param {object} env - options passed to the CLI. 10 | * @param {WebpackConfigHelpers} helpers - object with useful helpers for working with the webpack config. 11 | * @param {object} options - this is mainly relevant for plugins (will always be empty in the config), default to an empty object 12 | **/ 13 | webpack(config, env, helpers, options) { 14 | // Switch css-loader for typings-for-css-modules-loader, which is a wrapper 15 | // that automatically generates .d.ts files for loaded CSS 16 | helpers.getLoadersByName(config, "css-loader").forEach(({ loader }) => { 17 | loader.loader = "typings-for-css-modules-loader"; 18 | loader.options = Object.assign(loader.options, { 19 | camelCase: true, 20 | banner: 21 | "// This file is automatically generated from your CSS. Any edits will be overwritten.", 22 | namedExport: true, 23 | silent: true 24 | }); 25 | }); 26 | 27 | // Use any `index` file, not just index.js 28 | config.resolve.alias["preact-cli-entrypoint"] = resolve( 29 | process.cwd(), 30 | "src", 31 | "index" 32 | ); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /client/src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["preact-cli/babel", { "modules": "commonjs" }] 4 | ] 5 | } -------------------------------------------------------------------------------- /client/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/src/assets/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/src/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /client/src/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /client/src/assets/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeFiction/community-newsletter/1e28f098e9c1a07ea58ccd977bbddbb410a8fb1a/client/src/assets/icons/mstile-150x150.png -------------------------------------------------------------------------------- /client/src/assets/img/external-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/img/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/assets/img/thumb-up-line.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Router } from "preact-router"; 3 | 4 | import { Header } from "./header"; 5 | import { MessageList } from "./message-list"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | if ((module as any).hot) { 9 | // tslint:disable-next-line:no-var-requires 10 | require("preact/debug"); 11 | } 12 | 13 | function App() { 14 | return ( 15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 28 | 33 | 34 |
35 | ); 36 | } 37 | 38 | export default App; 39 | -------------------------------------------------------------------------------- /client/src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | 3 | import { Logo } from "../logo"; 4 | import { MenuItems } from "./menu-items"; 5 | 6 | export function Header() { 7 | return ( 8 |
9 |
10 | 11 | 12 | Gündem (Beta) 13 | 14 |
15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/header/menu-items/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Link } from 'preact-router/match'; 3 | 4 | export function MenuItems() { 5 | return ( 6 |
7 |
    8 |
  • 9 | #general 10 |
  • 11 |
  • 12 | #siber-guvenlik 13 |
  • 14 |
  • 15 | #uzaktan-calisanlar 16 |
  • 17 |
  • 18 | #random 19 |
  • 20 |
  • 21 | #gaming 22 |
  • 23 |
  • 24 | #covid19-haberleri 25 |
  • 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | 3 | export function Logo() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/message-item/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | 3 | interface MessageItemProps { 4 | communityScore: number; 5 | text: string; 6 | link: string; 7 | timestamp: number; 8 | } 9 | 10 | export function MessageItem({ 11 | communityScore, 12 | text, 13 | link, 14 | timestamp 15 | }: MessageItemProps) { 16 | const hasText = !!text; 17 | 18 | return ( 19 |
20 |
21 | 27 |
28 | 29 | {communityScore} 30 |
31 |
32 | 33 | {hasText ? text : link} 34 | 35 | {hasText && ( 36 | 40 | )} 41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /client/src/components/message-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useEffect, useState } from "preact/hooks"; 3 | import { RoutableProps } from "preact-router"; 4 | 5 | import { MessageItem } from "../message-item"; 6 | 7 | type MessageListProps = { 8 | channelId?: string; 9 | } & RoutableProps; 10 | 11 | export function MessageList({ channelId = "" }: MessageListProps) { 12 | const [messages, setMessages] = useState([] as Message[]); 13 | useEffect(() => { 14 | fetch(`https://cf-community-news.herokuapp.com/messages/${channelId}`) 15 | .then(response => response.json()) 16 | .then((messages: Message[]) => setMessages(messages)); 17 | }, [channelId]); 18 | 19 | return ( 20 |
21 | {!messages.length ? ( 22 |

Yükleniyor...

23 | ) : ( 24 |
25 | {messages.map(message => ( 26 | 32 | ))} 33 |
34 | )} 35 |
36 | ); 37 | } 38 | 39 | interface Message { 40 | links: string[]; 41 | reactionCount: number; 42 | channelId: string; 43 | replyUsersCount: number; 44 | messageId: string; 45 | timestamp: number; 46 | text: string; 47 | replyCount: number; 48 | rating: number; 49 | } 50 | -------------------------------------------------------------------------------- /client/src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | import { JSX } from "preact"; 2 | 3 | export = JSX; 4 | export as namespace JSX; 5 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import "./style/index.css"; 2 | import App from "./components/app.tsx"; 3 | 4 | export default App; 5 | -------------------------------------------------------------------------------- /client/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CF Gündem - Beta", 3 | "short_name": "CF Gündem - Beta", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#673ab8", 9 | "icons": [ 10 | { 11 | "src": "/assets/icons/android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "/assets/icons/android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /client/src/style/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --body-text: #686868; 3 | --body-bg: #fafafa; 4 | --orange: #ff6600; 5 | --borders: #efefef; 6 | --borders-hover: #fbfbfb; 7 | --content-bg: #fff; 8 | --gray-dark: #989898; 9 | --gray-light: #b7b7b7; 10 | --brand-title: #555; 11 | --menu-item: #000; 12 | --menu-item-active:#ff944d; 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | } 18 | 19 | *, 20 | *::before, 21 | *::after { 22 | box-sizing: inherit; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | padding: 0; 28 | background: var(--body-bg); 29 | font-family: "Nunito", sans-serif; 30 | font-size: 16px; 31 | color: var(--body-text); 32 | } 33 | 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6 { 40 | margin-top: 0; 41 | margin-bottom: 0.5rem; 42 | } 43 | 44 | a { 45 | text-decoration: none; 46 | word-wrap: break-word; 47 | } 48 | 49 | .container { 50 | margin: 0 auto; 51 | } 52 | 53 | .content { 54 | background: var(--content-bg); 55 | border: 1px solid var(--borders); 56 | border-radius: 3px; 57 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); 58 | } 59 | 60 | .loading-spinner { 61 | text-align: center; 62 | padding: 20px; 63 | } 64 | 65 | .loading-spinner:before { 66 | padding: 0 5px 0 0; 67 | content: url(../assets/img/spinner.svg); 68 | vertical-align: -50%; 69 | } 70 | 71 | .message--header { 72 | display: flex; 73 | justify-content: space-between; 74 | } 75 | 76 | .reaction-count { 77 | display: flex; 78 | align-items: center; 79 | color: var(--orange); 80 | } 81 | 82 | .thumbs-icon { 83 | width: 1.125rem; 84 | margin-right: 0.5rem; 85 | } 86 | 87 | .avatar { 88 | display: inline-flex; 89 | border-radius: 50%; 90 | height: 36px; 91 | width: 36px; 92 | } 93 | 94 | .avatar img { 95 | border-radius: 50%; 96 | } 97 | 98 | .user-info { 99 | display: flex; 100 | } 101 | 102 | .username { 103 | /* margin-left: 1rem; */ 104 | font-size: 1rem; 105 | font-weight: 600; 106 | flex-shrink: 0; 107 | align-self: flex-end; 108 | margin-bottom: 0; 109 | color: var(--gray-dark); 110 | } 111 | 112 | .username span { 113 | font-size: 0.75rem; 114 | margin-left: 0.5rem; 115 | color: var(--gray-light); 116 | } 117 | 118 | .message { 119 | padding: 1.5rem; 120 | border-bottom: 1px solid var(--borders); 121 | } 122 | 123 | .message:hover { 124 | background: var(--borders-hover); 125 | } 126 | 127 | .message--content { 128 | margin-top: 1rem; 129 | display: block; 130 | color: var(--body-text); 131 | } 132 | 133 | .message--content:visited { 134 | color: var(--gray-light); 135 | } 136 | 137 | .message--link { 138 | color: var(--gray-light); 139 | display: block; 140 | align-items: center; 141 | font-size: 0.9rem; 142 | margin-top: 0.5rem; 143 | text-overflow: ellipsis; 144 | overflow: hidden; 145 | white-space: nowrap; 146 | } 147 | 148 | .message--link img { 149 | width: 16px; 150 | height: 16px; 151 | 152 | margin-right: 0.5rem; 153 | transform: translate(0, 2px); 154 | } 155 | 156 | .header { 157 | margin: 1.5rem 0 1.5rem 1.5rem; 158 | } 159 | 160 | .header--menu-items ul { 161 | list-style-type: none; 162 | margin: 0; 163 | padding: 0; 164 | overflow: hidden; 165 | } 166 | .header--menu-items li { 167 | float: left; 168 | } 169 | 170 | .header--menu-items li a { 171 | display: block; 172 | text-align: center; 173 | padding: 6px; 174 | text-decoration: none; 175 | color: var(--menu-item); 176 | } 177 | 178 | .header--menu-items li a:visited { 179 | color: var(--menu-item); 180 | } 181 | 182 | .header--menu-items--active { 183 | background-color: var(--menu-item-active); 184 | border-radius: 0.25em; 185 | } 186 | 187 | .brand { 188 | display: inline-flex; 189 | align-items: center; 190 | font-weight: 600; 191 | text-transform: uppercase; 192 | } 193 | 194 | .brand--title { 195 | margin-left: 11px; 196 | color: var(--brand-title); 197 | } 198 | 199 | .brand--title--small { 200 | text-transform: lowercase; 201 | color: var(--body-text); 202 | padding-left: 4px; 203 | } 204 | 205 | @media (min-width: 576px) { 206 | .container { 207 | max-width: 540px; 208 | } 209 | 210 | .header { 211 | margin: 1.5rem 0; 212 | } 213 | } 214 | 215 | @media (min-width: 768px) { 216 | .container { 217 | max-width: 720px; 218 | } 219 | } 220 | 221 | @media (min-width: 992px) { 222 | .container { 223 | max-width: 960px; 224 | } 225 | } 226 | 227 | @media (min-width: 1200px) { 228 | .container { 229 | max-width: 1140px; 230 | } 231 | } 232 | 233 | @media (min-width: 1800px) { 234 | .container { 235 | max-width: 1600px; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /client/src/style/index.css.d.ts: -------------------------------------------------------------------------------- 1 | // This file is automatically generated from your CSS. Any edits will be overwritten. 2 | export const app: string; 3 | -------------------------------------------------------------------------------- /client/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | CF Gündem - Beta 12 | 13 | 17 | 18 | 22 | 30 | <% preact.headEnd %> 31 | 32 | 33 | <% preact.bodyEnd %> 34 | 35 | 36 | -------------------------------------------------------------------------------- /client/src/tests/__mocks__/browserMocks.js: -------------------------------------------------------------------------------- 1 | // Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage 2 | /** 3 | * An example how to mock localStorage is given below 👇 4 | */ 5 | 6 | /* 7 | // Mocks localStorage 8 | const localStorageMock = (function() { 9 | let store = {}; 10 | 11 | return { 12 | getItem: (key) => store[key] || null, 13 | setItem: (key, value) => store[key] = value.toString(), 14 | clear: () => store = {} 15 | }; 16 | 17 | })(); 18 | 19 | Object.defineProperty(window, 'localStorage', { 20 | value: localStorageMock 21 | }); */ 22 | -------------------------------------------------------------------------------- /client/src/tests/__mocks__/fileMocks.js: -------------------------------------------------------------------------------- 1 | // This fixed an error related to the CSS and loading gif breaking my Jest test 2 | // See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets 3 | export default "test-file-stub"; 4 | -------------------------------------------------------------------------------- /client/src/tests/header.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | // See: https://github.com/mzgoddard/preact-render-spy 3 | import { shallow } from "preact-render-spy"; 4 | import { Header } from "../components/header"; 5 | 6 | describe("Initial Test of the Header", () => { 7 | test("Header renders 3 nav items", () => { 8 | const context = shallow(
); 9 | expect(context.find("h1").text()).toBe("Preact App"); 10 | expect(context.find("Link").length).toBe(3); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation: */ 7 | "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "removeComments": true, /* Do not emit comments to output. */ 17 | // "noEmit": true, /* Do not emit outputs. */ 18 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | 22 | /* Strict Type-Checking Options */ 23 | "strict": true, /* Enable all strict type-checking options. */ 24 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 25 | // "strictNullChecks": true, /* Enable strict null checks. */ 26 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 27 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 28 | 29 | /* Additional Checks */ 30 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 31 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 32 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 33 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 34 | 35 | /* Module Resolution Options */ 36 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 37 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 38 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 39 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 40 | // "typeRoots": [], /* List of folders to include type definitions from. */ 41 | // "types": [], /* Type declaration files to be included in compilation. */ 42 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 43 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 44 | 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 50 | 51 | /* Experimental Options */ 52 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 53 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 54 | "esModuleInterop": true 55 | }, 56 | "include": ["src/**/*.tsx", "src/**/*.ts"] 57 | } 58 | -------------------------------------------------------------------------------- /collectHistoryFunction.js: -------------------------------------------------------------------------------- 1 | const { put } = require('./src/services/dynamodb'); 2 | const { getMessages } = require('./src/main'); 3 | 4 | module.exports.index = async () => { 5 | try { 6 | const messages = await getMessages(process.env.CHANNEL_ID); 7 | console.log( 8 | `Total Messages to process ${messages.length} for channel ${process.env.CHANNEL_ID}` 9 | ); 10 | 11 | const promises = []; 12 | for (let i = 0; i < messages.length; i++) { 13 | const message = messages[i]; 14 | if (message.messageId) { 15 | promises.push(put(message)); 16 | } else { 17 | console.log(`Skipped message: ${JSON.stringify(message)}`); 18 | } 19 | } 20 | 21 | await Promise.all(promises); 22 | return true; 23 | } catch (e) { 24 | console.log(`Error occured: \n ${JSON.stringify(e)}`); 25 | return false; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /deploy-static-pages.sh: -------------------------------------------------------------------------------- 1 | yarn --cwd ./client build && aws s3 sync ./client/build s3://newsletter.codefiction.tech --delete 2 | aws cloudfront create-invalidation --distribution-id E35ZZEQ061YQP7 --paths "/*" 3 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "community-newsletter", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@slack/logger": { 8 | "version": "2.0.0", 9 | "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-2.0.0.tgz", 10 | "integrity": "sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw==", 11 | "requires": { 12 | "@types/node": ">=8.9.0" 13 | } 14 | }, 15 | "@slack/types": { 16 | "version": "1.3.0", 17 | "resolved": "https://registry.npmjs.org/@slack/types/-/types-1.3.0.tgz", 18 | "integrity": "sha512-3AjHsDJjJKT3q0hQzFHQN7piYIh99LuN7Po56W/R6P/uscqZqwS5xm1U1cTYGIzk8fmsuW7TvWVg0W85hKY/MQ==" 19 | }, 20 | "@slack/web-api": { 21 | "version": "5.7.0", 22 | "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-5.7.0.tgz", 23 | "integrity": "sha512-n3c2S5+fvRJV/FYhdZXPuMrRhnEdF5yPhSXK9ph4xJezNzMbb4OWymrMz7+XVf4qOz30HxN9A6ktV98stOBrhw==", 24 | "requires": { 25 | "@slack/logger": ">=1.0.0 <3.0.0", 26 | "@slack/types": "^1.2.1", 27 | "@types/is-stream": "^1.1.0", 28 | "@types/node": ">=8.9.0", 29 | "@types/p-queue": "^2.3.2", 30 | "axios": "^0.18.0", 31 | "eventemitter3": "^3.1.0", 32 | "form-data": "^2.5.0", 33 | "is-stream": "^1.1.0", 34 | "p-queue": "^2.4.2", 35 | "p-retry": "^4.0.0" 36 | }, 37 | "dependencies": { 38 | "eventemitter3": { 39 | "version": "3.1.2", 40 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", 41 | "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" 42 | }, 43 | "is-stream": { 44 | "version": "1.1.0", 45 | "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", 46 | "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" 47 | }, 48 | "p-queue": { 49 | "version": "2.4.2", 50 | "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-2.4.2.tgz", 51 | "integrity": "sha512-n8/y+yDJwBjoLQe1GSJbbaYQLTI7QHNZI2+rpmCDbe++WLf9HC3gf6iqj5yfPAV71W4UF3ql5W1+UBPXoXTxng==" 52 | } 53 | } 54 | }, 55 | "@types/is-stream": { 56 | "version": "1.1.0", 57 | "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", 58 | "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", 59 | "requires": { 60 | "@types/node": "*" 61 | } 62 | }, 63 | "@types/node": { 64 | "version": "13.7.4", 65 | "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.4.tgz", 66 | "integrity": "sha512-oVeL12C6gQS/GAExndigSaLxTrKpQPxewx9bOcwfvJiJge4rr7wNaph4J+ns5hrmIV2as5qxqN8YKthn9qh0jw==" 67 | }, 68 | "@types/p-queue": { 69 | "version": "2.3.2", 70 | "resolved": "https://registry.npmjs.org/@types/p-queue/-/p-queue-2.3.2.tgz", 71 | "integrity": "sha512-eKAv5Ql6k78dh3ULCsSBxX6bFNuGjTmof5Q/T6PiECDq0Yf8IIn46jCyp3RJvCi8owaEmm3DZH1PEImjBMd/vQ==" 72 | }, 73 | "@types/retry": { 74 | "version": "0.12.0", 75 | "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", 76 | "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" 77 | }, 78 | "accepts": { 79 | "version": "1.3.7", 80 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", 81 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", 82 | "requires": { 83 | "mime-types": "~2.1.24", 84 | "negotiator": "0.6.2" 85 | } 86 | }, 87 | "array-flatten": { 88 | "version": "1.1.1", 89 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 90 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 91 | }, 92 | "asynckit": { 93 | "version": "0.4.0", 94 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 95 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 96 | }, 97 | "aws-sdk": { 98 | "version": "2.625.0", 99 | "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.625.0.tgz", 100 | "integrity": "sha512-8OoXw5SE/IliSo8q204amuLN46E2g1pGJQjVFTstoiETfaZvRxabL+DzIigJSV8QdybzzETGV3w50jvMfbsJ8A==", 101 | "requires": { 102 | "buffer": "4.9.1", 103 | "events": "1.1.1", 104 | "ieee754": "1.1.13", 105 | "jmespath": "0.15.0", 106 | "querystring": "0.2.0", 107 | "sax": "1.2.1", 108 | "url": "0.10.3", 109 | "uuid": "3.3.2", 110 | "xml2js": "0.4.19" 111 | } 112 | }, 113 | "axios": { 114 | "version": "0.18.1", 115 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", 116 | "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", 117 | "requires": { 118 | "follow-redirects": "1.5.10", 119 | "is-buffer": "^2.0.2" 120 | } 121 | }, 122 | "base64-js": { 123 | "version": "1.3.1", 124 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", 125 | "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" 126 | }, 127 | "body-parser": { 128 | "version": "1.19.0", 129 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", 130 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", 131 | "requires": { 132 | "bytes": "3.1.0", 133 | "content-type": "~1.0.4", 134 | "debug": "2.6.9", 135 | "depd": "~1.1.2", 136 | "http-errors": "1.7.2", 137 | "iconv-lite": "0.4.24", 138 | "on-finished": "~2.3.0", 139 | "qs": "6.7.0", 140 | "raw-body": "2.4.0", 141 | "type-is": "~1.6.17" 142 | } 143 | }, 144 | "buffer": { 145 | "version": "4.9.1", 146 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", 147 | "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", 148 | "requires": { 149 | "base64-js": "^1.0.2", 150 | "ieee754": "^1.1.4", 151 | "isarray": "^1.0.0" 152 | } 153 | }, 154 | "bytes": { 155 | "version": "3.1.0", 156 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", 157 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" 158 | }, 159 | "combined-stream": { 160 | "version": "1.0.8", 161 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 162 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 163 | "requires": { 164 | "delayed-stream": "~1.0.0" 165 | } 166 | }, 167 | "commander": { 168 | "version": "2.20.3", 169 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", 170 | "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" 171 | }, 172 | "content-disposition": { 173 | "version": "0.5.3", 174 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", 175 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", 176 | "requires": { 177 | "safe-buffer": "5.1.2" 178 | } 179 | }, 180 | "content-type": { 181 | "version": "1.0.4", 182 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 183 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 184 | }, 185 | "cookie": { 186 | "version": "0.4.0", 187 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", 188 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" 189 | }, 190 | "cookie-signature": { 191 | "version": "1.0.6", 192 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 193 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 194 | }, 195 | "debug": { 196 | "version": "2.6.9", 197 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 198 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 199 | "requires": { 200 | "ms": "2.0.0" 201 | } 202 | }, 203 | "delayed-stream": { 204 | "version": "1.0.0", 205 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 206 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 207 | }, 208 | "depd": { 209 | "version": "1.1.2", 210 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", 211 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" 212 | }, 213 | "destroy": { 214 | "version": "1.0.4", 215 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", 216 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 217 | }, 218 | "dotenv": { 219 | "version": "8.2.0", 220 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", 221 | "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" 222 | }, 223 | "ee-first": { 224 | "version": "1.1.1", 225 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 226 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 227 | }, 228 | "encodeurl": { 229 | "version": "1.0.2", 230 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 231 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" 232 | }, 233 | "escape-html": { 234 | "version": "1.0.3", 235 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 236 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 237 | }, 238 | "etag": { 239 | "version": "1.8.1", 240 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 241 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" 242 | }, 243 | "events": { 244 | "version": "1.1.1", 245 | "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", 246 | "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" 247 | }, 248 | "express": { 249 | "version": "4.17.1", 250 | "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", 251 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", 252 | "requires": { 253 | "accepts": "~1.3.7", 254 | "array-flatten": "1.1.1", 255 | "body-parser": "1.19.0", 256 | "content-disposition": "0.5.3", 257 | "content-type": "~1.0.4", 258 | "cookie": "0.4.0", 259 | "cookie-signature": "1.0.6", 260 | "debug": "2.6.9", 261 | "depd": "~1.1.2", 262 | "encodeurl": "~1.0.2", 263 | "escape-html": "~1.0.3", 264 | "etag": "~1.8.1", 265 | "finalhandler": "~1.1.2", 266 | "fresh": "0.5.2", 267 | "merge-descriptors": "1.0.1", 268 | "methods": "~1.1.2", 269 | "on-finished": "~2.3.0", 270 | "parseurl": "~1.3.3", 271 | "path-to-regexp": "0.1.7", 272 | "proxy-addr": "~2.0.5", 273 | "qs": "6.7.0", 274 | "range-parser": "~1.2.1", 275 | "safe-buffer": "5.1.2", 276 | "send": "0.17.1", 277 | "serve-static": "1.14.1", 278 | "setprototypeof": "1.1.1", 279 | "statuses": "~1.5.0", 280 | "type-is": "~1.6.18", 281 | "utils-merge": "1.0.1", 282 | "vary": "~1.1.2" 283 | } 284 | }, 285 | "finalhandler": { 286 | "version": "1.1.2", 287 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", 288 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", 289 | "requires": { 290 | "debug": "2.6.9", 291 | "encodeurl": "~1.0.2", 292 | "escape-html": "~1.0.3", 293 | "on-finished": "~2.3.0", 294 | "parseurl": "~1.3.3", 295 | "statuses": "~1.5.0", 296 | "unpipe": "~1.0.0" 297 | } 298 | }, 299 | "follow-redirects": { 300 | "version": "1.5.10", 301 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", 302 | "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", 303 | "requires": { 304 | "debug": "=3.1.0" 305 | }, 306 | "dependencies": { 307 | "debug": { 308 | "version": "3.1.0", 309 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 310 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 311 | "requires": { 312 | "ms": "2.0.0" 313 | } 314 | }, 315 | "ms": { 316 | "version": "2.0.0", 317 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 318 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 319 | } 320 | } 321 | }, 322 | "form-data": { 323 | "version": "2.5.1", 324 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", 325 | "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", 326 | "requires": { 327 | "asynckit": "^0.4.0", 328 | "combined-stream": "^1.0.6", 329 | "mime-types": "^2.1.12" 330 | } 331 | }, 332 | "forwarded": { 333 | "version": "0.1.2", 334 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", 335 | "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" 336 | }, 337 | "fresh": { 338 | "version": "0.5.2", 339 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 340 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" 341 | }, 342 | "http-errors": { 343 | "version": "1.7.2", 344 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", 345 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", 346 | "requires": { 347 | "depd": "~1.1.2", 348 | "inherits": "2.0.3", 349 | "setprototypeof": "1.1.1", 350 | "statuses": ">= 1.5.0 < 2", 351 | "toidentifier": "1.0.0" 352 | } 353 | }, 354 | "iconv-lite": { 355 | "version": "0.4.24", 356 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 357 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 358 | "requires": { 359 | "safer-buffer": ">= 2.1.2 < 3" 360 | } 361 | }, 362 | "ieee754": { 363 | "version": "1.1.13", 364 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", 365 | "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" 366 | }, 367 | "inherits": { 368 | "version": "2.0.3", 369 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 370 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 371 | }, 372 | "ipaddr.js": { 373 | "version": "1.9.1", 374 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 375 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 376 | }, 377 | "is-buffer": { 378 | "version": "2.0.4", 379 | "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", 380 | "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==" 381 | }, 382 | "isarray": { 383 | "version": "1.0.0", 384 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 385 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" 386 | }, 387 | "jmespath": { 388 | "version": "0.15.0", 389 | "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", 390 | "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" 391 | }, 392 | "json2csv": { 393 | "version": "4.5.4", 394 | "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-4.5.4.tgz", 395 | "integrity": "sha512-YxBhY4Lmn8IvVZ36nqg5omxneLy9JlorkqW1j/EDCeqvmi+CQ4uM+wsvXlcIqvGDewIPXMC/O/oF8DX9EH5aoA==", 396 | "requires": { 397 | "commander": "^2.15.1", 398 | "jsonparse": "^1.3.1", 399 | "lodash.get": "^4.4.2" 400 | } 401 | }, 402 | "jsonparse": { 403 | "version": "1.3.1", 404 | "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", 405 | "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" 406 | }, 407 | "lodash.get": { 408 | "version": "4.4.2", 409 | "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", 410 | "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" 411 | }, 412 | "media-typer": { 413 | "version": "0.3.0", 414 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 415 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 416 | }, 417 | "merge-descriptors": { 418 | "version": "1.0.1", 419 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", 420 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 421 | }, 422 | "methods": { 423 | "version": "1.1.2", 424 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 425 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 426 | }, 427 | "mime": { 428 | "version": "1.6.0", 429 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 430 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 431 | }, 432 | "mime-db": { 433 | "version": "1.43.0", 434 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", 435 | "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==" 436 | }, 437 | "mime-types": { 438 | "version": "2.1.26", 439 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", 440 | "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", 441 | "requires": { 442 | "mime-db": "1.43.0" 443 | } 444 | }, 445 | "moment": { 446 | "version": "2.24.0", 447 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", 448 | "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" 449 | }, 450 | "ms": { 451 | "version": "2.0.0", 452 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 453 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 454 | }, 455 | "negotiator": { 456 | "version": "0.6.2", 457 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", 458 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" 459 | }, 460 | "on-finished": { 461 | "version": "2.3.0", 462 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 463 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 464 | "requires": { 465 | "ee-first": "1.1.1" 466 | } 467 | }, 468 | "p-retry": { 469 | "version": "4.2.0", 470 | "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.2.0.tgz", 471 | "integrity": "sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA==", 472 | "requires": { 473 | "@types/retry": "^0.12.0", 474 | "retry": "^0.12.0" 475 | } 476 | }, 477 | "parseurl": { 478 | "version": "1.3.3", 479 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 480 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 481 | }, 482 | "path-to-regexp": { 483 | "version": "0.1.7", 484 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", 485 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 486 | }, 487 | "proxy-addr": { 488 | "version": "2.0.6", 489 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", 490 | "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", 491 | "requires": { 492 | "forwarded": "~0.1.2", 493 | "ipaddr.js": "1.9.1" 494 | } 495 | }, 496 | "punycode": { 497 | "version": "2.1.1", 498 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", 499 | "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" 500 | }, 501 | "qs": { 502 | "version": "6.7.0", 503 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", 504 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" 505 | }, 506 | "querystring": { 507 | "version": "0.2.0", 508 | "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", 509 | "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" 510 | }, 511 | "range-parser": { 512 | "version": "1.2.1", 513 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 514 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 515 | }, 516 | "raw-body": { 517 | "version": "2.4.0", 518 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", 519 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", 520 | "requires": { 521 | "bytes": "3.1.0", 522 | "http-errors": "1.7.2", 523 | "iconv-lite": "0.4.24", 524 | "unpipe": "1.0.0" 525 | } 526 | }, 527 | "retry": { 528 | "version": "0.12.0", 529 | "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", 530 | "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" 531 | }, 532 | "safe-buffer": { 533 | "version": "5.1.2", 534 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 535 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 536 | }, 537 | "safer-buffer": { 538 | "version": "2.1.2", 539 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 540 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 541 | }, 542 | "sax": { 543 | "version": "1.2.1", 544 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", 545 | "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" 546 | }, 547 | "send": { 548 | "version": "0.17.1", 549 | "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", 550 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", 551 | "requires": { 552 | "debug": "2.6.9", 553 | "depd": "~1.1.2", 554 | "destroy": "~1.0.4", 555 | "encodeurl": "~1.0.2", 556 | "escape-html": "~1.0.3", 557 | "etag": "~1.8.1", 558 | "fresh": "0.5.2", 559 | "http-errors": "~1.7.2", 560 | "mime": "1.6.0", 561 | "ms": "2.1.1", 562 | "on-finished": "~2.3.0", 563 | "range-parser": "~1.2.1", 564 | "statuses": "~1.5.0" 565 | }, 566 | "dependencies": { 567 | "ms": { 568 | "version": "2.1.1", 569 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", 570 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 571 | } 572 | } 573 | }, 574 | "serve-static": { 575 | "version": "1.14.1", 576 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", 577 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", 578 | "requires": { 579 | "encodeurl": "~1.0.2", 580 | "escape-html": "~1.0.3", 581 | "parseurl": "~1.3.3", 582 | "send": "0.17.1" 583 | } 584 | }, 585 | "setprototypeof": { 586 | "version": "1.1.1", 587 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", 588 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" 589 | }, 590 | "sprintf-js": { 591 | "version": "1.1.2", 592 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", 593 | "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" 594 | }, 595 | "statuses": { 596 | "version": "1.5.0", 597 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", 598 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" 599 | }, 600 | "toidentifier": { 601 | "version": "1.0.0", 602 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", 603 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" 604 | }, 605 | "type-is": { 606 | "version": "1.6.18", 607 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 608 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 609 | "requires": { 610 | "media-typer": "0.3.0", 611 | "mime-types": "~2.1.24" 612 | } 613 | }, 614 | "unpipe": { 615 | "version": "1.0.0", 616 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 617 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 618 | }, 619 | "url": { 620 | "version": "0.10.3", 621 | "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", 622 | "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", 623 | "requires": { 624 | "punycode": "1.3.2", 625 | "querystring": "0.2.0" 626 | }, 627 | "dependencies": { 628 | "punycode": { 629 | "version": "1.3.2", 630 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", 631 | "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" 632 | } 633 | } 634 | }, 635 | "utils-merge": { 636 | "version": "1.0.1", 637 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 638 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" 639 | }, 640 | "uuid": { 641 | "version": "3.3.2", 642 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", 643 | "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" 644 | }, 645 | "vary": { 646 | "version": "1.1.2", 647 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 648 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" 649 | }, 650 | "xml2js": { 651 | "version": "0.4.19", 652 | "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", 653 | "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", 654 | "requires": { 655 | "sax": ">=0.6.0", 656 | "xmlbuilder": "~9.0.1" 657 | } 658 | }, 659 | "xmlbuilder": { 660 | "version": "9.0.7", 661 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", 662 | "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" 663 | } 664 | } 665 | } 666 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "community-newsletter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "build": "mkdir ./terraform/environments/production/build && zip -x \"*.git*\" -r ./terraform/environments/production/build/collect-history-lambda.zip * -x \"*terraform*\" -x \"*.tf\" -qq", 9 | "deploy-terraform": "./build.sh" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@slack/web-api": "^5.7.0", 16 | "aws-sdk": "^2.625.0", 17 | "dotenv": "^8.2.0", 18 | "express": "^4.17.1", 19 | "json2csv": "^4.5.4", 20 | "moment": "^2.24.0", 21 | "punycode": "^2.1.1", 22 | "sprintf-js": "^1.1.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const { getMessagesFromDb } = require('./src/main'); 4 | const { sortByRating } = require('./src/services/ratings'); 5 | 6 | const app = express(); 7 | app.set('port', process.env.PORT || 4000); 8 | 9 | app.get('/messages/:channelId?', async (req, res) => { 10 | res.header('Access-Control-Allow-Origin', '*'); 11 | res.header( 12 | 'Access-Control-Allow-Headers', 13 | 'Origin, X-Requested-With, Content-Type, Accept' 14 | ); 15 | const channelId = req.params.channelId || ''; 16 | const messages = await getMessagesFromDb(channelId); 17 | return res 18 | .json(await sortByRating(req.query.sortby || 'HOT', messages)) 19 | .status(200); 20 | }); 21 | 22 | app.listen(app.get('port'), () => 23 | console.log(`Server is ready at http://localhost:${app.get('port')}`) 24 | ); 25 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const SlackService = require('./services/slack'); 3 | const { uploadFiles } = require('./services/s3'); 4 | const { getByChannelId } = require('./services/dynamodb'); 5 | const sprintf = require('sprintf-js').sprintf; 6 | const moment = require('moment'); 7 | 8 | module.exports.getMessages = async channelId => { 9 | const slackService = new SlackService(process.env.SLACK_BOT_TOKEN); 10 | let messages = await slackService.getMessagesInLastNDays(channelId, 90); 11 | return messages.filter(message => message.hasLink()); 12 | }; 13 | 14 | module.exports.getMessagesFromDb = async channelId => { 15 | const messages = await getByChannelId(channelId); 16 | return messages.Items; 17 | }; 18 | 19 | module.exports.uploadMessages = async messages => { 20 | const fileName = sprintf( 21 | '%s-messages.csv', 22 | moment() 23 | .subtract(1, 'days') 24 | .format('Y-M-D') 25 | ); 26 | return await uploadFiles({ 27 | bucket: process.env.S3_BUCKET, 28 | messages, 29 | fileName 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/models/message.js: -------------------------------------------------------------------------------- 1 | const { convertSlackEmojisToPunycode } = require("../utils"); 2 | 3 | module.exports = class Message { 4 | constructor({ 5 | text, 6 | sharer, 7 | reactionCount, 8 | timestamp, 9 | messageId, 10 | channelId, 11 | replyCount, 12 | replyUsersCount 13 | }) { 14 | this.links = []; 15 | this.setText(text); 16 | this.sharer = sharer; 17 | this.reactionCount = reactionCount; 18 | this.timestamp = timestamp; 19 | this.messageId = messageId; 20 | this.channelId = channelId; 21 | this.replyCount = replyCount; 22 | this.replyUsersCount = replyUsersCount; 23 | } 24 | 25 | setText(text) { 26 | text = convertSlackEmojisToPunycode(text); 27 | 28 | this.text = text 29 | .replace(/<(\S+)>/g, (_, match) => { 30 | // Remove match if it is a mention 31 | if (match.match(/^#|!|@/)) { 32 | return ""; 33 | } 34 | 35 | let link = match.split("|")[0]; 36 | this.links.push(link); 37 | 38 | return link; 39 | }) 40 | .trim(); 41 | } 42 | 43 | hasLink() { 44 | return this.links.length > 0; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/services/dynamodb.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | 3 | const dynamoDB = new AWS.DynamoDB.DocumentClient({ 4 | accessKeyId: process.env.S3_ACCESS_KEY, 5 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 6 | region: "eu-west-2" 7 | }); 8 | const tableName = "cf-prod-messages-history"; 9 | 10 | module.exports.put = async params => { 11 | return dynamoDB.put({ TableName: tableName, Item: params }).promise(); 12 | }; 13 | 14 | module.exports.getByChannelId = async channelId => { 15 | const params = { 16 | TableName: tableName 17 | }; 18 | if (channelId) { 19 | params.FilterExpression = "#cid = :id"; 20 | params.ExpressionAttributeNames = { 21 | "#cid": "channelId" 22 | }; 23 | params.ExpressionAttributeValues = { 24 | ":id": channelId 25 | }; 26 | } 27 | return dynamoDB.scan(params).promise(); 28 | }; 29 | -------------------------------------------------------------------------------- /src/services/ratings.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | 3 | const sortingMethods = { 4 | default: messages => { 5 | return messages.sort((msg1, msg2) => 6 | msg1.reactionCount < msg2.reactionCount ? 1 : -1 7 | ); 8 | }, 9 | hot: messages => { 10 | // The DConstant is the most important part of this calculation process. 11 | // Hence, this was left out for the moment until we understand the 12 | // significance of this constant in an emprical way. 13 | const dConstant = 12; 14 | const now = moment(); 15 | 16 | const calculateCommunityScore = msg => { 17 | let commentFactor = 0; 18 | if (msg.replyUsersCount) { 19 | commentFactor = msg.replyCount / msg.replyUsersCount; 20 | } 21 | return Math.ceil( 22 | ((2 * commentFactor + msg.reactionCount) / 23 | (dConstant + 24 | Math.floor(now.diff(moment(msg.timestamp)) / 86400000))) * 25 | 100 26 | ); 27 | }; 28 | 29 | return messages.sort((msg1, msg2) => { 30 | msg1.rating = calculateCommunityScore(msg1); 31 | msg2.rating = calculateCommunityScore(msg2); 32 | 33 | return msg1.rating < msg2.rating ? 1 : -1; 34 | }); 35 | }, 36 | latest: messages => { 37 | return messages.sort((msg1, msg2) => 38 | msg1.timestamp < msg2.timestamp ? 1 : -1 39 | ); 40 | } 41 | }; 42 | 43 | module.exports.sortByRating = (sortingMethod, messages) => { 44 | sortingMethod = sortingMethod ? sortingMethod.toLowerCase() : 'default'; 45 | if (typeof sortingMethods[sortingMethod] !== 'function') { 46 | sortingMethod = 'default'; 47 | } 48 | return sortingMethods[sortingMethod](messages); 49 | }; 50 | -------------------------------------------------------------------------------- /src/services/s3.js: -------------------------------------------------------------------------------- 1 | const S3Client = require('aws-sdk/clients/s3'); 2 | const json2csv = require('json2csv'); 3 | 4 | const s3 = new S3Client({ 5 | accessKeyId: process.env.S3_ACCESS_KEY, 6 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY 7 | }); 8 | 9 | module.exports.uploadFiles = async ({ bucket, messages, fileName }) => { 10 | return await s3 11 | .putObject({ 12 | Bucket: bucket, 13 | Key: fileName, 14 | Body: Buffer.from(json2csv.parse(messages)) 15 | }) 16 | .promise(); 17 | }; 18 | -------------------------------------------------------------------------------- /src/services/slack.js: -------------------------------------------------------------------------------- 1 | const { WebClient } = require("@slack/web-api"); 2 | const moment = require("moment"); 3 | const Message = require("../models/message"); 4 | 5 | module.exports = class SlackService { 6 | constructor(token) { 7 | this.client = new WebClient(token); 8 | } 9 | 10 | async getMessagesInLastNDays(channelId, n = 7, unit = "days") { 11 | let cursor = null; 12 | let messages = []; 13 | 14 | while (true) { 15 | let response = await this.client.conversations.history({ 16 | channel: channelId, 17 | oldest: moment() 18 | .subtract(n, unit) 19 | .unix(), 20 | cursor: cursor, 21 | limit: 500 22 | }); 23 | 24 | messages.push( 25 | ...(await Promise.all( 26 | response.messages.map(async msg => { 27 | // Commented as we face rate limit by this api call 28 | // let sharer = await this.getUserRealNameById(msg.user); 29 | let reactionCount = !msg.hasOwnProperty("reactions") 30 | ? 0 31 | : msg.reactions.reduce( 32 | (total, reaction) => total + reaction.count, 33 | 0 34 | ); 35 | return new Message({ 36 | text: msg.text, 37 | reactionCount, 38 | timestamp: msg.ts * 1000, 39 | messageId: msg.client_msg_id, 40 | channelId, 41 | replyCount: msg.reply_count || 0, 42 | replyUsersCount: msg.reply_users_count || 0 43 | }); 44 | }) 45 | )) 46 | ); 47 | 48 | if (!response.has_more) { 49 | break; 50 | } 51 | 52 | cursor = response.response_metadata.next_cursor; 53 | } 54 | 55 | return messages; 56 | } 57 | 58 | async getUserRealNameById(id) { 59 | let response = await this.client.users.info({ 60 | user: id 61 | }); 62 | 63 | return response.user.real_name; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const punycode = require('punycode'); 2 | const emojiData = require('./emoji_data.json'); 3 | 4 | // Taken from: https://github.com/aaronpk/Slack-IRC-Gateway/blob/541cc464e60e6146c305afd5efc521f6553f690c/emoji.js 5 | module.exports.convertSlackEmojisToPunycode = (text) => { 6 | let emojiRegex = /\:([a-zA-Z0-9\-_\+]+)\:(?:\:([a-zA-Z0-9\-_\+]+)\:)?/g; 7 | let converted = text; 8 | let match; 9 | 10 | // Find all Slack emoji in the message 11 | while (match = emojiRegex.exec(text)) { 12 | let emoji = emojiData.find(function (el) { 13 | return el.short_name == match[1]; 14 | }); 15 | 16 | if (emoji) { 17 | let points = emoji.unified.split("-"); 18 | points = points.map(function (p) { 19 | return parseInt(p, 16) 20 | }); 21 | converted = converted.replace(match[0], punycode.ucs2.encode(points)); 22 | } 23 | } 24 | 25 | return converted; 26 | }; 27 | -------------------------------------------------------------------------------- /terraform/environments/production/backend.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "eu-west-2" 3 | } 4 | 5 | terraform { 6 | backend "s3" { 7 | bucket = "community-newsletter-app-state" 8 | key = "terraform/dev/terraform_dev.tfstate" 9 | region = "eu-west-2" 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /terraform/environments/production/chat-history-database.tf: -------------------------------------------------------------------------------- 1 | module "newsletter-chat-history-database" { 2 | source = "../../modules/dynamodb" 3 | namespace = "cf" 4 | stage = "prod" 5 | name = "messages-history" 6 | hash_key = "messageId" 7 | hash_key_type = "S" 8 | enable_autoscaler = false 9 | } 10 | 11 | -------------------------------------------------------------------------------- /terraform/environments/production/scheduled-lambda.tf: -------------------------------------------------------------------------------- 1 | module "newsletter-collect-history-lambda" { 2 | source = "spring-media/lambda/aws" 3 | version = "5.1.0" 4 | filename = "${path.module}/build/collect-history-lambda.zip" 5 | function_name = "CollectHistory" 6 | handler = "collectHistoryFunction.index" 7 | runtime = "nodejs10.x" 8 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 9 | publish = true 10 | timeout = 15 11 | memory_size = 256 12 | 13 | event = { 14 | type = "cloudwatch-event" 15 | schedule_expression = "rate(4 hours)" 16 | } 17 | 18 | environment = { 19 | variables = { 20 | SLACK_BOT_TOKEN = var.slack_bot_token 21 | CHANNEL_ID = "C0MFTF0Q4" 22 | S3_ACCESS_KEY = var.s3_access_key 23 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 24 | S3_BUCKET = var.s3_bucket 25 | } 26 | } 27 | } 28 | 29 | module "newsletter-collect-history-lambda-security-channel" { 30 | source = "spring-media/lambda/aws" 31 | version = "5.1.0" 32 | filename = "${path.module}/build/collect-history-lambda.zip" 33 | function_name = "CollectHistorySecurity" 34 | handler = "collectHistoryFunction.index" 35 | runtime = "nodejs10.x" 36 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 37 | publish = true 38 | timeout = 15 39 | memory_size = 256 40 | 41 | event = { 42 | type = "cloudwatch-event" 43 | schedule_expression = "rate(4 hours)" 44 | } 45 | 46 | environment = { 47 | variables = { 48 | SLACK_BOT_TOKEN = var.slack_bot_token 49 | CHANNEL_ID = "CTKGFDWKA" 50 | S3_ACCESS_KEY = var.s3_access_key 51 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 52 | S3_BUCKET = var.s3_bucket 53 | } 54 | } 55 | } 56 | 57 | module "newsletter-collect-history-lambda-gaming-channel" { 58 | source = "spring-media/lambda/aws" 59 | version = "5.1.0" 60 | filename = "${path.module}/build/collect-history-lambda.zip" 61 | function_name = "CollectHistoryGaming" 62 | handler = "collectHistoryFunction.index" 63 | runtime = "nodejs10.x" 64 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 65 | publish = true 66 | timeout = 15 67 | memory_size = 256 68 | 69 | event = { 70 | type = "cloudwatch-event" 71 | schedule_expression = "rate(4 hours)" 72 | } 73 | 74 | environment = { 75 | variables = { 76 | SLACK_BOT_TOKEN = var.slack_bot_token 77 | CHANNEL_ID = "CQ01KDCTE" 78 | S3_ACCESS_KEY = var.s3_access_key 79 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 80 | S3_BUCKET = var.s3_bucket 81 | } 82 | } 83 | } 84 | 85 | module "newsletter-collect-history-lambda-random-channel" { 86 | source = "spring-media/lambda/aws" 87 | version = "5.1.0" 88 | filename = "${path.module}/build/collect-history-lambda.zip" 89 | function_name = "CollectHistoryRandom" 90 | handler = "collectHistoryFunction.index" 91 | runtime = "nodejs10.x" 92 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 93 | publish = true 94 | timeout = 15 95 | memory_size = 256 96 | 97 | event = { 98 | type = "cloudwatch-event" 99 | schedule_expression = "rate(4 hours)" 100 | } 101 | 102 | environment = { 103 | variables = { 104 | SLACK_BOT_TOKEN = var.slack_bot_token 105 | CHANNEL_ID = "CKX5L3UTS" 106 | S3_ACCESS_KEY = var.s3_access_key 107 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 108 | S3_BUCKET = var.s3_bucket 109 | } 110 | } 111 | } 112 | 113 | module "newsletter-collect-history-lambda-remote-workers-channel" { 114 | source = "spring-media/lambda/aws" 115 | version = "5.1.0" 116 | filename = "${path.module}/build/collect-history-lambda.zip" 117 | function_name = "CollectHistoryRemoteWorking" 118 | handler = "collectHistoryFunction.index" 119 | runtime = "nodejs10.x" 120 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 121 | publish = true 122 | timeout = 15 123 | memory_size = 256 124 | 125 | event = { 126 | type = "cloudwatch-event" 127 | schedule_expression = "rate(4 hours)" 128 | } 129 | 130 | environment = { 131 | variables = { 132 | SLACK_BOT_TOKEN = var.slack_bot_token 133 | CHANNEL_ID = "C010284QJ1E" 134 | S3_ACCESS_KEY = var.s3_access_key 135 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 136 | S3_BUCKET = var.s3_bucket 137 | } 138 | } 139 | } 140 | 141 | 142 | module "newsletter-collect-history-lambda-covid-channel" { 143 | source = "spring-media/lambda/aws" 144 | version = "5.1.0" 145 | filename = "${path.module}/build/collect-history-lambda.zip" 146 | function_name = "CollectHistoryCovidNews" 147 | handler = "collectHistoryFunction.index" 148 | runtime = "nodejs10.x" 149 | source_code_hash = filebase64sha256("${path.module}/build/collect-history-lambda.zip") 150 | publish = true 151 | timeout = 15 152 | memory_size = 256 153 | 154 | event = { 155 | type = "cloudwatch-event" 156 | schedule_expression = "rate(4 hours)" 157 | } 158 | 159 | environment = { 160 | variables = { 161 | SLACK_BOT_TOKEN = var.slack_bot_token 162 | CHANNEL_ID = "CVB7MGSV7" 163 | S3_ACCESS_KEY = var.s3_access_key 164 | S3_SECRET_ACCESS_KEY = var.s3_secret_access_key 165 | S3_BUCKET = var.s3_bucket 166 | } 167 | } 168 | } -------------------------------------------------------------------------------- /terraform/environments/production/site-hosting-bucket.tf: -------------------------------------------------------------------------------- 1 | module "newsletter-static-bucket" { 2 | source = "../../modules/s3/web_hosting_s3_bucket" 3 | bucket = "newsletter.codefiction.tech" 4 | region = "eu-west-2" 5 | remote_state = var.remote_state 6 | web_page_user = "newsletter-static-bucket-user" 7 | } 8 | 9 | -------------------------------------------------------------------------------- /terraform/environments/production/variables_inputs.tf: -------------------------------------------------------------------------------- 1 | variable "remote_state" { 2 | default = { 3 | bucket = "community-newsletter-app-state" 4 | key = "terraform/dev/terraform_dev.tfstate" 5 | region = "eu-west-2" 6 | } 7 | } 8 | 9 | variable "slack_bot_token" {} 10 | variable "channel_id" {} 11 | variable "s3_access_key" {} 12 | variable "s3_secret_access_key" {} 13 | variable "s3_bucket" {} 14 | -------------------------------------------------------------------------------- /terraform/environments/production/versions.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | required_version = ">= 0.12" 4 | } 5 | -------------------------------------------------------------------------------- /terraform/modules/codepipeline/codepipeline.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | id = "codepipeline-${var.namespace}-${var.stage}-${var.name}" 3 | } 4 | 5 | # CodePipeline resources 6 | resource "aws_s3_bucket" "build_artifact_bucket" { 7 | bucket = "${local.id}-artifacts-bucket" 8 | acl = "private" 9 | } 10 | 11 | data "aws_iam_policy_document" "codepipeline_assume_policy" { 12 | statement { 13 | effect = "Allow" 14 | actions = ["sts:AssumeRole"] 15 | 16 | principals { 17 | type = "Service" 18 | identifiers = ["codepipeline.amazonaws.com"] 19 | } 20 | } 21 | } 22 | 23 | resource "aws_iam_role" "codepipeline_role" { 24 | name = "${local.id}-codepipeline-role" 25 | assume_role_policy = "${data.aws_iam_policy_document.codepipeline_assume_policy.json}" 26 | } 27 | 28 | # CodePipeline policy needed to use CodeCommit and CodeBuild 29 | resource "aws_iam_role_policy" "attach_codepipeline_policy" { 30 | name = "${local.id}-codepipeline-policy" 31 | role = "${aws_iam_role.codepipeline_role.id}" 32 | 33 | policy = < 0 ? 0 : 1 30 | 31 | attributes_final = slice(local.attributes, local.from_index, length(local.attributes)) 32 | } 33 | 34 | resource "null_resource" "global_secondary_index_names" { 35 | count = (var.enabled ? 1 : 0) * length(var.global_secondary_index_map) 36 | 37 | # Convert the multi-item `global_secondary_index_map` into a simple `map` with just one item `name` since `triggers` does not support `lists` in `maps` (which are used in `non_key_attributes`) 38 | # See `examples/complete` 39 | # https://www.terraform.io/docs/providers/aws/r/dynamodb_table.html#non_key_attributes-1 40 | triggers = { 41 | "name" = var.global_secondary_index_map[count.index]["name"] 42 | } 43 | } 44 | 45 | resource "null_resource" "local_secondary_index_names" { 46 | count = (var.enabled ? 1 : 0) * length(var.local_secondary_index_map) 47 | 48 | # Convert the multi-item `local_secondary_index_map` into a simple `map` with just one item `name` since `triggers` does not support `lists` in `maps` (which are used in `non_key_attributes`) 49 | # See `examples/complete` 50 | # https://www.terraform.io/docs/providers/aws/r/dynamodb_table.html#non_key_attributes-1 51 | triggers = { 52 | "name" = var.local_secondary_index_map[count.index]["name"] 53 | } 54 | } 55 | 56 | resource "aws_dynamodb_table" "default" { 57 | count = var.enabled ? 1 : 0 58 | name = module.dynamodb_label.id 59 | billing_mode = var.billing_mode 60 | read_capacity = var.autoscale_min_read_capacity 61 | write_capacity = var.autoscale_min_write_capacity 62 | hash_key = var.hash_key 63 | range_key = var.range_key 64 | stream_enabled = var.enable_streams 65 | stream_view_type = var.enable_streams ? var.stream_view_type : "" 66 | 67 | server_side_encryption { 68 | enabled = var.enable_encryption 69 | } 70 | 71 | point_in_time_recovery { 72 | enabled = var.enable_point_in_time_recovery 73 | } 74 | 75 | lifecycle { 76 | ignore_changes = [ 77 | read_capacity, 78 | write_capacity 79 | ] 80 | } 81 | 82 | dynamic "attribute" { 83 | for_each = local.attributes_final 84 | content { 85 | name = attribute.value.name 86 | type = attribute.value.type 87 | } 88 | } 89 | 90 | dynamic "global_secondary_index" { 91 | for_each = var.global_secondary_index_map 92 | content { 93 | hash_key = global_secondary_index.value.hash_key 94 | name = global_secondary_index.value.name 95 | non_key_attributes = lookup(global_secondary_index.value, "non_key_attributes", null) 96 | projection_type = global_secondary_index.value.projection_type 97 | range_key = lookup(global_secondary_index.value, "range_key", null) 98 | read_capacity = lookup(global_secondary_index.value, "read_capacity", null) 99 | write_capacity = lookup(global_secondary_index.value, "write_capacity", null) 100 | } 101 | } 102 | 103 | dynamic "local_secondary_index" { 104 | for_each = var.local_secondary_index_map 105 | content { 106 | name = local_secondary_index.value.name 107 | non_key_attributes = lookup(local_secondary_index.value, "non_key_attributes", null) 108 | projection_type = local_secondary_index.value.projection_type 109 | range_key = local_secondary_index.value.range_key 110 | } 111 | } 112 | 113 | ttl { 114 | attribute_name = var.ttl_attribute 115 | enabled = var.ttl_attribute != "" ? true : false 116 | } 117 | 118 | tags = module.dynamodb_label.tags 119 | } 120 | 121 | module "dynamodb_autoscaler" { 122 | source = "git::https://github.com/cloudposse/terraform-aws-dynamodb-autoscaler.git?ref=tags/0.4.0" 123 | enabled = var.enabled && var.enable_autoscaler && var.billing_mode == "PROVISIONED" 124 | namespace = var.namespace 125 | stage = var.stage 126 | name = var.name 127 | delimiter = var.delimiter 128 | attributes = var.attributes 129 | dynamodb_table_name = concat(aws_dynamodb_table.default.*.id, [""])[0] 130 | dynamodb_table_arn = concat(aws_dynamodb_table.default.*.arn, [""])[0] 131 | dynamodb_indexes = null_resource.global_secondary_index_names.*.triggers.name 132 | autoscale_write_target = var.autoscale_write_target 133 | autoscale_read_target = var.autoscale_read_target 134 | autoscale_min_read_capacity = var.autoscale_min_read_capacity 135 | autoscale_max_read_capacity = var.autoscale_max_read_capacity 136 | autoscale_min_write_capacity = var.autoscale_min_write_capacity 137 | autoscale_max_write_capacity = var.autoscale_max_write_capacity 138 | } 139 | -------------------------------------------------------------------------------- /terraform/modules/dynamodb/output.tf: -------------------------------------------------------------------------------- 1 | output "table_name" { 2 | value = concat(aws_dynamodb_table.default.*.name, [""])[0] 3 | description = "DynamoDB table name" 4 | } 5 | 6 | output "table_id" { 7 | value = concat(aws_dynamodb_table.default.*.id, [""])[0] 8 | description = "DynamoDB table ID" 9 | } 10 | 11 | output "table_arn" { 12 | value = concat(aws_dynamodb_table.default.*.arn, [""])[0] 13 | description = "DynamoDB table ARN" 14 | } 15 | 16 | output "global_secondary_index_names" { 17 | value = null_resource.global_secondary_index_names.*.triggers.name 18 | description = "DynamoDB secondary index names" 19 | } 20 | 21 | output "local_secondary_index_names" { 22 | value = null_resource.local_secondary_index_names.*.triggers.name 23 | description = "DynamoDB local index names" 24 | } 25 | 26 | output "table_stream_arn" { 27 | value = concat(aws_dynamodb_table.default.*.stream_arn, [""])[0] 28 | description = "DynamoDB table stream ARN" 29 | } 30 | 31 | output "table_stream_label" { 32 | value = concat(aws_dynamodb_table.default.*.stream_label, [""])[0] 33 | description = "DynamoDB table stream label" 34 | } 35 | -------------------------------------------------------------------------------- /terraform/modules/dynamodb/variables_input.tf: -------------------------------------------------------------------------------- 1 | variable "aws_region" { 2 | type = "string" 3 | default = "eu-west-2" 4 | description = "(Optional) AWS Region, e.g. us-east-1. Used as CodeBuild ENV variable when building Docker images. For more info: http://docs.aws.amazon.com/codebuild/latest/userguide/sample-docker.html" 5 | } 6 | 7 | variable "namespace" { 8 | type = string 9 | description = "Namespace (e.g. `eg` or `cp`)" 10 | default = "" 11 | } 12 | 13 | variable "stage" { 14 | type = string 15 | description = "Stage (e.g. `prod`, `dev`, `staging`, `infra`)" 16 | default = "" 17 | } 18 | 19 | variable "enabled" { 20 | type = bool 21 | description = "Set to false to prevent the module from creating any resources" 22 | default = true 23 | } 24 | 25 | variable "name" { 26 | type = string 27 | description = "Name (e.g. `app` or `cluster`)" 28 | } 29 | 30 | variable "delimiter" { 31 | type = string 32 | default = "-" 33 | description = "Delimiter to be used between `namespace`, `stage`, `name`, and `attributes`" 34 | } 35 | 36 | variable "attributes" { 37 | type = list(string) 38 | default = [] 39 | description = "Additional attributes (e.g. `policy` or `role`)" 40 | } 41 | 42 | variable "tags" { 43 | type = map(string) 44 | default = {} 45 | description = "Additional tags (e.g. map(`BusinessUnit`,`XYZ`)" 46 | } 47 | 48 | variable "autoscale_write_target" { 49 | type = number 50 | default = 50 51 | description = "The target value (in %) for DynamoDB write autoscaling" 52 | } 53 | 54 | variable "autoscale_read_target" { 55 | type = number 56 | default = 50 57 | description = "The target value (in %) for DynamoDB read autoscaling" 58 | } 59 | 60 | variable "autoscale_min_read_capacity" { 61 | type = number 62 | default = 5 63 | description = "DynamoDB autoscaling min read capacity" 64 | } 65 | 66 | variable "autoscale_max_read_capacity" { 67 | type = number 68 | default = 20 69 | description = "DynamoDB autoscaling max read capacity" 70 | } 71 | 72 | variable "autoscale_min_write_capacity" { 73 | type = number 74 | default = 5 75 | description = "DynamoDB autoscaling min write capacity" 76 | } 77 | 78 | variable "autoscale_max_write_capacity" { 79 | type = number 80 | default = 20 81 | description = "DynamoDB autoscaling max write capacity" 82 | } 83 | 84 | variable "billing_mode" { 85 | type = string 86 | default = "PROVISIONED" 87 | description = "DynamoDB Billing mode. Can be PROVISIONED or PAY_PER_REQUEST" 88 | } 89 | 90 | variable "enable_streams" { 91 | type = bool 92 | default = false 93 | description = "Enable DynamoDB streams" 94 | } 95 | 96 | variable "stream_view_type" { 97 | type = string 98 | default = "" 99 | description = "When an item in the table is modified, what information is written to the stream" 100 | } 101 | 102 | variable "enable_encryption" { 103 | type = bool 104 | default = true 105 | description = "Enable DynamoDB server-side encryption" 106 | } 107 | 108 | variable "enable_point_in_time_recovery" { 109 | type = bool 110 | default = true 111 | description = "Enable DynamoDB point in time recovery" 112 | } 113 | 114 | variable "hash_key" { 115 | type = string 116 | description = "DynamoDB table Hash Key" 117 | } 118 | 119 | variable "hash_key_type" { 120 | type = string 121 | default = "S" 122 | description = "Hash Key type, which must be a scalar type: `S`, `N`, or `B` for (S)tring, (N)umber or (B)inary data" 123 | } 124 | 125 | variable "range_key" { 126 | type = string 127 | default = "" 128 | description = "DynamoDB table Range Key" 129 | } 130 | 131 | variable "range_key_type" { 132 | type = string 133 | default = "S" 134 | description = "Range Key type, which must be a scalar type: `S`, `N`, or `B` for (S)tring, (N)umber or (B)inary data" 135 | } 136 | 137 | variable "ttl_attribute" { 138 | type = string 139 | default = "Expires" 140 | description = "DynamoDB table TTL attribute" 141 | } 142 | 143 | variable "enable_autoscaler" { 144 | type = string 145 | default = "true" 146 | description = "Flag to enable/disable DynamoDB autoscaling" 147 | } 148 | 149 | variable "dynamodb_attributes" { 150 | type = list(object({ 151 | name = string 152 | type = string 153 | })) 154 | default = [] 155 | description = "Additional DynamoDB attributes in the form of a list of mapped values" 156 | } 157 | 158 | variable "global_secondary_index_map" { 159 | type = list(object({ 160 | hash_key = string 161 | name = string 162 | non_key_attributes = list(string) 163 | projection_type = string 164 | range_key = string 165 | read_capacity = number 166 | write_capacity = number 167 | })) 168 | default = [] 169 | description = "Additional global secondary indexes in the form of a list of mapped values" 170 | } 171 | 172 | variable "local_secondary_index_map" { 173 | type = list(object({ 174 | name = string 175 | non_key_attributes = list(string) 176 | projection_type = string 177 | range_key = string 178 | })) 179 | default = [] 180 | description = "Additional local secondary indexes in the form of a list of mapped values" 181 | } 182 | 183 | variable "regex_replace_chars" { 184 | type = string 185 | default = "/[^a-zA-Z0-9-]/" 186 | description = "Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. By default only hyphens, letters and digits are allowed, all other chars are removed" 187 | } 188 | -------------------------------------------------------------------------------- /terraform/modules/s3/bucket/bucket.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_user" "user" { 2 | name = "${var.bucket_user}" 3 | } 4 | 5 | resource "aws_iam_access_key" "access_key" { 6 | user = "${aws_iam_user.user.name}" 7 | } 8 | 9 | data "aws_iam_policy_document" "policy" { 10 | statement { 11 | effect = "Allow" 12 | 13 | actions = [ 14 | "s3:ListBucket", 15 | ] 16 | 17 | resources = [ 18 | "arn:aws:s3:::${var.bucket}", 19 | ] 20 | 21 | principals { 22 | type = "AWS" 23 | identifiers = ["${aws_iam_user.user.arn}"] 24 | } 25 | } 26 | 27 | statement { 28 | effect = "Allow" 29 | 30 | actions = [ 31 | "s3:DeleteObject", 32 | "s3:GetObject", 33 | "s3:GetObjectAcl", 34 | "s3:PutObject", 35 | "s3:PutObjectAcl", 36 | ] 37 | 38 | resources = [ 39 | "arn:aws:s3:::${var.bucket}/*", 40 | ] 41 | 42 | principals { 43 | type = "AWS" 44 | identifiers = ["${aws_iam_user.user.arn}"] 45 | } 46 | } 47 | } 48 | 49 | resource "aws_s3_bucket" "bucket" { 50 | bucket = "${var.bucket}" 51 | region = "${var.region}" 52 | policy = "${data.aws_iam_policy_document.policy.json}" 53 | } 54 | -------------------------------------------------------------------------------- /terraform/modules/s3/bucket/variables_input.tf: -------------------------------------------------------------------------------- 1 | variable "bucket" {} 2 | 3 | variable "bucket_user" {} 4 | 5 | variable "region" { 6 | default = "eu-west-2" 7 | } 8 | -------------------------------------------------------------------------------- /terraform/modules/s3/web_hosting_s3_bucket/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy_document" "policy" { 2 | statement { 3 | effect = "Allow" 4 | 5 | actions = [ 6 | "s3:DeleteObject", 7 | "s3:GetObject", 8 | "s3:GetObjectAcl", 9 | "s3:PutObject", 10 | "s3:PutObjectAcl", 11 | ] 12 | 13 | resources = [ 14 | "arn:aws:s3:::${var.bucket}/*", 15 | ] 16 | 17 | principals { 18 | type = "AWS" 19 | identifiers = ["${aws_iam_user.user.arn}"] 20 | } 21 | } 22 | 23 | statement { 24 | effect = "Allow" 25 | 26 | actions = [ 27 | "s3:GetObject", 28 | ] 29 | 30 | resources = [ 31 | "arn:aws:s3:::${var.bucket}/*", 32 | ] 33 | 34 | principals { 35 | type = "AWS" 36 | identifiers = ["*"] 37 | } 38 | } 39 | } 40 | 41 | resource "aws_s3_bucket" "bucket" { 42 | bucket = "${var.bucket}" 43 | region = "${var.region}" 44 | acl = "public-read" 45 | policy = "${data.aws_iam_policy_document.policy.json}" 46 | 47 | website { 48 | index_document = "index.html" 49 | error_document = "index.html" 50 | } 51 | } 52 | output "user_access_key" { 53 | value = "${aws_iam_access_key.access_key.id}" 54 | } 55 | 56 | output "user_access_secret" { 57 | value = "${aws_iam_access_key.access_key.secret}" 58 | sensitive = true 59 | } 60 | -------------------------------------------------------------------------------- /terraform/modules/s3/web_hosting_s3_bucket/s3_user.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_user" "user" { 2 | name = "${var.web_page_user}" 3 | } 4 | 5 | resource "aws_iam_access_key" "access_key" { 6 | user = "${aws_iam_user.user.name}" 7 | } -------------------------------------------------------------------------------- /terraform/modules/s3/web_hosting_s3_bucket/variables_input.tf: -------------------------------------------------------------------------------- 1 | variable "bucket" {} 2 | variable "region" { 3 | default = "eu-west-2" 4 | } 5 | 6 | variable "web_page_user" {} 7 | 8 | variable "remote_state" { 9 | default = { 10 | bucket = {} 11 | key_base = {} 12 | region = "eu-west-2" 13 | } 14 | } 15 | --------------------------------------------------------------------------------