diff --git a/client/src/scripts/howToUse.js b/client/src/scripts/howToUse.js index e7d2679..6cf8d9d 100644 --- a/client/src/scripts/howToUse.js +++ b/client/src/scripts/howToUse.js @@ -1,6 +1,8 @@ import { injectNavbar } from '../modules/Navbar.js'; -const howToUse = () => { injectNavbar(); }; +const howToUse = () => { + injectNavbar(); +}; if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = howToUse; diff --git a/client/src/scripts/notFound.js b/client/src/scripts/notFound.js index 68e7047..e83aec9 100644 --- a/client/src/scripts/notFound.js +++ b/client/src/scripts/notFound.js @@ -1,6 +1,8 @@ import { injectNavbar } from '../modules/Navbar.js'; -const notFound = () => { injectNavbar(); }; +const notFound = () => { + injectNavbar(); +}; if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = notFound; diff --git a/client/src/styles/game.css b/client/src/styles/game.css index ab35553..4d6708c 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -271,6 +271,10 @@ h1 { -moz-user-select: none; } +#game-role:active, #game-role-back:active { + filter: brightness(0.85); +} + .game-role-good { border: 5px solid #5469c5 !important; } diff --git a/index.js b/index.js index 28fcfce..a473d39 100644 --- a/index.js +++ b/index.js @@ -3,15 +3,12 @@ const express = require('express'); const path = require('path'); const app = express(); -const bodyParser = require('body-parser'); const GameManager = require('./server/modules/GameManager.js'); +const SocketManager = require('./server/modules/SocketManager.js'); const globals = require('./server/config/globals'); const ServerBootstrapper = require('./server/modules/ServerBootstrapper'); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ - extended: true -})); +app.use(express.json()); const args = ServerBootstrapper.processCLIArgs(); @@ -34,6 +31,9 @@ if (process.env.NODE_ENV.trim() === 'development') { gameManager = new GameManager(logger, globals.ENVIRONMENT.PRODUCTION, gameNamespace).getInstance(); } +/* Instantiate the singleton socket manager */ +const socketManager = new SocketManager(logger, inGameSocketServer).getInstance(); + gameNamespace.on('connection', function (socket) { socket.on('disconnecting', (reason) => { logger.trace('client socket disconnecting because: ' + reason); @@ -43,7 +43,9 @@ gameNamespace.on('connection', function (socket) { /* api endpoints */ const games = require('./server/api/GamesAPI'); +const admin = require('./server/api/AdminAPI'); app.use('/api/games', games); +app.use('/api/admin', admin); /* serve all the app's pages */ app.use('/manifest.json', (req, res) => { diff --git a/server/api/AdminAPI.js b/server/api/AdminAPI.js new file mode 100644 index 0000000..5e2e89b --- /dev/null +++ b/server/api/AdminAPI.js @@ -0,0 +1,73 @@ +const express = require('express'); +const router = express.Router(); +const debugMode = Array.from(process.argv.map((arg) => arg.trim().toLowerCase())).includes('debug'); +const logger = require('../modules/Logger')(debugMode); +const socketManager = new (require('../modules/SocketManager.js'))().getInstance(); +const gameManager = new (require('../modules/GameManager.js'))().getInstance(); +const globals = require('../config/globals.js'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit').default; + +const KEY = process.env.NODE_ENV.trim() === 'development' + ? globals.MOCK_AUTH + : process.env.ADMIN_KEY; + +const apiLimiter = rateLimit({ + windowMs: 60000, + max: 50, + standardHeaders: true, + legacyHeaders: false +}); + +if (process.env.NODE_ENV.trim() === 'production') { + router.use(apiLimiter); +} + +router.use(cors(globals.CORS)); + +router.use((req, res, next) => { + req.accepts(); + if (isAuthorized(req)) { + next(); + } else { + res.status(401).send('You are not authorized to make this request.'); + } +}); + +router.post('/sockets/broadcast', (req, res, next) => { + globals.CONTENT_TYPE_VALIDATOR(req, res, next); +}); +router.put('/games/state', (req, res, next) => { + globals.CONTENT_TYPE_VALIDATOR(req, res, next); +}); + +// TODO: implement client-side display of this message. +router.post('/sockets/broadcast', function (req, res) { + logger.info('admin user broadcasting message: ' + req.body?.message); + socketManager.broadcast(req.body?.message); + res.status(201).send('Broadcasted message to all connected sockets: ' + req.body?.message); +}); + +router.get('/games/state', function (req, res) { + res.status(200).send(gameManager.activeGameRunner.activeGames); +}); + +router.put('/games/state', function (req, res) { + // TODO: validate the JSON object sent - ones that don't match the expected model could break the application. + gameManager.activeGameRunner.activeGames = req.body; + res.status(201).send(gameManager.activeGameRunner.activeGames); +}); + +/* validates Basic Auth */ +function isAuthorized (req) { + const header = req.headers.authorization; + if (header) { + const token = header.split(/\s+/).pop() || ''; + const decodedToken = Buffer.from(token, 'base64').toString(); + return decodedToken.trim() === KEY.trim(); + } + + return false; +} + +module.exports = router; diff --git a/server/api/GamesAPI.js b/server/api/GamesAPI.js index 834b523..4fa21bf 100644 --- a/server/api/GamesAPI.js +++ b/server/api/GamesAPI.js @@ -28,6 +28,16 @@ router.options('/:code/players', cors(globals.CORS)); router.options('/create', cors(globals.CORS)); router.options('/restart', cors(globals.CORS)); +router.post('/create', (req, res, next) => { + globals.CONTENT_TYPE_VALIDATOR(req, res, next); +}); +router.patch('/players', (req, res, next) => { + globals.CONTENT_TYPE_VALIDATOR(req, res, next); +}); +router.patch('/restart', (req, res, next) => { + globals.CONTENT_TYPE_VALIDATOR(req, res, next); +}); + if (process.env.NODE_ENV.trim() === 'production') { router.use(apiLimiter); router.use('/create', gameEndpointLimiter); diff --git a/server/config/globals.js b/server/config/globals.js index 47442dd..b686fcb 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -12,6 +12,14 @@ const globals = { origin: 'https://play-werewolf.app', optionsSuccessStatus: 200 }, + CONTENT_TYPE_VALIDATOR: (req, res, next) => { + req.accepts(); + if (req.is('application/json')) { + next(); + } else { + res.status(400).send('Request has invalid content type.'); + } + }, STALE_GAME_HOURS: 12, CLIENT_COMMANDS: { FETCH_GAME_STATE: 'fetchGameState', diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index 9fb7079..e83b8e1 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -199,12 +199,7 @@ class GameManager { checkAvailability = (code) => { const game = this.activeGameRunner.activeGames[code.toUpperCase()]; if (game) { - const unassignedPerson = game.people.find((person) => person.assigned === false); - if (!unassignedPerson) { - return Promise.resolve(new Error(globals.ERROR_MESSAGE.GAME_IS_FULL)); - } else { - return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams }); - } + return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams }); } else { return Promise.resolve(404); } diff --git a/server/modules/SocketManager.js b/server/modules/SocketManager.js new file mode 100644 index 0000000..35bf0e0 --- /dev/null +++ b/server/modules/SocketManager.js @@ -0,0 +1,27 @@ +const globals = require('../config/globals.js'); + +class SocketManager { + constructor (logger, io) { + this.logger = logger; + this.io = io; + } + + broadcast = (message) => { + this.io.emit(globals.EVENTS.BROADCAST, message); + }; +} + +class Singleton { + constructor (logger, io) { + if (!Singleton.instance) { + logger.info('CREATING SINGLETON SOCKET MANAGER'); + Singleton.instance = new SocketManager(logger, io); + } + } + + getInstance () { + return Singleton.instance; + } +} + +module.exports = Singleton;