├── .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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 | 
23 |
24 | ## GCP Architecture
25 |
26 | 
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 |
--------------------------------------------------------------------------------