├── .babelrc ├── .dockerignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── deploy.yml │ └── main.yml ├── .gitignore ├── .idea ├── .gitignore ├── fifa-api.iml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── vcs.xml ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── docker-compose.yml ├── package.json ├── sample.env ├── src ├── app.js ├── config.js ├── controller │ ├── authController.js │ ├── searchController.js │ └── usersController.js ├── database │ └── db.js ├── middlewares │ ├── authenticated.js │ ├── error.js │ └── index.js ├── models │ ├── roomsModel.js │ └── usersModel.js ├── routes.js ├── schema │ ├── rooms.js │ └── users.js └── socker │ ├── corsFixer.js │ ├── index.js │ ├── roomManager.js │ └── sockerController.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"], 4 | "sourceMaps": true 5 | } 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["xo-space"], 3 | "globals": { 4 | "Atomics": "readonly", 5 | "SharedArrayBuffer": "readonly" 6 | }, 7 | "plugins": ["prettier"], 8 | "rules": { 9 | "no-warning-comments": "off", 10 | "prettier/prettier": "warn", 11 | "object-curly-spacing": ["warn", "always"], 12 | "comma-dangle": "off", 13 | "max-len": [ 14 | "warn", 15 | { 16 | "code": 120, 17 | "comments": 120, 18 | "ignoreComments": false, 19 | "ignoreTrailingComments": true, 20 | "ignoreUrls": true, 21 | "ignoreStrings": true, 22 | "ignoreTemplateLiterals": true, 23 | "ignoreRegExpLiterals": true 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] Example - `/search` endpoint throws 500 error" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Bug description** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps to Reproduce** 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context:** 26 | - Device Info (please attach if necessary) 27 | - Add any other context about the problem here. 28 | 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] Example - Monitoring for API requests" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Google App Engine 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Cache Node.js modules 14 | uses: actions/cache@v2 15 | with: 16 | path: '**/node_modules' 17 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 18 | 19 | - name: Install modules 20 | run: yarn 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 23 | 24 | - name: Deploy to App Engine 25 | id: deploy 26 | uses: google-github-actions/deploy-appengine@main 27 | with: 28 | deliverables: app.yaml 29 | project_id: ${{ secrets.GCP_PROJECT }} 30 | credentials: ${{ secrets.GCP_SA_KEY }} 31 | 32 | - name: Show Output 33 | run: echo ${{ steps.deploy.outputs.url }} 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - name: Cache Node.js modules 12 | uses: actions/cache@v2 13 | with: 14 | path: '**/node_modules' 15 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 16 | 17 | - name: Install modules 18 | run: yarn 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 21 | 22 | - name: Run eslint 23 | run: yarn lint 24 | 25 | - name: Run build 26 | run: yarn build 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.production 60 | 61 | .DS_Store 62 | 63 | # Build directory 64 | /build 65 | 66 | # GCP Keys 67 | google-compute-engine-account.json 68 | 69 | ### Ansible ### 70 | *.retry 71 | 72 | ### Terraform ### 73 | # Local .terraform directories 74 | **/.terraform/* 75 | 76 | # .tfstate files 77 | *.tfstate 78 | *.tfstate.* 79 | 80 | # Crash log files 81 | crash.log 82 | 83 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as 84 | # password, private keys, and other secrets. These should not be part of version 85 | # control as they are data points which are potentially sensitive and subject 86 | # to change depending on the environment. 87 | # 88 | *.tfvars 89 | 90 | # Ignore override files as they are usually used to override resources locally and so 91 | # are not checked in 92 | override.tf 93 | override.tf.json 94 | *_override.tf 95 | *_override.tf.json 96 | 97 | # Include override files you do wish to add to version control using negated pattern 98 | # !example_override.tf 99 | 100 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 101 | # example: *tfplan* 102 | 103 | # Ignore CLI configuration files 104 | .terraformrc 105 | terraform.rc 106 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/fifa-api.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauravhiremath/fifa-api/e01079ac993f94f33589c7753bebb6e61ee592e6/.prettierignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": true, 4 | "trailingComma": "es5", 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "singleQuote": true, 8 | "arrowParens": "avoid", 9 | "bracketSpacing": true 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 mohammad hossein mardani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | This is the backend for **"Football Draft Simulator"** project. It exposes a http server and a websockets server in NodeJS environment. Also servers the Algolia Search engine for supporting football players search from data scraped using **[Football Players Data Crawler](https://github.com/sauravhiremath/fifa-stats-crawler)** 4 | 5 | ## How the game works 6 | 7 | It's a turn-based multiplayer game. This game in particular allows you to build your own Football Team, by choosing players based on turns. You can search these football players in the same platform. Filter them based on any stats (their name, rating, team, positions, and a lot more!) 8 | 9 | **To play:** 10 | 11 | - Create a room (add a password, if you wanna keep it private) 12 | - Share the room ID with your friends, and they can join the room 13 | - Once, all players are ready the draft begins 14 | - Each user can choose their player from the search box 15 | - Every turn has a time limit, so pick before the time runs out! 16 | - After you create your dream teams, use the same teams on your FIFA game and play with each other 17 | 18 | This was created mainly for offline gaming, to avoid writing player lists manually on a paper or sending on chats when creating custom teams amongst a group of friends. Easily search and add players to your teams with this platform as a middleman :smile: 19 | 20 | ## Project Architecture 21 | 22 | ![architecture](https://miro.medium.com/max/1400/1*QEqiWlUQaaJ1DsjEUhN4dA.png) 23 | 24 | ## GCP Architecture 25 | 26 | ![image](https://user-images.githubusercontent.com/28642011/124379112-b140f800-dcd2-11eb-9e2b-c67a8ed42d64.png) 27 | 28 | ### Local environment setup: 29 | 30 | 1. Update the `.env` file using reference from `sample.env` 31 | 2. Run `yarn install` to install packages 32 | 3. Install docker-compose (https://docs.docker.com/compose/install/) in your system. Proceed further if already done 33 | 4. Start MongoDB and Redis service using `docker-compose up` 34 | 5. Running `yarn start:dev` will start the server with hot-reload 35 | 36 | #### Links 37 | 38 | - HTTP API server: http://localhost:8080 39 | - Socket.io server: http://localhost:65080 40 | 41 | ### Production setup (maintainers only): 42 | 43 | - To be updated for docker-container and k8s support 44 | 45 | #### Setup terraforn 46 | 47 | - Create a ssh key to be used for gcp deployments (do not use your personal ssh keys) 48 | - ``` 49 | ssh-keygen -f ~/.ssh/gcloud_id_rsa 50 | ``` 51 | 52 | #### Old version 53 | 54 | Build and use pm2 to start your process 55 | 56 | ```bash 57 | npm run build 58 | pm2 start ./build/app.js --name fifa-api 59 | ``` 60 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | redis: 4 | image: redis:alpine 5 | container_name: redis 6 | volumes: 7 | - redis-vol:/var/lib/redis 8 | ports: 9 | - '${REDIS_PORT}:${REDIS_PORT}' 10 | networks: 11 | - fifa-network 12 | 13 | mongodb: 14 | image: mongo 15 | container_name: mongodb 16 | environment: 17 | - PUID=1000 18 | - PGID=1000 19 | - MONGO_INITDB_DATABASE=fifa-db 20 | volumes: 21 | - mongo-vol:/data/db 22 | restart: on-failure 23 | ports: 24 | - 27017:27017 25 | networks: 26 | - fifa-network 27 | 28 | networks: 29 | fifa-network: 30 | driver: bridge 31 | volumes: 32 | redis-vol: {} 33 | mongo-vol: 34 | driver: local 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fifa-api", 3 | "version": "1.1.0", 4 | "description": "Fifa custom Team builder API based on express js with es6 and integrated with mongodb", 5 | "type": "module", 6 | "exports": "./build/app.js", 7 | "engines": { 8 | "node": ">=12.19.0" 9 | }, 10 | "scripts": { 11 | "build": "babel ./src --out-dir ./build -s", 12 | "start": "node build/app.js", 13 | "dev": "nodemon --exec babel-node src/app.js", 14 | "lint": "xo" 15 | }, 16 | "xo": { 17 | "prettier": true, 18 | "plugins": [ 19 | "unicorn" 20 | ], 21 | "rules": { 22 | "unicorn/filename-case": [ 23 | "error", 24 | { 25 | "case": "camelCase" 26 | } 27 | ] 28 | } 29 | }, 30 | "author": "Saurav M. H", 31 | "url": "https://github.com/sauravhiremath/fifa-api", 32 | "license": "ISC", 33 | "dependencies": { 34 | "@socket.io/sticky": "^1.0.0", 35 | "axios": "^0.21.2", 36 | "cookie-parser": "^1.4.5", 37 | "cors": "^2.8.5", 38 | "express": "^4.17.1", 39 | "jsonwebtoken": "^8.5.1", 40 | "mongoose": "^5.11.9", 41 | "socket.io": "^3.0.0", 42 | "socket.io-redis": "^6.0.1" 43 | }, 44 | "devDependencies": { 45 | "@babel/cli": "7.12.17", 46 | "@babel/core": "7.12.17", 47 | "@babel/node": "7.12.17", 48 | "@babel/plugin-transform-runtime": "^7.13.8", 49 | "@babel/preset-env": "7.12.17", 50 | "@babel/runtime": "7.12.18", 51 | "algoliasearch": "4.8.5", 52 | "babel-eslint": "10.1.0", 53 | "bcrypt": "5.0.0", 54 | "consola": "2.15.3", 55 | "dateformat": "4.5.1", 56 | "dotenv": "8.2.0", 57 | "eslint-config-xo-space": "^0.27.0", 58 | "eslint-plugin-prettier": "^3.4.0", 59 | "nodemon": "2.0.7", 60 | "prettier": "2.2.1", 61 | "xo": "^0.40.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | JWT_SECRET=example-jwt-secret 2 | 3 | ALGOLIA_SEARCH_API_KEY=0f2689a6db4288bb1cf5436490b6514b 4 | ALGOLIA_INDEX_NAME=dev_PLAYERS 5 | ALGOLIA_ID=QZWP9XEPO2 6 | 7 | REDIS_PORT=6379 8 | API_PORT=8080 9 | SOCKET_PORT=65080 10 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import './database/db.js'; 2 | import http from 'node:http'; 3 | import express from 'express'; 4 | import cors from 'cors'; 5 | import consola from 'consola'; 6 | import routes from './routes.js'; 7 | 8 | import { socker } from './socker/index.js'; 9 | import { handleError, authenticated } from './middlewares/index.js'; 10 | import { config } from './config.js'; 11 | 12 | const app = express(); 13 | const server = new http.Server(app); 14 | socker(server); 15 | 16 | app.use(cors({ origin: config.ALLOWLIST_HOSTS, credentials: true })); 17 | app.use(express.json()); 18 | app.use('/users', authenticated); 19 | app.use('/search', authenticated); 20 | 21 | routes(app); 22 | 23 | app.use((error, _request, response, _) => { 24 | handleError(error, response); 25 | }); 26 | 27 | app.listen(config.API_PORT, () => { 28 | consola.success(`Api listening on port ${config.API_PORT}!`); 29 | }); 30 | 31 | server.listen(config.SOCKET_PORT, () => { 32 | consola.success(`Socker listening on port ${config.SOCKET_PORT}!`); 33 | consola.info(`Api and socker whitelisted for ${config.ALLOWLIST_HOSTS}`); 34 | }); 35 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable operator-linebreak */ 2 | import dotenv from 'dotenv'; 3 | import axios from 'axios'; 4 | import consola from 'consola'; 5 | dotenv.config(); 6 | 7 | function getDefault(value, defaultValue) { 8 | if (!value || value === 'undefined') { 9 | return defaultValue; 10 | } 11 | 12 | return value; 13 | } 14 | 15 | const productionHosts = ['https://fifa.sauravmh.com', 'https://sauravmh.vercel.app', 'playfifa.vercel.app']; 16 | const devHosts = ['http://localhost:3000']; 17 | 18 | export const config = { 19 | IS_DEVELOPMENT: getDefault(process.env.NODE_ENV, 'development') !== 'production', 20 | 21 | ALGOLIA_ID: getDefault(process.env.ALGOLIA_ID, 'latency'), 22 | ALGOLIA_SEARCH_API_KEY: getDefault(process.env.ALGOLIA_SEARCH_API_KEY, '56f24e4276091e774e8157fe4b8b11f6'), 23 | ALGOLIA_INDEX_NAME: getDefault(process.env.ALGOLIA_INDEX_NAME, 'movies'), 24 | 25 | DB_URL: getDefault(process.env.DB_URL, 'mongodb://localhost:27017/fifa-db'), 26 | JWT_SECRET: getDefault(process.env.JWT_SECRET, 'REDACTED'), 27 | API_PORT: process.env.API_PORT ? Number.parseInt(process.env.API_PORT, 10) : 8080, 28 | SOCKET_PORT: process.env.SOCKET_PORT ? Number.parseInt(process.env.SOCKET_PORT, 10) : 65080, 29 | REDIS_PORT: process.env.REDIS_PORT ? Number.parseInt(process.env.REDIS_PORT, 10) : 6379, 30 | REDIS_HOST: getDefault(process.env.REDIS_HOST, 'localhost'), 31 | 32 | SALT_ROUNDS: process.env.SALT_ROUNDS ? Number.parseInt(process.env.SALT_ROUNDS, 10) : 6, 33 | DEFAULT_MAX_TIMER: 120 * 1000, 34 | DEFAULT_MAX_PLAYERS: 14, 35 | 36 | ALLOWLIST_HOSTS: getDefault(process.env.NODE_ENV, 'development') === 'production' ? productionHosts : devHosts, 37 | 38 | ROOM_ID_RX: /^([A-Z\d]){6}$/, 39 | ATTRIBUTES_TO_RETRIEVE: 40 | getDefault(process.env.NODE_ENV, 'development') === 'production' 41 | ? ['name', 'positions', 'Overall Rating', 'Skill Moves', 'objectID', 'photo_url'] 42 | : ['title', 'image', 'rating', 'score', 'year', 'genre'], 43 | 44 | METADATA_NETWORK_INTERFACE_URL: getDefault(process.env.METADATA_NETWORK_INTERFACE_URL, 'REDACTED'), 45 | getExternalIp: async () => { 46 | const options = { 47 | headers: { 48 | 'Metadata-Flavor': 'Google', 49 | }, 50 | }; 51 | 52 | try { 53 | const response = await axios.get(config.METADATA_NETWORK_INTERFACE_URL, options); 54 | if (response.status !== 200) { 55 | consola.warn('Error while talking to metadata server, assuming localhost'); 56 | return 'localhost'; 57 | } 58 | 59 | return response.data.body; 60 | } catch (error) { 61 | consola.warn(error, 'Error while talking to metadata server, assuming localhost'); 62 | return 'localhost'; 63 | } 64 | }, 65 | }; 66 | 67 | export const algoliaConfig = { 68 | eCommerce: { 69 | ALGOLIA_ID: 'B1G2GM9NG0', 70 | ALGOLIA_SEARCH_API_KEY: 'aadef574be1f9252bb48d4ea09b5cfe5', 71 | ALGOLIA_INDEX_NAME: 'demo_ecommerce', 72 | }, 73 | movies: { 74 | ALGOLIA_ID: 'latency', 75 | ALGOLIA_SEARCH_API_KEY: '56f24e4276091e774e8157fe4b8b11f6', 76 | ALGOLIA_INDEX_NAME: 'movies', 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /src/controller/authController.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | import { config } from '../config.js'; 5 | import { ErrorHandler, verifyToken } from '../middlewares/index.js'; 6 | import Users, { checkExisting } from '../models/usersModel.js'; 7 | 8 | const { JWT_SECRET, getExternalIp, SALT_ROUNDS } = config; 9 | 10 | const authController = { 11 | login: async (request, response, next) => { 12 | try { 13 | const { username, password, reserved } = request.body; 14 | 15 | if (!username) { 16 | throw new ErrorHandler(401, 'No username found. Enter a username and try again!'); 17 | } 18 | 19 | if (!reserved && username) { 20 | const check = await checkExisting(username); 21 | if (check) { 22 | throw new ErrorHandler(401, 'Username is already in use. Try another username or provide valid password!'); 23 | } 24 | 25 | const token = jwt.sign({ username, reserved: false }, JWT_SECRET); 26 | return response.json({ success: true, token }); 27 | } 28 | 29 | const match = await bcrypt.compare(password, user.password); 30 | const user = await Users.findOne({ username }); 31 | 32 | if (user && match) { 33 | const token = jwt.sign({ username: user.username }, JWT_SECRET); 34 | return response.json({ success: true, token }); 35 | } 36 | 37 | throw new ErrorHandler(401, 'Username or password is incorrect. Try again!'); 38 | } catch (error) { 39 | next(error); 40 | } 41 | }, 42 | 43 | register: async (request, response, next) => { 44 | try { 45 | if (!request.body) { 46 | throw new ErrorHandler(400, 'Invalid Request'); 47 | } 48 | 49 | const { username, password } = request.body; 50 | const check = await checkExisting(username); 51 | 52 | if (check) { 53 | throw new ErrorHandler(400, 'Username already exists. Try another one!'); 54 | } 55 | 56 | const hash = bcrypt.hashSync(password, SALT_ROUNDS); 57 | const newUser = new Users({ username, password: hash }); 58 | 59 | await newUser.save(); 60 | 61 | return response.json({ 62 | success: true, 63 | message: 'Successfully registered', 64 | }); 65 | } catch (error) { 66 | next(error); 67 | } 68 | }, 69 | 70 | verify: (request, response) => { 71 | if (!request.body) { 72 | throw new ErrorHandler(401, 'Unauthorized user and/or route'); 73 | } 74 | 75 | const { token } = request.body; 76 | const decoded = verifyToken(token); 77 | 78 | if (!decoded) { 79 | throw new ErrorHandler(401, 'Unauthorized action. JWT expired'); 80 | } 81 | 82 | return response.json({ success: true, decoded }); 83 | }, 84 | 85 | getExternalIp: async (request, response) => { 86 | const ip = await getExternalIp(); 87 | 88 | return response.json({ success: true, ip }); 89 | }, 90 | }; 91 | 92 | export default authController; 93 | -------------------------------------------------------------------------------- /src/controller/searchController.js: -------------------------------------------------------------------------------- 1 | import algoliasearch from 'algoliasearch/lite.js'; 2 | import consola from 'consola'; 3 | 4 | import { config } from '../config.js'; 5 | 6 | const searchController = { 7 | search: async (request, response) => { 8 | const { requests } = request.body; 9 | 10 | const client = algoliasearch(config.ALGOLIA_ID, config.ALGOLIA_SEARCH_API_KEY); 11 | 12 | try { 13 | const results = await client.search(requests); 14 | response.status(200).send(results); 15 | } catch (error) { 16 | consola.error({ 17 | custom: { 18 | message: 'Algolia Error. Kindly check Algolia API keys!', 19 | ALGOLIA_ID: config.ALGOLIA_ID, 20 | ALGOLIA_SEARCH_API_KEY: config.ALGOLIA_SEARCH_API_KEY, 21 | }, 22 | error, 23 | }); 24 | } 25 | }, 26 | }; 27 | 28 | export default searchController; 29 | -------------------------------------------------------------------------------- /src/controller/usersController.js: -------------------------------------------------------------------------------- 1 | import usersModel from '../models/usersModel.js'; 2 | 3 | const usersController = { 4 | getAll: async (request, response, _) => { 5 | usersModel.find({}, (error, users) => { 6 | if (error) { 7 | return response.json(error); 8 | } 9 | 10 | response.json(users); 11 | }); 12 | }, 13 | 14 | getOne: (request, response, _) => { 15 | usersModel.findById(request.params.id, (error, user) => { 16 | if (error) { 17 | return response.json(error); 18 | } 19 | 20 | response.json(user || {}); 21 | }); 22 | }, 23 | 24 | create: (request, response, _) => { 25 | usersModel.create(request.body, (error, user) => { 26 | if (error) { 27 | return response.json(error); 28 | } 29 | 30 | response.json(user); 31 | }); 32 | }, 33 | 34 | update: (request, response, _) => { 35 | usersModel.findOneAndUpdate(request.params.id, request.body, { new: true }, (error, user) => { 36 | if (error) { 37 | return response.json(error); 38 | } 39 | 40 | response.json(user); 41 | }); 42 | }, 43 | 44 | delete: (request, response, _) => { 45 | usersModel.remove({ _id: request.params.id }, (error, _) => { 46 | if (error) { 47 | return response.json(error); 48 | } 49 | }); 50 | response.json(true); 51 | }, 52 | }; 53 | 54 | export default usersController; 55 | -------------------------------------------------------------------------------- /src/database/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import consola from 'consola'; 3 | import { config } from '../config.js'; 4 | 5 | mongoose.connect(config.DB_URL, { 6 | useNewUrlParser: true, 7 | useCreateIndex: true, 8 | useUnifiedTopology: true, 9 | }); 10 | 11 | const db = mongoose.connection; 12 | 13 | // When successfully connected 14 | db.on('connected', () => { 15 | consola.success('Mongoose connection open to', config.DB_URL); 16 | }); 17 | 18 | // If the connection throws an error 19 | db.on('error', error => { 20 | consola.warn(`Mongoose connection error: ${error}`); 21 | }); 22 | 23 | // When the connection is disconnected 24 | db.on('disconnected', () => { 25 | consola.warn('Mongoose connection disconnected'); 26 | }); 27 | 28 | export default mongoose; 29 | -------------------------------------------------------------------------------- /src/middlewares/authenticated.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { config } from '../config.js'; 3 | 4 | const authenticated = (request, response, next) => { 5 | const token = request.headers.authorization; 6 | jwt.verify(token, config.JWT_SECRET, (error, _) => { 7 | if (error) { 8 | response.json('Token not provided'); 9 | } else { 10 | next(); 11 | } 12 | }); 13 | }; 14 | 15 | export const verifyToken = token => { 16 | try { 17 | const decoded = jwt.verify(token, config.JWT_SECRET); 18 | return decoded; 19 | } catch { 20 | return false; 21 | } 22 | }; 23 | 24 | export default authenticated; 25 | -------------------------------------------------------------------------------- /src/middlewares/error.js: -------------------------------------------------------------------------------- 1 | export class ErrorHandler extends Error { 2 | constructor(statusCode, message) { 3 | super(); 4 | this.statusCode = statusCode; 5 | this.message = message; 6 | } 7 | } 8 | 9 | export const handleError = (error, response) => { 10 | const { statusCode, message } = error; 11 | response.status(statusCode).json({ 12 | status: 'error', 13 | success: false, 14 | statusCode, 15 | message, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/middlewares/index.js: -------------------------------------------------------------------------------- 1 | export { default as authenticated, verifyToken } from './authenticated.js'; 2 | export { handleError, ErrorHandler } from './error.js'; 3 | -------------------------------------------------------------------------------- /src/models/roomsModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import roomsSchema from '../schema/rooms.js'; 3 | 4 | const roomsModel = mongoose.model('rooms', roomsSchema); 5 | 6 | export default roomsModel; 7 | -------------------------------------------------------------------------------- /src/models/usersModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import usersSchema from '../schema/users.js'; 3 | 4 | const Users = mongoose.model('Users', usersSchema); 5 | 6 | export default Users; 7 | 8 | /** 9 | * Checks if username already exists 10 | * @param {username} username 11 | * @returns {(boolean|Object)} True if doc existing, false otherwise 12 | */ 13 | export async function checkExisting(username) { 14 | const match = await Users.findOne({ username }); 15 | return match; 16 | } 17 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import usersController from './controller/usersController.js'; 2 | import authController from './controller/authController.js'; 3 | import searchController from './controller/searchController.js'; 4 | 5 | const routes = route => { 6 | route.get('/', (request, response) => { 7 | response.send(`Api server in running (${new Date()})`); 8 | }); 9 | 10 | route.route('/auth/getWebsocketIp').get(authController.getExternalIp); 11 | 12 | route.route('/auth/login').post(authController.login); 13 | 14 | route.route('/auth/verify').post(authController.verify); 15 | 16 | route.route('/auth/register').post(authController.register); 17 | 18 | route.route('/users').get(usersController.getAll).post(usersController.create); 19 | 20 | route.route('/users/:id').get(usersController.getOne).put(usersController.update).delete(usersController.delete); 21 | 22 | route.route('/search').post(searchController.search); 23 | }; 24 | 25 | export default routes; 26 | -------------------------------------------------------------------------------- /src/schema/rooms.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { config } from '../config.js'; 3 | 4 | const roomsSchema = new mongoose.Schema({ 5 | roomId: { 6 | type: String, 7 | }, 8 | password: { 9 | type: String, 10 | required: true, 11 | }, 12 | email: { 13 | type: String, 14 | index: true, 15 | unique: true, 16 | required: true, 17 | }, 18 | }); 19 | 20 | export default roomsSchema; 21 | 22 | export function isValid(roomId) { 23 | // Check if roomId matches the specified regex 24 | const isValid = config.ROOM_ID_RX.test(roomId); 25 | return isValid; 26 | } 27 | -------------------------------------------------------------------------------- /src/schema/users.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const usersSchema = new mongoose.Schema({ 4 | username: { 5 | type: String, 6 | }, 7 | protected: { 8 | type: Boolean, 9 | default: false, 10 | }, 11 | password: { 12 | type: String, 13 | }, 14 | teams: { 15 | type: Array, 16 | default: [], 17 | }, 18 | }); 19 | 20 | export default usersSchema; 21 | -------------------------------------------------------------------------------- /src/socker/corsFixer.js: -------------------------------------------------------------------------------- 1 | export const fixedOrigin = hosts => { 2 | const isPortPresent = /(https?:\/\/.*):(\d*)\/?(.*)/g; 3 | return hosts.map(host => { 4 | // eslint-disable-next-line no-eq-null, eqeqeq 5 | if (host.includes('https:') && host.match(isPortPresent) == null) { 6 | return [...host, ':443']; 7 | } 8 | 9 | // eslint-disable-next-line no-eq-null, eqeqeq 10 | if (host.includes('http:') && host.match(isPortPresent) == null) { 11 | return [...host, ':80']; 12 | } 13 | 14 | return host; 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/socker/index.js: -------------------------------------------------------------------------------- 1 | export { default as socker } from './sockerController.js'; 2 | -------------------------------------------------------------------------------- /src/socker/roomManager.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import consola from 'consola'; 3 | // eslint-disable-next-line no-unused-vars 4 | import { Server, Socket } from 'socket.io'; 5 | // eslint-disable-next-line no-unused-vars 6 | import { Adapter } from 'socket.io-adapter'; 7 | 8 | import { config } from '../config.js'; 9 | 10 | const { SALT_ROUNDS, DEFAULT_MAX_PLAYERS, DEFAULT_MAX_TIMER } = config; 11 | 12 | export default class Room { 13 | constructor(options) { 14 | /** @type { Server } */ 15 | this.io = options.io; // Shortname for -> io.of('/your_namespace_here') 16 | /** @type { Socket } */ 17 | this.socker = options.socket; 18 | this.username = options.username; 19 | this.roomId = options.roomId; 20 | this.password = options.password; // Optional 21 | this.action = options.action; // [join, create] 22 | /** @type { Adapter } */ 23 | this.store = options.io.adapter; // Later expanded to io.adapter.rooms[roomId] 24 | this.options = { 25 | maxPlayersLimit: DEFAULT_MAX_PLAYERS, 26 | maxTimerLimit: DEFAULT_MAX_TIMER, 27 | }; 28 | if (!options.options) { 29 | this.options = JSON.parse(options.options); 30 | } 31 | } 32 | 33 | /** 34 | * Initialises steps on first connection. 35 | * 36 | * Checks if room available: 37 | * If yes, then joins the room 38 | * If no, then creates new room. 39 | * 40 | * @access public 41 | * @return {bool} Returns true if initialization is successfull, false otherwise 42 | */ 43 | async init(username) { 44 | // Stores an array containing socket ids in 'roomId' 45 | const clients = await this.io.in(this.roomId).allSockets(); 46 | if (!clients) { 47 | consola.error('[INTERNAL ERROR] Room creation failed!'); 48 | } 49 | 50 | consola.debug(`Connected Clients are: ${clients}`); 51 | 52 | if (this.action === 'join') { 53 | // @optional Check if correct password for room 54 | // Check if room size is equal to or more than 1 55 | // If yes, join the socket to the room 56 | // If not, emit 'invalid operation: room does not exist' 57 | 58 | this.store = this.store.rooms.get(this.roomId); 59 | if (clients.size > 0) { 60 | if (this.store.password && !(await bcrypt.compare(this.password, this.store.password))) { 61 | consola.info(`[JOIN FAILED] Incorrect password for room ${this.roomId}`); 62 | this.socker.emit('Error: Incorrect password!'); 63 | return false; 64 | } 65 | 66 | await this.socker.join(this.roomId); 67 | this.store.clients.push({ id: this.socker.id, username, isReady: false }); 68 | this.socker.username = username; 69 | this.socker.emit('[SUCCESS] Successfully initialised', { 70 | roomId: this.roomId, 71 | password: this.password, 72 | options: this.options, 73 | }); 74 | consola.info(`[JOIN] Client joined room ${this.roomId}`); 75 | return true; 76 | } 77 | 78 | consola.warn(`[JOIN FAILED] Client denied join, as roomId ${this.roomId} not created`); 79 | this.socker.emit('Error: Create a room first!'); 80 | return false; 81 | } 82 | 83 | if (this.action === 'create') { 84 | // Check if room size is equal to zero 85 | // If yes, create new room and join socket to the room 86 | // If not, emit 'invalid operation: room already exists' 87 | 88 | if (clients.size === 0) { 89 | await this.socker.join(this.roomId); 90 | this.store = this.store.rooms.get(this.roomId); 91 | 92 | if (this.password) { 93 | this.store.password = await bcrypt.hash(this.password, SALT_ROUNDS); 94 | } 95 | 96 | this.store.clients = [{ id: this.socker.id, username, isReady: false }]; 97 | 98 | this.socker.username = username; 99 | consola.info(`[CREATE] Client created and joined room ${this.roomId}`); 100 | this.socker.emit('[SUCCESS] Successfully initialised', { 101 | roomId: this.roomId, 102 | password: this.password, 103 | options: this.options, 104 | }); 105 | return true; 106 | } 107 | 108 | consola.warn(`[CREATE FAILED] Client denied create, as roomId ${this.roomId} already present`); 109 | this.socker.emit('Error: Room already created. Join the room!'); 110 | return false; 111 | } 112 | } 113 | 114 | /** 115 | * Broadcast info about all players and their ready status joined to given room. Deafult status as 'Not ready'. 116 | * 117 | * @access public 118 | */ 119 | showPlayers() { 120 | const { clients } = this.store; 121 | this.io.to(this.roomId).emit('show-players-joined', { playersJoined: clients }); 122 | } 123 | 124 | /** 125 | * Broadcast Array of Teams [player_socket_id: [playerId1, playerId2]]. 126 | * 127 | * @access public 128 | */ 129 | showTeams() { 130 | const { teams } = this.store.draft; 131 | this.io.to(this.roomId).emit('show-players-teams', { teams }); 132 | } 133 | 134 | /** 135 | * Mark player as ready ---> to start the draft in the given room. If all players ready then initiate the draft 136 | * 137 | * @access public 138 | */ 139 | isReady() { 140 | this.socker.on('is-ready', () => { 141 | for (const player of this.store.clients) { 142 | if (player.id === this.socker.id) { 143 | player.isReady = true; 144 | } 145 | } 146 | 147 | this.showPlayers(); 148 | 149 | const arePlayersReady = this.store.clients.every(player => player.isReady === true); 150 | if (arePlayersReady) { 151 | this.beginDraft(); 152 | } 153 | }); 154 | } 155 | 156 | /** 157 | * Initiates the draft, by resetting the game -> emitting initial turn 158 | * 159 | * @access public 160 | */ 161 | beginDraft() { 162 | this.store.clients = this.shufflePlayers(this.store.clients); 163 | this.showPlayers(); 164 | this.io.to(this.roomId).emit('draft-start', 'The players order is shuffled and the draft has started...'); 165 | consola.info('Draft started...'); 166 | 167 | // Reset draft object to initial state 168 | this._resetCurrentGame(); 169 | 170 | this._emitTurn(0); 171 | this.showTeams(); 172 | } 173 | 174 | /** 175 | * Consume player item and update the gameState. Reset the timeout and initiate next turn. 176 | * 177 | * @access public 178 | */ 179 | shiftTurn() { 180 | this.socker.on('player-turn-pass', (item = undefined) => { 181 | // NAME Change: player-turn-trigger would be better name 182 | if (this.store.clients[this.store.draft.turnNum].id === this.socker.id) { 183 | // Add the selected item object to the collection 184 | if (item) { 185 | this.store.draft.teams[this.socker.id] = [...(this.store.draft.teams[this.socker.id] || []), item]; 186 | } 187 | 188 | this._resetTimeOut(); 189 | this._nextTurn(); 190 | } 191 | 192 | this.showTeams(); 193 | }); 194 | } 195 | 196 | /** 197 | * Emit End current draft event 198 | * 199 | * @access public 200 | */ 201 | endDraft() { 202 | // TODO: Save the teams in DB as a collection 203 | this.io.to(this.roomId).emit('draft-end', 'The draft has ended'); 204 | } 205 | 206 | /** 207 | * Shuffle the players ready in a given room in random order. 208 | * Uses Fisher-Yates shuffle algorithm 209 | * 210 | * @param {Array} clients Original clients list from this.store.clients 211 | * @return {Array} Shuffled order of this.store.clients 212 | */ 213 | shufflePlayers(clients) { 214 | // Shuffle the order of players and return a new order 215 | let j; 216 | let x; 217 | let i; 218 | 219 | for (i = clients.length - 1; i > 0; i--) { 220 | j = Math.floor(Math.random() * (i + 1)); 221 | x = clients[i]; 222 | clients[i] = clients[j]; 223 | clients[j] = x; 224 | } 225 | 226 | return clients; 227 | } 228 | 229 | _nextTurn() { 230 | this.io 231 | .to(this.roomId) 232 | .emit('player-turn-end', `${this.store.clients[this.store.draft.turnNum].username} chance ended`); 233 | this.io.to(this.store.clients[this.store.draft.turnNum].id).emit('personal-turn-end', 'Your chance ended'); 234 | 235 | consola.info(`[TURN CHANGE] ${this.store.clients[this.store.draft.turnNum].username} had timeout turn change`); 236 | 237 | const currentTurnNumber = (this.store.draft.turnNum + 1) % this.store.clients.length; 238 | this.store.draft.turnNum = currentTurnNumber; 239 | 240 | this._emitTurn(currentTurnNumber); 241 | } 242 | 243 | _emitTurn(currentTurnNumber) { 244 | this.io.to(this.store.clients[currentTurnNumber].id).emit('personal-turn-start', 'It is your chance to pick'); 245 | this.io.to(this.roomId).emit('player-turn-start', `${this.store.clients[currentTurnNumber].username} is picking`); 246 | consola.info( 247 | `[TURN CHANGE] ${this.store.clients[currentTurnNumber].username} is the new drafter. Turn number: ${currentTurnNumber}` 248 | ); 249 | this._triggerTimeout(); 250 | } 251 | 252 | _triggerTimeout() { 253 | this.store.draft.timeOut = setTimeout(() => { 254 | this._nextTurn(); 255 | }, this.store.draft.maxTimerLimit); 256 | } 257 | 258 | _resetTimeOut() { 259 | if (typeof this.store.draft?.timeOut === 'object') { 260 | consola.info('[TURN CHANGE] Timeout reset'); 261 | clearTimeout(this.store.draft.timeOut); 262 | } 263 | } 264 | 265 | _resetCurrentGame() { 266 | if (this.store) { 267 | this._resetTimeOut(); 268 | this.store.draft = { 269 | teams: {}, 270 | sTime: new Date(), 271 | timeOut: 0, 272 | turnNum: 0, 273 | maxPlayersLimit: this.options.maxPlayersLimit, 274 | maxTimerLimit: this.options.maxTimerLimit, 275 | }; 276 | } 277 | 278 | if (this.options) { 279 | consola.info(`[USER-CONFIG] ${JSON.stringify(this.options)}`); 280 | } else { 281 | consola.info(`[DEFAULT-CONFIG] ${JSON.stringify(this.options)}`); 282 | } 283 | } 284 | 285 | /** 286 | * Gracefully disconnect the user from the game and end the draft 287 | * Preserving the gameState 288 | * 289 | * @access public 290 | */ 291 | onDisconnect() { 292 | this.socker.on('disconnect', () => { 293 | try { 294 | this.store.clients = this.store.clients.filter(player => player.id !== this.socker.id); 295 | this.showPlayers(); 296 | 297 | // Handle game reset 298 | this._resetTimeOut(); 299 | this.endDraft(); 300 | this._resetCurrentGame(); 301 | } catch { 302 | consola.info('[FORCE DISCONNECT] Server closed forcefully'); 303 | } 304 | 305 | consola.info('Client Disconnected!'); 306 | }); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/socker/sockerController.js: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io'; 2 | import redisAdapter from 'socket.io-redis'; 3 | import consola from 'consola'; 4 | 5 | import { verifyToken } from '../middlewares/index.js'; 6 | import { config } from '../config.js'; 7 | import Room from './roomManager.js'; 8 | import { fixedOrigin } from './corsFixer.js'; 9 | 10 | const { ALLOWLIST_HOSTS, REDIS_PORT, REDIS_HOST } = config; 11 | 12 | const app = app => { 13 | const io = new Server(app, { 14 | transports: ['websocket'], // To avoid sticky sessions when using multiple servers 15 | path: '/classic-mode', 16 | cors: fixedOrigin(ALLOWLIST_HOSTS), 17 | rememberUpgrade: true, 18 | }); 19 | 20 | try { 21 | const adapter = redisAdapter({ host: REDIS_HOST, port: Number(REDIS_PORT) }); 22 | io.adapter(adapter); 23 | } catch (error) { 24 | consola.warn('Start redis docker container using `docker-compose up`'); 25 | consola.warn(error); 26 | } 27 | 28 | consola.info('Socketio initialised!'); 29 | 30 | const classicMode = io.of('/classic-mode'); 31 | classicMode.use(verifySocker).on('connection', async socket => { 32 | const { username, roomId, password, action, options } = socket.handshake.query; 33 | const room = new Room({ io: classicMode, socket, username, roomId, password, action, options }); 34 | 35 | const joinedRoom = await room.init(username); 36 | consola.info('Client Connected'); 37 | 38 | if (joinedRoom) { 39 | room.showPlayers(); 40 | room.isReady(); 41 | room.shiftTurn(); 42 | } 43 | 44 | room.onDisconnect(); 45 | }); 46 | 47 | return io; 48 | }; 49 | 50 | const verifySocker = (socket, next) => { 51 | if (socket.handshake.query && socket.handshake.query.token) { 52 | const decoded = verifyToken(socket.handshake.query.token); 53 | socket.decoded = decoded; 54 | next(); 55 | } 56 | }; 57 | 58 | export default app; 59 | --------------------------------------------------------------------------------