├── .editorconfig ├── .env ├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── Dockerfile ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── TODO.md ├── deploy.sh ├── global.d.ts ├── package-lock.json ├── package.json ├── src ├── .gitignore ├── api │ ├── controllers │ │ ├── auth.controller.ts │ │ ├── upload.controller.ts │ │ └── user.controller.ts │ ├── middlewares │ │ ├── auth.d.ts │ │ ├── auth.ts │ │ └── error.ts │ ├── models │ │ ├── index.ts │ │ ├── refreshToken.model.ts │ │ ├── user.model.ts │ │ └── userNote.model.ts │ ├── routes │ │ └── v1 │ │ │ ├── auth.route.ts │ │ │ ├── index.ts │ │ │ ├── upload.route.ts │ │ │ └── user.route.ts │ ├── services │ │ ├── authProviders.ts │ │ └── socket.ts │ ├── utils │ │ ├── APIError.ts │ │ ├── Const.ts │ │ ├── InitData.ts │ │ ├── ModelUtils.ts │ │ ├── MsgUtils.ts │ │ └── Utils.ts │ └── validations │ │ ├── auth.validation.ts │ │ └── user.validation.ts ├── config │ ├── express.ts │ ├── https │ │ ├── cert.pem │ │ ├── key.pem │ │ ├── keytmp.pem │ │ ├── localhost-key.pem │ │ └── localhost.pem │ ├── mongoose.ts │ ├── passport.ts │ └── vars.ts ├── index.ts └── templates │ └── emails │ ├── forgot-password.html │ ├── forgot-password.txt │ ├── welcome.html │ └── welcome.txt ├── src_docs ├── build.md ├── dependencies.md ├── features.md ├── postman-examples.json └── rest-client-examples.rest ├── tests └── integration │ ├── auth.test.ts │ └── user.test.ts ├── tsconfig.json ├── tslint.json ├── ui ├── .env ├── .gitignore ├── README.md ├── editorconfig ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ ├── Home │ │ │ ├── Home.tsx │ │ │ └── LikeIcon.tsx │ │ ├── ItemView │ │ │ └── ItemView.tsx │ │ ├── Login │ │ │ ├── Login.tsx │ │ │ ├── LoginBox.css │ │ │ └── LoginBox.tsx │ │ └── base │ │ │ ├── index.tsx │ │ │ └── styles.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── styles │ │ ├── app.css │ │ └── tailwind.css │ └── utils │ │ └── apiUtil.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock ├── uploads └── README.md ├── vercel.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,jsx,ts,tsx}] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | quote_type = single 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3009 3 | 4 | MONGO_URI=mongodb://localhost:27017/node-rem 5 | MONGO_URI_TESTS=mongodb://localhost:27017/node-rem 6 | 7 | SOCKET_ENABLED=true 8 | 9 | JWT_SECRET=bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4 10 | JWT_EXPIRATION_MINUTES=1440 11 | 12 | SLACK_WEBHOOK_URL= 13 | 14 | EMAIL_FROM_SUPPORT=Support 15 | EMAIL_MAILGUN_API_KEY= 16 | EMAIL_MAILGUN_DOMAIN= 17 | 18 | SEC_ADMIN_EMAIL=admin@yourdomain.com 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3009 3 | 4 | MONGO_URI=mongodb://localhost:27017/node-rem 5 | MONGO_URI_TESTS=mongodb://localhost:27017/node-rem 6 | 7 | SOCKET_ENABLED=true 8 | 9 | JWT_SECRET=bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4 10 | JWT_EXPIRATION_MINUTES=1440 11 | 12 | SLACK_WEBHOOK_URL= 13 | 14 | EMAIL_FROM_SUPPORT=Support 15 | EMAIL_MAILGUN_API_KEY= 16 | EMAIL_MAILGUN_DOMAIN= 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "no-underscore-dangle": 0, 5 | "no-unused-vars": ["error", { "argsIgnorePattern": "next" }], 6 | "no-use-before-define": ["error", { "variables": false }], 7 | "no-multi-str": 0, 8 | "arrow-parens": 0, 9 | "comma-dangle": 0, 10 | "object-curly-newline": 0, 11 | "import/no-extraneous-dependencies": 0, 12 | "no-mixed-operators": 0, 13 | "max-len": ["error", 120], 14 | "import/no-unresolved": 0 15 | }, 16 | "env": { 17 | "node": true, 18 | "mocha": true 19 | }, 20 | "parserOptions": { 21 | "ecmaVersion": 8 22 | }, 23 | "extends": [ 24 | "airbnb-base" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom 2 | built 3 | 4 | .DS_Store 5 | /**/.DS_Store 6 | 7 | # Environment variables 8 | .env.prod 9 | .env.production 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | 16 | # Documentation 17 | docs 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (http://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules 44 | jspm_packages 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Others 53 | .python-version 54 | .netlify 55 | 56 | uploads/* 57 | !uploads/README.md 58 | 59 | .vercel 60 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 120, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "none", 7 | "jsxBracketSameLine": false, 8 | "parser": "typescript" 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '14' 3 | 4 | git: 5 | depth: 3 6 | 7 | branches: 8 | only: 9 | - master 10 | - /^greenkeeper/.*$/ 11 | 12 | services: 13 | - mongodb 14 | 15 | env: 16 | global: 17 | - NODE_ENV=test 18 | - PORT=3009 19 | - JWT_SECRET=bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4 20 | - JWT_EXPIRATION_MINUTES=15 21 | 22 | script: yarn validate 23 | 24 | before_install: yarn global add greenkeeper-lockfile@1 25 | before_script: greenkeeper-lockfile-update 26 | after_script: greenkeeper-lockfile-upload 27 | 28 | # deploy: 29 | # - provider: script 30 | # script: yarn deploy 31 | 32 | after_success: yarn coverage 33 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | { 3 | "recommendations": [ 4 | "waderyan.gitblame", 5 | "editorconfig.editorconfig", 6 | "donjayamanne.githistory", 7 | "esbenp.prettier-vscode", 8 | "eg2.vscode-npm-script", 9 | "ms-azuretools.vscode-docker", 10 | "redhat.vscode-yaml", 11 | "me-dutour-mathieu.vscode-github-actions", 12 | "coddx.coddx-alpha", 13 | "humao.rest-client" 14 | ], 15 | "unwantedRecommendations": [ 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "dist/": true, 5 | "built/": true, 6 | "static/": true 7 | }, 8 | "files.exclude": { 9 | "**/node_modules": true, 10 | "npm-debug.log*": true }, 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "[json]": { 14 | "editor.formatOnSave": false 15 | }, 16 | "tailwindCSS.includeLanguages": { 17 | "tsx": "tsx" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.11.5] - 2022-03-17 2 | - update dependencies. 3 | - removed mstime. 4 | 5 | ## [0.11.2] - 2021-04-28 6 | - added new routes: POST /:userId/notes/:noteId/like 7 | - UI Example: added simple components, Modal. 8 | 9 | ## [0.11.0] - 2021-03-07 10 | - UI Example: added selectedItem, ItemView, read & update Item. 11 | - added new routes: GET, POST /user/:userId/notes/:noteId 12 | - user.validation.ts: updated listUsers validation. 13 | - added "rest-client-example.rest" - used in VSCode Rest Client extension. 14 | - upgraded dependencies. 15 | - added vercel.json config file for deploying to Vercel. 16 | - BREAKING: use "/api/v1/" for all endpoints. 17 | 18 | ## [0.10.5] - 2021-02-23 19 | - fixed npm run build: added rimraf. 20 | - added TODO.md 21 | 22 | ## [0.10.2] - 2021-02-03 23 | - removed symlink script (package_symlinks.js) to run on Windows. 24 | - changed to use HTTP (easier for beginners to use) instead of HTTPS. 25 | - added "postman-examples.json" (Postman Collection). 26 | 27 | ## [0.10.1] - 2020-11-05 28 | - upgraded dependencies. 29 | - added endpoint: create User Note: POST /users/USERID/notes - payload { title, note } 30 | - added endpoint: POST /auth/logout - payload { userId } 31 | - added a CRA v4 webapp as an example to access APIs. 32 | - added "yarn build" using "tsc" 33 | 34 | ## [0.9.0] - 2019-10-27 35 | 36 | ### Added 37 | - support MongoDB populate - example: '&populate=author:_id,firstName&populate=book:_id,url' 38 | ### Changed 39 | - switched to CodeClimate for better static code analysis. 40 | - codeclimate: refactored ModelUtils.listData; fixed duplicate logic. 41 | - BREAKING: Utils: startTimer, endTimer: changed function arguments. 42 | 43 | ## [0.8.0] - 2019-05-08 44 | 45 | ### Changed 46 | - updated self-signed cert generated by mkcert 47 | - upgraded dependencies: slack, mongoose 48 | 49 | ## [0.7.8] - 2019-03-23 50 | 51 | ### Added 52 | - new self-signed cert (localhost.key, localhost.crt) 53 | - route & controller to delete user note /:userId/notes/:noteId 54 | ### Changed 55 | - /status returns a json now 56 | 57 | ## [0.7.2] - 2019-03-06 58 | 59 | ### Added 60 | - initData.ts - initialize dev data (admin user & some data) 61 | - userNote model - a simple example of model 62 | - listUserNotes - a simple example to query & return data 63 | - Utils.getQuery - support partial text search (e.g. ¬e=*sometext*) 64 | ### Fixed 65 | - socket on connect 66 | ### Changed 67 | - BREAKING: renamed ALLOW_FIELDS to ALLOWED_FIELDS 68 | 69 | ## [0.6.6] - 2019-02-28 70 | 71 | ### Added 72 | - support for "&fields" param in Model.transform(req) to include specific fields in API response 73 | - added Utils.getQuery to get safe query fields from req.query 74 | - added ModelUtils transformData and listData 75 | - added MsgUtils slackWebhook to send message using Slack Incoming Webhook 76 | - added MsgUtils sendEmail (using nodemailer & mailgun) 77 | - added MsgUtils email template function, e.g. sendEmail(welcomeEmail({ name, email })) 78 | - added multer to handle file upload 79 | - added "features.md" to explain features in details 80 | - added /forgot-password route & controller 81 | ### Fixed 82 | - fixed yarn lint 83 | - fixed lint errors 84 | - fixed to run on Windows 10 (Powershell) 85 | 86 | ## [0.4.7] - 2019-02-21 87 | 88 | ### Changed 89 | - upgraded mocha, joi to latest, removed pinned versions. 90 | - upgraded other dependencies 91 | 92 | ## [0.4.5] - 2018-10-05 93 | 94 | ### Added 95 | - use [mstime](https://github.com/ngduc/mstime) to measure API run time. 96 | - measure API response time & show it in response "meta" 97 | ### Changed 98 | - BREAKING: refactor apiJson's "listModel" to "model" 99 | 100 | ## [0.3.0] - 2018-10-02 101 | 102 | ### Changed 103 | - BREAKING: refactor code to use this syntax: import { User } from 'api/models'; 104 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | EXPOSE 3009 4 | 5 | ARG NODE_ENV 6 | ENV NODE_ENV $NODE_ENV 7 | 8 | RUN mkdir /app 9 | ADD package.json yarn.lock /app/ 10 | 11 | ADD . /app 12 | 13 | RUN cd /app/ && yarn --pure-lockfile 14 | 15 | WORKDIR /app 16 | 17 | CMD ["yarn", "docker:start"] 18 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Title** 2 | - bug: XYZ broken... 3 | - feature: please add... 4 | - enhancement: add this to existing features... 5 | 6 | **Short Description:** 7 | - Unable to get a result from blah... 8 | 9 | **Environment Details** 10 | * OS: 11 | * Node.js version: 12 | * npm version: 13 | 14 | **Long Description** 15 | - etc. 16 | 17 | **Code** 18 | ```JS 19 | JS code example goes here... 20 | ``` 21 | 22 | **Workaround** 23 | 24 | ... 25 | 26 | Please help with a PR if you have a solution. Thanks! 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Duc Nguyen 4 | Copyright (c) 2017 Daniel Sousa 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node REM 2 | 3 | NodeJS Rest Express MongoDB (REM) - a production-ready lightweight backend setup. 4 | 5 | [![Build Status](https://travis-ci.org/ngduc/node-rem.svg?branch=master)](https://travis-ci.org/ngduc/node-rem) [![Maintainability](https://api.codeclimate.com/v1/badges/11155b15b675ef311f72/maintainability)](https://codeclimate.com/github/ngduc/node-rem/maintainability) 6 | 7 | [Live Demo](https://node-rem-ngduc.vercel.app/) (login with a test user: user1@example.com, user111 - inspect API calls to learn more) 8 | 9 | 🌟 It rains ~~cats and dogs~~ features: 10 | 11 | ``` 12 | Typescript Express CORS Helmet DotEnv joi (validation) forever 13 | Mongoose Passport JWT Await 14 | Tslint Apidoc Docker Husky Morgan Travis Unix/Mac/Win (Powershell) 15 | Tests 16 | Mocha Chai Sinon istanbul 17 | MORE: 18 | HTTPS HTTP2 (spdy) Socketio 2.1 Init DB Data 19 | Slack message Nodemailer Mailgun Email Templates Forgot Password 20 | VSCode Debug Dependabot Codacy File upload (multer) 21 | API 22 | API response (data, meta: limit, offset, sort) Transform res 23 | apiJson Pagination query 24 | Regex query Whitelist fields in response Populate deep fields 25 | mstime API response time Stack trace in Response 26 | UI Example 27 | CRA, Typescript, React-router, Axios, PostCSS, Tailwind. Components: Login, Home, ItemView. 28 | Portable-react 29 | ``` 30 | - More details in [Documentation / Features](src_docs/features.md) 31 | 32 | ### 📦 Installation 33 | 34 | Require: `MongoDB` and `NodeJS v8.12.0 +` 35 | 36 | Clone this project: 37 | ``` 38 | git clone https://github.com/ngduc/node-rem.git your-app 39 | cd your-app 40 | rm -rf .git (remove this github repo's git settings) 41 | yarn 42 | ``` 43 | - Update `package.json` and `.env` file with your information. 44 | - Run `yarn dev`, it will create a new Mongo DB "node-rem" 45 | - Verify `yarn test` can run all unit tests. 46 | - Verify: use Postman to POST http://localhost:3009/api/v1/auth/register to create a new user. (set payload to have email, password) 47 | ``` 48 | curl -k -d '{"email": "example1@email.com", "password": "testpsw"}' -H "Content-Type: application/json" -X POST http://localhost:3009/api/v1/auth/register 49 | ``` 50 | 51 | ### 🔧 Commands 52 | 53 | ``` 54 | - Start MongoDB first. Verify .env variables. 55 | 56 | yarn dev launch DEV mode 57 | yarn start launch PROD mode 58 | yarn stop 59 | 60 | yarn test Run tests (requires MongoDB) 61 | ``` 62 | 63 | #### Frontend Example - uses this node-rem backend: 64 | ``` 65 | - First, start the Backend with: yarn dev 66 | 67 | - Then, start UI: 68 | cd ./ui 69 | yarn 70 | yarn start (then open http://localhost:3000 - login with a test user: user1@example.com, user111) 71 | ``` 72 | 73 | ### 📖 Features 74 | 75 | Your simple `API Route Handler` will have a nice syntax like this: (packed with ~~vitamins~~ cool stuffs) 76 | ```js 77 | exports.list = async (req: Request, res: Response, next: NextFunction) => { 78 | try { 79 | const data = (await User.list(req)).transform(req); // query & run userSchema.transform() for response 80 | apiJson({ req, res, data, model: User }); // return standard API Response 81 | } catch (e) { 82 | next(e); 83 | } 84 | }; 85 | ``` 86 | 87 | API Response is similar to [JSON API](http://jsonapi.org/examples/#pagination) standard: 88 | 89 | ```js 90 | GET http://localhost:3009/api/v1/users?fields=id,email&email=*user1* (get id & email only in response) 91 | GET http://localhost:3009/api/v1/users?page=1&perPage=20 (query & pagination) 92 | GET http://localhost:3009/api/v1/users?limit=5&offset=0&sort=email:desc,createdAt 93 | { 94 | "meta": { 95 | "limit": 5, 96 | "offset": 0, 97 | "sort": { 98 | "email": -1, 99 | "createdAt": 1 100 | }, 101 | "totalCount": 4, 102 | "timer": 3.85, 103 | "timerAvg": 5.62 104 | }, 105 | "data": [ 106 | { 107 | "id": "5bad07cdc099dfbe49ef69d7", 108 | "name": "John Doe", 109 | "email": "john.doe@gmail.com", 110 | "role": "user", 111 | "createdAt": "2018-09-27T16:39:41.498Z" 112 | }, 113 | // more items... 114 | ] 115 | } 116 | ``` 117 | Example of generated API Docs (using `apidoc`) - https://node-rem.netlify.com 118 | 119 | ### 📖 Documentation 120 | 121 | - [Documentation / Features](src_docs/features.md) 122 | - [Build System](src_docs/build.md) 123 | - [Dependencies Notes](src_docs/dependencies.md) 124 | - [Change Log](CHANGELOG.md) 125 | 126 | ### 🙌 Thanks 127 | 128 | All contributions are welcome! 129 | 130 | UI Example uses [Portable-react](https://github.com/ngduc/portable-react) -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Node-REM 2 | 3 | Node-REM TODO List 4 | 5 | ### Todo 6 | 7 | - [ ] Stylings for UI Example. 8 | 9 | ### In Progress 10 | 11 | ### Done ✓ 12 | 13 | - [x] Enhance UI Example. 14 | - [x] Fix npm run build. 2021-02-24 15 | 16 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build -t ngduc/node-rem . 3 | docker push ngduc/node-rem 4 | 5 | ssh deploy@$DEPLOY_SERVER << EOF 6 | docker pull ngduc/node-rem 7 | docker stop api-boilerplate || true 8 | docker rm api-boilerplate || true 9 | docker rmi ngduc/node-rem:current || true 10 | docker tag ngduc/node-rem:latest ngduc/node-rem:current 11 | docker run -d --restart always --name api-boilerplate -p 3009:3009 ngduc/node-rem:current 12 | EOF 13 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | // declare module 2 | 3 | declare module 'mstime'; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-rem", 3 | "version": "0.11.2 ", 4 | "description": "Node REM - NodeJS Rest Express MongoDB and more: typescript, passport, JWT, socket.io, HTTPS, HTTP2, async/await, nodemailer, templates, pagination, docker, etc.", 5 | "author": "Duc Nguyen", 6 | "main": "src/index.js", 7 | "private": false, 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=12", 11 | "yarn": "*" 12 | }, 13 | "scripts": { 14 | "precommit": "yarn lint", 15 | "prestart": "yarn docs", 16 | "forever-start": "cross-env NODE_ENV=production forever start -a --uid 'node-rem' -v -c ts-node --files src/index.ts", 17 | "start": "node built/index.js", 18 | "stop": "cross-env NODE_ENV=production forever stop 'node-rem'", 19 | "build": "tsc -p . && cp -R src/config ./built && npx rimraf -rf ./built/config/*.ts", 20 | "logs": "forever logs", 21 | "dev": "npx nodemon --watch 'src/**/*.ts' --ignore 'src/**/*.spec.ts' --exec ts-node --files src/index.ts", 22 | "lint": "tslint src/**/*.ts", 23 | "lint:fix": "yarn lint --fix", 24 | "lint:watch": "yarn lint --watch", 25 | "test": "cross-env NODE_ENV=test nyc --reporter=html --reporter=text --reporter=lcov mocha --require ts-node/register --full-trace --bail --timeout 20000 --exit tests/**/*.ts", 26 | "test:unit": "cross-env NODE_ENV=test mocha tests/unit", 27 | "test:integration": "cross-env NODE_ENV=test mocha --timeout 20000 tests/integration", 28 | "test:watch": "cross-env NODE_ENV=test mocha --watch tests/unit", 29 | "coverage": "nyc report --reporter=text-lcov | coveralls", 30 | "codacy": "cat coverage/lcov.info | codacy-coverage -t $CODACY_PROJECT_TOKEN", 31 | "postcoverage": "opn coverage/lcov-report/index.html", 32 | "validate": "yarn lint && yarn test", 33 | "postpublish": "git push --tags", 34 | "deploy": "sh ./deploy.sh", 35 | "docs": "apidoc -i src -o docs", 36 | "postdocs": "opn docs/index.html", 37 | "docker:start": "cross-env NODE_ENV=production forever start -a --uid 'node-rem' -v -c ts-node src/index.ts", 38 | "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", 39 | "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", 40 | "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/ngduc/node-rem.git" 45 | }, 46 | "keywords": [ 47 | "rem", 48 | "express", 49 | "node", 50 | "node.js", 51 | "mongodb", 52 | "mongoose", 53 | "passport", 54 | "es6", 55 | "es7", 56 | "es8", 57 | "es2017", 58 | "mocha", 59 | "istanbul", 60 | "nyc", 61 | "eslint", 62 | "Travis CI", 63 | "coveralls", 64 | "REST", 65 | "API", 66 | "boilerplate", 67 | "generator", 68 | "starter project", 69 | "typescript", 70 | "tslint" 71 | ], 72 | "nyc": { 73 | "extension": [ 74 | ".ts", 75 | ".tsx" 76 | ], 77 | "exclude": [ 78 | "**/*.d.ts" 79 | ], 80 | "reporter": [ 81 | "html" 82 | ], 83 | "all": true 84 | }, 85 | "dependencies": { 86 | "@slack/client": "^5.0.2", 87 | "axios": "^0.26.1", 88 | "bcryptjs": "2.4.3", 89 | "bluebird": "^3.7.2", 90 | "body-parser": "^1.19.0", 91 | "compression": "^1.7.4", 92 | "cors": "^2.8.5", 93 | "cross-env": "^7.0.3", 94 | "dotenv-safe": "^6.1.0", 95 | "express": "^4.17.1", 96 | "express-validation": "^1.0.2", 97 | "handlebars": "^4.7.7", 98 | "helmet": "^5.0.2", 99 | "http-status": "^1.5.0", 100 | "joi": "^14.3.1", 101 | "jwt-simple": "^0.5.6", 102 | "lodash": "^4.17.21", 103 | "method-override": "^3.0.0", 104 | "moment-timezone": "^0.5.33", 105 | "mongoose": "^5.12.6", 106 | "morgan": "^1.10.0", 107 | "multer": "^1.4.2", 108 | "nodemailer": "^6.6.0", 109 | "nodemailer-mailgun-transport": "^2.0.3", 110 | "passport": "^0.4.1", 111 | "passport-http-bearer": "^1.0.1", 112 | "passport-jwt": "4.0.0", 113 | "serverless-http": "^2.7.0", 114 | "socket.io": "^2.2.0", 115 | "spdy": "^4.0.2", 116 | "uuid": "^7.0.3" 117 | }, 118 | "devDependencies": { 119 | "@types/bluebird": "^3.5.33", 120 | "@types/express": "^4.17.11", 121 | "@types/joi": "^14.3.2", 122 | "@types/mocha": "^5.2.6", 123 | "@types/mongoose": "^5.10.3", 124 | "@types/node": "^14.14.31", 125 | "apidoc": "^0.17.7", 126 | "chai": "^4.3.3", 127 | "chai-as-promised": "^7.1.1", 128 | "codacy-coverage": "^3.2.0", 129 | "coveralls": "^3.1.0", 130 | "eslint": "^8.11.0", 131 | "eslint-config-airbnb-base": "^15.0.0", 132 | "eslint-plugin-import": "^2.25.4", 133 | "forever": "^3.0.4", 134 | "husky": "^6.0.0", 135 | "mocha": "^8.3.0", 136 | "nodemon": "^2.0.7", 137 | "nyc": "^15.1.0", 138 | "opn-cli": "^4.1.0", 139 | "rimraf": "^3.0.2", 140 | "sinon": "^9.2.4", 141 | "sinon-chai": "^3.5.0", 142 | "supertest": "^6.1.3", 143 | "ts-node": "^9.1.1", 144 | "tslint": "^5.20.1", 145 | "tslint-config-airbnb": "^5.11.2", 146 | "tslint-config-prettier": "^1.18.0", 147 | "typescript": "^4.6.2" 148 | }, 149 | "resolutions": { 150 | "deep-extend": "^0.5.1" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /src/api/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import { NextFunction, Request, Response, Router } from 'express'; 3 | const httpStatus = require('http-status'); 4 | import { User } from '../../api/models'; 5 | const RefreshToken = require('../models/refreshToken.model'); 6 | const moment = require('moment-timezone'); 7 | import { apiJson, randomString } from '../../api/utils/Utils'; 8 | import { sendEmail, welcomeEmail, forgotPasswordEmail, slackWebhook } from '../../api/utils/MsgUtils'; 9 | const { SEC_ADMIN_EMAIL, JWT_EXPIRATION_MINUTES, slackEnabled, emailEnabled } = require('../../config/vars'); 10 | 11 | /** 12 | * Returns a formated object with tokens 13 | * @private 14 | */ 15 | function generateTokenResponse(user: any, accessToken: string) { 16 | const tokenType = 'Bearer'; 17 | const refreshToken = RefreshToken.generate(user).token; 18 | const expiresIn = moment().add(JWT_EXPIRATION_MINUTES, 'minutes'); 19 | return { 20 | tokenType, 21 | accessToken, 22 | refreshToken, 23 | expiresIn 24 | }; 25 | } 26 | 27 | /** 28 | * Returns jwt token if registration was successful 29 | * @public 30 | */ 31 | exports.register = async (req: Request, res: Response, next: NextFunction) => { 32 | try { 33 | const user = await new User(req.body).save(); 34 | const userTransformed = user.transform(); 35 | const token = generateTokenResponse(user, user.token()); 36 | res.status(httpStatus.CREATED); 37 | const data = { token, user: userTransformed }; 38 | if (slackEnabled) { 39 | slackWebhook(`New User: ${user.email}`); // notify when new user registered 40 | } 41 | if (emailEnabled) { 42 | // for testing: it can only email to "authorized recipients" in Mailgun Account Settings. 43 | // sendEmail(welcomeEmail({ name: user.name, email: user.email })); 44 | } 45 | return apiJson({ req, res, data }); 46 | } catch (error) { 47 | return next(User.checkDuplicateEmail(error)); 48 | } 49 | }; 50 | 51 | /** 52 | * Returns jwt token if valid username and password is provided 53 | * @public 54 | */ 55 | exports.login = async (req: Request, res: Response, next: NextFunction) => { 56 | try { 57 | const { user, accessToken } = await User.findAndGenerateToken(req.body); 58 | const { email } = user; 59 | const token = generateTokenResponse(user, accessToken); 60 | 61 | if (email === SEC_ADMIN_EMAIL) { 62 | // setAdminToken(token); // remember admin token for checking later 63 | } else { 64 | const { ip, headers } = req; 65 | slackWebhook(`User logged in: ${email} - IP: ${ip} - User Agent: ${headers['user-agent']}`); 66 | } 67 | const userTransformed = user.transform(); 68 | const data = { token, user: userTransformed }; 69 | return apiJson({ req, res, data }); 70 | } catch (error) { 71 | return next(error); 72 | } 73 | }; 74 | 75 | /** 76 | * Logout function: delete token from DB. 77 | * @public 78 | */ 79 | exports.logout = async (req: Request, res: Response, next: NextFunction) => { 80 | console.log('- logout'); 81 | try { 82 | const { userId } = req.body; 83 | await RefreshToken.findAndDeleteToken({ userId }); 84 | const data = { status: 'OK' }; 85 | return apiJson({ req, res, data }); 86 | } catch (error) { 87 | return next(error); 88 | } 89 | }; 90 | 91 | /** 92 | * login with an existing user or creates a new one if valid accessToken token 93 | * Returns jwt token 94 | * @public 95 | */ 96 | exports.oAuth = async (req: any, res: Response, next: NextFunction) => { 97 | try { 98 | const { user } = req; 99 | const accessToken = user.token(); 100 | const token = generateTokenResponse(user, accessToken); 101 | const userTransformed = user.transform(); 102 | return res.json({ token, user: userTransformed }); 103 | } catch (error) { 104 | return next(error); 105 | } 106 | }; 107 | 108 | /** 109 | * Returns a new jwt when given a valid refresh token 110 | * @public 111 | */ 112 | exports.refresh = async (req: Request, res: Response, next: NextFunction) => { 113 | try { 114 | const { email, refreshToken } = req.body; 115 | const refreshObject = await RefreshToken.findOneAndRemove({ 116 | userEmail: email, 117 | token: refreshToken 118 | }); 119 | const { user, accessToken } = await User.findAndGenerateToken({ email, refreshObject }); 120 | const response = generateTokenResponse(user, accessToken); 121 | return res.json(response); 122 | } catch (error) { 123 | return next(error); 124 | } 125 | }; 126 | 127 | /** 128 | * Send email to a registered user's email with a one-time temporary password 129 | * @public 130 | */ 131 | exports.forgotPassword = async (req: Request, res: Response, next: NextFunction) => { 132 | try { 133 | const { email: reqEmail } = req.body; 134 | const user = await User.findOne({ email: reqEmail }); 135 | if (!user) { 136 | // RETURN A GENERIC ERROR - DON'T EXPOSE the real reason (user not found) for security. 137 | return next({ message: 'Invalid request' }); 138 | } 139 | // user found => generate temp password, then email it to user: 140 | const { name, email } = user; 141 | const tempPass = randomString(10, 'abcdefghijklmnopqrstuvwxyz0123456789'); 142 | user.tempPassword = tempPass; 143 | await user.save(); 144 | sendEmail(forgotPasswordEmail({ name, email, tempPass })); 145 | 146 | return apiJson({ req, res, data: { status: 'OK' } }); 147 | } catch (error) { 148 | return next(error); 149 | } 150 | }; 151 | -------------------------------------------------------------------------------- /src/api/controllers/upload.controller.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import { apiJson } from '../../api/utils/Utils'; 5 | 6 | exports.upload = async (req: Request, res: Response, next: NextFunction) => { 7 | try { 8 | const data = { status: 'OK' }; 9 | return apiJson({ req, res, data }); 10 | } catch (error) { 11 | return next(error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/api/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import { NextFunction, Request, Response, Router } from 'express'; 3 | const mongoose = require('mongoose'); 4 | const ObjectId = mongoose.Types.ObjectId; 5 | const httpStatus = require('http-status'); 6 | const { omit } = require('lodash'); 7 | import { User, UserNote } from '../../api/models'; 8 | import { apiJson } from '../../api/utils/Utils'; 9 | const { handler: errorHandler } = require('../middlewares/error'); 10 | 11 | const likesMap: any = {}; // key (userId__noteId) : 1 12 | 13 | /** 14 | * Load user and append to req. 15 | * @public 16 | */ 17 | exports.load = async (req: Request, res: Response, next: NextFunction, id: any) => { 18 | try { 19 | const user = await User.get(id); 20 | req.route.meta = req.route.meta || {}; 21 | req.route.meta.user = user; 22 | return next(); 23 | } catch (error) { 24 | return errorHandler(error, req, res); 25 | } 26 | }; 27 | 28 | /** 29 | * Get logged in user info 30 | * @public 31 | */ 32 | const loggedIn = (req: Request, res: Response) => res.json(req.route.meta.user.transform()); 33 | exports.loggedIn = loggedIn; 34 | 35 | /** 36 | * Get user 37 | * @public 38 | */ 39 | exports.get = loggedIn; 40 | 41 | /** 42 | * Create new user 43 | * @public 44 | */ 45 | exports.create = async (req: Request, res: Response, next: NextFunction) => { 46 | try { 47 | const user = new User(req.body); 48 | const savedUser = await user.save(); 49 | res.status(httpStatus.CREATED); 50 | res.json(savedUser.transform()); 51 | } catch (error) { 52 | next(User.checkDuplicateEmail(error)); 53 | } 54 | }; 55 | 56 | /** 57 | * Replace existing user 58 | * @public 59 | */ 60 | exports.replace = async (req: Request, res: Response, next: NextFunction) => { 61 | try { 62 | const { user } = req.route.meta; 63 | const newUser = new User(req.body); 64 | const ommitRole = user.role !== 'admin' ? 'role' : ''; 65 | const newUserObject = omit(newUser.toObject(), '_id', ommitRole); 66 | 67 | await user.update(newUserObject, { override: true, upsert: true }); 68 | const savedUser = await User.findById(user._id); 69 | 70 | res.json(savedUser.transform()); 71 | } catch (error) { 72 | next(User.checkDuplicateEmail(error)); 73 | } 74 | }; 75 | 76 | /** 77 | * Update existing user 78 | * @public 79 | */ 80 | exports.update = (req: Request, res: Response, next: NextFunction) => { 81 | const ommitRole = req.route.meta.user.role !== 'admin' ? 'role' : ''; 82 | const updatedUser = omit(req.body, ommitRole); 83 | const user = Object.assign(req.route.meta.user, updatedUser); 84 | 85 | user 86 | .save() 87 | .then((savedUser: any) => res.json(savedUser.transform())) 88 | .catch((e: any) => next(User.checkDuplicateEmail(e))); 89 | }; 90 | 91 | /** 92 | * Get user list 93 | * @public 94 | * @example GET /v1/users?role=admin&limit=5&offset=0&sort=email:desc,createdAt 95 | */ 96 | exports.list = async (req: Request, res: Response, next: NextFunction) => { 97 | try { 98 | const data = (await User.list(req)).transform(req); 99 | apiJson({ req, res, data, model: User }); 100 | } catch (e) { 101 | next(e); 102 | } 103 | }; 104 | 105 | /** 106 | * Get user's notes. 107 | * @public 108 | * @example GET /v1/users/userId/notes 109 | */ 110 | exports.listUserNotes = async (req: Request, res: Response, next: NextFunction) => { 111 | try { 112 | const { userId } = req.params; 113 | req.query = { ...req.query, user: new ObjectId(userId) }; // append to query (by userId) to final query 114 | const data = (await UserNote.list({ query: req.query })).transform(req); 115 | apiJson({ req, res, data, model: UserNote }); 116 | } catch (e) { 117 | next(e); 118 | } 119 | }; 120 | 121 | /** 122 | * Add a note. 123 | * @example POST /v1/users/userId/notes - payload { title, note } 124 | */ 125 | exports.createNote = async (req: Request, res: Response, next: NextFunction) => { 126 | const { userId } = req.params; 127 | const { title, note } = req.body; 128 | try { 129 | const newNote = new UserNote({ 130 | user: new ObjectId(userId), 131 | title, 132 | note 133 | }); 134 | const data = await newNote.save(); 135 | apiJson({ req, res, data, model: UserNote }); 136 | } catch (e) { 137 | next(e); 138 | } 139 | }; 140 | 141 | /** 142 | * Read a user's note. 143 | * NOTE: Any logged in user can get a list of notes of any user. Implement your own checks. 144 | * @public 145 | * @example GET /v1/users/userId/notes/noteId 146 | */ 147 | exports.readUserNote = async (req: Request, res: Response, next: NextFunction) => { 148 | const { userId, noteId } = req.params; 149 | const { _id } = req.route.meta.user; 150 | const currentUserId = _id.toString(); 151 | if (userId !== currentUserId) { 152 | return next(); // only logged in user can delete her own notes 153 | } 154 | try { 155 | const data = await UserNote.findOne({ user: new ObjectId(userId), _id: new ObjectId(noteId) }); 156 | apiJson({ req, res, data }); 157 | } catch (e) { 158 | next(e); 159 | } 160 | }; 161 | 162 | /** 163 | * Update a user's note. 164 | * @public 165 | * @example POST /v1/users/userId/notes/noteId 166 | */ 167 | exports.updateUserNote = async (req: Request, res: Response, next: NextFunction) => { 168 | const { userId, noteId } = req.params; 169 | const { _id } = req.route.meta.user; 170 | const { note } = req.body; 171 | const currentUserId = _id.toString(); 172 | if (userId !== currentUserId) { 173 | return next(); // only logged in user can delete her own notes 174 | } 175 | try { 176 | const query = { user: new ObjectId(userId), _id: new ObjectId(noteId) }; 177 | await UserNote.findOneAndUpdate(query, { note }, {}); 178 | apiJson({ req, res, data: {} }); 179 | } catch (e) { 180 | next(e); 181 | } 182 | }; 183 | 184 | /** 185 | * Delete user note 186 | * @public 187 | */ 188 | exports.deleteUserNote = async (req: Request, res: Response, next: NextFunction) => { 189 | const { userId, noteId } = req.params; 190 | const { _id } = req.route.meta.user; 191 | const currentUserId = _id.toString(); 192 | if (userId !== currentUserId) { 193 | return next(); // only logged in user can delete her own notes 194 | } 195 | try { 196 | await UserNote.remove({ user: new ObjectId(userId), _id: new ObjectId(noteId) }); 197 | apiJson({ req, res, data: {} }); 198 | } catch (e) { 199 | next(e); 200 | } 201 | }; 202 | 203 | /** 204 | * Like user note 205 | * @public 206 | */ 207 | exports.likeUserNote = async (req: Request, res: Response, next: NextFunction) => { 208 | const { noteId } = req.params; 209 | const { _id } = req.route.meta.user; 210 | const currentUserId = _id.toString(); 211 | if (likesMap[`${currentUserId}__${noteId}`]) { 212 | return next(); // already liked => return. 213 | } 214 | try { 215 | const query = { _id: new ObjectId(noteId) }; 216 | const dbItem = await UserNote.findOne(query); 217 | const newLikes = (dbItem.likes > 0 ? dbItem.likes : 0) + 1; 218 | 219 | await UserNote.findOneAndUpdate(query, { likes: newLikes }, {}); 220 | likesMap[`${currentUserId}__${noteId}`] = 1; // flag as already liked. 221 | apiJson({ req, res, data: {} }); 222 | } catch (e) { 223 | next(e); 224 | } 225 | }; 226 | 227 | /** 228 | * Delete user 229 | * @public 230 | */ 231 | exports.remove = (req: Request, res: Response, next: NextFunction) => { 232 | const { user } = req.route.meta; 233 | user 234 | .remove() 235 | .then(() => res.status(httpStatus.NO_CONTENT).end()) 236 | .catch((e: any) => next(e)); 237 | }; 238 | -------------------------------------------------------------------------------- /src/api/middlewares/auth.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'Promise' { 2 | export function promisify( 3 | func: (data: any, cb: (err: NodeJS.ErrnoException, data?: T) => void) => void 4 | ): (...input: any[]) => Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/api/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const httpStatus = require('http-status'); 3 | const passport = require('passport'); 4 | import { User } from '../../api/models'; 5 | const APIError = require('../utils/APIError'); 6 | 7 | const ADMIN = 'admin'; 8 | const LOGGED_USER = '_loggedUser'; 9 | 10 | import * as Bluebird from 'bluebird'; 11 | // declare global { 12 | // export interface Promise extends Bluebird {} 13 | // } 14 | 15 | const handleJWT = (req: any, res: any, next: any, roles: any) => async (err: any, user: any, info: any) => { 16 | const error = err || info; 17 | const logIn: any = Bluebird.promisify(req.logIn); 18 | const apiError = new APIError({ 19 | message: error ? error.message : 'Unauthorized', 20 | status: httpStatus.UNAUTHORIZED, 21 | stack: error ? error.stack : undefined 22 | }); 23 | 24 | try { 25 | if (error || !user) { 26 | throw error; 27 | } 28 | await logIn(user, { session: false }); 29 | } catch (e) { 30 | return next(apiError); 31 | } 32 | 33 | if (roles === LOGGED_USER) { 34 | // validate if the "Logged User Id" is the same with "params.userId" (resource Id) 35 | // only the same logged in user can access the resource Id. (unless it has admin role) 36 | if (user.role !== 'admin' && req.params.userId !== user._id.toString()) { 37 | apiError.status = httpStatus.FORBIDDEN; 38 | apiError.message = 'Forbidden'; 39 | return next(apiError); 40 | } 41 | } else if (!roles.includes(user.role)) { 42 | apiError.status = httpStatus.FORBIDDEN; 43 | apiError.message = 'Forbidden'; 44 | return next(apiError); 45 | } else if (err || !user) { 46 | return next(apiError); 47 | } 48 | 49 | req.route.meta = req.route.meta || {}; 50 | req.route.meta.user = user; 51 | 52 | return next(); 53 | }; 54 | 55 | exports.ADMIN = ADMIN; 56 | exports.LOGGED_USER = LOGGED_USER; 57 | 58 | exports.authorize = (roles = User.roles) => (req: any, res: any, next: any) => 59 | passport.authenticate('jwt', { session: false }, handleJWT(req, res, next, roles))(req, res, next); 60 | 61 | exports.oAuth = (service: any) => passport.authenticate(service, { session: false }); 62 | -------------------------------------------------------------------------------- /src/api/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import * as httpStatus from 'http-status'; 3 | const expressValidation = require('express-validation'); 4 | const APIError = require('../utils/APIError'); 5 | const { env } = require('../../config/vars'); 6 | 7 | /** 8 | * Error handler. Send stacktrace only during development 9 | * @public 10 | */ 11 | const handler = (err: any, req: any, res: any, next: any) => { 12 | const response = { 13 | code: err.status, 14 | message: err.message || err.status, // httpStatus[err.status], (// FIX: TYPE) 15 | errors: err.errors, 16 | stack: err.stack 17 | }; 18 | 19 | if (env !== 'development') { 20 | delete response.stack; 21 | } 22 | 23 | res.status(err.status); 24 | res.json(response); 25 | }; 26 | exports.handler = handler; 27 | 28 | /** 29 | * If error is not an instanceOf APIError, convert it. 30 | * @public 31 | */ 32 | exports.converter = (err: any, req: any, res: any, next: any) => { 33 | let convertedError = err; 34 | 35 | if (err instanceof expressValidation.ValidationError) { 36 | convertedError = new APIError({ 37 | message: 'Validation Error', 38 | errors: err.errors, 39 | status: err.status, 40 | stack: err.stack 41 | }); 42 | } else if (!(err instanceof APIError)) { 43 | convertedError = new APIError({ 44 | message: err.message, 45 | status: err.status, 46 | stack: err.stack 47 | }); 48 | } 49 | 50 | return handler(convertedError, req, res, next); 51 | }; 52 | 53 | /** 54 | * Catch 404 and forward to error handler 55 | * @public 56 | */ 57 | exports.notFound = (req: any, res: any, next: any) => { 58 | const err = new APIError({ 59 | message: 'Not found', 60 | status: httpStatus.NOT_FOUND 61 | }); 62 | return handler(err, req, res, next); 63 | }; 64 | -------------------------------------------------------------------------------- /src/api/models/index.ts: -------------------------------------------------------------------------------- 1 | export const User = require('./user.model'); 2 | export const UserNote = require('./userNote.model'); 3 | -------------------------------------------------------------------------------- /src/api/models/refreshToken.model.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import * as crypto from 'crypto'; 3 | const mongoose = require('mongoose'); 4 | // const crypto = require('crypto'); 5 | const moment = require('moment-timezone'); 6 | const APIError = require('../../api/utils/APIError'); 7 | const httpStatus = require('http-status'); 8 | 9 | /** 10 | * Refresh Token Schema 11 | * @private 12 | */ 13 | const refreshTokenSchema = new mongoose.Schema({ 14 | token: { 15 | type: String, 16 | required: true, 17 | index: true 18 | }, 19 | userId: { 20 | type: mongoose.Schema.Types.ObjectId, 21 | ref: 'User', 22 | required: true 23 | }, 24 | userEmail: { 25 | type: 'String', 26 | ref: 'User', 27 | required: true 28 | }, 29 | expires: { type: Date } 30 | }); 31 | 32 | refreshTokenSchema.statics = { 33 | /** 34 | * Generate a refresh token object and saves it into the database 35 | * 36 | * @param {User} user 37 | * @returns {RefreshToken} 38 | */ 39 | generate(user: any) { 40 | const userId = user._id; 41 | const userEmail = user.email; 42 | const token = `${userId}.${crypto.randomBytes(40).toString('hex')}`; 43 | const expires = moment().add(30, 'days').toDate(); 44 | const tokenObject = new RefreshToken({ 45 | token, 46 | userId, 47 | userEmail, 48 | expires 49 | }); 50 | tokenObject.save(); 51 | return tokenObject; 52 | }, 53 | 54 | /** 55 | * Find user by user ID then delete token record from DB. 56 | * 57 | * @param {ObjectId} id - The objectId of user. 58 | * @returns {Promise} 59 | */ 60 | async findAndDeleteToken(options: any) { 61 | const { userId } = options; 62 | if (!userId) { 63 | throw new APIError({ message: 'An userId is required to delete a token' }); 64 | } 65 | const tokenRec = await this.findOne({ userId: new mongoose.Types.ObjectId(userId) }).exec(); 66 | const err: any = { 67 | status: httpStatus.UNAUTHORIZED, 68 | isPublic: true 69 | }; 70 | if (!tokenRec) { 71 | err.message = 'Logout failed. User already logged out?'; 72 | throw new APIError(err); 73 | } 74 | await this.remove({ userId: new mongoose.Types.ObjectId(userId) }); 75 | return { status: 'OK' }; 76 | } 77 | }; 78 | 79 | /** 80 | * @typedef RefreshToken 81 | */ 82 | const RefreshToken = mongoose.model('RefreshToken', refreshTokenSchema); 83 | module.exports = RefreshToken; 84 | -------------------------------------------------------------------------------- /src/api/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import { NextFunction, Request, Response, Router } from 'express'; 3 | const mongoose = require('mongoose'); 4 | const httpStatus = require('http-status'); 5 | const bcrypt = require('bcryptjs'); 6 | const moment = require('moment-timezone'); 7 | const jwt = require('jwt-simple'); 8 | const uuidv4 = require('uuid/v4'); 9 | const APIError = require('../../api/utils/APIError'); 10 | import { transformData, listData } from '../../api/utils/ModelUtils'; 11 | const { env, JWT_SECRET, JWT_EXPIRATION_MINUTES } = require('../../config/vars'); 12 | 13 | /** 14 | * User Roles 15 | */ 16 | const roles = ['user', 'admin']; 17 | 18 | /** 19 | * User Schema 20 | * @private 21 | */ 22 | const userSchema = new mongoose.Schema( 23 | { 24 | email: { 25 | type: String, 26 | match: /^\S+@\S+\.\S+$/, 27 | required: true, 28 | unique: true, 29 | trim: true, 30 | lowercase: true, 31 | index: { unique: true } 32 | }, 33 | password: { 34 | type: String, 35 | required: true, 36 | minlength: 6, 37 | maxlength: 128 38 | }, 39 | tempPassword: { 40 | type: String, // one-time temporary password (must delete after user logged in) 41 | required: false, 42 | minlength: 6, 43 | maxlength: 128 44 | }, 45 | name: { 46 | type: String, 47 | maxlength: 128, 48 | index: true, 49 | trim: true 50 | }, 51 | services: { 52 | facebook: String, 53 | google: String 54 | }, 55 | role: { 56 | type: String, 57 | enum: roles, 58 | default: 'user' 59 | }, 60 | picture: { 61 | type: String, 62 | trim: true 63 | } 64 | }, 65 | { 66 | timestamps: true 67 | } 68 | ); 69 | const ALLOWED_FIELDS = ['id', 'name', 'email', 'picture', 'role', 'createdAt']; 70 | 71 | /** 72 | * Add your 73 | * - pre-save hooks 74 | * - validations 75 | * - virtuals 76 | */ 77 | userSchema.pre('save', async function save(next: NextFunction) { 78 | try { 79 | // modifying password => encrypt it: 80 | const rounds = env === 'test' ? 1 : 10; 81 | if (this.isModified('password')) { 82 | const hash = await bcrypt.hash(this.password, rounds); 83 | this.password = hash; 84 | } else if (this.isModified('tempPassword')) { 85 | const hash = await bcrypt.hash(this.tempPassword, rounds); 86 | this.tempPassword = hash; 87 | } 88 | return next(); // normal save 89 | } catch (error) { 90 | return next(error); 91 | } 92 | }); 93 | 94 | /** 95 | * Methods 96 | */ 97 | userSchema.method({ 98 | // query is optional, e.g. to transform data for response but only include certain "fields" 99 | transform({ query = {} }: { query?: any } = {}) { 100 | // transform every record (only respond allowed fields and "&fields=" in query) 101 | return transformData(this, query, ALLOWED_FIELDS); 102 | }, 103 | 104 | token() { 105 | const playload = { 106 | exp: moment().add(JWT_EXPIRATION_MINUTES, 'minutes').unix(), 107 | iat: moment().unix(), 108 | sub: this._id 109 | }; 110 | return jwt.encode(playload, JWT_SECRET); 111 | }, 112 | 113 | async passwordMatches(password: string) { 114 | return bcrypt.compare(password, this.password); 115 | } 116 | }); 117 | 118 | /** 119 | * Statics 120 | */ 121 | userSchema.statics = { 122 | roles, 123 | 124 | /** 125 | * Get user 126 | * 127 | * @param {ObjectId} id - The objectId of user. 128 | * @returns {Promise} 129 | */ 130 | async get(id: any) { 131 | try { 132 | let user; 133 | 134 | if (mongoose.Types.ObjectId.isValid(id)) { 135 | user = await this.findById(id).exec(); 136 | } 137 | if (user) { 138 | return user; 139 | } 140 | 141 | throw new APIError({ 142 | message: 'User does not exist', 143 | status: httpStatus.NOT_FOUND 144 | }); 145 | } catch (error) { 146 | throw error; 147 | } 148 | }, 149 | 150 | /** 151 | * Find user by email and tries to generate a JWT token 152 | * 153 | * @param {ObjectId} id - The objectId of user. 154 | * @returns {Promise} 155 | */ 156 | async findAndGenerateToken(options: any) { 157 | const { email, password, refreshObject } = options; 158 | if (!email) { 159 | throw new APIError({ message: 'An email is required to generate a token' }); 160 | } 161 | 162 | const user = await this.findOne({ email }).exec(); 163 | const err: any = { 164 | status: httpStatus.UNAUTHORIZED, 165 | isPublic: true 166 | }; 167 | if (password) { 168 | if (user && (await user.passwordMatches(password))) { 169 | return { user, accessToken: user.token() }; 170 | } 171 | err.message = 'Incorrect email or password'; 172 | } else if (refreshObject && refreshObject.userEmail === email) { 173 | if (moment(refreshObject.expires).isBefore()) { 174 | err.message = 'Invalid refresh token.'; 175 | } else { 176 | return { user, accessToken: user.token() }; 177 | } 178 | } else { 179 | err.message = 'Incorrect email or refreshToken'; 180 | } 181 | throw new APIError(err); 182 | }, 183 | 184 | /** 185 | * List users. 186 | * @returns {Promise} 187 | */ 188 | list({ query }: { query: any }) { 189 | return listData(this, query, ALLOWED_FIELDS); 190 | }, 191 | 192 | /** 193 | * Return new validation error 194 | * if error is a mongoose duplicate key error 195 | * 196 | * @param {Error} error 197 | * @returns {Error|APIError} 198 | */ 199 | checkDuplicateEmail(error: any) { 200 | if (error.name === 'MongoError' && error.code === 11000) { 201 | return new APIError({ 202 | message: 'Validation Error', 203 | errors: [ 204 | { 205 | field: 'email', 206 | location: 'body', 207 | messages: ['"email" already exists'] 208 | } 209 | ], 210 | status: httpStatus.CONFLICT, 211 | isPublic: true, 212 | stack: error.stack 213 | }); 214 | } 215 | return error; 216 | }, 217 | 218 | async oAuthLogin({ service, id, email, name, picture }: any) { 219 | const user = await this.findOne({ $or: [{ [`services.${service}`]: id }, { email }] }); 220 | if (user) { 221 | user.services[service] = id; 222 | if (!user.name) { 223 | user.name = name; 224 | } 225 | if (!user.picture) { 226 | user.picture = picture; 227 | } 228 | return user.save(); 229 | } 230 | const password = uuidv4(); 231 | return this.create({ 232 | services: { [service]: id }, 233 | email, 234 | password, 235 | name, 236 | picture 237 | }); 238 | }, 239 | 240 | async count() { 241 | return this.find().count(); 242 | } 243 | }; 244 | 245 | /** 246 | * @typedef User 247 | */ 248 | const User = mongoose.model('User', userSchema); 249 | User.ALLOWED_FIELDS = ALLOWED_FIELDS; 250 | module.exports = User; 251 | -------------------------------------------------------------------------------- /src/api/models/userNote.model.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const mongoose = require('mongoose'); 3 | import { transformData, listData } from '../../api/utils/ModelUtils'; 4 | 5 | const userNoteSchema = new mongoose.Schema( 6 | { 7 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 8 | title: { type: String, default: '' }, 9 | note: String, 10 | likes: { type: Number, default: 0 } 11 | }, 12 | { timestamps: true } 13 | ); 14 | const ALLOWED_FIELDS = ['id', 'user', 'title', 'note', 'likes', 'createdAt']; 15 | 16 | userNoteSchema.method({ 17 | // query is optional, e.g. to transform data for response but only include certain "fields" 18 | transform({ query = {} }: { query?: any } = {}) { 19 | // transform every record (only respond allowed fields and "&fields=" in query) 20 | return transformData(this, query, ALLOWED_FIELDS); 21 | } 22 | }); 23 | 24 | userNoteSchema.statics = { 25 | list({ query }: { query: any }) { 26 | return listData(this, query, ALLOWED_FIELDS); 27 | } 28 | }; 29 | 30 | const Model = mongoose.model('UserNote', userNoteSchema); 31 | Model.ALLOWED_FIELDS = ALLOWED_FIELDS; 32 | 33 | module.exports = Model; 34 | -------------------------------------------------------------------------------- /src/api/routes/v1/auth.route.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const express = require('express'); 3 | const validate = require('express-validation'); 4 | const controller = require('../../controllers/auth.controller'); 5 | const oAuthLogin = require('../../middlewares/auth').oAuth; 6 | const { login, register, oAuth, refresh, forgotPassword } = require('../../validations/auth.validation'); 7 | const { authorize, LOGGED_USER } = require('../../middlewares/auth'); 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {post} v1/auth/register Register 13 | * @apiDescription Register a new user 14 | * @apiVersion 1.0.0 15 | * @apiName Register 16 | * @apiGroup Auth 17 | * @apiPermission public 18 | * 19 | * @apiParam {String} email User's email 20 | * @apiParam {String{6..128}} password User's password 21 | * 22 | * @apiSuccess (Created 201) {String} token.tokenType Access Token's type 23 | * @apiSuccess (Created 201) {String} token.accessToken Authorization Token 24 | * @apiSuccess (Created 201) {String} token.refreshToken Token to get a new accessToken 25 | * after expiration time 26 | * @apiSuccess (Created 201) {Number} token.expiresIn Access Token's expiration time 27 | * in miliseconds 28 | * @apiSuccess (Created 201) {String} token.timezone The server's Timezone 29 | * 30 | * @apiSuccess (Created 201) {String} user.id User's id 31 | * @apiSuccess (Created 201) {String} user.name User's name 32 | * @apiSuccess (Created 201) {String} user.email User's email 33 | * @apiSuccess (Created 201) {String} user.role User's role 34 | * @apiSuccess (Created 201) {Date} user.createdAt Timestamp 35 | * 36 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 37 | */ 38 | router.route('/register').post(validate(register), controller.register); 39 | 40 | /** 41 | * @api {post} v1/auth/login Login 42 | * @apiDescription Get an accessToken 43 | * @apiVersion 1.0.0 44 | * @apiName Login 45 | * @apiGroup Auth 46 | * @apiPermission public 47 | * 48 | * @apiParam {String} email User's email 49 | * @apiParam {String{..128}} password User's password 50 | * 51 | * @apiSuccess {String} token.tokenType Access Token's type 52 | * @apiSuccess {String} token.accessToken Authorization Token 53 | * @apiSuccess {String} token.refreshToken Token to get a new accessToken 54 | * after expiration time 55 | * @apiSuccess {Number} token.expiresIn Access Token's expiration time 56 | * in miliseconds 57 | * 58 | * @apiSuccess {String} user.id User's id 59 | * @apiSuccess {String} user.name User's name 60 | * @apiSuccess {String} user.email User's email 61 | * @apiSuccess {String} user.role User's role 62 | * @apiSuccess {Date} user.createdAt Timestamp 63 | * 64 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 65 | * @apiError (Unauthorized 401) Unauthorized Incorrect email or password 66 | */ 67 | router.route('/login').post(validate(login), controller.login); 68 | 69 | /** 70 | * @api {post} v1/auth/refresh-token Refresh Token 71 | * @apiDescription Refresh expired accessToken 72 | * @apiVersion 1.0.0 73 | * @apiName RefreshToken 74 | * @apiGroup Auth 75 | * @apiPermission public 76 | * 77 | * @apiParam {String} email User's email 78 | * @apiParam {String} refreshToken Refresh token aquired when user logged in 79 | * 80 | * @apiSuccess {String} tokenType Access Token's type 81 | * @apiSuccess {String} accessToken Authorization Token 82 | * @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time 83 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 84 | * 85 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 86 | * @apiError (Unauthorized 401) Unauthorized Incorrect email or refreshToken 87 | */ 88 | router.route('/refresh-token').post(validate(refresh), controller.refresh); 89 | 90 | /** 91 | * TODO: POST /v1/auth/reset-password 92 | */ 93 | 94 | /** 95 | * @api {post} v1/auth/facebook Facebook Login 96 | * @apiDescription Login with facebook. Creates a new user if it does not exist 97 | * @apiVersion 1.0.0 98 | * @apiName FacebookLogin 99 | * @apiGroup Auth 100 | * @apiPermission public 101 | * 102 | * @apiParam {String} access_token Facebook's access_token 103 | * 104 | * @apiSuccess {String} tokenType Access Token's type 105 | * @apiSuccess {String} accessToken Authorization Token 106 | * @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time 107 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 108 | * 109 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 110 | * @apiError (Unauthorized 401) Unauthorized Incorrect access_token 111 | */ 112 | router.route('/facebook').post(validate(oAuth), oAuthLogin('facebook'), controller.oAuth); 113 | 114 | /** 115 | * @api {post} v1/auth/google Google Login 116 | * @apiDescription Login with google. Creates a new user if it does not exist 117 | * @apiVersion 1.0.0 118 | * @apiName GoogleLogin 119 | * @apiGroup Auth 120 | * @apiPermission public 121 | * 122 | * @apiParam {String} access_token Google's access_token 123 | * 124 | * @apiSuccess {String} tokenType Access Token's type 125 | * @apiSuccess {String} accessToken Authorization Token 126 | * @apiSuccess {String} refreshToken Token to get a new accpessToken after expiration time 127 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 128 | * 129 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 130 | * @apiError (Unauthorized 401) Unauthorized Incorrect access_token 131 | */ 132 | router.route('/google').post(validate(oAuth), oAuthLogin('google'), controller.oAuth); 133 | 134 | router.route('/forgot-password').post(validate(forgotPassword), controller.forgotPassword); 135 | router.route('/logout').post(authorize(LOGGED_USER), controller.logout); 136 | 137 | module.exports = router; 138 | -------------------------------------------------------------------------------- /src/api/routes/v1/index.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import * as express from 'express'; 3 | import { apiJson } from '../../../api/utils/Utils'; 4 | 5 | const userRoutes = require('./user.route'); 6 | const authRoutes = require('./auth.route'); 7 | const uploadRoutes = require('./upload.route'); 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * GET v1/status 13 | */ 14 | router.get('/status', (req, res, next) => { 15 | apiJson({ req, res, data: { status: 'OK' } }); 16 | return next(); 17 | }); 18 | 19 | /** 20 | * GET v1/docs 21 | */ 22 | router.use('/docs', express.static('docs')); 23 | 24 | router.use('/users', userRoutes); 25 | router.use('/auth', authRoutes); 26 | router.use('/upload', uploadRoutes); 27 | 28 | module.exports = router; 29 | -------------------------------------------------------------------------------- /src/api/routes/v1/upload.route.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const express = require('express'); 3 | import { NextFunction, Request, Response, Router } from 'express'; 4 | const router = express.Router(); 5 | const { authorize } = require('../../middlewares/auth'); 6 | const { UPLOAD_LIMIT } = require('../../../config/vars'); 7 | 8 | const controller = require('../../controllers/upload.controller'); 9 | 10 | const multer = require('multer'); 11 | const storage = multer.diskStorage({ 12 | destination(req: Request, file: any, cb: any) { 13 | cb(null, 'uploads/'); 14 | }, 15 | filename(req: Request, file: any, cb: any) { 16 | // fieldname, originalname, mimetype 17 | cb(null, `${file.fieldname}-${Date.now()}.png`); 18 | } 19 | }); 20 | const upload = multer({ storage, limits: { fieldSize: `${UPLOAD_LIMIT}MB` } }); 21 | 22 | router.route('/file').post(authorize(), upload.single('file'), controller.upload); 23 | 24 | module.exports = router; 25 | -------------------------------------------------------------------------------- /src/api/routes/v1/user.route.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const express = require('express'); 3 | const validate = require('express-validation'); 4 | const controller = require('../../controllers/user.controller'); 5 | const { authorize, ADMIN, LOGGED_USER } = require('../../middlewares/auth'); 6 | const { listUsers, createUser, replaceUser, updateUser } = require('../../validations/user.validation'); 7 | 8 | const router = express.Router(); 9 | 10 | /** 11 | * Load user when API with userId route parameter is hit 12 | */ 13 | router.param('userId', controller.load); 14 | 15 | router 16 | .route('/') 17 | /** 18 | * @api {get} v1/users List Users 19 | * @apiDescription Get a list of users 20 | * @apiVersion 1.0.0 21 | * @apiName ListUsers 22 | * @apiGroup User 23 | * @apiPermission admin 24 | * 25 | * @apiHeader {String} Athorization User's access token 26 | * 27 | * @apiParam {Number{1-}} [page=1] List page 28 | * @apiParam {Number{1-100}} [perPage=1] Users per page 29 | * @apiParam {String} [name] User's name 30 | * @apiParam {String} [email] User's email 31 | * @apiParam {String=user,admin} [role] User's role 32 | * 33 | * @apiSuccess {Object[]} users List of users. 34 | * 35 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data 36 | * @apiError (Forbidden 403) Forbidden Only admins can access the data 37 | */ 38 | .get(authorize(ADMIN), validate(listUsers), controller.list) 39 | /** 40 | * @api {post} v1/users Create User 41 | * @apiDescription Create a new user 42 | * @apiVersion 1.0.0 43 | * @apiName CreateUser 44 | * @apiGroup User 45 | * @apiPermission admin 46 | * 47 | * @apiHeader {String} Athorization User's access token 48 | * 49 | * @apiParam {String} email User's email 50 | * @apiParam {String{6..128}} password User's password 51 | * @apiParam {String{..128}} [name] User's name 52 | * @apiParam {String=user,admin} [role] User's role 53 | * 54 | * @apiSuccess (Created 201) {String} id User's id 55 | * @apiSuccess (Created 201) {String} name User's name 56 | * @apiSuccess (Created 201) {String} email User's email 57 | * @apiSuccess (Created 201) {String} role User's role 58 | * @apiSuccess (Created 201) {Date} createdAt Timestamp 59 | * 60 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 61 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can create the data 62 | * @apiError (Forbidden 403) Forbidden Only admins can create the data 63 | */ 64 | .post(authorize(ADMIN), validate(createUser), controller.create); 65 | 66 | router 67 | .route('/profile') 68 | /** 69 | * @api {get} v1/users/profile User Profile 70 | * @apiDescription Get logged in user profile information 71 | * @apiVersion 1.0.0 72 | * @apiName UserProfile 73 | * @apiGroup User 74 | * @apiPermission user 75 | * 76 | * @apiHeader {String} Athorization User's access token 77 | * 78 | * @apiSuccess {String} id User's id 79 | * @apiSuccess {String} name User's name 80 | * @apiSuccess {String} email User's email 81 | * @apiSuccess {String} role User's role 82 | * @apiSuccess {Date} createdAt Timestamp 83 | * 84 | * @apiError (Unauthorized 401) Unauthorized Only authenticated Users can access the data 85 | */ 86 | .get(authorize(), controller.loggedIn); 87 | 88 | router 89 | .route('/:userId') 90 | /** 91 | * @api {get} v1/users/:id Get User 92 | * @apiDescription Get user information 93 | * @apiVersion 1.0.0 94 | * @apiName GetUser 95 | * @apiGroup User 96 | * @apiPermission user 97 | * 98 | * @apiHeader {String} Athorization User's access token 99 | * 100 | * @apiSuccess {String} id User's id 101 | * @apiSuccess {String} name User's name 102 | * @apiSuccess {String} email User's email 103 | * @apiSuccess {String} role User's role 104 | * @apiSuccess {Date} createdAt Timestamp 105 | * 106 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data 107 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can access the data 108 | * @apiError (Not Found 404) NotFound User does not exist 109 | */ 110 | .get(authorize(LOGGED_USER), controller.get) 111 | /** 112 | * @api {put} v1/users/:id Replace User 113 | * @apiDescription Replace the whole user document with a new one 114 | * @apiVersion 1.0.0 115 | * @apiName ReplaceUser 116 | * @apiGroup User 117 | * @apiPermission user 118 | * 119 | * @apiHeader {String} Athorization User's access token 120 | * 121 | * @apiParam {String} email User's email 122 | * @apiParam {String{6..128}} password User's password 123 | * @apiParam {String{..128}} [name] User's name 124 | * @apiParam {String=user,admin} [role] User's role 125 | * (You must be an admin to change the user's role) 126 | * 127 | * @apiSuccess {String} id User's id 128 | * @apiSuccess {String} name User's name 129 | * @apiSuccess {String} email User's email 130 | * @apiSuccess {String} role User's role 131 | * @apiSuccess {Date} createdAt Timestamp 132 | * 133 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 134 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data 135 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data 136 | * @apiError (Not Found 404) NotFound User does not exist 137 | */ 138 | .put(authorize(LOGGED_USER), validate(replaceUser), controller.replace) 139 | /** 140 | * @api {patch} v1/users/:id Update User 141 | * @apiDescription Update some fields of a user document 142 | * @apiVersion 1.0.0 143 | * @apiName UpdateUser 144 | * @apiGroup User 145 | * @apiPermission user 146 | * 147 | * @apiHeader {String} Athorization User's access token 148 | * 149 | * @apiParam {String} email User's email 150 | * @apiParam {String{6..128}} password User's password 151 | * @apiParam {String{..128}} [name] User's name 152 | * @apiParam {String=user,admin} [role] User's role 153 | * (You must be an admin to change the user's role) 154 | * 155 | * @apiSuccess {String} id User's id 156 | * @apiSuccess {String} name User's name 157 | * @apiSuccess {String} email User's email 158 | * @apiSuccess {String} role User's role 159 | * @apiSuccess {Date} createdAt Timestamp 160 | * 161 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 162 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data 163 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data 164 | * @apiError (Not Found 404) NotFound User does not exist 165 | */ 166 | .patch(authorize(LOGGED_USER), validate(updateUser), controller.update) 167 | /** 168 | * @api {patch} v1/users/:id Delete User 169 | * @apiDescription Delete a user 170 | * @apiVersion 1.0.0 171 | * @apiName DeleteUser 172 | * @apiGroup User 173 | * @apiPermission user 174 | * 175 | * @apiHeader {String} Athorization User's access token 176 | * 177 | * @apiSuccess (No Content 204) Successfully deleted 178 | * 179 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can delete the data 180 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can delete the data 181 | * @apiError (Not Found 404) NotFound User does not exist 182 | */ 183 | .delete(authorize(LOGGED_USER), controller.remove); 184 | 185 | router.route('/:userId/notes').get(authorize(LOGGED_USER), controller.listUserNotes); 186 | router.route('/:userId/notes').post(authorize(LOGGED_USER), controller.createNote); 187 | router.route('/:userId/notes/:noteId').get(authorize(LOGGED_USER), controller.readUserNote); 188 | router.route('/:userId/notes/:noteId').post(authorize(LOGGED_USER), controller.updateUserNote); 189 | router.route('/:userId/notes/:noteId/like').post(authorize(LOGGED_USER), controller.likeUserNote); 190 | router.route('/:userId/notes/:noteId').delete(authorize(LOGGED_USER), controller.deleteUserNote); 191 | 192 | module.exports = router; 193 | -------------------------------------------------------------------------------- /src/api/services/authProviders.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | export {}; 3 | const axios = require('axios'); 4 | 5 | exports.facebook = async (access_token: string) => { 6 | const fields = 'id, name, email, picture'; 7 | const url = 'https://graph.facebook.com/me'; 8 | const params = { access_token, fields }; 9 | const response = await axios.get(url, { params }); 10 | const { id, name, email, picture } = response.data; 11 | return { 12 | service: 'facebook', 13 | picture: picture.data.url, 14 | id, 15 | name, 16 | email 17 | }; 18 | }; 19 | 20 | exports.google = async (access_token: string) => { 21 | const url = 'https://www.googleapis.com/oauth2/v3/userinfo'; 22 | const params = { access_token }; 23 | const response = await axios.get(url, { params }); 24 | const { sub, name, email, picture } = response.data; 25 | return { 26 | service: 'google', 27 | picture, 28 | id: sub, 29 | name, 30 | email 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/api/services/socket.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const socketio = require('socket.io'); 3 | 4 | exports.setup = (server: any) => { 5 | socketio(server).on('connect', (client: any) => { 6 | console.log('--- socket.io connection ready'); 7 | 8 | client.on('customMessage', (msg: any) => { 9 | console.log('on message - ', msg); 10 | 11 | client.emit('customReply', { test: 789 }); 12 | }); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /src/api/utils/APIError.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const httpStatus = require('http-status'); 3 | 4 | /** 5 | * @extends Error 6 | */ 7 | class ExtendableError extends Error { 8 | errors: any; 9 | status: any; 10 | isPublic: any; 11 | isOperational: any; 12 | 13 | constructor({ message, errors, status, isPublic, stack }: any) { 14 | super(message); 15 | this.name = this.constructor.name; 16 | this.message = message; 17 | this.errors = errors; 18 | this.status = status; 19 | this.isPublic = isPublic; 20 | this.isOperational = true; // This is required since bluebird 4 doesn't append it anymore. 21 | this.stack = stack; 22 | // Error.captureStackTrace(this, this.constructor.name); 23 | console.error(stack) 24 | } 25 | } 26 | 27 | /** 28 | * Class representing an API error. 29 | * @extends ExtendableError 30 | */ 31 | class APIError extends ExtendableError { 32 | /** 33 | * Creates an API error. 34 | * @param {string} message - Error message. 35 | * @param {number} status - HTTP status code of error. 36 | * @param {boolean} isPublic - Whether the message should be visible to user or not. 37 | */ 38 | constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false }: any) { 39 | super({ 40 | message, 41 | errors, 42 | status, 43 | isPublic, 44 | stack 45 | }); 46 | } 47 | } 48 | 49 | module.exports = APIError; 50 | -------------------------------------------------------------------------------- /src/api/utils/Const.ts: -------------------------------------------------------------------------------- 1 | export const ITEMS_PER_PAGE = 20; 2 | -------------------------------------------------------------------------------- /src/api/utils/InitData.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import { User, UserNote } from '../../api/models'; 3 | 4 | const USER_1 = { 5 | email: 'user1@example.com', 6 | role: 'admin', 7 | password: 'user111' 8 | }; 9 | const ADMIN_USER_1 = { 10 | email: 'admin1@example.com', 11 | role: 'admin', 12 | password: '1admin1' 13 | }; 14 | const ADMIN_USER_2 = { 15 | email: 'admin2@example.com', 16 | role: 'admin', 17 | password: '2admin2' 18 | }; 19 | 20 | async function setup() { 21 | const createUserNotes = async (user: any, num: number, text: string) => { 22 | for (let i = 0; i < num; i += 1) { 23 | const note = new UserNote({ user, note: `${text} ${i}` }); 24 | await note.save(); 25 | } 26 | }; 27 | const user1 = new User(USER_1); 28 | await user1.save(); 29 | await createUserNotes(user1, 100, 'user1 note'); 30 | 31 | const adminUser1 = new User(ADMIN_USER_1); 32 | await adminUser1.save(); 33 | await createUserNotes(adminUser1, 20, 'admin1 note'); 34 | 35 | const adminUser2 = new User(ADMIN_USER_2); 36 | await adminUser2.save(); 37 | await createUserNotes(adminUser2, 20, 'admin2 note'); 38 | } 39 | 40 | async function checkNewDB() { 41 | const user1 = await User.findOne({ email: USER_1.email }); 42 | if (!user1) { 43 | console.log('- New DB detected ===> Initializing Dev Data...'); 44 | await setup(); 45 | } else { 46 | console.log('- Skip InitData'); 47 | } 48 | } 49 | 50 | checkNewDB(); 51 | -------------------------------------------------------------------------------- /src/api/utils/ModelUtils.ts: -------------------------------------------------------------------------------- 1 | import { getMongoQuery, getPageQuery, queryPromise } from '../../api/utils/Utils'; 2 | 3 | // transform every record (only respond allowed fields and "&fields=" in query) 4 | export function transformData(context: any, query: any, allowedFields: string[]) { 5 | const queryParams = getPageQuery(query); 6 | const transformed: any = {}; 7 | allowedFields.forEach((field: string) => { 8 | if (queryParams && queryParams.fields && queryParams.fields.indexOf(field) < 0) { 9 | return; // if "fields" is set => only include those fields, return if not. 10 | } 11 | transformed[field] = context[field]; 12 | }); 13 | return transformed; 14 | } 15 | 16 | // example: URL queryString = '&populate=author:_id,firstName&populate=withUrlData:_id,url' 17 | // => queryArray = ['author:_id,firstName', 'withUrlData:_id,url'] 18 | // return array of fields we want to populate (MongoDB spec) 19 | const getPopulateArray = (queryArray: [], allowedFields: string[]) => { 20 | if (!queryArray) { 21 | return []; 22 | } 23 | const ret: any[] = []; 24 | queryArray.map((str: string = '') => { 25 | const arr = str.split(':'); 26 | // only populate fields belong to "allowedFields" 27 | if (arr && arr.length === 2 && allowedFields.indexOf(arr[0]) >= 0) { 28 | ret.push({ 29 | path: arr[0], 30 | select: arr[1].split(',') 31 | }); 32 | } 33 | }); 34 | // example of returned array (MongoDB spec): 35 | // ret = [ 36 | // { 37 | // path: 'author', 38 | // select: ['_id', 'firstName', 'lastName', 'category', 'avatarUrl'] 39 | // } 40 | // ]; 41 | return ret; 42 | }; 43 | 44 | const queryPagination = (mongoQuery: any, query: any) => { 45 | const { page = 1, perPage = 30, limit, offset, sort } = getPageQuery(query); 46 | 47 | mongoQuery.sort(sort); 48 | 49 | // 2 ways to have pagination using: offset & limit OR page & perPage 50 | if (query.perPage) { 51 | mongoQuery.skip(perPage * (page - 1)).limit(perPage); 52 | } 53 | if (typeof offset !== 'undefined') { 54 | mongoQuery.skip(offset); 55 | } 56 | if (typeof limit !== 'undefined') { 57 | mongoQuery.limit(limit); 58 | } 59 | }; 60 | 61 | // list data with pagination support 62 | // return a promise for chaining. (e.g. list then transform) 63 | export function listData(context: any, query: any, allowedFields: string[]) { 64 | const mongoQueryObj = getMongoQuery(query, allowedFields); // allowed filter fields 65 | 66 | // console.log('--- query: ', query); 67 | // console.log('--- allowedFields: ', allowedFields); 68 | // console.log('--- populateArr: ', query.populate); 69 | let result = context.find(mongoQueryObj); 70 | 71 | queryPagination(result, query); 72 | 73 | const queryPopulate = Array.isArray(query.populate) ? query.populate : [query.populate]; // to array. 74 | const populateArr = getPopulateArray(queryPopulate, allowedFields); // get Mongo-spec's populate array. 75 | populateArr.forEach((item: any) => { 76 | result = result.populate(item); // Mongo's populate() to populate nested object. 77 | }); 78 | 79 | const execRes = result.exec(); 80 | return queryPromise(execRes); 81 | } 82 | -------------------------------------------------------------------------------- /src/api/utils/MsgUtils.ts: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | // configure for Slack 4 | const { SLACK_WEBHOOK_URL } = require('../../config/vars'); 5 | const { IncomingWebhook } = require('@slack/client'); 6 | let incomingWebhook: any = null; 7 | if (SLACK_WEBHOOK_URL) { 8 | incomingWebhook = new IncomingWebhook(SLACK_WEBHOOK_URL); 9 | } 10 | 11 | // configure for emailing 12 | const { 13 | EMAIL_MAILGUN_API_KEY, 14 | EMAIL_FROM_SUPPORT, 15 | EMAIL_MAILGUN_DOMAIN, 16 | EMAIL_TEMPLATE_BASE 17 | } = require('../../config/vars'); 18 | const handlebars = require('handlebars'); 19 | 20 | // load template file & inject data => return content with injected data. 21 | const template = (fileName: string, data: any) => { 22 | const content = fs.readFileSync(EMAIL_TEMPLATE_BASE + fileName).toString(); 23 | const inject = handlebars.compile(content); 24 | return inject(data); 25 | }; 26 | 27 | // --------- Email Templates --------- // 28 | 29 | export function welcomeEmail({ name, email }: { name: string; email: string }) { 30 | return { 31 | from: EMAIL_FROM_SUPPORT, 32 | to: `${name} <${email}>`, 33 | subject: `Welcome!`, 34 | text: template('welcome.txt', { name, email }), 35 | html: template('welcome.html', { name, email }) 36 | }; 37 | } 38 | 39 | export function forgotPasswordEmail({ name, email, tempPass }: { name: string; email: string; tempPass: string }) { 40 | return { 41 | from: EMAIL_FROM_SUPPORT, 42 | to: `${name} <${email}>`, 43 | subject: `Your one-time temporary password`, 44 | text: template('forgot-password.txt', { name, email, tempPass }), 45 | html: template('forgot-password.html', { name, email, tempPass }) 46 | }; 47 | } 48 | 49 | // resetPswEmail, forgotPswEmail, etc. 50 | 51 | // --------- Nodemailer and Mailgun setup --------- // 52 | const nodemailer = require('nodemailer'); 53 | const mailgunTransport = require('nodemailer-mailgun-transport'); 54 | let emailClient: any = null; 55 | if (EMAIL_MAILGUN_API_KEY) { 56 | // Configure transport options 57 | const mailgunOptions = { 58 | auth: { 59 | api_key: EMAIL_MAILGUN_API_KEY, // process.env.MAILGUN_ACTIVE_API_KEY, 60 | domain: EMAIL_MAILGUN_DOMAIN // process.env.MAILGUN_DOMAIN, 61 | } 62 | }; 63 | const transport = mailgunTransport(mailgunOptions); 64 | emailClient = nodemailer.createTransport(transport); 65 | } 66 | 67 | export function sendEmail(data: any) { 68 | if (!emailClient) { 69 | return; 70 | } 71 | return new Promise((resolve, reject) => { 72 | emailClient 73 | ? emailClient.sendMail(data, (err: any, info: any) => { 74 | if (err) { 75 | reject(err); 76 | } else { 77 | resolve(info); 78 | } 79 | }) 80 | : ''; 81 | }); 82 | } 83 | 84 | // send slack message using incoming webhook url 85 | // @example: slackWebhook('message') 86 | export function slackWebhook(message: string) { 87 | incomingWebhook ? incomingWebhook.send(message) : ''; 88 | } 89 | -------------------------------------------------------------------------------- /src/api/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import { ITEMS_PER_PAGE } from '../../api/utils/Const'; 3 | 4 | export function jsonClone(obj: any) { 5 | return JSON.parse(JSON.stringify(obj)); 6 | } 7 | 8 | // Helper functions for Utils.uuid() 9 | const lut = Array(256) 10 | .fill('') 11 | .map((_, i) => (i < 16 ? '0' : '') + i.toString(16)); 12 | const formatUuid = ({ d0, d1, d2, d3 }: { d0: number; d1: number; d2: number; d3: number }) => 13 | /* tslint:disable-next-line */ 14 | lut[d0 & 0xff] + 15 | lut[(d0 >> 8) & 0xff] + 16 | lut[(d0 >> 16) & 0xff] + 17 | lut[(d0 >> 24) & 0xff] + 18 | '-' + 19 | lut[d1 & 0xff] + 20 | lut[(d1 >> 8) & 0xff] + 21 | '-' + 22 | lut[((d1 >> 16) & 0x0f) | 0x40] + 23 | lut[(d1 >> 24) & 0xff] + 24 | '-' + 25 | lut[(d2 & 0x3f) | 0x80] + 26 | lut[(d2 >> 8) & 0xff] + 27 | '-' + 28 | lut[(d2 >> 16) & 0xff] + 29 | lut[(d2 >> 24) & 0xff] + 30 | lut[d3 & 0xff] + 31 | lut[(d3 >> 8) & 0xff] + 32 | lut[(d3 >> 16) & 0xff] + 33 | lut[(d3 >> 24) & 0xff]; 34 | 35 | const getRandomValuesFunc = 36 | typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues 37 | ? () => { 38 | const dvals = new Uint32Array(4); 39 | window.crypto.getRandomValues(dvals); 40 | return { 41 | d0: dvals[0], 42 | d1: dvals[1], 43 | d2: dvals[2], 44 | d3: dvals[3] 45 | }; 46 | } 47 | : () => ({ 48 | d0: (Math.random() * 0x100000000) >>> 0, 49 | d1: (Math.random() * 0x100000000) >>> 0, 50 | d2: (Math.random() * 0x100000000) >>> 0, 51 | d3: (Math.random() * 0x100000000) >>> 0 52 | }); 53 | 54 | /* -------------------------------------------------------------------------------- */ 55 | 56 | export function uuid() { 57 | return formatUuid(getRandomValuesFunc()); 58 | } 59 | 60 | // get url path only - remove query string (after "?"): 61 | const getUrlPathOnly = (fullUrl: string) => { 62 | return `${fullUrl}?`.slice(0, fullUrl.indexOf('?')); 63 | }; 64 | 65 | // from "sort" string (URL param) => build sort object (mongoose), e.g. "sort=name:desc,age" 66 | export function getSortQuery(sortStr: string, defaultKey = 'createdAt') { 67 | let arr = [sortStr || defaultKey]; 68 | if (sortStr && sortStr.indexOf(',')) { 69 | arr = sortStr.split(','); 70 | } 71 | let ret = {}; 72 | for (let i = 0; i < arr.length; i += 1) { 73 | let order = 1; // default: ascending (a-z) 74 | let keyName = arr[i].trim(); 75 | if (keyName.indexOf(':') >= 0) { 76 | const [keyStr, orderStr] = keyName.split(':'); // e.g. "name:desc" 77 | keyName = keyStr.trim(); 78 | order = orderStr.trim() === 'desc' || orderStr.trim() === '-1' ? -1 : 1; 79 | } 80 | ret = { ...ret, [keyName]: order }; 81 | } 82 | return ret; 83 | } 84 | 85 | // from "req" (req.query) => transform to: query object, e.g. { limit: 5, sort: { name: 1 } } 86 | export function getPageQuery(reqQuery: any) { 87 | if (!reqQuery) { 88 | return null; 89 | } 90 | const output: any = {}; 91 | if (reqQuery.page) { 92 | output.perPage = reqQuery.perPage || ITEMS_PER_PAGE; // if page is set => take (or set default) perPage 93 | } 94 | if (reqQuery.fields) { 95 | output.fields = reqQuery.fields.split(',').map((field: string) => field.trim()); // to array 96 | } 97 | // number (type) query params => parse them: 98 | const numParams = ['page', 'perPage', 'limit', 'offset']; 99 | numParams.forEach((field) => { 100 | if (reqQuery[field]) { 101 | output[field] = parseInt(reqQuery[field], 10); 102 | } 103 | }); 104 | output.sort = getSortQuery(reqQuery.sort, 'createdAt'); 105 | return output; 106 | } 107 | 108 | // normalize req.query to get "safe" query fields => return "query" obj for mongoose (find, etc.) 109 | export function getMongoQuery(reqQuery: any, fieldArray: string[]) { 110 | const queryObj: any = {}; 111 | fieldArray.map((field) => { 112 | // get query fields excluding pagination fields: 113 | if (['page', 'perPage', 'limit', 'offset'].indexOf(field) < 0 && reqQuery[field]) { 114 | // TODO: do more checks of query parameters for better security... 115 | let val = reqQuery[field]; 116 | if (typeof val === 'string' && val.length >= 2 && (val[0] === '*' || val[val.length - 1] === '*')) { 117 | // field value has "*text*" => use MongoDB Regex query: (partial text search) 118 | val = val.replace(/\*/g, ''); // remove "*" 119 | val = val.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape other special chars - https://goo.gl/eWCVDH 120 | queryObj[field] = { $regex: val, $options: 'i' }; 121 | } else { 122 | queryObj[field] = reqQuery[field]; // exact search 123 | } 124 | } 125 | }); 126 | console.log('- queryObj: ', JSON.stringify(queryObj)); 127 | return queryObj; 128 | } 129 | 130 | // function to decorate a promise with useful helpers like: .transform(), etc. 131 | // @example: return queryPromise( this.find({}) ) 132 | export function queryPromise(mongoosePromise: any) { 133 | return new Promise(async (resolve) => { 134 | const items = await mongoosePromise; 135 | 136 | // decorate => transform() on the result 137 | items.transform = (params: any) => { 138 | return items.map((item: any) => (item.transform ? item.transform(params) : item)); 139 | }; 140 | resolve(items); 141 | }); 142 | } 143 | 144 | type apiJsonTypes = { 145 | req: Request; 146 | res: Response; 147 | data: any | any[]; // data can be object or array 148 | model?: any; // e.g. "listModal: User" to get meta.totalCount (User.countDocuments()) 149 | meta?: any; 150 | json?: boolean; // retrieve JSON only (won't use res.json(...)) 151 | }; 152 | /** 153 | * prepare a standard API Response, e.g. { meta: {...}, data: [...], errors: [...] } 154 | * @param param0 155 | */ 156 | export async function apiJson({ req, res, data, model, meta = {}, json = false }: apiJsonTypes) { 157 | const queryObj = getPageQuery(req.query); 158 | const metaData = { ...queryObj, ...meta }; 159 | 160 | if (model) { 161 | // if pass in "model" => query for totalCount & put in "meta" 162 | const isPagination = req.query.limit || req.query.page; 163 | if (isPagination && model.countDocuments) { 164 | const query = getMongoQuery(req.query, model.ALLOWED_FIELDS); 165 | const countQuery = jsonClone(query); 166 | const totalCount = await model.countDocuments(countQuery); 167 | metaData.totalCount = totalCount; 168 | if (queryObj.perPage) { 169 | metaData.pageCount = Math.ceil(totalCount / queryObj.perPage); 170 | } 171 | metaData.count = data && data.length ? data.length : 0; 172 | } 173 | } 174 | 175 | const output = { data, meta: metaData }; 176 | if (json) { 177 | return output; 178 | } 179 | return res.json(output); 180 | } 181 | 182 | export function randomString(len = 10, charStr = 'abcdefghijklmnopqrstuvwxyz0123456789') { 183 | const chars = [...`${charStr}`]; 184 | return [...Array(len)].map((i) => chars[(Math.random() * chars.length) | 0]).join(''); 185 | } 186 | -------------------------------------------------------------------------------- /src/api/validations/auth.validation.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const Joi = require('joi'); 3 | 4 | module.exports = { 5 | // POST /v1/auth/register 6 | register: { 7 | body: { 8 | email: Joi.string() 9 | .email() 10 | .required(), 11 | password: Joi.string() 12 | .required() 13 | .min(6) 14 | .max(128) 15 | } 16 | }, 17 | 18 | // POST /v1/auth/login 19 | login: { 20 | body: { 21 | email: Joi.string() 22 | .email() 23 | .required(), 24 | password: Joi.string() 25 | .required() 26 | .max(128) 27 | } 28 | }, 29 | 30 | // POST /v1/auth/facebook 31 | // POST /v1/auth/google 32 | oAuth: { 33 | body: { 34 | access_token: Joi.string().required() 35 | } 36 | }, 37 | 38 | // POST /v1/auth/refresh 39 | refresh: { 40 | body: { 41 | email: Joi.string() 42 | .email() 43 | .required(), 44 | refreshToken: Joi.string().required() 45 | } 46 | }, 47 | 48 | // POST /v1/auth/forgot-password 49 | forgotPassword: { 50 | body: { 51 | email: Joi.string() 52 | .email() 53 | .required() 54 | } 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/api/validations/user.validation.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import * as Joi from 'joi'; 3 | import { User } from '../../api/models'; 4 | 5 | const requireEmail = () => Joi.string().email().required(); 6 | 7 | const postPutBody = () => { 8 | return { 9 | email: requireEmail(), 10 | password: Joi.string().min(6).max(128).required(), 11 | name: Joi.string().max(128), 12 | role: Joi.string().valid(User.roles) 13 | }; 14 | }; 15 | 16 | module.exports = { 17 | // GET /v1/users 18 | listUsers: { 19 | query: { 20 | limit: Joi.number().min(1).max(9999), 21 | offset: Joi.number().min(0), 22 | page: Joi.number().min(0), 23 | perPage: Joi.number().min(1), 24 | sort: Joi.string(), 25 | name: Joi.string(), 26 | email: Joi.string(), 27 | role: Joi.string().valid(User.roles) 28 | } 29 | }, 30 | 31 | // POST /v1/users 32 | createUser: { 33 | body: postPutBody() 34 | }, 35 | 36 | // PUT /v1/users/:userId 37 | replaceUser: { 38 | body: postPutBody(), 39 | params: { 40 | userId: Joi.string() 41 | .regex(/^[a-fA-F0-9]{24}$/) 42 | .required() 43 | } 44 | }, 45 | 46 | // PATCH /v1/users/:userId 47 | updateUser: { 48 | body: { 49 | email: Joi.string().email(), 50 | password: Joi.string().min(6).max(128), 51 | name: Joi.string().max(128), 52 | role: Joi.string().valid(User.roles) 53 | }, 54 | params: { 55 | userId: Joi.string() 56 | .regex(/^[a-fA-F0-9]{24}$/) 57 | .required() 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/config/express.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import * as express from 'express'; 3 | const morgan = require('morgan'); 4 | const bodyParser = require('body-parser'); 5 | const compress = require('compression'); 6 | const methodOverride = require('method-override'); 7 | const cors = require('cors'); 8 | const helmet = require('helmet'); 9 | const passport = require('passport'); 10 | const routes = require('../api/routes/v1'); 11 | const { logs, UPLOAD_LIMIT } = require('./vars'); 12 | const strategies = require('./passport'); 13 | const error = require('../api/middlewares/error'); 14 | 15 | /** 16 | * Express instance 17 | * @public 18 | */ 19 | const app = express(); 20 | 21 | // request logging. dev: console | production: file 22 | app.use(morgan(logs)); 23 | 24 | // parse body params and attache them to req.body 25 | app.use(bodyParser.json({ limit: `${UPLOAD_LIMIT}mb` })); 26 | app.use(bodyParser.urlencoded({ extended: true, limit: `${UPLOAD_LIMIT}mb` })); 27 | 28 | // gzip compression 29 | app.use(compress()); 30 | 31 | // lets you use HTTP verbs such as PUT or DELETE 32 | // in places where the client doesn't support it 33 | app.use(methodOverride()); 34 | 35 | // secure apps by setting various HTTP headers 36 | app.use(helmet()); 37 | 38 | // enable CORS - Cross Origin Resource Sharing 39 | app.use(cors()); 40 | 41 | // --- NOTE: for testing in DEV, allow Access-Control-Allow-Origin: (ref: https://goo.gl/pyjO1H) 42 | // app.all('/*', function(req, res, next) { 43 | // res.header("Access-Control-Allow-Origin", "*"); 44 | // res.header("Access-Control-Allow-Headers", "X-Requested-With"); 45 | // next(); 46 | // }); 47 | 48 | app.use((req: any, res: express.Response, next: express.NextFunction) => { 49 | req.uuid = `uuid_${Math.random()}`; // use "uuid" lib 50 | next(); 51 | }); 52 | 53 | // enable authentication 54 | app.use(passport.initialize()); 55 | passport.use('jwt', strategies.jwt); 56 | passport.use('facebook', strategies.facebook); 57 | passport.use('google', strategies.google); 58 | 59 | // mount api v1 routes 60 | app.use('/api/v1', routes); 61 | 62 | // if error is not an instanceOf APIError, convert it. 63 | app.use(error.converter); 64 | 65 | // catch 404 and forward to error handler 66 | app.use(error.notFound); 67 | 68 | // error handler, send stacktrace only during development 69 | app.use(error.handler); 70 | 71 | module.exports = app; 72 | -------------------------------------------------------------------------------- /src/config/https/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIJANoc7ThAtAzWMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0xODA5MjYxNzAyMjlaFw0xOTA5MjYxNzAyMjlaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAO94J7LEPgZmXj5RAbO3IVGgEhjKeNuDMhe4qamgcDYYPhX0cVFDwc5Uptqa 6 | LYBFZ1R1VvYVMTa//f0W5AyxZ2nF+jsnyKmdX1ZnUmTc4pdLYaxLzbeBP/3xUzFq 7 | ejpY2bQvKOQIp6Et8kgMtcQn0UTq4EAQe/M0NZNja7pOo+t1RJsUx1J3WQYspz9y 8 | p2VHZ0AIRopLQYhsRw9c6KMbveMqPE15Fsde6U2Cvj74ZNSXVgtDZKpn47+tZrRL 9 | eQJ0y2ALKVoQX9TDqWCm4OsaQWige6kLNwUUtZ2LhQLH3yEj5c3Ayb/RcRt5cj01 10 | ZFBDfq+GTqUv1PEbhPCghFNmtd8CAwEAAaNQME4wHQYDVR0OBBYEFLh/JMco2yEV 11 | eEeEEctOX8W3Q5U0MB8GA1UdIwQYMBaAFLh/JMco2yEVeEeEEctOX8W3Q5U0MAwG 12 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAEZBlSsCmYg5Fi00bmgu6m7h 13 | VOF92Y1Jp2Y7diHQ16fsu1g7Niff7gzy03F2Kwp00+BwfEsEHk8SXt5HINJ/Q0yA 14 | 2tQ3HKsoJ05GndgZs1IlMeu1w6eR/lLutUvz569UYTgKAO1sEiA8pOJj1+IqDhS0 15 | mv1pAgQMlMNVhCK1NLr5sw97cei7ysVW67l4JYkI7TnXjME17p398Wd+SfvOjNqN 16 | PiFLxVq/UGm3UOAQPH1LziKCX8mH1t2ElDZ0W4dx0seGkS+CwJ0tqBVDqpe8O8cr 17 | 5yG0XKWek9mWwJqoNdU8C/GoxZTO2XGLq1tYshwQCCXJ5/0Wi5ESfgI3qYfzOts= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /src/config/https/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA73gnssQ+BmZePlEBs7chUaASGMp424MyF7ipqaBwNhg+FfRx 3 | UUPBzlSm2potgEVnVHVW9hUxNr/9/RbkDLFnacX6OyfIqZ1fVmdSZNzil0thrEvN 4 | t4E//fFTMWp6OljZtC8o5AinoS3ySAy1xCfRROrgQBB78zQ1k2Nruk6j63VEmxTH 5 | UndZBiynP3KnZUdnQAhGiktBiGxHD1zooxu94yo8TXkWx17pTYK+Pvhk1JdWC0Nk 6 | qmfjv61mtEt5AnTLYAspWhBf1MOpYKbg6xpBaKB7qQs3BRS1nYuFAsffISPlzcDJ 7 | v9FxG3lyPTVkUEN+r4ZOpS/U8RuE8KCEU2a13wIDAQABAoIBAHeArcbvWciV01gj 8 | 0sadj/oM+Jr4h6F78kaWN8zXrMfCB1Grf9U/C/tskSusHyLQ/8TNHc2GO94Hp7di 9 | cgHHkkTdsIdOzjuetlIHE74T4NwBmUeDOLDnxkhy4sJZUY/GgTYDBtPtYcH0jODZ 10 | vueZuzw6PhiaYVC38DiSN9NspS69ionznaCXwYhVH0WcP2C+RbnHdf39kp/MN+xm 11 | qf5Qd4tPwXF7MdX72Z/SLZG9XJyXFrdeHGv5+NPgJehLQfCDwx7IhoHRKpsaB8GX 12 | RFvfAukw5L5jcwsssfYQduAO6uqlHwxBfHIxHhiwqTYeMJbbA8OuhziQx6Va74t0 13 | c2/wuBkCgYEA/E/mPE9alJbKmUy9xZxUYr+s1d1Ve45UsOojICtE0oPlCbZZq8BM 14 | TrlXH9Gqo72BRSoZ3wryDfKDibVVEjdB++V5CWanZwBkZHDE1jYsiNs7V5ediply 15 | hdVPmz2hrdEImxe6jhSGHzgKxLRKArry0biXyaJbcqqi0v1Ym9rABl0CgYEA8vgz 16 | ZD2cyzKIy86wHXZXDs1S8vQtskWnF1WSZ0wOcvx19xVcRlipY5PBz5GnyLLtXjxS 17 | vFQ2AsebLTON9OubW6p6abMF7z0djOAMM47CMZCNFdve7UxRS7J0V6oxVtl00WOO 18 | k18asWDQ4BA7LXGP3tQUyAmhuuvU64rYg9xZcWsCgYBZ1ISPMl14i5tEKythgMAW 19 | jFGXrv6xR8JlXmb1HBKbtLPF2nNgj81bNeeL/5T2SREOM+gUHgyB5LbqP3IzUxMe 20 | ANwv+aleJaiYjgG2loESlKMzE1rWrr00Yva9uZqnjMW4miEkVrBNyyEiWWIESNUM 21 | z+DSvAg88f2YivSSQLafTQKBgQCNjQY7vx1q0F0cB6VY9MnRaNOLkbBRrPykvojV 22 | v1ynpud+9KXWoe7FgZ6nNB+A8KUTgqdc6t6wjeOc2O06JE1VgSNiExdBAuUFXr4d 23 | B22hfKqFcpwUuaKfLC9Vp2SpSXUiTBMTTpP5hQMLlskzN3bvBbW5uMrNYHmiZDix 24 | 0Z0WUwKBgQCMV1N1i+b5IoT05j0CsRaQbYTSDi71MNMu9Qg2VL2q237c/4vGx+r2 25 | bbLlhbEAjTMI/alxUcY0II0uFY+OwEsUaQ9QZJIsJDaQU4tD9h3XE7xiYHg+ek2y 26 | Tfh71I+cTb+z7iNSclR2Hdlntjiw9yOhMjdJ0VBzq9VYLHVOsocJ9A== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /src/config/https/keytmp.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIgEsBP5VR12kCAggA 3 | MBQGCCqGSIb3DQMHBAirU3rF7ENpDwSCBMjnmvrKnRL/jd8SRxZPoIOhWIBSijTz 4 | C67al8tn1ZYQ0F3byTGTbvF8BDPnv/fx84VLy6ygE7+AxjGtz0d/cfW1x1kVRpyS 5 | vrtbaZYPeHtNcaU2nmnyAbO7fMjst9h2mhyycMcAk3oe0ZVz6edR0krlpJnm6q5z 6 | Cu8MBrhZ17WT+dLoLx23kqjntvYL8iluiomV5Nyf0+biYLVj5PWTRzPH3yj5OwIO 7 | sSQUvuaz/xdIDVQfva9CRoJptaoAwSJ5qnhiIjS0TOBhYbiSYc9VAQFLtJ+6Pc9e 8 | TcE6MQ4HPy19JFE3yLOPHgFzxo0RbqoaD+ZQhjCgVJIWWxrEp62tGD/43qs60CRg 9 | nHBHydvhbxMjJpltDDxQNCJSPsU3BS9tQjBFNxInObC+7mQTPaphdm+k0mxPBoOI 10 | Ib+Y2xcT2Ih3Yfv6Gz2hI0rOAMTKLq3LeuzMOWsDDn6Q610ovFM2At7ZLAavm5R9 11 | Ng2bzTJguRvSKp2Bg/doFo3BRvBF3Kq6U+XBfHpfEs1NZ1HkkK+ZPEVoc2C+l6UH 12 | vHb3JlTFhpMLomoQ0orB10TQUAMAsNO4JfeQtp8kGJu4s8L4J/zJ5rBdkEZKLCjd 13 | LbWkOKOG1rjFQYZ3z9mx0+Ux2fP6NiJCEosuRU+3lUodaUJ5Qf1dminDgK4rsE5L 14 | cqmBf1e64mgzWSXFblLCK3ggUht3F8DVCnCflUHy12UgT4CGtc/vxiGWIE+Cc+6J 15 | cRr0YEDDBfr5c46DL9RBsYH1YAIP5iG1TQ90bAa4ReBgLc0shNRO+aO5LwNonRia 16 | 4wVbO64eCsPI0Veif+3f6/dTrhI4Y8mYTFkLkq1CxsWv5xFeYSqnRJ95Mx2GdFCw 17 | RhAxi7ToDiC/2GahBZkxjxJHz4Utg21cfZV937TaaTluHDYA1PmyzxshgIQd7O1A 18 | DAcjUTvmISD78vWLTjDjgdqtm4yjEg1CTxtJh6pgbnNx8SV0Qxy3AvEqsZS+z0zR 19 | wVHc6jdQzhc6qPO9FCF2nICGZs4CZJ3YC0lXhCdeotNAfDW+imQZaSycM9Nvqgy6 20 | TOzhe/JqPAmLfb7PgH3SG3IceM/x23o3XjQP5MX6FGUZLg72ZV7m34tiV7llRmmw 21 | QH2Vb7EjCXdy5F5oKwNVzXUYq9g5nfbD+ETODn6AGQA8BwrWgaklBe2AbsQlV95M 22 | wuJKLmoZ2rGFdLsqxcl5JxCaAar5dsB9Q9Mn9ocNLjALDzWtb0K1caiagUOiWKvm 23 | jmYsqpCpfb5fiSdNUf3AG8nGbdW6bvTuCk0x+ZxkvN/bkBXa+AH2eKDXb/UZ/3cw 24 | 1eRhqvRw2KoujLhh5VjtTxQiTlSpJlYL0EMYBcAQY2ask7gfY3wnmqSEtKbdj0rE 25 | srmaEe32dMFdUCSooUWvJ1MrSi91xqlIDZL4b2HHrJx7MBOLXU5g1yCSAUwXkstZ 26 | DlsWakauSAAAKULEnvDeDJ9SPl18bcEzID4K1nwV7S2WdtMZGNbiIhz9dIHq09B4 27 | FrJISzxg4yaNjCSHebIfCTp9US4NBYENrpGblBojNwO2q7c8MJeomUHbiPMePx3E 28 | IJsAHWMmzY+gsZvtAhAGimapSp+Ki/0D3DwSiv9OTBlj+Ufp5lkoDF0vYnBGSEQV 29 | gsc= 30 | -----END ENCRYPTED PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /src/config/https/localhost-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDbB+bha0tr8DeN 3 | Tq/G2iJwDvcM2bWPIXsZ4CgEy3bNbvZ0Uc8yCAAoiJsCeqX3EOLVKfE/TsLyWJE9 4 | B5f5zqdVNyKSSLxGwCTvYOizhctPIu8VlbqGlfdo+jiF857FdPfWqeJx7R2S2a9Z 5 | kblhwSBs3yJv/u3WRor2U2fLEaCTbPj3zDN15J8zZ+MYWB2q+k9ANbVw2XR+YcNK 6 | fdrnwtFRCsMquoiDxeYSYaaEta9J3Ih2EPeC/Q41BeVf5dy12XvUsJVTbdCzSRXO 7 | vUts3bwmLHwcXzJrOJcoldwJKORt3NrjpBS3PqZPsfVEf1mO90gFabNqQ4MsB7Or 8 | UxkQnUfXAgMBAAECggEAb0j6g7nSHkt0zjUWlkAqkyjnWP4cg1aNhz4e1yDRgdtJ 9 | GRyf21m5St7tczsBrCBGP+wJiRLpddyOHqYHD4Nx/Zf/1rdiAciKe52PXfcyr0ie 10 | lSb4GH2FsS7BHT4Eg2rEqaqzRRMmnYBA2Y9y0zoe0XIluyWZje9Vl4YVDFYucfhQ 11 | 39z0Xspm3NZuucY0ucMojZOFLh74DsukVTUjVR7wx82MkaQOhO0tqwah29v0gPgl 12 | u7lASzHVzwZs7kw37FKH+pYkF7K5QZEFnBkLp6wwwcsEEjGiJr91fvDnU5V76IHK 13 | JpwEDTZZ+8rKAg4dHMnG5LIpbhMJE/H2MfKz3cLpYQKBgQD+cG7ipbaLB+I2+Lqp 14 | 37Me34CROkJa0WTP+uV8XHoV2QYZImIKEN2NO6pUEg8oXWCgSC9O+tyLPDPeTjyj 15 | BnbS4EvgtoM6lXDC5DUGLAzRi/4fkPZxdUz9kcL30XsYOamVWCaKgCw3HhjCzhOs 16 | m4kqcHYMv/3AWVP5wfY8xY8raQKBgQDcX902xIjBZ7282fAYmPPNBp4Gaq8Z8F6M 17 | 7dJFtZj7YeOiDvvCHMWbflDBXmLOmdjGPqZCcfAhS4sJG8UR8tZ04cWSK9kggEQ9 18 | uA/wAGANE3iZbOem1VdHPvnWa9Q+COM3tAFytfBSDfcxqBHaiOGEYlcanbDsyvW0 19 | cDS61K6xPwKBgQCruaadQcraOw/qmiUh0eFKzP4xBmkScpDf7i5EqQWdCUScYiBZ 20 | OtjNIZ/r7eRdEejNROrpG1cOgitftt4mCY7Y9JlBqO0Y9RON9gfzind8VkfFdkle 21 | ehTkbyRvrequhvx113DruWYeLSn5EK0mqjMBebWzFUFmOOP8hXRzv8LJQQKBgQCL 22 | MPmWSBhgoFfVWoot3x3OV+mj/+pNJedyeBwh74uK689NYs1dU7L0fZogKK+b8sxa 23 | muOEgFa9kOtme2XD6m/OL3QM2SkxQBLaMNHQM0x3td6seX7vfzy7QWmoJz9NV2u8 24 | mTN48rWx/iQt9wwfzekzJrIBm6xOQ/thVqXXg9I7HQKBgDHekW4G7j+8mBPVznXH 25 | t4P4eXcNwlbxocZWRtzOS5G+pYIIpgJJlnJN/H6N88WipYpKuhWgx6eQwdB+78Tw 26 | fxhaSPTHUgU394QZOm0UVO7D9j2a0atSbCqBJUyahtL+jHhaxruCm2yWkgImaIZr 27 | h5Xy1jS00//8+pVo5/1QvDT+ 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/config/https/localhost.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIETTCCArWgAwIBAgIRAIX+QNd+LwunaoJwQlqWAk4wDQYJKoZIhvcNAQELBQAw 3 | VTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMRUwEwYDVQQLDAxkdWNA 4 | RHVjcy1NQlAxHDAaBgNVBAMME21rY2VydCBkdWNARHVjcy1NQlAwHhcNMTkwNDA2 5 | MTQ0MzI1WhcNMjkwNDA2MTQ0MzI1WjBAMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxv 6 | cG1lbnQgY2VydGlmaWNhdGUxFTATBgNVBAsMDGR1Y0BEdWNzLU1CUDCCASIwDQYJ 7 | KoZIhvcNAQEBBQADggEPADCCAQoCggEBANsH5uFrS2vwN41Or8baInAO9wzZtY8h 8 | exngKATLds1u9nRRzzIIACiImwJ6pfcQ4tUp8T9OwvJYkT0Hl/nOp1U3IpJIvEbA 9 | JO9g6LOFy08i7xWVuoaV92j6OIXznsV099ap4nHtHZLZr1mRuWHBIGzfIm/+7dZG 10 | ivZTZ8sRoJNs+PfMM3XknzNn4xhYHar6T0A1tXDZdH5hw0p92ufC0VEKwyq6iIPF 11 | 5hJhpoS1r0nciHYQ94L9DjUF5V/l3LXZe9SwlVNt0LNJFc69S2zdvCYsfBxfMms4 12 | lyiV3Ako5G3c2uOkFLc+pk+x9UR/WY73SAVps2pDgywHs6tTGRCdR9cCAwEAAaOB 13 | rDCBqTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0T 14 | AQH/BAIwADAfBgNVHSMEGDAWgBREutliwF63tEVfD9zj/T5JZWwmLjBTBgNVHREE 15 | TDBKggtleGFtcGxlLmNvbYINKi5leGFtcGxlLm9yZ4IJbG9jYWwuZGV2gglsb2Nh 16 | bGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGB 17 | ADm87629Hw4aNTCrD46CX1AYWej2obd/4K/2kW33Y2MpKBrKJsc2ZWuAIMZP+Z6v 18 | ZLdRdJGJl3x13JyCNq6+gcspXM1QftKDa25ang+pnDLYVUqxnFRXByWt6kHRyS3R 19 | z5/2m+0aW3ByB1LchwqYDS0Z/A0tkcA55CEjRysDHc67Zvj51M1x926V/+NTYm+2 20 | 9IPTeZ97K8qW8SWnsYMnnIWsZeG0Ja2SLp+yIjQV33XSiq9ObhctE4AQLyneP9pY 21 | QLjABfajSkUvNdibdRPS64OAANIb+wUoACQKjaIVSALbIrXv02qbE3NzsoO3EM92 22 | L6/l+P3uxnNxalqJ6Kj6MXZAJXoG0XOJ9dxwzKE+WL7CnVXWJb7NeEyG/Sv6VbFq 23 | fiWx4bRy0KA8OCE9YHppMtHeumV2n1RfCgtXwHhfp7C9xz3P1TsRG0L0F9iOr/Er 24 | ymLBHLYqx7pm8D+okjl8OIGDxptW/voSA37fXS8nJ1CEodIbO8dqw+gZj9UDtxp+ 25 | ew== 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /src/config/mongoose.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const mongoose = require('mongoose'); 3 | const { mongo, env } = require('./vars'); 4 | 5 | // set mongoose Promise to Bluebird 6 | mongoose.Promise = Promise; 7 | 8 | // Exit application on error 9 | mongoose.connection.on('error', (err: any) => { 10 | console.error(`MongoDB connection error: ${err}`); 11 | process.exit(-1); 12 | }); 13 | 14 | // print mongoose logs in dev env 15 | if (env === 'development') { 16 | mongoose.set('debug', true); 17 | } 18 | 19 | /** 20 | * Connect to mongo db 21 | * 22 | * @returns {object} Mongoose connection 23 | * @public 24 | */ 25 | exports.connect = () => { 26 | mongoose.connect( 27 | mongo.uri, 28 | { 29 | keepAlive: 1, 30 | useNewUrlParser: true 31 | } 32 | ); 33 | return mongoose.connection; 34 | }; 35 | -------------------------------------------------------------------------------- /src/config/passport.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const jwtStrategy = require('passport-jwt').Strategy; 3 | const bearerStrategy = require('passport-http-bearer'); 4 | const { ExtractJwt } = require('passport-jwt'); 5 | const { JWT_SECRET } = require('./vars'); 6 | const authProviders = require('../api/services/authProviders'); 7 | import { User } from '../api/models'; 8 | 9 | const jwtOptions = { 10 | secretOrKey: JWT_SECRET, 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer') 12 | }; 13 | 14 | const jwt = async (payload: any, done: any) => { 15 | try { 16 | const user = await User.findById(payload.sub); 17 | if (user) { 18 | return done(null, user); 19 | } 20 | return done(null, false); 21 | } catch (error) { 22 | return done(error, false); 23 | } 24 | }; 25 | 26 | const oAuth = (service: any) => async (token: any, done: any) => { 27 | try { 28 | const userData = await authProviders[service](token); 29 | const user = await User.oAuthLogin(userData); 30 | return done(null, user); 31 | } catch (err) { 32 | return done(err); 33 | } 34 | }; 35 | 36 | exports.jwt = new jwtStrategy(jwtOptions, jwt); 37 | exports.facebook = new bearerStrategy(oAuth('facebook')); 38 | exports.google = new bearerStrategy(oAuth('google')); 39 | -------------------------------------------------------------------------------- /src/config/vars.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const path = require('path'); 3 | 4 | // import .env variables 5 | require('dotenv-safe').load({ 6 | path: path.join(__dirname, '../../.env'), 7 | sample: path.join(__dirname, '../../.env.example'), 8 | allowEmptyValues: true 9 | }); 10 | 11 | const env = process.env; // this has ".env" keys & values 12 | // let adminToken = ''; 13 | 14 | module.exports = { 15 | env: env.NODE_ENV, 16 | port: env.PORT, 17 | socketEnabled: ['1', 'true', 'yes'].indexOf(env.SOCKET_ENABLED || '') >= 0, 18 | slackEnabled: env.SLACK_WEBHOOK_URL ? true : false, 19 | emailEnabled: env.EMAIL_MAILGUN_API_KEY ? true : false, 20 | JWT_SECRET: env.JWT_SECRET, 21 | JWT_EXPIRATION_MINUTES: env.JWT_EXPIRATION_MINUTES, 22 | UPLOAD_LIMIT: 5, // MB 23 | SLACK_WEBHOOK_URL: env.SLACK_WEBHOOK_URL, 24 | EMAIL_TEMPLATE_BASE: './src/templates/emails/', 25 | EMAIL_FROM_SUPPORT: env.EMAIL_FROM_SUPPORT, 26 | EMAIL_MAILGUN_API_KEY: env.EMAIL_MAILGUN_API_KEY, 27 | EMAIL_MAILGUN_DOMAIN: env.EMAIL_MAILGUN_DOMAIN, 28 | SEC_ADMIN_EMAIL: env.SEC_ADMIN_EMAIL, 29 | // setAdminToken: (admToken: string) => (adminToken = admToken), 30 | isAdmin: (user: any) => user && user.email === env.SEC_ADMIN_EMAIL, 31 | mongo: { 32 | uri: env.NODE_ENV === 'test' ? env.MONGO_URI_TESTS : env.MONGO_URI 33 | }, 34 | logs: env.NODE_ENV === 'production' ? 'combined' : 'dev' 35 | }; 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | const serverless = require('serverless-http'); // Netlify 2 | 3 | // make bluebird default Promise 4 | Promise = require('bluebird'); // eslint-disable-line no-global-assign 5 | const { port, env, socketEnabled } = require('./config/vars'); 6 | 7 | const http = require('http'); // to use HTTPS, use: require('https') and the "options" with key, cert below. 8 | // const https = require('spdy'); // for HTTP2 9 | const fs = require('fs'); 10 | const app = require('./config/express'); 11 | const socket = require('./api/services/socket'); 12 | 13 | const mongoose = require('./config/mongoose'); 14 | 15 | mongoose.connect(); // open mongoose connection 16 | 17 | // HTTPS options 18 | const options = {}; 19 | // const options = { 20 | // key: fs.readFileSync(__dirname + '/config/https/localhost-key.pem'), 21 | // cert: fs.readFileSync(__dirname + '/config/https/localhost.pem') 22 | // }; 23 | const server = http.createServer(options, app); 24 | 25 | if (socketEnabled) { 26 | socket.setup(server); 27 | } 28 | 29 | server.listen(port, () => { 30 | console.info(`--- 🌟 Started (${env}) --- http://localhost:${port}`); 31 | }); 32 | 33 | if (env === 'development') { 34 | // initialize test data once (admin@example.com) 35 | require('./api/utils/InitData'); 36 | } 37 | 38 | /** 39 | * Exports express 40 | * @public 41 | */ 42 | module.exports = app; 43 | 44 | module.exports.handler = serverless(app); 45 | -------------------------------------------------------------------------------- /src/templates/emails/forgot-password.html: -------------------------------------------------------------------------------- 1 |

Dear {{name}},

2 |

Below is your one-time temporary password that you can use to login. After logging in, please change your previous password.

3 | 4 |

{{tempPass}}

5 | 6 |

7 | Regards,
8 | Team. 9 |

10 | -------------------------------------------------------------------------------- /src/templates/emails/forgot-password.txt: -------------------------------------------------------------------------------- 1 | Dear {{name}}, 2 | Below is your one-time temporary password that you can use to login. After logging in, please change your previous password. 3 | 4 | {{tempPass}} 5 | 6 | Regards, 7 | Team 8 | -------------------------------------------------------------------------------- /src/templates/emails/welcome.html: -------------------------------------------------------------------------------- 1 |

2 | Welcome {{ name }}! 3 |

4 | -------------------------------------------------------------------------------- /src/templates/emails/welcome.txt: -------------------------------------------------------------------------------- 1 | Welcome {{ name }}! -------------------------------------------------------------------------------- /src_docs/build.md: -------------------------------------------------------------------------------- 1 | ### Build System 2 | 3 | yarn build 4 | - using "tsc", output directory is "built" -------------------------------------------------------------------------------- /src_docs/dependencies.md: -------------------------------------------------------------------------------- 1 | ### NOTES 2 | 3 | uuid - pin to 7.x - 03/04/2021 4 | - 8.x will fail unit tests. 5 | 6 | Last ncu upgrades: 04/28/2021 7 | 8 | dotenv-safe ^6.1.0 → ^8.2.0 9 | express-validation ^1.0.2 → ^3.0.8 10 | joi ^14.3.1 → ^17.4.0 11 | socket.io ^2.2.0 → ^4.0.1 12 | uuid ^7.0.3 → ^8.3.2 13 | @types/joi ^14.3.2 → ^17.2.2 14 | @types/mocha ^5.2.6 → ^8.2.2 15 | @types/mongoose ^5.10.3 → ^5.10.5 16 | @types/node ^14.14.31 → ^15.0.1 17 | apidoc ^0.17.7 → ^0.27.1 18 | chai ^4.3.3 → ^4.3.4 19 | eslint ^7.21.0 → ^7.25.0 20 | mocha ^8.3.0 → ^8.3.2 21 | sinon ^9.2.4 → ^10.0.0 22 | sinon-chai ^3.5.0 → ^3.6.0 -------------------------------------------------------------------------------- /src_docs/features.md: -------------------------------------------------------------------------------- 1 | ## DOCUMENTATION 2 | 3 | ## DEVELOPMENT 4 | 5 | ### Docker (optional) 6 | - Built on lightweight docker image "node:8-alpine" (see Dockerfile). 7 | - Command lines to launch docker images: 8 | - `yarn docker:dev` launch project in DEV mode. 9 | - more... (see package.json) 10 | 11 | ### Requirements 12 | Platforms: 13 | - Mainly tested on MacOS, node 14.7.x. 14 | - Also tested on Windows 10 (Powershell) with MongoDB, latest nodejs. 15 | 16 | Require: 17 | - MongoDB - e.g. install: `docker run -p 27017:27017 -v ~/mongo_data:/data/db mvertes/alpine-mongo` 18 | 19 | Good to have: 20 | - A client tool to manage data like Robo 3T. 21 | - VSCode Rest Client extension to run examples in "rest-client-examples.rest". 22 | 23 | ### Environments 24 | - Env vars are declared in ".env" file (npm: dotenv-safe). 25 | - They are loaded into "config/vars" and exported to be used across the app. 26 | 27 | ### Initialize DB Data 28 | - When launching in development mode, it will check if the default user1 not existed (New DB) to generate some dev data. 29 | - Example: [../src/api/utils/InitData.ts](../src/api/utils/InitData.ts) 30 | 31 | ### Express 32 | - config/express.ts is where we set up the Express server. 33 | 34 | ### SSL Self-signed Cert (for HTTPS localhost) 35 | - Source: https://goo.gl/Ztv8tt 36 | - Use crt & key files in "index.ts" 37 | - Generate cert files locally: 38 | ``` 39 | openssl req -x509 -out localhost.crt -keyout localhost.key \ 40 | -newkey rsa:2048 -nodes -sha256 \ 41 | -subj '/CN=localhost' -extensions EXT -config <( \ 42 | printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") 43 | ``` 44 | 45 | ## FEATURES 46 | 47 | ### Add a new Model 48 | - Model has Mongoose schema & default functions like: transform, list. 49 | - "transform" method: to sanitize mongoose results with ALLOWED_FIELDS (so we don't expose unneeded fields) 50 | - "list" function: to list data with pagination support (URL parameters). 51 | - Always export Model with Model.ALLOWED_FIELDS 52 | - Example: [...src/api/models/userNote.model.ts](../src/api/models/userNote.model.ts) 53 | 54 | ### Add a new API Route 55 | - Steps to create a new route: 56 | - src/api/routes/your-new-route.route.ts 57 | - src/api/controllers/route-controller.ts 58 | - src/api/routes/v1/index.ts - add your route here. 59 | - Example: [../src/api/routes/v1/user.route.ts](../src/api/routes/v1/user.route.ts) (CRUD routes for user endpoints). 60 | 61 | ### API Controller 62 | - An API Route is assigned with an API Controller to handle that route. 63 | - Example of user controller that runs user.model's functions: 64 | - [../src/api/controllers/user.controller.ts](../src/api/controllers/user.controller.ts) 65 | - [../src/api/models/user.model.ts](../src/api/models/user.model.ts) (see "transform" and "list" functions) 66 | 67 | ### API - URL Parameters 68 | - a Model has "ALLOWED_FIELDS" array to allow those fields in API response. 69 | - Additionally, you can add "&fields=" as an URL param to include just a few fields. (to reduce response size) 70 | - API list endpoints also support URL params for pagination 71 | - Example 1: GET http://localhost:3009/api/v1/users?fields=id,email&email=*user1* (get id & email only in response) 72 | - Example 2: GET http://localhost:3009/api/v1/users?page=1&perPage=20 (query & pagination) 73 | - Example 3: GET http://localhost:3009/api/v1/users/5c7f85009d65d4210efffa42/notes?note=*partialtext* 74 | 75 | ### Registration / Authentication 76 | - auth.controller.ts 77 | - for registration, it goes to: exports.register. 78 | - for authentication (login/logout), it goes to: exports.login, logout. 79 | - when logging in, an "accessToken" is generated and saved (generateTokenResponse()) to "refreshtokens" table in DB. 80 | - to get the logged in user object, use ```const { user } = req.route.meta;``` 81 | 82 | - Register: POST http://localhost:3009/api/v1/auth/register 83 | - payload: { "email": "newuser@example.com", "password": "1user1", "name": "John" } 84 | - Login: POST http://localhost:3009/api/v1/auth/login 85 | - payload: { "email": "admin1@example.com", "password": "1admin1" } 86 | - Logout: POST http://localhost:3009/api/v1/auth/logout 87 | - payload: { "userId": "..." } 88 | - Subsequent API calls will need "Authorization" header set to "Bearer ...accessToken..." 89 | 90 | ### Authorization / Permission Validation 91 | - auth.ts - handleJWT: validate: only the same logged in userId can call REST endpoints /userId/... 92 | 93 | ### API - Upload File /upload/file 94 | - Using "multer" to parse form (file) data & store files to "/uploads" 95 | - Example: POST http://localhost:3009/api/v1/upload/file 96 | - set Authorization: Bearer TOKEN, Content-Type: application/x-www-form-urlencoded 97 | - set form-data, field "file" and select a file to upload. 98 | - uploaded files will be stored in "/uploads" directory. 99 | - Example: [../src/api/routes/v1/upload.route.ts](../src/api/routes/v1/upload.route.ts) 100 | 101 | ### API - Forgot Password /forgot-password 102 | - a POST handler to generate a one-time temporary password, then email it to an existing user. 103 | - Example: [../src/api/controllers/auth.controller.ts](../src/api/controllers/auth.controller.ts) 104 | 105 | ### Slack 106 | - Obtain your Slack Incoming Webhook (tie to a channel) from your Slack account & put it in .env file. 107 | - Example: [../src/api/controllers/auth.controller.ts](../src/api/controllers/auth.controller.ts) (send slack a message after user registered (POST v1/auth/register)) 108 | 109 | ### Send Email 110 | - Using "nodemailer" to send email 111 | - Using "handlebars" to get email templates: welcomeEmail({ name: 'John Doe', email: 'emailexample@gmail.com' }) 112 | - Obtain your Mailgun API Key & Email Domain (use sandbox domain name for testing) & put it in .env file. 113 | - Example: [../src/api/controllers/auth.controller.ts](../src/api/controllers/auth.controller.ts) (send email after user registered (POST v1/auth/register)) 114 | 115 | ## UI Example 116 | 117 | - UI Example location: /ui 118 | - Using CRA (create-react-app). 119 | - Typescript, React-router, Axios, PostCSS, Tailwind. Components: Home, ItemView, Login. 120 | 121 | ## Deployment 122 | 123 | With Vercel: 124 | - Node-rem has vercel.json config file. You can build BE & FE, then run "npx vercel" to deploy it with your Vercel account 125 | - Try this repo: https://github.com/ngduc/vercel-express -------------------------------------------------------------------------------- /src_docs/postman-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "e5d5f786-aec1-4eaf-9b45-ea0373f68b78", 4 | "name": "--- Node-rem", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "POST /auth/login", 10 | "request": { 11 | "method": "POST", 12 | "header": [], 13 | "body": { 14 | "mode": "raw", 15 | "raw": "{ \"email\": \"admin1@example.com\", \"password\": \"1admin1\" }", 16 | "options": { 17 | "raw": { 18 | "language": "json" 19 | } 20 | } 21 | }, 22 | "url": { 23 | "raw": "http://localhost:3009/v1/auth/login", 24 | "protocol": "http", 25 | "host": [ 26 | "localhost" 27 | ], 28 | "port": "3009", 29 | "path": [ 30 | "v1", 31 | "auth", 32 | "login" 33 | ] 34 | } 35 | }, 36 | "response": [] 37 | }, 38 | { 39 | "name": "POST /auth/logout", 40 | "request": { 41 | "method": "POST", 42 | "header": [ 43 | { 44 | "key": "Authorization", 45 | "value": "Bearer {{TOKEN}}", 46 | "type": "text" 47 | } 48 | ], 49 | "body": { 50 | "mode": "raw", 51 | "raw": "{ \"userId\": \"5fa6386a00204e0821e9fb65\" }", 52 | "options": { 53 | "raw": { 54 | "language": "json" 55 | } 56 | } 57 | }, 58 | "url": { 59 | "raw": "http://localhost:3009/v1/auth/logout", 60 | "protocol": "https", 61 | "host": [ 62 | "localhost" 63 | ], 64 | "port": "3009", 65 | "path": [ 66 | "v1", 67 | "auth", 68 | "logout" 69 | ] 70 | } 71 | }, 72 | "response": [] 73 | }, 74 | { 75 | "name": "GET /users - (require admin)", 76 | "request": { 77 | "auth": { 78 | "type": "noauth" 79 | }, 80 | "method": "GET", 81 | "header": [ 82 | { 83 | "key": "Authorization", 84 | "value": "Bearer {{TOKEN}}", 85 | "type": "text" 86 | } 87 | ], 88 | "url": { 89 | "raw": "http://localhost:3009/v1/users", 90 | "protocol": "http", 91 | "host": [ 92 | "localhost" 93 | ], 94 | "port": "3009", 95 | "path": [ 96 | "v1", 97 | "users" 98 | ] 99 | } 100 | }, 101 | "response": [] 102 | }, 103 | { 104 | "name": "POST /users/USERID/notes", 105 | "request": { 106 | "auth": { 107 | "type": "noauth" 108 | }, 109 | "method": "POST", 110 | "header": [ 111 | { 112 | "key": "Authorization", 113 | "value": "Bearer {{TOKEN}}", 114 | "type": "text" 115 | } 116 | ], 117 | "body": { 118 | "mode": "raw", 119 | "raw": "{ \"title\": \"Title 1\", \"note\": \"note 1\" }", 120 | "options": { 121 | "raw": { 122 | "language": "json" 123 | } 124 | } 125 | }, 126 | "url": { 127 | "raw": "http://localhost:3009/v1/users/601ba66975aa395170c7153a/notes", 128 | "protocol": "http", 129 | "host": [ 130 | "localhost" 131 | ], 132 | "port": "3009", 133 | "path": [ 134 | "v1", 135 | "users", 136 | "601ba66975aa395170c7153a", 137 | "notes" 138 | ] 139 | } 140 | }, 141 | "response": [] 142 | }, 143 | { 144 | "name": "GET /users/USERID/notes", 145 | "request": { 146 | "auth": { 147 | "type": "noauth" 148 | }, 149 | "method": "GET", 150 | "header": [ 151 | { 152 | "key": "Authorization", 153 | "value": "Bearer {{TOKEN}}", 154 | "type": "text" 155 | } 156 | ], 157 | "url": { 158 | "raw": "http://localhost:3009/v1/users/601ba66975aa395170c7153a/notes", 159 | "protocol": "http", 160 | "host": [ 161 | "localhost" 162 | ], 163 | "port": "3009", 164 | "path": [ 165 | "v1", 166 | "users", 167 | "601ba66975aa395170c7153a", 168 | "notes" 169 | ] 170 | } 171 | }, 172 | "response": [] 173 | } 174 | ] 175 | } -------------------------------------------------------------------------------- /src_docs/rest-client-examples.rest: -------------------------------------------------------------------------------- 1 | @base = http://localhost:3009 2 | @token = TOKEN_FROM_LOGIN_CALL 3 | @authHeader = Authorization: Bearer {{token}} 4 | 5 | ### Login 6 | POST {{base}}/v1/auth/login 7 | Content-Type: application/json 8 | 9 | { 10 | "email": "user1@example.com", 11 | "password": "user111" 12 | } 13 | 14 | ### Find users by email (partially matching) 15 | GET {{base}}/v1/users?fields=id,email&email=*user1* 16 | Authorization: Bearer {{token}} 17 | 18 | ### List with Pagination 19 | GET {{base}}/v1/users?limit=5&offset=0&sort=email:desc,createdAt&role=bogus 20 | Authorization: Bearer {{token}} -------------------------------------------------------------------------------- /tests/integration/auth.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | /* eslint-disable max-len */ 3 | export {}; 4 | const request = require('supertest'); 5 | const httpStatus = require('http-status'); 6 | const { expect } = require('chai'); 7 | const sinon = require('sinon'); 8 | const moment = require('moment-timezone'); 9 | const app = require('../../src/index'); 10 | import { User } from '../../src/api/models'; 11 | const RefreshToken = require('../../src/api/models/refreshToken.model'); 12 | const authProviders = require('../../src/api/services/authProviders'); 13 | 14 | const sandbox = sinon.createSandbox(); 15 | 16 | const fakeOAuthRequest = () => 17 | Promise.resolve({ 18 | service: 'facebook', 19 | id: '123', 20 | name: 'user', 21 | email: 'test@test.com', 22 | picture: 'test.jpg' 23 | }); 24 | 25 | describe('Authentication API', () => { 26 | let dbUser: any; 27 | let user: any; 28 | let refreshToken: any; 29 | let expiredRefreshToken: any; 30 | 31 | beforeEach(async () => { 32 | dbUser = { 33 | email: 'branstark@gmail.com', 34 | password: 'mypassword', 35 | name: 'Bran Stark', 36 | role: 'admin' 37 | }; 38 | 39 | user = { 40 | email: 'sousa.dfs@gmail.com', 41 | password: '123456', 42 | name: 'Daniel Sousa' 43 | }; 44 | 45 | refreshToken = { 46 | token: 47 | '5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d', 48 | userId: '5947397b323ae82d8c3a333b', 49 | userEmail: dbUser.email, 50 | expires: moment().add(1, 'day').toDate() 51 | }; 52 | 53 | expiredRefreshToken = { 54 | token: 55 | '5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d', 56 | userId: '5947397b323ae82d8c3a333b', 57 | userEmail: dbUser.email, 58 | expires: moment().subtract(1, 'day').toDate() 59 | }; 60 | 61 | await User.remove({}); 62 | await User.create(dbUser); 63 | await RefreshToken.remove({}); 64 | }); 65 | 66 | afterEach(() => sandbox.restore()); 67 | 68 | describe('POST /api/v1/auth/register', () => { 69 | it('should register a new user when request is ok', () => { 70 | return request(app) 71 | .post('/api/v1/auth/register') 72 | .send(user) 73 | .expect(httpStatus.CREATED) 74 | .then((res: any) => { 75 | delete user.password; 76 | expect(res.body.data.token).to.have.a.property('accessToken'); 77 | expect(res.body.data.token).to.have.a.property('refreshToken'); 78 | expect(res.body.data.token).to.have.a.property('expiresIn'); 79 | expect(res.body.data.user).to.include(user); 80 | }); 81 | }); 82 | 83 | it('should report error when email already exists', () => { 84 | return request(app) 85 | .post('/api/v1/auth/register') 86 | .send(dbUser) 87 | .expect(httpStatus.CONFLICT) 88 | .then((res: any) => { 89 | const { field } = res.body.errors[0]; 90 | const { location } = res.body.errors[0]; 91 | const { messages } = res.body.errors[0]; 92 | expect(field).to.be.equal('email'); 93 | expect(location).to.be.equal('body'); 94 | expect(messages).to.include('"email" already exists'); 95 | }); 96 | }); 97 | 98 | it('should report error when the email provided is not valid', () => { 99 | user.email = 'this_is_not_an_email'; 100 | return request(app) 101 | .post('/api/v1/auth/register') 102 | .send(user) 103 | .expect(httpStatus.BAD_REQUEST) 104 | .then((res: any) => { 105 | const { field } = res.body.errors[0]; 106 | const { location } = res.body.errors[0]; 107 | const { messages } = res.body.errors[0]; 108 | expect(field[0]).to.be.equal('email'); 109 | expect(location).to.be.equal('body'); 110 | expect(messages).to.include('"email" must be a valid email'); 111 | }); 112 | }); 113 | 114 | it('should report error when email and password are not provided', () => { 115 | return request(app) 116 | .post('/api/v1/auth/register') 117 | .send({}) 118 | .expect(httpStatus.BAD_REQUEST) 119 | .then((res: any) => { 120 | const { field } = res.body.errors[0]; 121 | const { location } = res.body.errors[0]; 122 | const { messages } = res.body.errors[0]; 123 | expect(field[0]).to.be.equal('email'); 124 | expect(location).to.be.equal('body'); 125 | expect(messages).to.include('"email" is required'); 126 | }); 127 | }); 128 | }); 129 | 130 | describe('POST /api/v1/auth/login', () => { 131 | it('should return an accessToken and a refreshToken when email and password matches', () => { 132 | return request(app) 133 | .post('/api/v1/auth/login') 134 | .send(dbUser) 135 | .expect(httpStatus.OK) 136 | .then((res: any) => { 137 | delete dbUser.password; 138 | expect(res.body.data.token).to.have.a.property('accessToken'); 139 | expect(res.body.data.token).to.have.a.property('refreshToken'); 140 | expect(res.body.data.token).to.have.a.property('expiresIn'); 141 | expect(res.body.data.user).to.include(dbUser); 142 | }); 143 | }); 144 | 145 | it('should report error when email and password are not provided', () => { 146 | return request(app) 147 | .post('/api/v1/auth/login') 148 | .send({}) 149 | .expect(httpStatus.BAD_REQUEST) 150 | .then((res: any) => { 151 | const { field } = res.body.errors[0]; 152 | const { location } = res.body.errors[0]; 153 | const { messages } = res.body.errors[0]; 154 | expect(field[0]).to.be.equal('email'); 155 | expect(location).to.be.equal('body'); 156 | expect(messages).to.include('"email" is required'); 157 | }); 158 | }); 159 | 160 | it('should report error when the email provided is not valid', () => { 161 | user.email = 'this_is_not_an_email'; 162 | return request(app) 163 | .post('/api/v1/auth/login') 164 | .send(user) 165 | .expect(httpStatus.BAD_REQUEST) 166 | .then((res: any) => { 167 | const { field } = res.body.errors[0]; 168 | const { location } = res.body.errors[0]; 169 | const { messages } = res.body.errors[0]; 170 | expect(field[0]).to.be.equal('email'); 171 | expect(location).to.be.equal('body'); 172 | expect(messages).to.include('"email" must be a valid email'); 173 | }); 174 | }); 175 | 176 | it("should report error when email and password don't match", () => { 177 | dbUser.password = 'xxx'; 178 | return request(app) 179 | .post('/api/v1/auth/login') 180 | .send(dbUser) 181 | .expect(httpStatus.UNAUTHORIZED) 182 | .then((res: any) => { 183 | const { code } = res.body; 184 | const { message } = res.body; 185 | expect(code).to.be.equal(401); 186 | expect(message).to.be.equal('Incorrect email or password'); 187 | }); 188 | }); 189 | }); 190 | 191 | describe('POST /api/v1/auth/facebook', () => { 192 | it('should create a new user and return an accessToken when user does not exist', () => { 193 | sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest); 194 | return request(app) 195 | .post('/api/v1/auth/facebook') 196 | .send({ access_token: '123' }) 197 | .expect(httpStatus.OK) 198 | .then((res: any) => { 199 | expect(res.body.token).to.have.a.property('accessToken'); 200 | expect(res.body.token).to.have.a.property('refreshToken'); 201 | expect(res.body.token).to.have.a.property('expiresIn'); 202 | expect(res.body.user).to.be.an('object'); 203 | }); 204 | }); 205 | 206 | it('should return an accessToken when user already exists', async () => { 207 | dbUser.email = 'test@test.com'; 208 | await User.create(dbUser); 209 | sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest); 210 | return request(app) 211 | .post('/api/v1/auth/facebook') 212 | .send({ access_token: '123' }) 213 | .expect(httpStatus.OK) 214 | .then((res: any) => { 215 | expect(res.body.token).to.have.a.property('accessToken'); 216 | expect(res.body.token).to.have.a.property('refreshToken'); 217 | expect(res.body.token).to.have.a.property('expiresIn'); 218 | expect(res.body.user).to.be.an('object'); 219 | }); 220 | }); 221 | 222 | it('should return error when access_token is not provided', async () => { 223 | return request(app) 224 | .post('/api/v1/auth/facebook') 225 | .expect(httpStatus.BAD_REQUEST) 226 | .then((res: any) => { 227 | const { field } = res.body.errors[0]; 228 | const { location } = res.body.errors[0]; 229 | const { messages } = res.body.errors[0]; 230 | expect(field[0]).to.be.equal('access_token'); 231 | expect(location).to.be.equal('body'); 232 | expect(messages).to.include('"access_token" is required'); 233 | }); 234 | }); 235 | }); 236 | 237 | describe('POST /api/v1/auth/google', () => { 238 | it('should create a new user and return an accessToken when user does not exist', () => { 239 | sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest); 240 | return request(app) 241 | .post('/api/v1/auth/google') 242 | .send({ access_token: '123' }) 243 | .expect(httpStatus.OK) 244 | .then((res: any) => { 245 | expect(res.body.token).to.have.a.property('accessToken'); 246 | expect(res.body.token).to.have.a.property('refreshToken'); 247 | expect(res.body.token).to.have.a.property('expiresIn'); 248 | expect(res.body.user).to.be.an('object'); 249 | }); 250 | }); 251 | 252 | it('should return an accessToken when user already exists', async () => { 253 | dbUser.email = 'test@test.com'; 254 | await User.create(dbUser); 255 | sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest); 256 | return request(app) 257 | .post('/api/v1/auth/google') 258 | .send({ access_token: '123' }) 259 | .expect(httpStatus.OK) 260 | .then((res: any) => { 261 | expect(res.body.token).to.have.a.property('accessToken'); 262 | expect(res.body.token).to.have.a.property('refreshToken'); 263 | expect(res.body.token).to.have.a.property('expiresIn'); 264 | expect(res.body.user).to.be.an('object'); 265 | }); 266 | }); 267 | 268 | it('should return error when access_token is not provided', async () => { 269 | return request(app) 270 | .post('/api/v1/auth/google') 271 | .expect(httpStatus.BAD_REQUEST) 272 | .then((res: any) => { 273 | const { field } = res.body.errors[0]; 274 | const { location } = res.body.errors[0]; 275 | const { messages } = res.body.errors[0]; 276 | expect(field[0]).to.be.equal('access_token'); 277 | expect(location).to.be.equal('body'); 278 | expect(messages).to.include('"access_token" is required'); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('POST /api/v1/auth/refresh-token', () => { 284 | it('should return a new accessToken when refreshToken and email match', async () => { 285 | await RefreshToken.create(refreshToken); 286 | return request(app) 287 | .post('/api/v1/auth/refresh-token') 288 | .send({ email: dbUser.email, refreshToken: refreshToken.token }) 289 | .expect(httpStatus.OK) 290 | .then((res: any) => { 291 | expect(res.body).to.have.a.property('accessToken'); 292 | expect(res.body).to.have.a.property('refreshToken'); 293 | expect(res.body).to.have.a.property('expiresIn'); 294 | }); 295 | }); 296 | 297 | it("should report error when email and refreshToken don't match", async () => { 298 | await RefreshToken.create(refreshToken); 299 | return request(app) 300 | .post('/api/v1/auth/refresh-token') 301 | .send({ email: user.email, refreshToken: refreshToken.token }) 302 | .expect(httpStatus.UNAUTHORIZED) 303 | .then((res: any) => { 304 | const { code } = res.body; 305 | const { message } = res.body; 306 | expect(code).to.be.equal(401); 307 | expect(message).to.be.equal('Incorrect email or refreshToken'); 308 | }); 309 | }); 310 | 311 | it('should report error when email and refreshToken are not provided', () => { 312 | return request(app) 313 | .post('/api/v1/auth/refresh-token') 314 | .send({}) 315 | .expect(httpStatus.BAD_REQUEST) 316 | .then((res: any) => { 317 | const field1 = res.body.errors[0].field; 318 | const location1 = res.body.errors[0].location; 319 | const messages1 = res.body.errors[0].messages; 320 | const field2 = res.body.errors[1].field; 321 | const location2 = res.body.errors[1].location; 322 | const messages2 = res.body.errors[1].messages; 323 | expect(field1[0]).to.be.equal('email'); 324 | expect(location1).to.be.equal('body'); 325 | expect(messages1).to.include('"email" is required'); 326 | expect(field2[0]).to.be.equal('refreshToken'); 327 | expect(location2).to.be.equal('body'); 328 | expect(messages2).to.include('"refreshToken" is required'); 329 | }); 330 | }); 331 | 332 | it('should report error when the refreshToken is expired', async () => { 333 | await RefreshToken.create(expiredRefreshToken); 334 | 335 | return request(app) 336 | .post('/api/v1/auth/refresh-token') 337 | .send({ email: dbUser.email, refreshToken: expiredRefreshToken.token }) 338 | .expect(httpStatus.UNAUTHORIZED) 339 | .then((res: any) => { 340 | expect(res.body.code).to.be.equal(401); 341 | expect(res.body.message).to.be.equal('Invalid refresh token.'); 342 | }); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /tests/integration/user.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | /* eslint-disable no-unused-expressions */ 3 | export {}; 4 | const request = require('supertest'); 5 | const httpStatus = require('http-status'); 6 | const { expect } = require('chai'); 7 | const sinon = require('sinon'); 8 | const bcrypt = require('bcryptjs'); 9 | const { some, omitBy, isNil } = require('lodash'); 10 | const app = require('../../src/index'); 11 | import { User } from '../../src/api/models'; 12 | const JWT_EXPIRATION_MINUTES = require('../../src/config/vars').JWT_EXPIRATION_MINUTES; 13 | 14 | /** 15 | * root level hooks 16 | */ 17 | 18 | async function format(user: any) { 19 | const formated = user; 20 | 21 | // delete password 22 | delete formated.password; 23 | 24 | // get users from database 25 | const dbUser = (await User.findOne({ email: user.email })).transform(); 26 | 27 | // remove null and undefined properties 28 | return omitBy(dbUser, isNil); 29 | } 30 | 31 | describe('Users API', async () => { 32 | let adminAccessToken: any; 33 | let userAccessToken: any; 34 | let dbUsers: any; 35 | let user: any; 36 | let admin: any; 37 | 38 | const password = '123456'; 39 | const passwordHashed = await bcrypt.hash(password, 1); 40 | 41 | beforeEach(async () => { 42 | dbUsers = { 43 | branStark: { 44 | email: 'branstark@gmail.com', 45 | password: passwordHashed, 46 | name: 'Bran Stark', 47 | role: 'admin' 48 | }, 49 | jonSnow: { 50 | email: 'jonsnow@gmail.com', 51 | password: passwordHashed, 52 | name: 'Jon Snow' 53 | } 54 | }; 55 | 56 | user = { 57 | email: 'sousa.dfs@gmail.com', 58 | password, 59 | name: 'Daniel Sousa' 60 | }; 61 | 62 | admin = { 63 | email: 'sousa.dfs@gmail.com', 64 | password, 65 | name: 'Daniel Sousa', 66 | role: 'admin' 67 | }; 68 | 69 | await User.remove({}); 70 | await User.insertMany([dbUsers.branStark, dbUsers.jonSnow]); 71 | dbUsers.branStark.password = password; 72 | dbUsers.jonSnow.password = password; 73 | adminAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken; 74 | userAccessToken = (await User.findAndGenerateToken(dbUsers.jonSnow)).accessToken; 75 | }); 76 | 77 | describe('POST /api/v1/users', () => { 78 | it('should create a new user when request is ok', () => { 79 | return request(app) 80 | .post('/api/v1/users') 81 | .set('Authorization', `Bearer ${adminAccessToken}`) 82 | .send(admin) 83 | .expect(httpStatus.CREATED) 84 | .then((res: any) => { 85 | delete admin.password; 86 | expect(res.body).to.include(admin); 87 | }); 88 | }); 89 | 90 | it('should create a new user and set default role to "user"', () => { 91 | return request(app) 92 | .post('/api/v1/users') 93 | .set('Authorization', `Bearer ${adminAccessToken}`) 94 | .send(user) 95 | .expect(httpStatus.CREATED) 96 | .then((res: any) => { 97 | expect(res.body.role).to.be.equal('user'); 98 | }); 99 | }); 100 | 101 | it('should report error when email already exists', () => { 102 | user.email = dbUsers.branStark.email; 103 | 104 | return request(app) 105 | .post('/api/v1/users') 106 | .set('Authorization', `Bearer ${adminAccessToken}`) 107 | .send(user) 108 | .expect(httpStatus.CONFLICT) 109 | .then((res: any) => { 110 | const { field } = res.body.errors[0]; 111 | const { location } = res.body.errors[0]; 112 | const { messages } = res.body.errors[0]; 113 | expect(field).to.be.equal('email'); 114 | expect(location).to.be.equal('body'); 115 | expect(messages).to.include('"email" already exists'); 116 | }); 117 | }); 118 | 119 | it('should report error when email is not provided', () => { 120 | delete user.email; 121 | 122 | return request(app) 123 | .post('/api/v1/users') 124 | .set('Authorization', `Bearer ${adminAccessToken}`) 125 | .send(user) 126 | .expect(httpStatus.BAD_REQUEST) 127 | .then((res: any) => { 128 | const { field } = res.body.errors[0]; 129 | const { location } = res.body.errors[0]; 130 | const { messages } = res.body.errors[0]; 131 | expect(field[0]).to.be.equal('email'); 132 | expect(location).to.be.equal('body'); 133 | expect(messages).to.include('"email" is required'); 134 | }); 135 | }); 136 | 137 | it('should report error when password length is less than 6', () => { 138 | user.password = '12345'; 139 | 140 | return request(app) 141 | .post('/api/v1/users') 142 | .set('Authorization', `Bearer ${adminAccessToken}`) 143 | .send(user) 144 | .expect(httpStatus.BAD_REQUEST) 145 | .then((res: any) => { 146 | const { field } = res.body.errors[0]; 147 | const { location } = res.body.errors[0]; 148 | const { messages } = res.body.errors[0]; 149 | expect(field[0]).to.be.equal('password'); 150 | expect(location).to.be.equal('body'); 151 | expect(messages).to.include('"password" length must be at least 6 characters long'); 152 | }); 153 | }); 154 | 155 | it('should report error when logged user is not an admin', () => { 156 | return request(app) 157 | .post('/api/v1/users') 158 | .set('Authorization', `Bearer ${userAccessToken}`) 159 | .send(user) 160 | .expect(httpStatus.FORBIDDEN) 161 | .then((res: any) => { 162 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 163 | expect(res.body.message).to.be.equal('Forbidden'); 164 | }); 165 | }); 166 | }); 167 | 168 | describe('GET /api/v1/users', () => { 169 | it('should get all users', () => { 170 | return request(app) 171 | .get('/api/v1/users') 172 | .set('Authorization', `Bearer ${adminAccessToken}`) 173 | .expect(httpStatus.OK) 174 | .then(async (res: any) => { 175 | const bran = await format(dbUsers.branStark); 176 | const john = await format(dbUsers.jonSnow); 177 | 178 | const { data } = res.body; 179 | 180 | // before comparing it is necessary to convert String to Date 181 | data[0].createdAt = new Date(data[0].createdAt); 182 | data[1].createdAt = new Date(data[1].createdAt); 183 | 184 | expect(data).to.be.an('array'); 185 | expect(data).to.have.lengthOf(2); 186 | expect(some(data, bran)).to.be.true; // includesBranStark 187 | expect(some(data, john)).to.be.true; // includesjonSnow 188 | }); 189 | }); 190 | 191 | it('should get users with pagination - with perPage', () => { 192 | return request(app) 193 | .get('/api/v1/users') 194 | .set('Authorization', `Bearer ${adminAccessToken}`) 195 | .query({ page: 2, perPage: 1 }) 196 | .expect(httpStatus.OK) 197 | .then(async (res: any) => { 198 | delete dbUsers.jonSnow.password; 199 | const john = await format(dbUsers.jonSnow); 200 | const { data } = res.body; 201 | 202 | // before comparing it is necessary to convert String to Date 203 | data[0].createdAt = new Date(data[0].createdAt); 204 | 205 | expect(data).to.be.an('array'); 206 | expect(data).to.have.lengthOf(1); 207 | expect(some(data, john)).to.be.true; // includesjonSnow 208 | }); 209 | }); 210 | 211 | it('should get users with pagination - with offset, limit', () => { 212 | return request(app) 213 | .get('/api/v1/users') 214 | .set('Authorization', `Bearer ${adminAccessToken}`) 215 | .query({ offset: 1, limit: 1 }) 216 | .expect(httpStatus.OK) 217 | .then(async (res: any) => { 218 | delete dbUsers.jonSnow.password; 219 | const john = await format(dbUsers.jonSnow); 220 | const { data } = res.body; 221 | 222 | // before comparing it is necessary to convert String to Date 223 | data[0].createdAt = new Date(data[0].createdAt); 224 | 225 | expect(data).to.be.an('array'); 226 | expect(data).to.have.lengthOf(1); 227 | expect(some(data, john)).to.be.true; // includesjonSnow 228 | }); 229 | }); 230 | 231 | it('should filter users', () => { 232 | return request(app) 233 | .get('/api/v1/users') 234 | .set('Authorization', `Bearer ${adminAccessToken}`) 235 | .query({ email: dbUsers.jonSnow.email }) 236 | .expect(httpStatus.OK) 237 | .then(async (res: any) => { 238 | delete dbUsers.jonSnow.password; 239 | const john = await format(dbUsers.jonSnow); 240 | 241 | const { data } = res.body; 242 | 243 | // before comparing it is necessary to convert String to Date 244 | data[0].createdAt = new Date(data[0].createdAt); 245 | 246 | expect(data).to.be.an('array'); 247 | expect(data).to.have.lengthOf(1); 248 | expect(some(data, john)).to.be.true; // includesjonSnow 249 | }); 250 | }); 251 | 252 | it("should report error when pagination's parameters are not a number", () => { 253 | return request(app) 254 | .get('/api/v1/users') 255 | .set('Authorization', `Bearer ${adminAccessToken}`) 256 | .query({ page: '?', perPage: 'whaat' }) 257 | .expect(httpStatus.BAD_REQUEST) 258 | .then((res: any) => { 259 | const { field } = res.body.errors[0]; 260 | const { location } = res.body.errors[0]; 261 | const { messages } = res.body.errors[0]; 262 | expect(field[0]).to.be.equal('page'); 263 | expect(location).to.be.equal('query'); 264 | expect(messages).to.include('"page" must be a number'); 265 | return Promise.resolve(res); 266 | }) 267 | .then((res: any) => { 268 | const { field } = res.body.errors[1]; 269 | const { location } = res.body.errors[1]; 270 | const { messages } = res.body.errors[1]; 271 | expect(field[0]).to.be.equal('perPage'); 272 | expect(location).to.be.equal('query'); 273 | expect(messages).to.include('"perPage" must be a number'); 274 | }); 275 | }); 276 | 277 | it('should report error if logged user is not an admin', () => { 278 | return request(app) 279 | .get('/api/v1/users') 280 | .set('Authorization', `Bearer ${userAccessToken}`) 281 | .expect(httpStatus.FORBIDDEN) 282 | .then((res: any) => { 283 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 284 | expect(res.body.message).to.be.equal('Forbidden'); 285 | }); 286 | }); 287 | }); 288 | 289 | describe('GET /api/v1/users/:userId', () => { 290 | it('should get user', async () => { 291 | const id = (await User.findOne({}))._id; 292 | delete dbUsers.branStark.password; 293 | 294 | return request(app) 295 | .get(`/api/v1/users/${id}`) 296 | .set('Authorization', `Bearer ${adminAccessToken}`) 297 | .expect(httpStatus.OK) 298 | .then((res: any) => { 299 | expect(res.body).to.include(dbUsers.branStark); 300 | }); 301 | }); 302 | 303 | it('should report error "User does not exist" when user does not exists', () => { 304 | return request(app) 305 | .get('/api/v1/users/56c787ccc67fc16ccc1a5e92') 306 | .set('Authorization', `Bearer ${adminAccessToken}`) 307 | .expect(httpStatus.NOT_FOUND) 308 | .then((res: any) => { 309 | expect(res.body.code).to.be.equal(404); 310 | expect(res.body.message).to.be.equal('User does not exist'); 311 | }); 312 | }); 313 | 314 | it('should report error "User does not exist" when id is not a valid ObjectID', () => { 315 | return request(app) 316 | .get('/api/v1/users/palmeiras1914') 317 | .set('Authorization', `Bearer ${adminAccessToken}`) 318 | .expect(httpStatus.NOT_FOUND) 319 | .then((res: any) => { 320 | expect(res.body.code).to.be.equal(404); 321 | expect(res.body.message).to.equal('User does not exist'); 322 | }); 323 | }); 324 | 325 | it('should report error when logged user is not the same as the requested one', async () => { 326 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 327 | 328 | return request(app) 329 | .get(`/api/v1/users/${id}`) 330 | .set('Authorization', `Bearer ${userAccessToken}`) 331 | .expect(httpStatus.FORBIDDEN) 332 | .then((res: any) => { 333 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 334 | expect(res.body.message).to.be.equal('Forbidden'); 335 | }); 336 | }); 337 | }); 338 | 339 | describe('PUT /api/v1/users/:userId', () => { 340 | it('should replace user', async () => { 341 | delete dbUsers.branStark.password; 342 | const id = (await User.findOne(dbUsers.branStark))._id; 343 | 344 | return request(app) 345 | .put(`/api/v1/users/${id}`) 346 | .set('Authorization', `Bearer ${adminAccessToken}`) 347 | .send(user) 348 | .expect(httpStatus.OK) 349 | .then((res: any) => { 350 | delete user.password; 351 | expect(res.body).to.include(user); 352 | expect(res.body.role).to.be.equal('user'); 353 | }); 354 | }); 355 | 356 | it('should report error when email is not provided', async () => { 357 | const id = (await User.findOne({}))._id; 358 | delete user.email; 359 | 360 | return request(app) 361 | .put(`/api/v1/users/${id}`) 362 | .set('Authorization', `Bearer ${adminAccessToken}`) 363 | .send(user) 364 | .expect(httpStatus.BAD_REQUEST) 365 | .then((res: any) => { 366 | const { field } = res.body.errors[0]; 367 | const { location } = res.body.errors[0]; 368 | const { messages } = res.body.errors[0]; 369 | expect(field[0]).to.be.equal('email'); 370 | expect(location).to.be.equal('body'); 371 | expect(messages).to.include('"email" is required'); 372 | }); 373 | }); 374 | 375 | it('should report error user when password length is less than 6', async () => { 376 | const id = (await User.findOne({}))._id; 377 | user.password = '12345'; 378 | 379 | return request(app) 380 | .put(`/api/v1/users/${id}`) 381 | .set('Authorization', `Bearer ${adminAccessToken}`) 382 | .send(user) 383 | .expect(httpStatus.BAD_REQUEST) 384 | .then((res: any) => { 385 | const { field } = res.body.errors[0]; 386 | const { location } = res.body.errors[0]; 387 | const { messages } = res.body.errors[0]; 388 | expect(field[0]).to.be.equal('password'); 389 | expect(location).to.be.equal('body'); 390 | expect(messages).to.include('"password" length must be at least 6 characters long'); 391 | }); 392 | }); 393 | 394 | it('should report error "User does not exist" when user does not exists', () => { 395 | return request(app) 396 | .put('/api/v1/users/palmeiras1914') 397 | .set('Authorization', `Bearer ${adminAccessToken}`) 398 | .expect(httpStatus.NOT_FOUND) 399 | .then((res: any) => { 400 | expect(res.body.code).to.be.equal(404); 401 | expect(res.body.message).to.be.equal('User does not exist'); 402 | }); 403 | }); 404 | 405 | it('should report error when logged user is not the same as the requested one', async () => { 406 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 407 | 408 | return request(app) 409 | .put(`/api/v1/users/${id}`) 410 | .set('Authorization', `Bearer ${userAccessToken}`) 411 | .expect(httpStatus.FORBIDDEN) 412 | .then((res: any) => { 413 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 414 | expect(res.body.message).to.be.equal('Forbidden'); 415 | }); 416 | }); 417 | 418 | it('should not replace the role of the user (not admin)', async () => { 419 | const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id; 420 | const role = 'admin'; 421 | 422 | return request(app) 423 | .put(`/api/v1/users/${id}`) 424 | .set('Authorization', `Bearer ${userAccessToken}`) 425 | .send(admin) 426 | .expect(httpStatus.OK) 427 | .then((res: any) => { 428 | expect(res.body.role).to.not.be.equal(role); 429 | }); 430 | }); 431 | }); 432 | 433 | describe('PATCH /api/v1/users/:userId', () => { 434 | it('should update user', async () => { 435 | delete dbUsers.branStark.password; 436 | const id = (await User.findOne(dbUsers.branStark))._id; 437 | const { name } = user; 438 | 439 | return request(app) 440 | .patch(`/api/v1/users/${id}`) 441 | .set('Authorization', `Bearer ${adminAccessToken}`) 442 | .send({ name }) 443 | .expect(httpStatus.OK) 444 | .then((res: any) => { 445 | expect(res.body.name).to.be.equal(name); 446 | expect(res.body.email).to.be.equal(dbUsers.branStark.email); 447 | }); 448 | }); 449 | 450 | it('should not update user when no parameters were given', async () => { 451 | delete dbUsers.branStark.password; 452 | const id = (await User.findOne(dbUsers.branStark))._id; 453 | 454 | return request(app) 455 | .patch(`/api/v1/users/${id}`) 456 | .set('Authorization', `Bearer ${adminAccessToken}`) 457 | .send() 458 | .expect(httpStatus.OK) 459 | .then((res: any) => { 460 | expect(res.body).to.include(dbUsers.branStark); 461 | }); 462 | }); 463 | 464 | it('should report error "User does not exist" when user does not exists', () => { 465 | return request(app) 466 | .patch('/api/v1/users/palmeiras1914') 467 | .set('Authorization', `Bearer ${adminAccessToken}`) 468 | .expect(httpStatus.NOT_FOUND) 469 | .then((res: any) => { 470 | expect(res.body.code).to.be.equal(404); 471 | expect(res.body.message).to.be.equal('User does not exist'); 472 | }); 473 | }); 474 | 475 | it('should report error when logged user is not the same as the requested one', async () => { 476 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 477 | 478 | return request(app) 479 | .patch(`/api/v1/users/${id}`) 480 | .set('Authorization', `Bearer ${userAccessToken}`) 481 | .expect(httpStatus.FORBIDDEN) 482 | .then((res: any) => { 483 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 484 | expect(res.body.message).to.be.equal('Forbidden'); 485 | }); 486 | }); 487 | 488 | it('should not update the role of the user (not admin)', async () => { 489 | const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id; 490 | const role = 'admin'; 491 | 492 | return request(app) 493 | .patch(`/api/v1/users/${id}`) 494 | .set('Authorization', `Bearer ${userAccessToken}`) 495 | .send({ role }) 496 | .expect(httpStatus.OK) 497 | .then((res: any) => { 498 | expect(res.body.role).to.not.be.equal(role); 499 | }); 500 | }); 501 | }); 502 | 503 | describe('DELETE /api/v1/users', () => { 504 | it('should delete user', async () => { 505 | const id = (await User.findOne({}))._id; 506 | 507 | return request(app) 508 | .delete(`/api/v1/users/${id}`) 509 | .set('Authorization', `Bearer ${adminAccessToken}`) 510 | .expect(httpStatus.NO_CONTENT) 511 | .then(() => request(app).get('/api/v1/users')) 512 | .then(async () => { 513 | const users = await User.find({}); 514 | expect(users).to.have.lengthOf(1); 515 | }); 516 | }); 517 | 518 | it('should report error "User does not exist" when user does not exists', () => { 519 | return request(app) 520 | .delete('/api/v1/users/palmeiras1914') 521 | .set('Authorization', `Bearer ${adminAccessToken}`) 522 | .expect(httpStatus.NOT_FOUND) 523 | .then((res: any) => { 524 | expect(res.body.code).to.be.equal(404); 525 | expect(res.body.message).to.be.equal('User does not exist'); 526 | }); 527 | }); 528 | 529 | it('should report error when logged user is not the same as the requested one', async () => { 530 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 531 | 532 | return request(app) 533 | .delete(`/api/v1/users/${id}`) 534 | .set('Authorization', `Bearer ${userAccessToken}`) 535 | .expect(httpStatus.FORBIDDEN) 536 | .then((res: any) => { 537 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 538 | expect(res.body.message).to.be.equal('Forbidden'); 539 | }); 540 | }); 541 | }); 542 | 543 | describe('GET /api/v1/users/profile', () => { 544 | it("should get the logged user's info", () => { 545 | delete dbUsers.jonSnow.password; 546 | 547 | return request(app) 548 | .get('/api/v1/users/profile') 549 | .set('Authorization', `Bearer ${userAccessToken}`) 550 | .expect(httpStatus.OK) 551 | .then((res: any) => { 552 | expect(res.body).to.include(dbUsers.jonSnow); 553 | }); 554 | }); 555 | 556 | it('should report error without stacktrace when accessToken is expired', async () => { 557 | // fake time 558 | const clock = sinon.useFakeTimers(); 559 | const expiredAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken; 560 | 561 | // move clock forward by minutes set in config + 1 minute 562 | clock.tick(JWT_EXPIRATION_MINUTES * 60000 + 60000); 563 | 564 | return request(app) 565 | .get('/api/v1/users/profile') 566 | .set('Authorization', `Bearer ${expiredAccessToken}`) 567 | .expect(httpStatus.UNAUTHORIZED) 568 | .then((res: any) => { 569 | expect(res.body.code).to.be.equal(httpStatus.UNAUTHORIZED); 570 | expect(res.body.message).to.be.equal('jwt expired'); 571 | expect(res.body).to.not.have.a.property('stack'); 572 | }); 573 | }); 574 | }); 575 | }); 576 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./built", 4 | "allowJs": true, 5 | "noImplicitAny": true, 6 | "strictNullChecks": true, 7 | "target": "es6", 8 | "module": "commonjs" 9 | }, 10 | "include": [ 11 | "./src/**/*" 12 | ], 13 | "exclude": [ 14 | "node_modules" 15 | ], 16 | "paths": { 17 | "api/*": ["api/*"], 18 | "config/*": ["config/*"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint-config-airbnb", "tslint-config-prettier"], 4 | "linterOptions": { 5 | "exclude": ["node_modules/**", "build/**", "e2e/**"] 6 | }, 7 | "jsRules": { 8 | "curly": true 9 | }, 10 | "rules": { 11 | "curly": true, 12 | "no-console": false, 13 | "member-access": false, 14 | "import-name": false, 15 | "object-shorthand-properties-first": false, 16 | "trailing-comma": false, 17 | "no-boolean-literal-compare": false, 18 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"] 19 | }, 20 | "rulesDirectory": [] 21 | } 22 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Tailwind processed CSS 26 | index.css 27 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Frontend Example 2 | 3 | Created from CRA (Create React Application - react-script). -------------------------------------------------------------------------------- /ui/editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,jsx,ts,tsx,json,css}] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | quote_type = single 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-rem-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "", 6 | "prettier": { 7 | "tabWidth": 2, 8 | "printWidth": 120, 9 | "singleQuote": true, 10 | "trailingComma": "none", 11 | "parser": "typescript" 12 | }, 13 | "engines": { 14 | "node": ">=14.0.0" 15 | }, 16 | "husky": { 17 | "hooks": { 18 | "pre-commit": "echo --- npm test" 19 | } 20 | }, 21 | "dependencies": { 22 | "@testing-library/jest-dom": "^5.11.9", 23 | "@testing-library/react": "^11.2.5", 24 | "@testing-library/user-event": "^12.7.3", 25 | "@types/axios": "^0.14.0", 26 | "@types/jest": "^26.0.20", 27 | "@types/node": "^14.14.31", 28 | "@types/react": "^17.0.2", 29 | "@types/react-dom": "^17.0.1", 30 | "authui-ui": "^0.1.0", 31 | "axios": "^0.26.1", 32 | "cross-env": "7.x", 33 | "history": "^5.0.0", 34 | "npm-run-all": "4.x", 35 | "postcss-cli": "^8.0.0", 36 | "postcss-preset-env": "6.x", 37 | "react": "^17.0.1", 38 | "react-dom": "^17.0.1", 39 | "react-router-dom": "^6.0.0-beta.0", 40 | "react-scripts": "4.0.3", 41 | "swr": "^0.4.2", 42 | "tailwindcss": "1.x", 43 | "typescript": "4.x", 44 | "unfetch": "^4.2.0" 45 | }, 46 | "scripts": { 47 | "start": "run-p watch:css react-scripts:start", 48 | "dev": "npm start", 49 | "build": "run-s build:css react-scripts:build", 50 | "test": "react-scripts test", 51 | "eject": "react-scripts eject", 52 | "build:css": "cross-env NODE_ENV=production postcss src/styles/tailwind.css -o src/styles/index.css", 53 | "watch:css": "cross-env NODE_ENV=development postcss src/styles/tailwind.css -o src/styles/index.css --watch", 54 | "react-scripts:start": "sleep 5 && react-scripts start", 55 | "react-scripts:build": "react-scripts build" 56 | }, 57 | "eslintConfig": { 58 | "extends": "react-app" 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | }, 72 | "devDependencies": { 73 | "husky": "^5.1.1", 74 | "postcss": "^8.4.12" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('tailwindcss'), 4 | require('postcss-preset-env'), 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/node-rem/d488838e41ed039eda497826ab6d637490a622d5/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Node-REM UI Example 28 | 29 | 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /ui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/node-rem/d488838e41ed039eda497826ab6d637490a622d5/ui/public/logo192.png -------------------------------------------------------------------------------- /ui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/node-rem/d488838e41ed039eda497826ab6d637490a622d5/ui/public/logo512.png -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /ui/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 3 | import { getLoginData } from './utils/apiUtil'; 4 | import { Dropdown } from './components/base'; 5 | 6 | import { Login } from './components/Login/Login'; 7 | import { Home } from './components/Home/Home'; 8 | import ItemView from './components/ItemView/ItemView'; 9 | 10 | function App() { 11 | const [loggedInEmail, setLoggedInEmail] = React.useState(''); 12 | 13 | const refreshEmail = () => { 14 | const { userEmail } = getLoginData(); 15 | setLoggedInEmail(userEmail); 16 | }; 17 | 18 | React.useEffect(() => { 19 | refreshEmail(); 20 | }, []); 21 | 22 | const onClickSignOut = () => { 23 | localStorage.removeItem('ld'); 24 | window.location.href = '/'; // TODO: use routing 25 | }; 26 | return ( 27 | <> 28 |
29 |
Node-REM - UI Example
30 | 31 |
32 | Sign out 33 |
34 |
35 |
36 | 37 | 38 | } /> 39 | } /> 40 | } /> 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | export default App; 48 | -------------------------------------------------------------------------------- /ui/src/components/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LikeIcon } from './LikeIcon'; 3 | import { Link } from 'react-router-dom'; 4 | import { Modal } from '../base'; 5 | import { apiGet, apiPost, apiDelete, getLoginData } from '../../utils/apiUtil'; 6 | 7 | export function Home() { 8 | const [error, setError] = React.useState(null); 9 | const [items, setItems] = React.useState(null); 10 | const [showCreateModal, setShowCreateModal] = React.useState(false); 11 | const [newTitle, setNewTitle] = React.useState(''); 12 | const { loginData, userId } = getLoginData(); 13 | console.log('loginData', loginData); 14 | 15 | const fetchData = async () => { 16 | const { data, error } = await apiGet(`/users/${userId}/notes?sort=createdAt:desc`); 17 | setError(error); 18 | data && setItems(data.data); 19 | }; 20 | 21 | React.useEffect(() => { 22 | fetchData(); 23 | }, []); 24 | 25 | const createNote = async () => { 26 | if (newTitle.trim()) { 27 | await apiPost(`/users/${userId}/notes`, { 28 | data: { title: newTitle, note: newTitle } 29 | }); 30 | await fetchData(); 31 | } 32 | setShowCreateModal(false); 33 | }; 34 | 35 | const onClickDelete = async (item: any) => { 36 | await apiDelete(`/users/${userId}/notes/${item.id}`); 37 | await fetchData(); 38 | }; 39 | 40 | const onClickLike = async (item: any) => { 41 | await apiPost(`/users/${userId}/notes/${item.id}/like`, {}); 42 | await fetchData(); 43 | }; 44 | 45 | if (error) { 46 | return ( 47 |
48 |
Error when fetching data: {error.message}
49 | Back to Login 50 |
51 | ); 52 | } 53 | return ( 54 |
55 |

List User's Notes:

56 | 57 |
58 | 63 | 64 | {items ? ( 65 | (items || []).map((item: any) => ( 66 | 83 | )) 84 | ) : ( 85 |
Loading..
86 | )} 87 | 88 | {showCreateModal && ( 89 | setNewTitle(ev.target.value)} 95 | className="w-full bg-gray-200 rounded p-2" 96 | placeholder="Note" 97 | /> 98 | } 99 | onCancel={() => setShowCreateModal(false)} 100 | onConfirm={() => createNote()} 101 | /> 102 | )} 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /ui/src/components/Home/LikeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const LikeIcon = () => ( 4 | 5 | 6 | 7 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /ui/src/components/ItemView/ItemView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { useParams } from 'react-router-dom'; 4 | import { apiGet, apiPost, getLoginData } from '../../utils/apiUtil'; 5 | 6 | export default () => { 7 | const { itemId } = useParams(); 8 | const { userId } = getLoginData(); 9 | const [item, setItem] = React.useState([]); 10 | 11 | React.useEffect(() => { 12 | const fetchData = async () => { 13 | const { data, error } = await apiGet(`/users/${userId}/notes/${itemId}`); 14 | data && setItem(data.data); 15 | }; 16 | fetchData(); 17 | }, []); 18 | 19 | const onClickSave = async () => { 20 | const { data, error } = await apiPost(`/users/${userId}/notes/${itemId}`, { data: { ...item } }); 21 | }; 22 | return ( 23 |
24 | {Object.keys(item).map((itemKey: string) => { 25 | return ( 26 |
27 | {itemKey} 28 | {(item as any)[itemKey]} 29 |
30 | ); 31 | })} 32 |
33 | 34 |
Edit Note:
35 | { 39 | item.note = ev.target.value; 40 | setItem({ ...item }); 41 | }} 42 | /> 43 | 44 |
45 | Go Back 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /ui/src/components/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoginBox from './LoginBox'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { apiPost } from '../../utils/apiUtil'; 5 | import './LoginBox.css'; 6 | 7 | export function Login({ onLogin }: { onLogin?: () => void }) { 8 | const [errorMsg, setErrorMsg] = React.useState(''); 9 | const navigate = useNavigate(); 10 | 11 | const onSubmit = async (formData: any) => { 12 | const { username: email, password, name } = formData; 13 | let err = null; 14 | let apiData = null; 15 | 16 | if (name) { 17 | const { data, error } = await apiPost('/auth/register', { data: { email, password, name } }); 18 | err = error; 19 | apiData = data; 20 | } else { 21 | const { data, error } = await apiPost('/auth/login', { data: { email, password } }); 22 | err = error; 23 | apiData = data; 24 | } 25 | 26 | if (apiData?.data?.token) { 27 | localStorage.setItem('ld', btoa(JSON.stringify(apiData))); // save to localStorage in base64 28 | navigate('/home'); 29 | if (onLogin) { 30 | onLogin(); 31 | } 32 | } else { 33 | console.log('ERROR: ', err); 34 | setErrorMsg(err?.message || ''); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 |
41 | 42 | 43 | {errorMsg &&
{errorMsg}
} 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /ui/src/components/Login/LoginBox.css: -------------------------------------------------------------------------------- 1 | .pxlogin-main {} 2 | 3 | .pxlogin-main a { 4 | cursor: pointer; 5 | } 6 | 7 | .pxlogin-main input { 8 | display: block; 9 | margin: 10px 0; 10 | padding: 5px; 11 | min-width: 300px; 12 | border: 1px solid darkgray; 13 | } 14 | 15 | .pxlogin-main footer { 16 | display: flex; 17 | justify-content: space-between; 18 | width: 300px; 19 | } 20 | 21 | .pxlogin-main button { 22 | padding: 5px 20px; 23 | } 24 | 25 | .pxlogin-main div[data-error="true"] { 26 | margin-top: 10px; 27 | color: darkred; 28 | } -------------------------------------------------------------------------------- /ui/src/components/Login/LoginBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IProps { 4 | renderTitle?: (title: any) => void; 5 | labels?: string[]; 6 | action?: string; 7 | actionRegister?: string; 8 | onSubmit?: (formData: any) => void; 9 | initialValues?: any; // example: initialValues={{ username: '...', password: '...' }} 10 | showError?: boolean; // default true 11 | onError?: (params: any) => void; // callback when error occurs 12 | } 13 | 14 | // tslint:disable-next-line 15 | const Mode = { 16 | LOGIN: 'Login', 17 | REGISTER: 'Sign Up' 18 | }; 19 | 20 | export default class extends React.Component { 21 | state = { 22 | mode: Mode.LOGIN, // this.props.mode || Mode.LOGIN, 23 | username: '', 24 | password: '', 25 | name: '', 26 | error: '' 27 | }; 28 | form: any = null; 29 | usernameLabel = this.props.labels ? this.props.labels[0] : 'Username'; 30 | passwordLabel = this.props.labels ? this.props.labels[1] : 'Password'; 31 | 32 | componentWillMount() { 33 | this.init(this.props); 34 | if (this.props.initialValues) { 35 | this.setState({ ...this.props.initialValues }); 36 | } 37 | } 38 | 39 | componentWillReceiveProps(nextProps: any) { 40 | this.init(nextProps); 41 | } 42 | 43 | init = (props: any) => { 44 | // switch to Register mode if URL includes '/register' 45 | const { href } = window.location; 46 | if (href.includes('/register') || href.includes('/sign-up')) { 47 | this.setState({ mode: Mode.REGISTER }); 48 | } 49 | if (props) { 50 | } 51 | }; 52 | 53 | submitForm = (formData: any) => { 54 | const { action, onSubmit } = this.props; 55 | if (action) { 56 | // call onSubmit (optional) & check its return value: 57 | if (onSubmit) { 58 | const result: any = onSubmit(formData); 59 | if (result) { 60 | // only submit if returned true 61 | this.form.submit(); // html-form.submit() 62 | } 63 | } else { 64 | this.form.submit(); // html-form.submit() 65 | } 66 | } else { 67 | // form without action => just call onSubmit (it should have fetch POST function) 68 | onSubmit && onSubmit(formData); 69 | } 70 | }; 71 | 72 | validateLogin = () => { 73 | const { username, password } = this.state; 74 | return username && password; 75 | }; 76 | 77 | validateRegister = () => { 78 | const { username, password, name } = this.state; 79 | return username && password && name; 80 | }; 81 | 82 | setError = ({ error, code }: { error: string; code: string }) => { 83 | this.setState({ error }); 84 | if (this.props.onError) { 85 | this.props.onError({ error, code }); 86 | } 87 | }; 88 | 89 | onLoginClick = () => { 90 | const { mode, username, password, name } = this.state; 91 | if (mode === Mode.LOGIN && !this.validateLogin()) { 92 | this.setError({ 93 | code: 'REQUIRED_LOGIN_FIELDS', 94 | error: `Please enter ${this.usernameLabel} and ${this.passwordLabel}.` 95 | }); 96 | } else if (mode === Mode.REGISTER && !this.validateRegister()) { 97 | this.setError({ 98 | code: 'REQUIRED_REGISTER_FIELDS', 99 | error: `Please enter ${this.usernameLabel}, ${this.passwordLabel} and Name.` 100 | }); 101 | } else { 102 | this.setState({ error: '' }, () => { 103 | // clear error (preact setState callback doesn't work => use setTimeout) 104 | setTimeout(() => { 105 | // clear error & call props.onSubmit 106 | this.submitForm({ username, password, name }); 107 | }, 0); 108 | }); 109 | } 110 | }; 111 | 112 | onRegisterClick = () => { 113 | this.setState({ mode: Mode.REGISTER }); 114 | }; 115 | 116 | onInputChange = (ev: any, field: string) => this.setState({ [field]: ev.target.value }); 117 | 118 | onKeyUpPassword = (ev: any) => { 119 | if (ev.keyCode === 13) { 120 | this.onLoginClick(); 121 | } 122 | }; 123 | 124 | switchMode = (mode: string) => { 125 | this.setState({ mode, error: '' }); 126 | }; 127 | 128 | render() { 129 | const { action, actionRegister, renderTitle, showError = true } = this.props; 130 | const { mode, username, password, name, error } = this.state; 131 | 132 | let formAction = mode === Mode.LOGIN && action ? action : ''; 133 | formAction = mode === Mode.REGISTER && actionRegister ? actionRegister : formAction; 134 | // const buttonType = action ? 'submit' : 'button'; 135 | 136 | let actionProp = {}; 137 | if (formAction) { 138 | actionProp = { action: formAction, method: 'post' }; 139 | } 140 | 141 | return ( 142 |
(this.form = ref)} {...actionProp} className="pxlogin-main"> 143 | {renderTitle && renderTitle(mode)} 144 | 145 | {this.props.children} 146 | 147 | 148 | this.onInputChange(ev, 'username')} /> 149 | 150 | 151 | this.onInputChange(ev, 'password')} 156 | onKeyUp={this.onKeyUpPassword} 157 | type="password" 158 | /> 159 | 160 | {mode === Mode.REGISTER && } 161 | {mode === Mode.REGISTER && ( 162 | this.onInputChange(ev, 'name')} /> 163 | )} 164 | 165 | {mode === Mode.LOGIN && ( 166 | 172 | )} 173 | {mode === Mode.REGISTER && ( 174 | 180 | )} 181 | 182 | {showError && error ?
{error}
: ''} 183 |
184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /ui/src/components/base/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './styles.css'; 3 | 4 | // version: 0.02 - 04/29/2021 5 | 6 | type BaseProps = { 7 | children?: React.ReactElement[] | React.ReactElement | string; 8 | className?: string; 9 | onClick?: () => void; 10 | isLoading?: boolean; 11 | style?: object; 12 | }; 13 | 14 | /* */ 15 | export const QuestionMark = (props: BaseProps) => ( 16 | 26 | 35 | 36 | ); 37 | 38 | /* Icons.IconName */ 39 | export const Icons: any = { 40 | Plus: ({ className }: BaseProps) => ( 41 | 48 | 52 | 53 | ), 54 | QuestionMark 55 | }; 56 | 57 | /* */ 58 | export type ButtonProps = BaseProps & { 59 | type?: 'button' | 'submit' | undefined; 60 | primary?: boolean; 61 | width?: number; 62 | }; 63 | export const Button = ({ type, onClick, children, className, isLoading, primary, width, style }: ButtonProps) => { 64 | let cn = 65 | 'mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm'; 66 | if (primary) { 67 | cn = `w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm`; 68 | } 69 | return ( 70 | 71 | 79 | {isLoading ? : null} 80 | 81 | ); 82 | }; 83 | 84 | /* ... */ 85 | type DropdownProps = BaseProps & { 86 | label?: React.ReactElement | string; 87 | }; 88 | export const Dropdown = ({ label = 'label', children }: DropdownProps) => { 89 | return ( 90 |
91 |
92 | 98 |
    99 | {React.Children.map(children, (child: any) => ( 100 |
  • 101 | 102 | {child} 103 | 104 |
  • 105 | ))} 106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | /* */ 113 | export const Spinner = () => ( 114 | 120 | 121 | 126 | 127 | ); 128 | 129 | /* */ 130 | export const Accordion = ({ 131 | openValue, 132 | label, 133 | children 134 | }: { 135 | openValue: boolean; 136 | label: React.ReactElement | string; 137 | children: React.ReactElement; 138 | }) => { 139 | // source: https://codepen.io/QJan84/pen/zYvRMMw 140 | const openCount = React.useRef(0); 141 | const [open, setOpen] = React.useState(openValue); 142 | React.useEffect(() => { 143 | setOpen(openValue); 144 | if (openValue) { 145 | openCount.current++; 146 | } 147 | }, [openValue]); 148 | return ( 149 |
150 |
    151 |
  • 152 | 167 |
    1 ? 'transition-all duration-700' : ''}`} 169 | x-ref="container1" 170 | style={{ maxHeight: open ? 200 : 0 }} 171 | > 172 | {children} 173 |
    174 |
  • 175 |
176 |
177 | ); 178 | }; 179 | 180 | export const Label = ({ 181 | children, 182 | iconName, 183 | iconClick, 184 | className 185 | }: { 186 | children: any; 187 | iconName?: string; 188 | iconClick?: any; 189 | className?: string; 190 | }) => { 191 | const Icon = iconName ? Icons[iconName] : null; 192 | return ( 193 |
194 | {children} 195 | {iconName ? : null} 196 |
197 | ); 198 | }; 199 | 200 | // source: https://tailwindui.com/components/application-ui/overlays/modals 201 | export const Modal = ({ 202 | title, 203 | content, 204 | onCancel, 205 | onConfirm 206 | }: { 207 | title?: string; 208 | content?: any; 209 | onCancel?: () => void; 210 | onConfirm?: () => void; 211 | }) => { 212 | return ( 213 |
214 |
215 | 220 | 221 | 224 | 225 |
226 |
227 |
228 | {/*
229 | 244 |
*/} 245 |
246 | 249 |
{content ?? 'Content'}
250 |
251 |
252 |
253 |
254 | 257 | 260 |
261 |
262 |
263 |
264 | ); 265 | }; 266 | -------------------------------------------------------------------------------- /ui/src/components/base/styles.css: -------------------------------------------------------------------------------- 1 | .dropdown:hover .dropdown-menu { 2 | display: block; 3 | } -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | import './styles/index.css'; 7 | import './styles/app.css'; 8 | 9 | ReactDOM.render(, document.getElementById('root')); 10 | 11 | // If you want your app to work offline and load faster, you can change 12 | // unregister() to register() below. Note this comes with some pitfalls. 13 | // Learn more about service workers: https://bit.ly/CRA-PWA 14 | serviceWorker.unregister(); 15 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /ui/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; -------------------------------------------------------------------------------- /ui/src/styles/app.css: -------------------------------------------------------------------------------- 1 | body, input { 2 | color: #333; 3 | } 4 | 5 | #authui-container { 6 | padding: 20px; 7 | width: 40vw; 8 | min-width: 400px; 9 | } -------------------------------------------------------------------------------- /ui/src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | /* Your own custom base styles */ 4 | 5 | /* Start purging... */ 6 | @tailwind components; 7 | /* Stop purging. */ 8 | 9 | /* Your own custom component styles */ 10 | 11 | /* Start purging... */ 12 | @tailwind utilities; 13 | /* Stop purging. */ 14 | 15 | /* Your own custom utilities */ 16 | -------------------------------------------------------------------------------- /ui/src/utils/apiUtil.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosRequestConfig } from 'axios'; 2 | import useSWR from 'swr'; 3 | 4 | export const getBaseUrl = () => { 5 | const baseUrl = 6 | window.location.host.indexOf('localhost') >= 0 ? 'http://localhost:3009' : `https://${window.location.host}`; 7 | return baseUrl; 8 | }; 9 | 10 | export const getBaseApiUrl = () => { 11 | return getBaseUrl() + '/api/v1'; 12 | }; 13 | 14 | // base URL and Path of API endpoints: 15 | const BaseUrl = getBaseUrl(); 16 | let BaseApiUrl = getBaseApiUrl(); 17 | 18 | // set BaseApiUrl (for example: at runtime) 19 | export const setBaseApiUrl = (newBaseApiUrl: string): void => { 20 | BaseApiUrl = newBaseApiUrl; 21 | }; 22 | 23 | export const ApiConfig = { 24 | get BaseUrl() { 25 | return BaseUrl; 26 | }, 27 | get BaseApiUrl() { 28 | return BaseApiUrl; 29 | } 30 | }; 31 | 32 | export const BaseAxiosConfig: AxiosRequestConfig = { 33 | baseURL: BaseApiUrl, 34 | headers: { 'Content-Type': 'application/json' } 35 | }; 36 | 37 | export function getLoginData() { 38 | try { 39 | const loginDataStr = atob(localStorage.getItem('ld') || '') || '{}'; // base64 => string 40 | const loginData = JSON.parse(loginDataStr); 41 | return { loginData, userId: loginData?.data?.user?.id ?? '', userEmail: loginData?.data?.user?.email ?? '' }; 42 | } catch { 43 | return { loginData: null, userId: '', userEmail: '' }; 44 | } 45 | } 46 | 47 | const axiosApi = axios.create({ ...BaseAxiosConfig }); 48 | 49 | axiosApi.interceptors.request.use((config) => { 50 | const { loginData } = getLoginData(); 51 | config = config || {}; 52 | if (loginData?.data?.token?.accessToken) { 53 | (config?.headers as any).Authorization = `Bearer ${loginData?.data?.token?.accessToken}`; 54 | } 55 | return config; 56 | }); 57 | 58 | const onError = function (error: AxiosError) { 59 | console.error('Request Failed:', error.config); 60 | if (error.response) { 61 | // Request was made but server responded with something 62 | // other than 2xx 63 | console.error('Status:', error.response.status); 64 | console.error('Data:', error.response.data); 65 | console.error('Headers:', error.response.headers); 66 | } else { 67 | // Something else happened while setting up the request 68 | // triggered the error 69 | console.error('Error Message:', error.message); 70 | } 71 | return Promise.resolve({ data: null, status: error?.response?.status, error }); // .reject(error.response || error.message); 72 | }; 73 | 74 | interface ResponseInterface { 75 | status?: number; 76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 77 | data?: any; 78 | error?: AxiosError; 79 | } 80 | 81 | // custom fetch function for useSWR 82 | export const swrFetcher = (url: string, options?: AxiosRequestConfig) => 83 | axiosApi({ url, method: 'GET', ...options }).catch(onError); 84 | 85 | // apiGet - uses axios GET - path can be a relative path (uses BaseApiUrl) or absolute url path (startsWith http...) 86 | export const apiGet = (path: string, options?: AxiosRequestConfig): Promise => { 87 | const url = path.startsWith('http') ? path : `${BaseApiUrl}${path}`; 88 | // TODO: sanitize data, track xhr errors, analytics... here 89 | return axiosApi({ url, method: 'GET', ...options }).catch(onError); 90 | }; 91 | 92 | // apiPost - uses axios POST - path can be a relative path (uses BaseApiUrl) or absolute url path (startsWith http...) 93 | export const apiPost = (path: string, options?: AxiosRequestConfig): Promise => { 94 | const url = path.startsWith('http') ? path : `${BaseApiUrl}${path}`; 95 | // TODO: sanitize data, track xhr errors, analytics... here 96 | return axiosApi({ url, method: 'POST', ...options }).catch(onError); 97 | }; 98 | 99 | // apiDelete - uses axios POST - path can be a relative path (uses BaseApiUrl) or absolute url path (startsWith http...) 100 | export const apiDelete = (path: string, options?: AxiosRequestConfig): Promise => { 101 | const url = path.startsWith('http') ? path : `${BaseApiUrl}${path}`; 102 | // TODO: sanitize data, track xhr errors, analytics... here 103 | return axiosApi({ url, method: 'DELETE', ...options }).catch(onError); 104 | }; 105 | 106 | /** 107 | * useRequest hook for calling an API endpoint using useSWR. 108 | * 109 | * @param {string} path API endpoint path. 110 | * @returns {*} Returns { data, error } 111 | * @example 112 | * `const { data } = useRequest('/aidt-session/list');` 113 | */ 114 | export const useRequest = (path: string) => { 115 | if (!path) { 116 | throw new Error('Path is required'); 117 | } 118 | const url = `${BaseApiUrl}${path}`; 119 | const { data, error } = useSWR(url, swrFetcher); 120 | return { data, error }; 121 | }; 122 | -------------------------------------------------------------------------------- /ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: {} 4 | }, 5 | variants: {}, 6 | plugins: [], 7 | purge: { 8 | // Filenames to scan for classes 9 | content: [ 10 | './src/**/*.html', 11 | './src/**/*.js', 12 | './src/**/*.jsx', 13 | './src/**/*.ts', 14 | './src/**/*.tsx', 15 | './public/index.html', 16 | ], 17 | // Options passed to PurgeCSS 18 | options: { 19 | // Whitelist specific selectors by name 20 | // whitelist: [], 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /uploads/README.md: -------------------------------------------------------------------------------- 1 | ### Upload File 2 | 3 | Uploaded files (using POST /upload/file) are stored here. 4 | See: src/api/routes/v1/upload.route.ts -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "env": { 4 | }, 5 | "builds": [ 6 | { 7 | "src": "built/index.js", 8 | "use": "@vercel/node" 9 | }, 10 | { 11 | "src": "ui/build/**", 12 | "use": "@vercel/static" 13 | } 14 | ], 15 | "routes": [ 16 | { 17 | "src": "/api/(.*)", 18 | "dest": "built/index.js" 19 | }, 20 | { 21 | "src": "/", 22 | "dest": "ui/build/index.html" 23 | }, 24 | { 25 | "src": "/home", 26 | "dest": "ui/build/index.html" 27 | }, 28 | { 29 | "src": "/(.+)", 30 | "dest": "ui/build/$1" 31 | } 32 | ] 33 | } --------------------------------------------------------------------------------