diff --git a/client/src/scripts/create.js b/client/src/scripts/create.js index c60d852..9303cc7 100644 --- a/client/src/scripts/create.js +++ b/client/src/scripts/create.js @@ -2,9 +2,15 @@ import { DeckStateManager } from '../modules/DeckStateManager.js'; import { GameCreationStepManager } from '../modules/GameCreationStepManager.js'; import { injectNavbar } from '../modules/Navbar.js'; import createTemplate from '../view_templates/CreateTemplate.js'; +import { io } from 'socket.io-client'; +import { toast } from '../modules/Toast'; const create = () => { injectNavbar(); + const socket = io(); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); document.getElementById('game-creation-container').innerHTML = createTemplate; const deckManager = new DeckStateManager(); const gameCreationStepManager = new GameCreationStepManager(deckManager); diff --git a/client/src/scripts/game.js b/client/src/scripts/game.js index 995b34c..3db7206 100644 --- a/client/src/scripts/game.js +++ b/client/src/scripts/game.js @@ -34,6 +34,9 @@ const game = () => { UserUtility.validateAnonUserSignature(res.content) ); }); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); socket.on('connect_error', (err) => { toast(err, 'error', true, false); }); diff --git a/client/src/scripts/home.js b/client/src/scripts/home.js index 17a9a94..d874772 100644 --- a/client/src/scripts/home.js +++ b/client/src/scripts/home.js @@ -1,8 +1,13 @@ import { XHRUtility } from '../modules/XHRUtility.js'; import { toast } from '../modules/Toast.js'; import { injectNavbar } from '../modules/Navbar.js'; +import { io } from 'socket.io-client'; const home = () => { + const socket = io(); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); injectNavbar(); document.getElementById('join-form').addEventListener('submit', attemptToJoinGame); }; diff --git a/client/src/scripts/howToUse.js b/client/src/scripts/howToUse.js index e7d2679..9446089 100644 --- a/client/src/scripts/howToUse.js +++ b/client/src/scripts/howToUse.js @@ -1,6 +1,14 @@ import { injectNavbar } from '../modules/Navbar.js'; +import { io } from 'socket.io-client'; +import { toast } from '../modules/Toast'; -const howToUse = () => { injectNavbar(); }; +const howToUse = () => { + injectNavbar(); + const socket = io(); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); +}; if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = howToUse; diff --git a/client/src/scripts/join.js b/client/src/scripts/join.js index cd8a3c0..222cc56 100644 --- a/client/src/scripts/join.js +++ b/client/src/scripts/join.js @@ -3,9 +3,14 @@ import { toast } from '../modules/Toast.js'; import { XHRUtility } from '../modules/XHRUtility.js'; import { UserUtility } from '../modules/UserUtility.js'; import { globals } from '../config/globals.js'; +import { io } from 'socket.io-client'; const join = () => { injectNavbar(); + const socket = io(); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); const splitUrl = window.location.pathname.split('/join/'); const accessCode = splitUrl[1]; if (/^[a-zA-Z0-9]+$/.test(accessCode) && accessCode.length === globals.ACCESS_CODE_LENGTH) { diff --git a/client/src/scripts/notFound.js b/client/src/scripts/notFound.js index 68e7047..dafcea0 100644 --- a/client/src/scripts/notFound.js +++ b/client/src/scripts/notFound.js @@ -1,6 +1,14 @@ import { injectNavbar } from '../modules/Navbar.js'; +import { io } from 'socket.io-client'; +import { toast } from '../modules/Toast'; -const notFound = () => { injectNavbar(); }; +const notFound = () => { + injectNavbar(); + const socket = io(); + socket.on('broadcast', (message) => { + toast(message, 'warning', true, false); + }); +}; if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = notFound; diff --git a/index.js b/index.js index 28fcfce..76b523b 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ 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'); @@ -34,6 +35,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 +47,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/package.json b/package.json index dc38d1f..027a4a6 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "start:dev:no-hot-reload": "NODE_ENV=development && node index.js", "start:dev:windows": "SET NODE_ENV=development && nodemon index.js", "start:dev:windows:no-hot-reload": "SET NODE_ENV=development && node index.js", - "start": "NODE_ENV=production node index.js -- loglevel=info", - "start:windows": "SET NODE_ENV=production && node index.js -- loglevel=warn port=8080", + "start": "NODE_ENV=production node index.js -- loglevel=debug", + "start:windows": "SET NODE_ENV=production && node index.js -- loglevel=debug port=8080", "test": "jasmine && karma start --single-run --browsers ChromeHeadless karma.conf.js", "test:unit": "jasmine", "test:e2e": "karma start --single-run --browsers ChromeHeadless karma.conf.js" diff --git a/server/api/AdminAPI.js b/server/api/AdminAPI.js new file mode 100644 index 0000000..9f2e08b --- /dev/null +++ b/server/api/AdminAPI.js @@ -0,0 +1,64 @@ +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) => { + if (isAuthorized(req)) { + next(); + } else { + res.status(401).send('You are not authorized to make this request.'); + } +}); + +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 request body - can break the application if malformed. + 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 ae4364b..090403a 100644 --- a/server/api/GamesAPI.js +++ b/server/api/GamesAPI.js @@ -4,33 +4,33 @@ const debugMode = Array.from(process.argv.map((arg) => arg.trim().toLowerCase()) const logger = require('../modules/Logger')(debugMode); const GameManager = require('../modules/GameManager.js'); const rateLimit = require('express-rate-limit').default; -const globals = require('../config/globals'); +const globals = require('../config/globals.js'); const cors = require('cors'); const gameManager = new GameManager().getInstance(); const apiLimiter = rateLimit({ - windowMs: 600000, - max: 5, + windowMs: 60000, + max: 100, standardHeaders: true, legacyHeaders: false }); -const corsOptions = process.env.NODE_ENV.trim() === 'development' - ? { - origin: '*', - optionsSuccessStatus: 200 - } - : { - origin: 'https://playwerewolf.uk.r.appspot.com', - optionsSuccessStatus: 200 - }; +const gameEndpointLimiter = rateLimit({ // further limit the rate of game creation to 30 games per 10 minutes. + windowMs: 600000, + max: 30, + standardHeaders: true, + legacyHeaders: false +}); -router.use(cors(corsOptions)); -router.options('/:code/players', cors(corsOptions)); +router.use(cors(globals.CORS)); +router.options('/:code/players', cors(globals.CORS)); +router.options('/create', cors(globals.CORS)); +router.options('/restart', cors(globals.CORS)); -if (process.env.NODE_ENV.trim() === 'production') { // in prod, limit clients to creating 5 games per 10 minutes. - router.use('/create', apiLimiter); +if (process.env.NODE_ENV.trim() === 'production') { + router.use(apiLimiter); + router.use('/create', gameEndpointLimiter); } router.post('/create', function (req, res) { diff --git a/server/config/globals.js b/server/config/globals.js index 17c7c81..242b91e 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -3,6 +3,15 @@ const globals = { ACCESS_CODE_LENGTH: 4, ACCESS_CODE_GENERATION_ATTEMPTS: 50, CLOCK_TICK_INTERVAL_MILLIS: 100, + CORS: process.env.NODE_ENV.trim() === 'development' + ? { + origin: '*', + optionsSuccessStatus: 200 + } + : { + origin: 'https://play-werewolf.app', + optionsSuccessStatus: 200 + }, STALE_GAME_HOURS: 12, CLIENT_COMMANDS: { FETCH_GAME_STATE: 'fetchGameState', @@ -41,7 +50,8 @@ const globals = { PLAYER_JOINED: 'playerJoined', PLAYER_LEFT: 'playerLeft', SYNC_GAME_STATE: 'syncGameState', - NEW_SPECTATOR: 'newSpectator' + NEW_SPECTATOR: 'newSpectator', + BROADCAST: 'broadcast' }, ENVIRONMENT: { LOCAL: 'local', @@ -61,7 +71,8 @@ const globals = { PAUSE_TIMER: 'pauseTimer', RESUME_TIMER: 'resumeTimer', GET_TIME_REMAINING: 'getTimeRemaining' - } + }, + MOCK_AUTH: 'mock_auth' }; module.exports = globals; 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;