game flow revamp

This commit is contained in:
Jose Conde 2024-07-06 20:32:41 +02:00
parent 733ac3891f
commit a974f576b3
50 changed files with 2994 additions and 268 deletions

902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "node --env-file=.env --watch -r ts-node/register src/server/index.ts", "dev": "node --env-file=.env --watch -r ts-node/register src/server/index.ts",
"create": "node --env-file=.env -r ts-node/register ./create.ts",
"test": "node --env-file=.env -r ts-node/register src/test.ts", "test": "node --env-file=.env -r ts-node/register src/test.ts",
"test:watch": "node --env-file=.env --watch -r ts-node/register src/test.ts", "test:watch": "node --env-file=.env --watch -r ts-node/register src/test.ts",
"docker-build": "docker build -t arhuako/domino:latest .", "docker-build": "docker build -t arhuako/domino:latest .",
@ -22,17 +23,29 @@
"type": "commonjs", "type": "commonjs",
"reposityory": "github:jmconde/domino", "reposityory": "github:jmconde/domino",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-validator": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.8.0",
"nodemailer": "^6.9.14",
"nodemailer-express-handlebars": "^6.1.2",
"pino": "^9.2.0", "pino": "^9.2.0",
"pino-http": "^10.2.0",
"pino-pretty": "^11.2.1", "pino-pretty": "^11.2.1",
"pino-rotating-file-stream": "^0.0.2",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"socket.io": "^4.7.5" "socket.io": "^4.7.5"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.8", "@types/node": "^20.14.8",
"@types/nodemailer": "^6.4.15",
"@types/nodemailer-express-handlebars": "^4.0.5",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.5.2" "typescript": "^5.5.2"

View File

@ -1,19 +1,20 @@
import pino, { BaseLogger } from 'pino'; import pino, { BaseLogger } from 'pino';
import path from 'path'; import path from 'path';
import httpPino from 'pino-http';
export class LoggingService { export class LoggingService {
static instance: LoggingService; static instance: LoggingService;
logsPath: string = path.join(process.cwd(), 'app', 'server', 'logs'); logsPath: string = path.join(process.cwd(), 'logs');
logger!: BaseLogger; logger!: BaseLogger;
level: string = process.env.LOG_LEVEL || 'info'; level: string = process.env.LOG_LEVEL || 'info';
/* /*
* ogger.fatal('fatal'); * 1 - fatal
logger.error('error'); 2 - error
logger.warn('warn'); 3 - warn
logger.info('info'); 4 - info
logger.debug('debug'); 5 - debug
logger.trace('trace'); 6 - trace
*/ */
constructor() { constructor() {
if ((!LoggingService.instance)) { if ((!LoggingService.instance)) {
@ -26,7 +27,7 @@ export class LoggingService {
return LoggingService.instance; return LoggingService.instance;
} }
get commonRorationOptions() : any { get commonRotationOptions() : any {
return { return {
interval: '1d', interval: '1d',
maxFiles: 10, maxFiles: 10,
@ -39,14 +40,14 @@ export class LoggingService {
get transports() { get transports() {
return pino.transport({ return pino.transport({
targets: [ targets: [
// { {
// target: 'pino-rotating-file-stream', target: 'pino-rotating-file-stream',
// level: this.level, level: this.level,
// options: { options: {
// filename: 'app.log', filename: 'app.log',
// ...this.commonRorationOptions ...this.commonRotationOptions
// }, },
// }, },
{ {
target: 'pino-pretty', target: 'pino-pretty',
level: this.level, level: this.level,
@ -59,6 +60,29 @@ export class LoggingService {
}); });
} }
get httpTransports() : any {
return pino.transport({
targets: [
{
target: 'pino-rotating-file-stream',
level: this.level,
options: {
filename: 'http.log',
...this.commonRotationOptions
},
},
],
});
}
middleware(): any {
return httpPino({
logger: pino({
level: this.level,
timestamp: pino.stdTimeFunctions.isoTime,
}, this.httpTransports) });
}
debug(message: string, data?: any) { debug(message: string, data?: any) {
this.logger.debug(this._getMessageWidthObject(message, data)); this.logger.debug(this._getMessageWidthObject(message, data));
} }

View File

@ -25,7 +25,7 @@ export class DominoesGame {
winner: PlayerInterface | null = null; winner: PlayerInterface | null = null;
rng: PRNG; rng: PRNG;
handSize: number = 7; handSize: number = 7;
notificationManager: PlayerNotificationManager = new PlayerNotificationManager(this); notificationManager: PlayerNotificationManager = new PlayerNotificationManager();
lastMove: PlayerMove | null = null; lastMove: PlayerMove | null = null;
constructor(public players: PlayerInterface[], seed: PRNG) { constructor(public players: PlayerInterface[], seed: PRNG) {
@ -44,6 +44,14 @@ export class DominoesGame {
this.board.boneyard = this.generateTiles(); this.board.boneyard = this.generateTiles();
} }
reset() {
this.board.reset();
this.initializeGame();
for (let player of this.players) {
player.hand = [];
}
}
generateTiles(): Tile[] { generateTiles(): Tile[] {
const tiles: Tile[] = []; const tiles: Tile[] = [];
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
@ -142,18 +150,25 @@ export class DominoesGame {
this.nextPlayer(); this.nextPlayer();
} }
resetPlayersScore() {
for (let player of this.players) {
player.score = 0;
}
}
async start(): Promise<GameSummary> { async start(): Promise<GameSummary> {
this.resetPlayersScore();
this.gameInProgress = false; this.gameInProgress = false;
this.tileSelectionPhase = true; this.tileSelectionPhase = true;
await this.notificationManager.notifyGameState(); await this.notificationManager.notifyGameState(this);
await this.notificationManager.notifyPlayersState(); await this.notificationManager.notifyPlayersState(this.players);
this.logger.debug('clients received boneyard :>> ' + this.board.boneyard); this.logger.debug('clients received boneyard :>> ' + this.board.boneyard);
await wait(1000); await wait(1000);
if (this.autoDeal) { if (this.autoDeal) {
this.dealTiles(); this.dealTiles();
await this.notificationManager.notifyGameState(); await this.notificationManager.notifyGameState(this);
await this.notificationManager.notifyPlayersState(); await this.notificationManager.notifyPlayersState(this.players);
} else { } else {
await this.tilesSelection(); await this.tilesSelection();
} }
@ -164,8 +179,8 @@ export class DominoesGame {
printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`); printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`);
while (!this.gameOver) { while (!this.gameOver) {
await this.playTurn(); await this.playTurn();
await this.notificationManager.notifyGameState(); await this.notificationManager.notifyGameState(this);
await this.notificationManager.notifyPlayersState(); await this.notificationManager.notifyPlayersState(this.players);
this.gameBlocked = this.isBlocked(); this.gameBlocked = this.isBlocked();
this.gameOver = this.isGameOver(); this.gameOver = this.isGameOver();
} }
@ -196,8 +211,8 @@ export class DominoesGame {
while (this.board.boneyard.length > 0) { while (this.board.boneyard.length > 0) {
for (let player of this.players) { for (let player of this.players) {
const choosen = await player.chooseTile(this.board); const choosen = await player.chooseTile(this.board);
await this.notificationManager.notifyGameState(); await this.notificationManager.notifyGameState(this);
await this.notificationManager.notifyPlayersState(); await this.notificationManager.notifyPlayersState(this.players);
if (this.board.boneyard.length === 0) { if (this.board.boneyard.length === 0) {
break; break;
} }
@ -217,16 +232,8 @@ export class DominoesGame {
gameTied: this.gameTied, gameTied: this.gameTied,
gameId: this.id, gameId: this.id,
boneyard: this.board.boneyard.map(tile => ({ id: tile.id})), boneyard: this.board.boneyard.map(tile => ({ id: tile.id})),
players: this.players.map(player => ({ players: this.players.map(player => player.getState()),
id: player.id, currentPlayer: currentPlayer.getState(),
name: player.name,
score: player.score,
hand: player.hand.map(tile => tile.id),
})),
currentPlayer: {
id: currentPlayer.id,
name: currentPlayer.name
},
board: this.board.tiles.map(tile => ({ board: this.board.tiles.map(tile => ({
id: tile.id, id: tile.id,
pips: tile.pips pips: tile.pips

View File

@ -1,149 +0,0 @@
import { DominoesGame } from "./DominoesGame";
import { PlayerAI } from "./entities/player/PlayerAI";
import { PlayerInterface } from "./entities/player/PlayerInterface";
import { LoggingService } from "../common/LoggingService";
import { getRandomSeed, uuid, wait } from "../common/utilities";
import { GameSessionState } from "./dto/GameSessionState";
import { PlayerNotificationManager } from './PlayerNotificationManager';
import seedrandom, { PRNG } from "seedrandom";
export class GameSession {
private game: DominoesGame | null = null;
private minHumanPlayers: number = 1;
private waitingForPlayers: boolean = true;
private waitingSeconds: number = 0;
private logger: LoggingService = new LoggingService();
private mode: string = 'classic';
private pointsToWin: number = 100;
private playerNotificationManager: PlayerNotificationManager;
id: string;
players: PlayerInterface[] = [];
sessionInProgress: boolean = false;
maxPlayers: number = 4;
seed!: string
rng!: PRNG
constructor(public creator: PlayerInterface, public name?: string) {
this.playerNotificationManager = new PlayerNotificationManager(this);
this.id = uuid();
this.name = name || `Game ${this.id}`;
this.addPlayer(creator);
this.logger.info(`GameSession created by: ${creator.name}`);
this.creator = creator;
this.playerNotificationManager.notifySessionState();
}
get numPlayers() {
return this.players.length;
}
private async startGame(seed: string) {
this.rng = seedrandom(seed);
const missingPlayers = this.maxPlayers - this.numPlayers;
for (let i = 0; i < missingPlayers; i++) {
this.addPlayer(this.createPlayerAI(i));
}
this.game = new DominoesGame(this.players, this.rng);
this.sessionInProgress = true;
this.logger.info('Game started');
this.playerNotificationManager.notifySessionState();
await this.game.start();
return this.endGame();
}
private endGame(): any {
if (this.game !== null) {
this.sessionInProgress = false;
const { gameBlocked, gameTied, winner } = this.game;
gameBlocked ? console.log('Game blocked!') : gameTied ? console.log('Game tied!') : console.log('Game over!');
console.log('Winner: ' + winner?.name + ' with ' + winner?.pipsCount() + ' points');
this.getScore(this.game);
this.sessionInProgress = false;
this.logger.info('Game ended');
this.game = null;
this.playerNotificationManager.notifySessionState();
return {
gameBlocked,
gameTied,
winner
};
}
}
private getScore(game: DominoesGame) {
const pips = game.players
.sort((a,b) => (b.pipsCount() - a.pipsCount()))
.map(player => {
return `${player.name}: ${player.pipsCount()}`;
});
console.log(`Pips count: ${pips.join(', ')}`);
const totalPoints = game.players.reduce((acc, player) => acc + player.pipsCount(), 0);
if (game.winner !== null) {
game.winner.score += totalPoints;
}
const scores = game.players
.sort((a,b) => (b.score - a.score))
.map(player => {
return `${player.name}: ${player.score}`;
});
console.log(`Scores: ${scores.join(', ')}`);
}
createPlayerAI(i: number) {
const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"];
return new PlayerAI(AInames[i], this.rng);
}
async start(seed?: string) {
this.seed = seed || getRandomSeed();
console.log('seed :>> ', this.seed);
if (this.sessionInProgress) {
throw new Error("Game already in progress");
}
this.waitingForPlayers = true;
this.logger.info('Waiting for players to join');
while (this.numPlayers < this.maxPlayers) {
this.waitingSeconds += 1;
this.logger.info(`Waiting for players to join: ${this.waitingSeconds}`);
await wait(1000);
}
this.waitingForPlayers = false;
this.logger.info('All players joined');
this.startGame(this.seed);
}
addPlayer(player: PlayerInterface) {
if (this.numPlayers >= this.maxPlayers) {
throw new Error("GameSession is full");
}
this.players.push(player);
this.logger.info(`${player.name} joined the game!`);
}
toString() {
return `GameSession:(${this.id} ${this.name})`;
}
getState(): GameSessionState {
return {
id: this.id,
name: this.name!,
creator: this.creator.id,
players: this.players.map(player =>( {
id: player.id,
name: player.name,
})),
sessionInProgress: this.sessionInProgress,
maxPlayers: this.maxPlayers,
numPlayers: this.numPlayers,
waitingForPlayers: this.waitingForPlayers,
waitingSeconds: this.waitingSeconds,
seed: this.seed,
mode: this.mode,
pointsToWin: this.pointsToWin,
status: this.sessionInProgress ? 'in progress' : 'waiting'
};
}
}

250
src/game/MatchSession.ts Normal file
View File

@ -0,0 +1,250 @@
import { DominoesGame } from "./DominoesGame";
import { PlayerAI } from "./entities/player/PlayerAI";
import { PlayerInterface } from "./entities/player/PlayerInterface";
import { LoggingService } from "../common/LoggingService";
import { getRandomSeed, uuid, wait } from "../common/utilities";
import { MatchSessionState } from "./dto/MatchSessionState";
import { PlayerNotificationManager } from './PlayerNotificationManager';
import seedrandom, { PRNG } from "seedrandom";
import { NetworkPlayer } from "./entities/player/NetworkPlayer";
import { PlayerHuman } from "./entities/player/PlayerHuman";
export class MatchSession {
private currentGame: DominoesGame | null = null;
private minHumanPlayers: number = 1;
private waitingForPlayers: boolean = true;
private waitingSeconds: number = 0;
private logger: LoggingService = new LoggingService();
private playerNotificationManager = new PlayerNotificationManager();
id: string;
matchInProgress: boolean = false;
matchWinner: PlayerInterface | null = null;
maxPlayers: number = 4;
mode: string = 'classic';
players: PlayerInterface[] = [];
pointsToWin: number = 50;
rng!: PRNG
scoreboard: Map<string, number> = new Map();
seed!: string
sessionInProgress: boolean = false;
state: string = 'created'
constructor(public creator: PlayerInterface, public name?: string) {
this.id = uuid();
this.name = name || `Game ${this.id}`;
this.addPlayer(creator);
this.creator = creator;
this.logger.info(`Match session created by: ${creator.name}`);
this.logger.info(`Match session ID: ${this.id}`);
this.logger.info(`Match session name: ${this.name}`);
this.logger.info(`Points to win: ${this.pointsToWin}`);
this.sessionInProgress = true;
this.matchInProgress = false;
this.playerNotificationManager.notifyMatchState(this);
this.playerNotificationManager.notifyPlayersState(this.players);
}
get numPlayers() {
return this.players.length;
}
get numPlayersReady() {
return this.players.filter(player => player.ready).length;
}
get numHumanPlayers() {
return this.players.filter(player => player instanceof PlayerHuman).length;
}
private async startMatch(seed: string) {
this.rng = seedrandom(seed);
const missingPlayers = this.maxPlayers - this.numPlayers;
for (let i = 0; i < missingPlayers; i++) {
this.addPlayer(this.createPlayerAI(i));
}
this.state = 'ready'
this.resetScoreboard()
let gameNumber: number = 0;
this.matchInProgress = true
this.playerNotificationManager.notifyMatchState(this);
while (this.matchInProgress) {
this.currentGame = new DominoesGame(this.players, this.rng);
gameNumber += 1;
this.state = 'started'
this.logger.info(`Game #${gameNumber} started`);
// this.game.reset()
await this.currentGame.start();
this.setScores();
this.checkMatchWinner();
this.resetReadiness();
this.state = 'waiting'
await this.playerNotificationManager.notifyMatchState(this);
this.playerNotificationManager.sendEventToPlayers('game-finished', this.players);
await this.checkHumanPlayersReady();
}
this.state = 'end'
// await this.game.start();
return this.endGame();
}
async checkHumanPlayersReady() {
this.logger.info('Waiting for human players to be ready');
return new Promise((resolve) => {
const interval = setInterval(() => {
this.logger.debug(`Human players ready: ${this.numPlayersReady}/${this.numHumanPlayers}`)
if (this.numPlayersReady === this.numHumanPlayers) {
clearInterval(interval);
resolve(true);
}
}, 1000);
});
}
resetReadiness() {
this.players.forEach(player => {
player.ready = false
});
}
checkMatchWinner() {
const scores = Array.from(this.scoreboard.values());
const maxScore = Math.max(...scores);
if (maxScore >= this.pointsToWin) {
this.matchWinner = this.players.find(player => this.scoreboard.get(player.id) === maxScore)!;
this.logger.info(`Match winner: ${this.matchWinner.name} with ${maxScore} points`);
this.matchInProgress = false;
}
}
resetScoreboard() {
this.scoreboard = new Map();
this.players.forEach(player => {
this.scoreboard.set(player.id, 0);
});
}
setScores() {
const totalPips = this.currentGame?.players.reduce((acc, player) => acc + player.pipsCount(), 0);
if (this.currentGame && this.currentGame.winner !== null) {
const winner = this.currentGame.winner;
this.scoreboard.set(winner.id, this.scoreboard.get(winner.id)! + totalPips!);
if (winner.teamedWith !== null) {
this.scoreboard.set(winner.teamedWith.id, this.scoreboard.get(winner.teamedWith.id)! + totalPips!);
}
}
}
private endGame(): any {
if (this.currentGame !== null) {
const { gameBlocked, gameTied, winner } = this.currentGame;
gameBlocked ? this.logger.info('Game blocked!') : gameTied ? this.logger.info('Game tied!') : this.logger.info('Game over!');
this.logger.info('Winner: ' + winner?.name + ' with ' + winner?.pipsCount() + ' points');
this.getScore(this.currentGame);
this.logger.info('Game ended');
this.currentGame = null;
this.playerNotificationManager.notifyMatchState(this);
return {
gameBlocked,
gameTied,
winner
};
}
}
private getScore(game: DominoesGame) {
const pips = game.players
.sort((a,b) => (b.pipsCount() - a.pipsCount()))
.map(player => {
return `${player.name}: ${player.pipsCount()}`;
});
this.logger.info(`Pips count: ${pips.join(', ')}`);
const totalPoints = game.players.reduce((acc, player) => acc + player.pipsCount(), 0);
if (game.winner !== null) {
game.winner.score += totalPoints;
}
const scores = game.players
.sort((a,b) => (b.score - a.score))
.map(player => {
return `${player.name}: ${player.score}`;
});
this.logger.info(`Scores: ${scores.join(', ')}`);
}
createPlayerAI(i: number) {
const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"];
const player = new PlayerAI(AInames[i], this.rng);
player.ready = true;
return player;
}
async start(seed?: string) {
this.seed = seed || getRandomSeed();
console.log('seed :>> ', this.seed);
if (this.matchInProgress) {
throw new Error("Game already in progress");
}
this.waitingForPlayers = true;
this.logger.info('Waiting for players to be ready');
while (this.numPlayers < this.maxPlayers) {
this.waitingSeconds += 1;
this.logger.info(`Waiting for players to join: ${this.waitingSeconds}`);
await wait(1000);
}
this.waitingForPlayers = false;
this.logger.info('All players joined');
await this.startMatch(this.seed);
}
addPlayer(player: PlayerInterface) {
if (this.numPlayers >= this.maxPlayers) {
throw new Error("GameSession is full");
}
this.players.push(player);
this.logger.info(`${player.name} joined the game!`);
}
setPlayerReady(user: string) {
const player = this.players.find(player => player.name === user);
if (!player) {
throw new Error("Player not found");
}
player.ready = true;
this.logger.info(`${player.name} is ready!`);
this.playerNotificationManager.notifyMatchState(this);
}
toString() {
return `GameSession:(${this.id} ${this.name})`;
}
getState(): MatchSessionState {
return {
id: this.id,
name: this.name!,
creator: this.creator.id,
players: this.players.map(player =>( {
id: player.id,
name: player.name,
ready: player.ready,
})),
playersReady: this.numPlayersReady,
sessionInProgress: this.sessionInProgress,
maxPlayers: this.maxPlayers,
numPlayers: this.numPlayers,
waitingForPlayers: this.waitingForPlayers,
waitingSeconds: this.waitingSeconds,
seed: this.seed,
mode: this.mode,
pointsToWin: this.pointsToWin,
status: this.sessionInProgress ? 'in progress' : 'waiting',
scoreboard: this.scoreboard,
matchWinner: this.matchWinner?.getState() || null,
matchInProgress: this.matchInProgress
};
}
}

View File

@ -17,7 +17,7 @@ export class NetworkClientNotifier {
this.io = io; this.io = io;
} }
async notifyPlayer(player: NetworkPlayer, event: string, data: any = {}, timeoutSecs: number = 300): Promise<any> { async notifyPlayer(player: NetworkPlayer, event: string, data: any = {}, timeoutSecs: number = 900): Promise<any> {
try { try {
const response = await this.io.to(player.socketId) const response = await this.io.to(player.socketId)
.timeout(timeoutSecs * 1000) .timeout(timeoutSecs * 1000)
@ -29,6 +29,10 @@ export class NetworkClientNotifier {
} }
} }
async sendEvent(player: NetworkPlayer, event: string, data?: any) {
this.io.to(player.socketId).emit(event, data);
}
async broadcast(event: string, data: any) { async broadcast(event: string, data: any) {
const responses = await this.io.emit(event, data); const responses = await this.io.emit(event, data);
this.logger.debug('responses :>> ', responses); this.logger.debug('responses :>> ', responses);

View File

@ -1,39 +1,36 @@
import { DominoesGame } from "./DominoesGame"; import { DominoesGame } from "./DominoesGame";
import { GameSession } from "./GameSession"; import { MatchSession } from "./MatchSession";
import { GameState } from "./dto/GameState"; import { GameState } from "./dto/GameState";
import { PlayerInterface } from "./entities/player/PlayerInterface";
export class PlayerNotificationManager { export class PlayerNotificationManager {
game!: DominoesGame;
session!: GameSession;
constructor(game: DominoesGame | GameSession) { async notifyGameState(game: DominoesGame) {
if (game instanceof GameSession) { const gameState: GameState = game.getGameState();
this.session = game; const { players } = game;
} else {
this.game = game;
}
}
async notifyGameState() {
if(!this.game) throw new Error('Game not initialized');
const gameState: GameState = this.game.getGameState();
const { players } = this.game;
let promises: Promise<void>[] = players.map(player => player.notifyGameState(gameState)); let promises: Promise<void>[] = players.map(player => player.notifyGameState(gameState));
return await Promise.all(promises); return await Promise.all(promises);
} }
async notifyPlayersState() { async notifyPlayersState(players: PlayerInterface[]) {
if(!this.game) throw new Error('Game not initialized');
const { players } = this.game;
let promises: Promise<void>[] = players.map(player => player.notifyPlayerState(player.getState())); let promises: Promise<void>[] = players.map(player => player.notifyPlayerState(player.getState()));
return await Promise.all(promises); return await Promise.all(promises);
} }
async notifySessionState() { async notifyMatchState(session: MatchSession) {
if(!this.session) throw new Error('Session not initialized'); const { players } = session;
const { players } = this.session; let promises: Promise<void>[] = players.map(player => player.notifyMatchState(session.getState()));
let promises: Promise<void>[] = players.map(player => player.notifySessionState(this.session.getState())); return await Promise.all(promises);
}
async waitForPlayersAction(actionId: string, data: any = {}, players: PlayerInterface[]) {
let promises: Promise<boolean>[] = players.map(player => player.waitForAction(actionId, data));
return await Promise.all(promises);
}
async sendEventToPlayers(event: string, players: PlayerInterface[]) {
let promises: Promise<void>[] = players.map(player => player.sendEvent(event));
return await Promise.all(promises); return await Promise.all(promises);
} }
} }

View File

@ -1,6 +1,6 @@
import { PlayerDto } from "./PlayerDto"; import { PlayerDto } from "./PlayerDto";
export interface GameSessionState { export interface MatchSessionState {
id: string; id: string;
name: string; name: string;
creator: string; creator: string;
@ -14,4 +14,8 @@ export interface GameSessionState {
maxPlayers: number; maxPlayers: number;
numPlayers: number; numPlayers: number;
waitingSeconds: number; waitingSeconds: number;
scoreboard: Map<string, number>;
matchWinner: PlayerDto | null;
matchInProgress: boolean;
playersReady: number
} }

View File

@ -2,5 +2,7 @@ export interface PlayerDto {
id: string; id: string;
name: string; name: string;
score?: number; score?: number;
hand?: string[]; hand?: any[];
teamedWith?: PlayerDto | null;
ready: boolean;
} }

View File

@ -1,7 +0,0 @@
export interface PlayerState {
id: string;
name: string;
score: number;
hand: any[];
teamedWith: string | undefined;
}

View File

@ -38,6 +38,11 @@ export class Board {
return this.rightEnd?.flippedPips[1]; return this.rightEnd?.flippedPips[1];
} }
reset() {
this.tiles = [];
this.boneyard = [];
}
getFreeEnds() { getFreeEnds() {
if(this.count === 0) { if(this.count === 0) {
return []; return [];

View File

@ -7,8 +7,8 @@ import { EventEmitter } from "stream";
import { PlayerInteractionInterface } from "../../PlayerInteractionInterface"; import { PlayerInteractionInterface } from "../../PlayerInteractionInterface";
import { uuid } from "../../../common/utilities"; import { uuid } from "../../../common/utilities";
import { GameState } from "../../dto/GameState"; import { GameState } from "../../dto/GameState";
import { PlayerState } from "../../dto/PlayerState"; import { MatchSessionState } from "../../dto/MatchSessionState";
import { GameSessionState } from "../../dto/GameSessionState"; import { PlayerDto } from "../../dto/PlayerDto";
export abstract class AbstractPlayer extends EventEmitter implements PlayerInterface { export abstract class AbstractPlayer extends EventEmitter implements PlayerInterface {
hand: Tile[] = []; hand: Tile[] = [];
@ -17,6 +17,7 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter
teamedWith: PlayerInterface | null = null; teamedWith: PlayerInterface | null = null;
playerInteraction: PlayerInteractionInterface = undefined as any; playerInteraction: PlayerInteractionInterface = undefined as any;
id: string = uuid(); id: string = uuid();
ready: boolean = false;
constructor(public name: string) { constructor(public name: string) {
super(); super();
@ -29,10 +30,17 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter
async notifyGameState(state: GameState): Promise<void> { async notifyGameState(state: GameState): Promise<void> {
} }
async notifyPlayerState(state: PlayerState): Promise<void> { async notifyPlayerState(state: PlayerDto): Promise<void> {
} }
async notifySessionState(state: GameSessionState): Promise<void> { async notifyMatchState(state: MatchSessionState): Promise<void> {
}
async waitForAction(actionId: string): Promise<boolean> {
return true;
}
async sendEvent(event: string): Promise<void> {
} }
pipsCount(): number { pipsCount(): number {
@ -54,17 +62,24 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter
return highestPair; return highestPair;
} }
getState(): PlayerState { getState(showPips: boolean = false): PlayerDto {
return { return {
id: this.id, id: this.id,
name: this.name, name: this.name,
score: this.score, score: this.score,
hand: this.hand.map(tile => ({ hand: this.hand.map(tile => {
id: tile.id, const d = {
pips: tile.pips, id: tile.id,
flipped: tile.revealed, pips: tile.pips,
})), flipped: tile.revealed,
teamedWith: this.teamedWith?.id, };
if (showPips) {
d.pips = tile.pips;
}
return d;
}),
teamedWith: this.teamedWith?.getState() ?? null,
ready: this.ready,
}; };
} }
} }

View File

@ -5,8 +5,8 @@ import { NetworkClientNotifier } from "../../NetworkClientNotifier";
import { Tile } from "../Tile"; import { Tile } from "../Tile";
import { Board } from "../Board"; import { Board } from "../Board";
import { GameState } from "../../dto/GameState"; import { GameState } from "../../dto/GameState";
import { PlayerState } from "../../dto/PlayerState"; import { PlayerDto } from "../../dto/PlayerDto";
import { GameSessionState } from "../../dto/GameSessionState"; import { MatchSessionState } from "../../dto/MatchSessionState";
import { SocketDisconnectedError } from "../../../common/exceptions/SocketDisconnectedError"; import { SocketDisconnectedError } from "../../../common/exceptions/SocketDisconnectedError";
export class NetworkPlayer extends PlayerHuman { export class NetworkPlayer extends PlayerHuman {
@ -27,7 +27,7 @@ export class NetworkPlayer extends PlayerHuman {
} }
} }
async notifyPlayerState(state: PlayerState): Promise<void> { async notifyPlayerState(state: PlayerDto): Promise<void> {
const response = await this.clientNotifier.notifyPlayer(this, 'playerState', state); const response = await this.clientNotifier.notifyPlayer(this, 'playerState', state);
console.log('player state notified :>> ', response); console.log('player state notified :>> ', response);
if (response === undefined || response.status !== 'ok' ) { if (response === undefined || response.status !== 'ok' ) {
@ -35,14 +35,25 @@ export class NetworkPlayer extends PlayerHuman {
} }
} }
async notifySessionState(state: GameSessionState): Promise<void> { async notifyMatchState(state: MatchSessionState): Promise<void> {
const response = await this.clientNotifier.notifyPlayer(this, 'sessionState', state); const response = await this.clientNotifier.notifyPlayer(this, 'matchState', state);
console.log('session state notified :>> ', response); console.log('session state notified :>> ', response);
if (response === undefined || response.status !== 'ok' ) { if (response === undefined || response.status !== 'ok' ) {
throw new SocketDisconnectedError(); throw new SocketDisconnectedError();
} }
} }
async waitForAction(actionId: string): Promise<boolean> {
const response = await this.clientNotifier.notifyPlayer(this, actionId);
if (response === undefined || response.status !== 'ok' ) {
throw new SocketDisconnectedError();
}
const { actionResult } = response;
return actionResult;
}
async sendEvent(event: string): Promise<void> {
this.clientNotifier.sendEvent(this, event);
}
async chooseTile(board: Board): Promise<Tile> { async chooseTile(board: Board): Promise<Tile> {
return await this.playerInteraction.chooseTile(board); return await this.playerInteraction.chooseTile(board);

View File

@ -2,9 +2,9 @@ import { PlayerInteractionInterface } from "../../PlayerInteractionInterface";
import { Board } from "../Board"; import { Board } from "../Board";
import { GameState } from "../../dto/GameState"; import { GameState } from "../../dto/GameState";
import { PlayerMove } from "../PlayerMove"; import { PlayerMove } from "../PlayerMove";
import { PlayerState } from "../../dto/PlayerState";
import { Tile } from "../Tile"; import { Tile } from "../Tile";
import { GameSessionState } from "../../dto/GameSessionState"; import { MatchSessionState } from "../../dto/MatchSessionState";
import { PlayerDto } from "../../dto/PlayerDto";
export interface PlayerInterface { export interface PlayerInterface {
id: string; id: string;
@ -13,12 +13,15 @@ export interface PlayerInterface {
hand: Tile[]; hand: Tile[];
teamedWith: PlayerInterface | null; teamedWith: PlayerInterface | null;
playerInteraction: PlayerInteractionInterface; playerInteraction: PlayerInteractionInterface;
ready: boolean;
makeMove(gameState: Board): Promise<PlayerMove | null>; makeMove(gameState: Board): Promise<PlayerMove | null>;
chooseTile(board: Board): Promise<Tile>; chooseTile(board: Board): Promise<Tile>;
pipsCount(): number; pipsCount(): number;
notifyGameState(state: GameState): Promise<void>; notifyGameState(state: GameState): Promise<void>;
notifyPlayerState(state: PlayerState): Promise<void>; notifyPlayerState(state: PlayerDto): Promise<void>;
notifySessionState(state: GameSessionState): Promise<void>; notifyMatchState(state: MatchSessionState): Promise<void>;
getState(): PlayerState; waitForAction(actionId: string, data: any): Promise<boolean>;
sendEvent(event: string): Promise<void>;
getState(): PlayerDto;
} }

View File

@ -0,0 +1,90 @@
import { Request, Response } from "express";
import { ApiTokenMongoManager } from "../db/mongo/ApiTokenMongoManager";
import { SecurityManager } from "../managers/SecurityManager";
import { BaseController } from "./BaseController";
import { Token } from "../db/interfaces";
import toObjectId from "../db/mongo/common/mongoUtils";
export class ApiKeyController extends BaseController{
apiTokenManager = new ApiTokenMongoManager();
security = new SecurityManager();
async deleteApiKey(req: Request, res: Response) {
try {
const { id } = req.params;
await this.apiTokenManager.deleteToken(id);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async createApiKey(req: Request, res: Response) {
try {
const token: Token = this._createTokenObject(req);
await this.apiTokenManager.addToken(token);
res.status(201).end();
} catch (error) {
this.handleError(res, error);
}
}
async listUserApiKeys(req: Request, res: Response) {
try {
const { user } = req;
const response = await this.apiTokenManager.getTokens(user._id);
res.json(response).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async listNamespaceApiKeys(req: Request, res: Response) {
try {
const { namespaceId } = req.user;
const response = await this.apiTokenManager.getTokensByNamespace(namespaceId);
res.json(response).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async deleteNamespaceApiKey(req: Request, res: Response) {
try {
const { tokenId: id } = req.params;
await this.apiTokenManager.deleteToken(id);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async createNamespaceApiKey(req: Request, res: Response) {
try {
const token = this._createTokenObject(req);
await this.apiTokenManager.addToken(token);
res.status(201).end();
} catch (error) {
this.handleError(res, error);
}
}
_createTokenObject(req: Request): Token {
const { user, body } = req;
const { roles = [ 'client' ], type = 'user', description = '' } = body;
const { _id: userId, namespaceId } = user;
const token = this.security.generateApiToken();
const newToken: Token = {
token,
description,
roles,
type
};
if (type === 'namespace') {
newToken.namespaceId = toObjectId(namespaceId);
} else if (type === 'user') {
newToken.userId = toObjectId(userId);
}
return newToken;
}
}

View File

@ -0,0 +1,213 @@
import bcrypt from 'bcryptjs';
import { UsersMongoManager } from "../db/mongo/UsersMongoManager";
import { SecurityManager } from '../managers/SecurityManager';
import { ApiTokenMongoManager } from '../db/mongo/ApiTokenMongoManager';
import { TemporalTokenMongoManager } from '../db/mongo/TemporalTokenMongoManager';
import { BaseController } from './BaseController';
import { NextFunction, Request, Response } from 'express';
import { AuthenticationOption, Token, User } from '../db/interfaces';
export class AuthController extends BaseController {
security = new SecurityManager();
usersManager = new UsersMongoManager();
temporalTokenManager = new TemporalTokenMongoManager();
async login(req: Request, res: Response): Promise<void> {
const { log } = req;
try {
let token = null
const { username, password } = req.body;
this.logger.debug('login', username, password);
const { valid: isValidPassword, user } = await this._checkPassword(username, password);
this.logger.debug('isValidPassword', isValidPassword);
if (!isValidPassword) {
res.status(401).json({ error: 'Unauthorized' }).end();
log.error('Unauthorized login attempt for user: ', username);
return;
}
this._jwtSignUser(user, res)
} catch (error) {
this.handleError(res, error);
}
}
_jwtSignUser(user: User | null, res: Response) {
if (user === null) {
res.status(401).json({ error: 'Unauthorized' }).end();
return;
}
delete user.hash;
const token = this.security.signJwt(user);
if (token === null) {
res.status(401).json({ error: 'Unauthorized' }).end();
} else {
res.status(200).json({ token }).end();
}
return;
}
async twoFactorCodeAuthentication(req: Request, res: Response) {
const { code, username } = req.body;
const { valid: isValid, user } = await this._isValidTemporalCode(username, code);
if (!isValid) {
res.status(406).json({ error: 'Unauthorized' }).end();
return;
}
res.status(200).end();
}
async _isValidTemporalCode(username: string, code: string) {
const user = await this.usersManager.getByUsername(username);
if (user === null || user._id === undefined) {
return { valid: false, user: null };
}
const temporalToken = await this.temporalTokenManager.getByUserAndType(user._id.toString(), TemporalTokenMongoManager.Types.PASSWORD_RECOVERY);
if (temporalToken === null) {
return { valid: false, user: null };
}
const { token } = temporalToken;
const valid = bcrypt.compareSync(code, token);
return { valid, user: valid ? user : null};
}
async changePasswordWithCode(req: Request, res: Response) {
try {
const { username, newPassword, code } = req.body;
const { valid: isValid, user } = await this._isValidTemporalCode(username, code);
if (isValid) {
await this._setNewPassword(username, newPassword);
this._jwtSignUser(user, res);
} else {
res.status(400).json({ error: 'Code not valid.' }).end();
}
} catch (error) {
this.handleError(res, error);
}
}
async changePassword(req: Request, res: Response) {
try {
const { username, oldPassword, newPassword } = req.body;
const { valid: isValidPassword } = await this._checkPassword(username, oldPassword);
if (isValidPassword) {
await this._setNewPassword(username, newPassword);
res.status(200).end();
}
res.status(400).json({ error: 'Password not valid.' }).end();
} catch (error) {
this.handleError(res, error);
}
}
async _setNewPassword(username: string, newPassword: string) {
const hash = this.security.getHashedPassword(newPassword);
await this.usersManager.updatePassword(username, hash);
}
async _checkPassword(username: string, password: string) {
let valid = false;
const user = await this.usersManager.getByUsername(username);
if (user && user.hash) {
const { hash } = user;
valid = bcrypt.compareSync(password, hash);
}
return { valid, user };
}
static async checkRolesToken(token: Token, rolesToCheck: string[]) {
if (rolesToCheck.length === 0) {
return true;
}
if (!token._id) {
return false;
}
const tokenFromDb = await new ApiTokenMongoManager().getById(token._id.toString());
if (!tokenFromDb) {
return false;
}
const { roles } = tokenFromDb;
const validRoles = rolesToCheck.filter((r: string) => roles.includes(r));
return validRoles.length === rolesToCheck.length;
}
static async checkRoles(user: User, rolesToCheck: string[]) {
if (rolesToCheck.length === 0) {
return true;
}
if (!user._id) {
return false;
}
const usersManager = new UsersMongoManager();
const userFromDb = await usersManager.getById(user._id.toString());
if (!userFromDb) {
return false;
}
const { roles } = userFromDb;
const validRoles = rolesToCheck.filter((r: string) => roles.includes(r));
return validRoles.length === rolesToCheck.length;
}
static authenticate(options: AuthenticationOption = {}) {
return async function(req: Request, res: Response, next: NextFunction) {
const security = new SecurityManager();
const token = req.headers.authorization;
const { roles = [] } = options;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const user: User = await security.verifyJwt(token);
const validRoles = await AuthController.checkRoles(user, roles);
if (!validRoles) {
return res.status(403).json({ error: 'Forbidden' });
}
req.user = user;
next();
} catch (error) {
return res.status(403).json({ error: 'Forbidden' });
}
}
}
static tokenAuthenticate(options: AuthenticationOption = {}) {
return async function(req: Request, res: Response, next: NextFunction) {
const { log } = req;
// log.info('tokenAuthenticate')
try {
const token: string = req.headers['x-api-key'] as string;
const dm = new ApiTokenMongoManager();
const apiToken = await dm.getByToken(token);
const { roles = [] } = options;
const valid = !!apiToken && await AuthController.checkRolesToken(apiToken, roles);
if (!valid) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.token = apiToken;
next();
} catch (error) {
return res.status(403).json({ error: 'Forbidden' });
}
}
}
static async withUser(req: Request, res: Response, next: NextFunction) {
try {
const token = req.token;
const dm = new UsersMongoManager();
const user = await dm.getById(token.userId);
req.user = user;
next();
} catch (error) {
return res.status(403).json({ error: 'Forbidden' });
}
}
}

View File

@ -0,0 +1,11 @@
import { Response } from "express";
import { LoggingService } from "../../common/LoggingService";
export class BaseController {
logger = new LoggingService().logger;
handleError(res: Response, error: any, data = {}) {
this.logger.error(error);
res.status(500).json({ error: error.message, ...data }).end();
}
}

View File

@ -0,0 +1,19 @@
import { Request, Response } from "express";
import { BaseController } from "./BaseController";
export class MatchSessionController extends BaseController {
// async createMatchSession(req: Request, res: Response): Promise<any> {
// const response = await this.sessionManager.createSession(data, socketId);
// return response;
// }
// async startMatchSession(data: any): Promise<any> {
// const response = await this.sessionManager.startSession(data);
// return response;
// }
// async joinMatchSession(data: any, socketId: string): Promise<any> {
// const response = await this.sessionManager.joinSession(data, socketId);
// return response;
// }
}

View File

@ -0,0 +1,62 @@
import { NamespacesService } from "../services/NamespacesService";
import { BaseController } from "./BaseController";
import { Request, Response } from "express";
export class NamespacesController extends BaseController{
private namespacesService: NamespacesService;
constructor() {
super();
this.namespacesService = new NamespacesService();
}
async getNamespaces(req: Request, res: Response) {
try {
const namespaces = await this.namespacesService.getNamespaces();
res.json(namespaces).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async getNamespace(req: Request, res: Response) {
try {
const { id } = req.params;
const namespace = await this.namespacesService.getNamespace(id);
res.json(namespace).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async createNamespace(req: Request, res: Response) {
try {
const namespace = req.body;
const user = req.user;
await this.namespacesService.createNamespace(namespace, user);
res.status(201).end();
} catch (error) {
this.handleError(res, error);
}
}
async updateNamespace(req: Request, res: Response) {
try {
const { body: namespace, params} = req;
const { id } = params;
const result = await this.namespacesService.updateNamespace(id, namespace);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async deleteNamespace(req: Request, res: Response) {
try {
const { id } = req.params;
await this.namespacesService.deleteNamespace(id);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
}

View File

@ -0,0 +1,152 @@
import { validationResult } from 'express-validator';
import { SecurityManager } from '../managers/SecurityManager';
import { CryptoService } from '../services/CryptoService';
import { TemporalTokenMongoManager } from '../db/mongo/TemporalTokenMongoManager';
import { BaseController } from './BaseController';
import { MailerService } from '../services/mailer/MailerService';
import { NamespacesService } from '../services/NamespacesService';
import { UsersService } from '../services/UsersService';
import { Request, Response } from 'express';
export class UserController extends BaseController {
security = new SecurityManager();
temporalTokenManager = new TemporalTokenMongoManager();
usersService = new UsersService();
mailService = new MailerService();
cryptoService = new CryptoService();
namespacesService = new NamespacesService();
constructor() {
super();
}
async getNamespaces(req: Request, res: Response) {
return await this.namespacesService.getNamespaces();
}
async getUser(req: Request, res: Response) {
try {
const { id } = req.params;
const user = await this.usersService.getById(id);
res.json(user).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async createUser(req: Request, res: Response) {
const user = req.body;
try {
const validation = validationResult(req);
if (!validation.isEmpty()) {
res.status(400).json({ errors: validation.array() });
return;
}
await this.usersService.createUser(user);
res.status(201).end();
} catch (error) {
this.handleError(res, error);
}
}
async updateUserNamespace(req: Request, res: Response) {
try {
const { userId, namespaceId } = req.params;
return await this.usersService.updateUserNamespace(userId, namespaceId);
} catch (error) {
this.handleError(res, error);
}
}
async resetUserNamespace(req: Request, res: Response) {
try {
const { userId } = req.params;
const defaultNS = await this.namespacesService.getDefaultNamespace();
if (!defaultNS._id) {
throw new Error('Default namespace not found');
}
return await this.usersService.updateUserNamespace(userId, defaultNS._id.toString());
} catch (error) {
this.handleError(res, error);
}
}
async updateUser(req: Request, res: Response) {
const user = req.body;
try {
const validation = validationResult(req);
if (!validation.isEmpty()) {
res.status(400).json({ errors: validation.array() });
return;
}
const { id } = req.params;
await this.usersService.updateUser(user, id);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async deleteUser(req: Request, res: Response) {
try {
const { id } = req.params;
await this.usersService.deleteUser(id);
res.status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async listUsers(req: Request, res: Response): Promise<void> {
try {
const { available = false } = req.query;
let users = [];
if (available) {
const defaultNamespace = await this.namespacesService.getDefaultNamespace();
users = await this.usersService.listUsers({ not: defaultNamespace._id });
} else {
users = await this.usersService.listUsers();
}
res.json(users).status(200).end();
} catch (error) {
this.handleError(res, error);
}
}
async passwordRecovery(req: Request, res: Response) {
try {
const { username } = req.body;
const user = await this.usersService.getByUsername(username);
if (user === null) {
res.status(404).json({ message: 'User not found', code: 'user-not-found'}).end();
return;
}
if (!user.email) {
res.status(404).json({ message: 'Email not found', code: 'email-not-found'}).end();
return;
}
const { email, firstname, lastname, _id: userId } = user;
const pin = this.cryptoService.generateRandomPin(8);
const token = this.security.getHashedPassword(pin);
const temporalToken = {
userId,
token,
createdAt: new Date().getTime(),
validUntil: new Date().getTime() + 1000 * 60 * 60 * 1,
type: TemporalTokenMongoManager.Types.PASSWORD_RECOVERY,
}
if (!userId) {
throw new Error('User not found');
}
this.temporalTokenManager.deleteAllByUserAndType(userId.toString(), TemporalTokenMongoManager.Types.PASSWORD_RECOVERY);
this.temporalTokenManager.addToken(temporalToken);
await this.mailService.sendRecoveryPasswordEmail(firstname, lastname, email, pin);
res.status(200).end();
} catch (error: any) {
this.handleError(res, error, { code: 'critical', message: error.message });
}
}
}

View File

@ -0,0 +1,20 @@
import { MatchSession } from "../../game/MatchSession";
import { DbMatchSession } from "./interfaces";
export function matchSessionAdapter(session: MatchSession) : DbMatchSession {
return {
id: session.id,
name: session.name || '',
creator: session.creator.id,
players: session.players.map(player => player.id),
seed: session.seed,
mode: session.mode,
pointsToWin: session.pointsToWin,
maxPlayers: session.maxPlayers,
numPlayers: session.numPlayers,
scoreboard: Array.from(session.scoreboard.entries()).map(([player, score]) => ({ player, score })),
matchWinner: session.matchWinner ? session.matchWinner.id : null,
state: session.state
}
}

View File

@ -0,0 +1,98 @@
import { ObjectId } from "mongodb";
export interface Entity {
createdAt?: number | null;
modifiedAt?: number | null;
createdBy?: ObjectId | null;
modifiedBy?: ObjectId | null;
}
export interface EntityMongo extends Entity {
_id?: ObjectId;
}
export interface Score {
player: string;
score: number;
}
export interface Namespace extends EntityMongo {
name?: string;
description?: string;
default: boolean;
type: string | null;
ownerId?: ObjectId;
users?: any[];
}
export interface User extends EntityMongo {
id: string,
username: string;
namespaceId: ObjectId;
hash?: string;
roles: string[];
firstname?: string;
lastname?: string;
email?: string;
profileId?: string;
password?: string | null;
namespace?: Namespace;
}
export interface DbMatchSession extends EntityMongo {
id: string;
name: string;
creator: string;
players: string[];
seed: string;
mode: string;
pointsToWin: number;
maxPlayers: number;
numPlayers: number;
scoreboard: Score[];
matchWinner: string | null;
state: string;
}
export interface DbUser extends EntityMongo {
id: string,
username: string;
namespaceId: ObjectId;
hash?: string;
roles: string[];
firstname?: string;
lastname?: string;
email?: string;
profileId?: string;
password?: string | null;
namespace?: DbNamespace;
}
export interface DbNamespace extends EntityMongo {
name?: string;
description?: string;
default: boolean;
type: string | null;
ownerId?: ObjectId;
}
export interface Token extends EntityMongo {
token: string;
userId?: ObjectId;
roles?: string[];
expiresAt?: number | null;
type: string;
namespaceId?: ObjectId
description?: string;
}
export interface AuthenticationOption {
roles?: string[];
}
export interface Role {
name: string;
description: string;
permissions: string[];
}

View File

@ -0,0 +1,39 @@
import { mongoExecute } from './common/mongoDBPool';
import { BaseMongoManager } from './common/BaseMongoManager';
import { Token } from '../interfaces';
export class ApiTokenMongoManager extends BaseMongoManager{
collection = 'tokens';
async addToken(token: Token) {
return await mongoExecute(async ({ collection }) => {
await collection?.insertOne(token);
return token;
}, { colName: this.collection });
}
async getTokens(userId: string): Promise<Token[]> {
return await mongoExecute(async ({ collection }) => {
return await collection?.find({ userId: this.toObjectId(userId) }).toArray();
}, { colName: this.collection });
}
async getTokensByNamespace(namespaceId: string): Promise<Token[]> {
return await mongoExecute(async ({ collection }) => {
return await collection?.find({ namespaceId: this.toObjectId(namespaceId) }).toArray();
}, { colName: this.collection });
}
async getByToken(token: string): Promise<Token | null>{
return await mongoExecute(async ({ collection }) => {
return await collection?.findOne({ token });
}, { colName: this.collection });
}
async deleteToken(tokenId: string): Promise<number> {
return await mongoExecute(async ({ collection }) => {
const res = await collection?.deleteOne({ _id: this.toObjectId(tokenId) });
return res?.deletedCount || 0
}, { colName: this.collection });
}
}

View File

@ -0,0 +1,5 @@
import { BaseMongoManager } from './common/BaseMongoManager';
export class MatchSessionMongoManager extends BaseMongoManager {
protected collection = "matchSessions";
}

View File

@ -0,0 +1,77 @@
import { PipelineLibrary } from './common/PipelineLibrary';
import { mongoExecute } from './common/mongoDBPool';
import { BaseMongoManager } from './common/BaseMongoManager';
import { Namespace } from '../interfaces.js';
import { Document, ObjectId } from 'mongodb';
export class NamespacesMongoManager extends BaseMongoManager{
collection = 'namespaces';
constructor() {
super();
}
async createNamespace(namespace: Namespace): Promise<string> {
return await mongoExecute(async ({collection}) => {
const now = new Date().getTime();
delete namespace.users;
const result = await collection?.insertOne({
...namespace,
createdAt: now,
modifiedAt: now
});
return result?.insertedId.toString() || '';
}, {colName: this.collection})
}
async updateNamespace(id: string, namespace: Namespace): Promise<number> {
return await mongoExecute(async ({collection}) => {
const now = new Date().getTime();
const { name, description, type } = namespace;
const result = await collection?.updateOne({
_id: this.toObjectId(id),
}, {
$set: {
name,
description,
type,
modifiedAt: now
}
});
return result?.modifiedCount || 0;
}, {colName: this.collection})
}
async getNamespace(namespaceId: string): Promise<Document | null> {
const pipeline = PipelineLibrary.namespacesGetById(this.toObjectId((namespaceId)));
return await mongoExecute(async ({collection}) => {
const cursor: Document[] | undefined = await collection?.aggregate(pipeline).toArray();
if (cursor === undefined ||cursor.length === 0) {
return null;
}
return cursor[0];
}, {colName: this.collection})
}
async getDefaultNamespace(): Promise<Namespace> {
return await mongoExecute(async ({collection}) => {
return await collection?.findOne({
default: true
});
}, {colName: this.collection});
}
async getNamespaces(): Promise<Namespace[]> {
const pipeline = PipelineLibrary.namespacesGetNamespaces();
return await mongoExecute(async ({collection}) => {
return await collection?.aggregate(pipeline).toArray();
}, {colName: this.collection})
}
async deleteNamespace(_id: string): Promise<number> {
return await mongoExecute(async ({collection}) => {
const result = await collection?.deleteOne({ _id: this.toObjectId(_id) });
return result?.deletedCount || 0;
}, {colName: this.collection})
}
}

View File

@ -0,0 +1,35 @@
import { DeleteResult } from "mongodb";
import { ApiTokenMongoManager } from "./ApiTokenMongoManager";
import { mongoExecute } from "./common/mongoDBPool";
import { Token } from "../interfaces";
export class TemporalTokenMongoManager extends ApiTokenMongoManager{
collection = 'temporalTokens';
static Types = {
PASSWORD_RECOVERY: 'password-recovery',
};
async getTokens(): Promise<Token[]> {
return this.getAllTokens();
}
async getAllTokens(): Promise<Token[]> {
return await mongoExecute(async ({ collection }) => {
return await collection?.find({ }).toArray();
}, { colName: this.collection });
}
async getByUserAndType(userId: string, type: String): Promise<Token | null>{
return await mongoExecute(async ({ collection }) => {
return await collection?.findOne({ userId: this.toObjectId(userId) , type });
}, { colName: this.collection });
}
async deleteAllByUserAndType(userId: string, type: String): Promise<number>{
return await mongoExecute(async ({ collection }) => {
const res: DeleteResult | undefined = await collection?.deleteMany({ userId: this.toObjectId(userId), type });
return res?.deletedCount || 0;
}, { colName: this.collection });
}
}

View File

@ -0,0 +1,70 @@
import { mongoExecute } from './common/mongoDBPool';
import { PipelineLibrary } from './common/PipelineLibrary';
import { BaseMongoManager } from './common/BaseMongoManager';
import { User } from '../interfaces';
export class UsersMongoManager extends BaseMongoManager {
collection = 'users';
addUser(user: User) {
return this.create(user);
}
updateUser(user: User) {
return this.update(user);
}
async updateUserNamespace(userId: string, namespaceId: string) {
return await mongoExecute(async ({ collection }) => {
return await
collection?.updateOne({ _id: this.toObjectId(userId) }, { $set: { namespaceId: this.toObjectId(namespaceId) } });
}
, { colName: this.collection });
}
async deleteUser(_id: string) {
return await mongoExecute(async ({ collection }) => {
await collection?.deleteOne({ _id: this.toObjectId(_id) });
}, { colName: this.collection });
}
async getAvailableUsers(defaultId: string): Promise<User[]>{
return await mongoExecute(async ({ collection }) => {
const pipeline = PipelineLibrary.usersGetAvailableUsers(this.toObjectId(defaultId));
return await collection?.aggregate(pipeline).toArray();
}, { colName: this.collection });
}
async getUsers(): Promise<User[]> {
return await mongoExecute(async ({ collection }) => {
const pipeline = PipelineLibrary.usersGetUsers();
return await collection?.aggregate(pipeline).toArray();
}, { colName: this.collection });
}
async getById(id: string): Promise<User | null>{
const pipeline = PipelineLibrary.usersGetById(this.toObjectId(id));
return await mongoExecute(async ({ collection }) => {
const users = await collection?.aggregate(pipeline).toArray();
if (users === undefined || users.length === 0) {
return null;
}
const user = users[0];
delete user.hash;
return user;
}
, { colName: this.collection });
}
async getByUsername(username: string): Promise<User | null>{
return await mongoExecute(async ({ collection }) => {
return await collection?.findOne({ username });
}, { colName: this.collection });
}
async updatePassword(username: string, hash: string) {
return await mongoExecute(async ({ collection }) => {
return await collection?.updateOne({ username }, { $set: { hash } });
}, { colName: this.collection });
}
}

View File

@ -0,0 +1,123 @@
import { ObjectId } from "mongodb";
import { mongoExecute } from "./mongoDBPool";
import { Entity } from "../../interfaces";
import { LoggingService } from "../../../../common/LoggingService";
import toObjectId from "./mongoUtils";
export abstract class BaseMongoManager {
protected abstract collection?: string;
logger = new LoggingService().logger;
create(data: Entity) {
return mongoExecute(
async ({ collection }) => {
await collection?.insertOne(data as any);
return data;
},
{ colName: this.collection }
);
}
delete(id: string) {
return mongoExecute(
async ({ collection }) => {
await collection?.deleteOne({ _id: this.toObjectId(id) });
},
{ colName: this.collection }
);
}
deleteByFilter(filter: any) {
return mongoExecute(
async ({ collection }) => {
await collection?.deleteOne(filter);
},
{ colName: this.collection }
);
}
getById(id: string) {
return mongoExecute(
async ({ collection }) => {
return await collection?.findOne({ _id: this.toObjectId(id) });
},
{ colName: this.collection }
);
}
getByFilter(filter: any) {
return mongoExecute(
async ({ collection }) => {
return await collection?.findOne(filter);
},
{ colName: this.collection }
);
}
list() {
return mongoExecute(
async ({ collection }) => {
return await collection?.find().toArray();
},
{ colName: this.collection }
);
}
listByFilter(filter: any) {
return mongoExecute(
async ({ collection }) => {
return await collection?.find(filter).toArray();
},
{ colName: this.collection }
);
}
update(object: Entity) {
const data: any = { ...object };
const id = data._id;
delete data._id;
return mongoExecute(async ({ collection }) => {
return await collection?.updateOne(
{ _id: this.toObjectId(id) },
{ $set: data }
);
},
{ colName: this.collection });
}
updateMany(filter: any, data: Entity) {
return mongoExecute(async ({ collection }) => {
return await collection?.updateMany(filter, { $set: data as any });
},
{ colName: this.collection });
}
replaceOne(filter: any, object: Entity) {
return mongoExecute(async ({collection}) => {
return await collection?.replaceOne(filter, object);
}, {colName: this.collection});
}
aggregation(pipeline: any) {
return mongoExecute(
async ({ collection }) => {
return await collection?.aggregate(pipeline).toArray();
},
{ colName: this.collection }
);
}
aggregationOne(pipeline: any) {
return mongoExecute(
async ({ collection }) => {
return await collection?.aggregate(pipeline).next();
},
{ colName: this.collection }
);
}
protected toObjectId = (oid: string) => {
return toObjectId(oid);
};
}

View File

@ -0,0 +1,111 @@
import { ObjectId } from "mongodb";
export class PipelineLibrary {
static usersGetById(id: ObjectId) {
return [
{
'$match': {
'_id': id
}
}, {
'$lookup': {
'from': 'namespaces',
'localField': 'namespaceId',
'foreignField': '_id',
'as': 'namespace'
}
}, {
'$unwind': {
'path': '$namespace',
'preserveNullAndEmptyArrays': true
}
}
];
}
static namespacesGetNamespaces() {
return [
{
'$lookup': {
'from': 'users',
'localField': '_id',
'foreignField': 'namespaceId',
'as': 'users'
}
}, {
'$project': {
'_id': 1,
'name': 1,
'description': 1,
'ownerId': 1,
'default': 1,
'createdAt': 1,
'modifiedAt': 1,
'users': {
'_id': 1,
'id': 1,
'username': 1,
'firstname': 1,
'lastname': 1,
'email': 1,
'profileId': 1
}
}
}
];
}
static namespacesGetById(id: ObjectId) {
return [
{
'$match': {
'_id': id
}
},
... PipelineLibrary.namespacesGetNamespaces()
];
}
static usersGetAvailableUsers(defaultId: Object) {
return [
{
'$match': {
'namespaceId': defaultId
},
},
...PipelineLibrary.usersGetUsers(),
];
}
static usersGetUsers() {
return [
{
'$lookup': {
'from': 'namespaces',
'localField': 'namespaceId',
'foreignField': '_id',
'as': 'namespace'
}
}, {
'$unwind': {
'path': '$namespace',
'preserveNullAndEmptyArrays': true
}
}, {
'$project': {
'_id': 1,
'username': 1,
'roles': 1,
'firstname': 1,
'lastname': 1,
'email': 1,
'modifiedAt': 1,
'createdAt': 1,
'namespace': {
'_id': 1,
'name': 1
}
}
}
];
}
}

View File

@ -0,0 +1,55 @@
import { MongoClient, Collection, Db } from 'mongodb';
const {
MONGO_HOST,
MONGO_PORT,
MONGO_USER,
MONGO_PASS = '',
MONGO_DB,
} = process.env;
const uri = `mongodb://${MONGO_USER}:${MONGO_PASS.replace(/[^A-Za-z0-9\-_.!~*'()%]/g, (c) => encodeURIComponent(c))}@${MONGO_HOST}:${MONGO_PORT}/?maxPoolSize=20`;
interface MongoExecuteOptions {
dbName?: string;
colName?: string;
};
interface MongoExecuteParams {
collection?: Collection,
database?: Db,
connection?: MongoClient
}
type MongoExecuteFunction = (options: MongoExecuteParams) => void | Promise<void> | Promise<any> | any;
export const getMongoConnection = async() : Promise<MongoClient> => {
const client = new MongoClient(uri);
return await client.connect();
};
export const getMongoDatabase = (client: MongoClient, dbName: string): Db => {
const DB = dbName || MONGO_DB;
return client.db(DB);
};
export const mongoExecute = async function(fn: MongoExecuteFunction, opts: MongoExecuteOptions): Promise<any> {
const { dbName, colName } = { dbName: MONGO_DB, ...opts };
let connection: MongoClient | null = null;
try {
connection = await getMongoConnection();
const database = connection.db(dbName);
if (colName) {
const collection: Collection = database.collection(colName);
return await fn({ collection, database, connection });
}
return await fn({ database, connection });
} catch (err: any) {
console.log('MOMGODB ERROR:', err.message);
throw err;
} finally {
if (connection !== null) {
await connection.close();
}
}
};

View File

@ -0,0 +1,5 @@
import { ObjectId } from "mongodb";
export default function toObjectId(id: string) {
return ObjectId.createFromHexString(id);
}

View File

@ -5,21 +5,30 @@ import { join } from 'path';
import { NetworkClientNotifier } from '../game/NetworkClientNotifier'; import { NetworkClientNotifier } from '../game/NetworkClientNotifier';
import { SocketIoService } from './services/SocketIoService'; import { SocketIoService } from './services/SocketIoService';
import { LoggingService } from '../common/LoggingService';
import { useRouter } from './router';
const clientNotifier = new NetworkClientNotifier(); const clientNotifier = new NetworkClientNotifier();
const logger = new LoggingService();
const app = express(); const app = express();
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
const socketIoService = new SocketIoService(httpServer); const socketIoService = new SocketIoService(httpServer);
clientNotifier.setSocket(socketIoService.getServer()); clientNotifier.setSocket(socketIoService.getServer());
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
console.log('__dirname :>> ', __dirname);
app.use(cors()); app.use(cors());
app.use(logger.middleware());
app.use(express.json({ limit: '50mb'}));
app.use(express.text());
app.use(express.urlencoded({extended: true }));
app.use(useRouter())
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(join(__dirname, 'index.html')); res.sendFile(join(__dirname, 'index.html'));
}); });
httpServer.listen(PORT, () => { httpServer.listen(PORT, () => {
console.log(`listening on *:${PORT}`); logger.info(`listening on *:${PORT}`);
}); });

View File

@ -1,5 +1,5 @@
import { LoggingService } from "../../common/LoggingService"; import { LoggingService } from "../../common/LoggingService";
export class ControllerBase { export class ManagerBase {
protected logger = new LoggingService(); protected logger = new LoggingService();
} }

View File

@ -0,0 +1,39 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { User } from '../db/interfaces';
export class SecurityManager {
saltRounds = Number(process.env.SALT_ROUNDS);
jwtSecretKey = process.env.JWT_SECRET_KEY || '';
generateId() {
return crypto.randomBytes(16).toString('hex');
}
getHashedPassword(password: string) {
const salt = bcrypt.genSaltSync(this.saltRounds);
return bcrypt.hashSync(password, salt);
}
generateApiToken() {
return crypto.randomBytes(32).toString('hex');
}
signJwt(data: any) {
return jwt.sign(data, this.jwtSecretKey, { expiresIn: '3h' });
}
// TODO: verificar esto
async verifyJwt(token: string): Promise<User> {
return new Promise((resolve, reject) => {
jwt.verify(token, this.jwtSecretKey, (err, decoded) => {
if (err) {
reject(err);
} else {
resolve(decoded as User);
}
});
});
}
}

View File

@ -1,11 +1,12 @@
import { LoggingService } from "../../common/LoggingService"; import { MatchSession } from "../../game/MatchSession";
import { GameSession } from "../../game/GameSession";
import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer"; import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer";
import { SessionService } from "../services/SessionService";
import { ControllerBase } from "./ControllerBase"; import { ManagerBase } from "./ManagerBase";
export class SessionController extends ControllerBase{ export class SessionManager extends ManagerBase {
private static sessions: any = {}; private static sessions: any = {};
private sessionService: SessionService = new SessionService();
constructor() { constructor() {
super(); super();
@ -15,8 +16,9 @@ export class SessionController extends ControllerBase{
createSession(data: any, socketId: string): any { createSession(data: any, socketId: string): any {
const { user, sessionName } = data; const { user, sessionName } = data;
const player = new NetworkPlayer(user, socketId); const player = new NetworkPlayer(user, socketId);
const session = new GameSession(player, sessionName); const session = new MatchSession(player, sessionName);
SessionController.sessions[session.id] = session; SessionManager.sessions[session.id] = session;
this.sessionService.createSession(session);
return { return {
status: 'ok', status: 'ok',
@ -29,9 +31,10 @@ export class SessionController extends ControllerBase{
this.logger.debug('joinSession data :>> ') this.logger.debug('joinSession data :>> ')
this.logger.object(data); this.logger.object(data);
const { user, sessionId } = data; const { user, sessionId } = data;
const session = SessionController.sessions[sessionId]; const session: MatchSession = SessionManager.sessions[sessionId];
const player = new NetworkPlayer(user, socketId); const player = new NetworkPlayer(user, socketId);
session.addPlayer(player); session.addPlayer(player);
this.sessionService.updateSession(session);
return { return {
status: 'ok', status: 'ok',
sessionId: session.id, sessionId: session.id,
@ -39,10 +42,16 @@ export class SessionController extends ControllerBase{
}; };
} }
setPlayerReady(data: any): any {
const { user, sessionId } = data;
const session: MatchSession = SessionManager.sessions[sessionId];
session.setPlayerReady(user)
}
startSession(data: any): any { startSession(data: any): any {
const sessionId: string = data.sessionId; const sessionId: string = data.sessionId;
const seed: string | undefined = data.seed; const seed: string | undefined = data.seed;
const session = SessionController.sessions[sessionId]; const session = SessionManager.sessions[sessionId];
if (!session) { if (!session) {
return ({ return ({
@ -68,10 +77,10 @@ export class SessionController extends ControllerBase{
getSession(id: string) { getSession(id: string) {
return SessionController.sessions[id]; return SessionManager.sessions[id];
} }
deleteSession(id: string) { deleteSession(id: string) {
delete SessionController.sessions[id]; delete SessionManager.sessions[id];
} }
} }

View File

@ -0,0 +1,33 @@
import { Request, Response, Router } from 'express';
import { AuthController } from '../controllers/AuthController';
import { NamespacesController } from '../controllers/NamespacesController';
import { Validations } from './validations';
import { UserController } from '../controllers/UserController';
import { ApiKeyController } from '../controllers/ApiKeyController';
export default function(): Router {
const userController = new UserController();
const namespacesController = new NamespacesController();
const apiKeyController = new ApiKeyController();
const router = Router();
const { authenticate } = AuthController;
router.get('/users', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.listUsers(req, res));
router.get('/user/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.getUser(req, res));
router.delete('/user/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.deleteUser(req, res));
router.post('/user', [ authenticate({ roles: ['admin']}), ...Validations.createUser ], (req: Request, res: Response) => userController.createUser(req, res));
router.patch('/user/:id', [ authenticate({ roles: ['admin']}), ...Validations.updateUser ], (req: Request, res: Response) => userController.updateUser(req, res));
router.patch('/user/:userId/namespace/:namespaceId/change', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.updateUserNamespace(req, res));
router.patch('/user/:userId/namespace/reset', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.resetUserNamespace(req, res));
router.get('/namespaces', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.getNamespaces(req, res));
router.post('/namespace', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.createNamespace(req, res));
router.get('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.getNamespace(req, res));
router.patch('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.updateNamespace(req, res));
router.delete('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.deleteNamespace(req, res));
router.get('/namespace/:id/tokens', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.listNamespaceApiKeys(req, res));
router.delete('/namespace/:id/token/:tokenId', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.deleteNamespaceApiKey(req, res));
router.post('/namespace/:id/token', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.createNamespaceApiKey(req, res));
return router;
}

View File

@ -0,0 +1,22 @@
import { Request, Response, Router } from 'express';
import { AuthController } from '../controllers/AuthController';
import adminRouter from './adminRouter';
import userRouter from './userRouter';
export default function(): Router {
const router = Router();
const authController = new AuthController();
router.get('/version', async function(req: Request, res: Response){
res.send('1.0.0').end();
});
router.post('/auth/code', (req: Request, res: Response) => authController.twoFactorCodeAuthentication(req, res));
router.post('/login', (req: Request, res: Response) => authController.login(req, res));
router .use('/admin', adminRouter());
router .use('/user', userRouter());
return router;
}

View File

@ -0,0 +1,15 @@
import { Router } from "express";
import { join } from 'path';
import apiRouter from "./apiRouter";
export function useRouter(): Router {
const router = Router();
router.get('/', (req, res) => {
res.sendFile(join(__dirname, 'index.html'));
});
router.use('/api', apiRouter());
return router;
}

View File

@ -0,0 +1,23 @@
import { Request, Response, Router } from 'express';
import { UserController } from '../controllers/UserController';
import { AuthController } from '../controllers/AuthController';
import { ApiKeyController } from '../controllers/ApiKeyController';
export default function() : Router {
const userController = new UserController();
const authController = new AuthController();
const apiKeyController = new ApiKeyController();
const router = Router();
const { authenticate } = AuthController;
router.get('/tokens', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.listUserApiKeys(req, res));
router.post('/token', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.createApiKey(req, res));
router.delete('/token/:id', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.deleteApiKey(req, res));
router.post('/password/change', authenticate({ roles: ['user']}), (req: Request, res: Response) => authController.changePassword(req, res));
router.post('/password/recovery', (req: Request, res: Response) => userController.passwordRecovery(req, res));
router.post('/password/recovery/change', (req: Request, res: Response) => authController.changePasswordWithCode(req, res));
router.get('/namespaces', authenticate({ roles: ['user']}), (req: Request, res: Response) => userController.getNamespaces(req, res));
return router;
}

View File

@ -0,0 +1,22 @@
import { body } from 'express-validator';
const username = body("username")
.trim()
.isLength({ min: 5 })
.escape()
.withMessage("Username min 8 characters.")
.matches(/^[a-zA-Z0-9\-_]+$/).withMessage("First name has non-alphanumeric characters.");
const password = body("password")
.notEmpty().withMessage('Password is required')
.matches(/^[a-zA-Z0-9!@#$%^&*()_+{}\[\]:;<>,.?~\-]+$/).withMessage('Password must contain at least one special character');
const email = body('email')
.optional({values: 'falsy'})
.trim()
.isEmail();
export const Validations = {
createUser: [username, password, email],
updateUser: [username, email],
}

View File

@ -0,0 +1,25 @@
import crypto from 'crypto';
export class CryptoService {
generateEmailRecoveryToken(): string {
return this.generateToken(32);
}
generateRandomPin(length: number): string {
const randomBytes = crypto.randomBytes(length);
let pin = '';
for (let i = 0; i < randomBytes.length; i++) {
// Convert each byte to a number between 0-9
pin += (randomBytes[i] % 10).toString();
}
return pin;
}
generateToken(length: number): string {
return crypto.randomBytes(length).toString('hex');
}
}

View File

@ -0,0 +1,45 @@
import { Namespace, User } from "../db/interfaces";
import { NamespacesMongoManager } from "../db/mongo/NamespacesMongoManager";
import { UsersService } from "./UsersService";
export class NamespacesService {
namespacesManager = new NamespacesMongoManager();
usersService = new UsersService();
async createNamespace(namespace: Namespace, user: User) {
const insertedId = await this.namespacesManager.createNamespace({ ownerId: user._id, ...namespace, createdBy: user._id });
await this._updateNamespaceUsers(namespace.users ?? [], insertedId);
return insertedId;
}
async _updateNamespaceUsers(users: any[], id: string) {
const defaultNamespace: Namespace = await this.namespacesManager.getDefaultNamespace();
for (const user of users) {
if (defaultNamespace._id === undefined) continue;
const namespaceId = user.removed ? defaultNamespace._id?.toString() : id;
await this.usersService.updateUserNamespace(user.id, namespaceId);
}
}
async updateNamespace(id: string, namespace: Namespace) {
const result = await this.namespacesManager.updateNamespace(id, namespace);
await this._updateNamespaceUsers(namespace.users ?? [], id);
return result;
}
async getNamespace(namespaceId: string) {
return await this.namespacesManager.getNamespace(namespaceId);
}
async getNamespaces() {
return await this.namespacesManager.getNamespaces();
}
getDefaultNamespace(): Promise<Namespace> {
return this.namespacesManager.getDefaultNamespace();
}
async deleteNamespace(namespaceId: string) {
return await this.namespacesManager.deleteNamespace(namespaceId);
}
}

View File

@ -0,0 +1,19 @@
import { MatchSession } from "../../game/MatchSession";
import { matchSessionAdapter } from "../db/DbAdapter";
import { MatchSessionMongoManager } from "../db/mongo/MatchSessionMongoManager";
import { ServiceBase } from "./ServiceBase";
export class SessionService extends ServiceBase{
private dbManager: MatchSessionMongoManager = new MatchSessionMongoManager();
constructor() {
super()
}
public createSession(session: MatchSession): any {
this.dbManager.create(matchSessionAdapter(session));
}
public updateSession(session: MatchSession): any {
this.dbManager.replaceOne({id: session.id}, matchSessionAdapter(session));
}
}

View File

@ -1,10 +1,12 @@
import { Server as HttpServer } from "http"; import { Server as HttpServer } from "http";
import { ServiceBase } from "./ServiceBase"; import { ServiceBase } from "./ServiceBase";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { SessionController } from "../controllers/SessionController"; import { SessionManager } from "../managers/SessionManager";
export class SocketIoService extends ServiceBase{ export class SocketIoService extends ServiceBase{
io: Server io: Server
clients: Map<string, any> = new Map();
constructor(private httpServer: HttpServer) { constructor(private httpServer: HttpServer) {
super() super()
this.io = this.socketIo(httpServer); this.io = this.socketIo(httpServer);
@ -16,23 +18,30 @@ export class SocketIoService extends ServiceBase{
} }
private initListeners() { private initListeners() {
const sessionController = new SessionController(); const sessionController = new SessionManager();
this.io.on('connection', (socket) => { this.io.on('connection', (socket) => {
console.log(`connect ${socket.id}`); this.logger.debug(`connect ${socket.id}`);
if (socket.recovered) { if (socket.recovered) {
// recovery was successful: socket.id, socket.rooms and socket.data were restored // recovery was successful: socket.id, socket.rooms and socket.data were restored
console.log("recovered!"); this.logger.debug("recovered!");
console.log("socket.rooms:", socket.rooms); this.logger.debug("socket.rooms:", socket.rooms);
console.log("socket.data:", socket.data); this.logger.debug("socket.data:", socket.data);
} else { } else {
console.log("new connection"); this.logger.debug("new connection");
this.clients.set(socket.id, { alive: true });
socket.join('room-general') socket.join('room-general')
socket.data.foo = "bar"; socket.data.foo = "bar";
} }
socket.on('pong', () => {
if (this.clients.has(socket.id)) {
this.clients.set(socket.id, { alive: true });
}
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('user disconnected'); this.logger.debug('user disconnected');
this.clients.delete(socket.id);
}); });
socket.on('createSession', (data, callback) => { socket.on('createSession', (data, callback) => {
@ -50,16 +59,30 @@ export class SocketIoService extends ServiceBase{
callback(response); callback(response);
}); });
// socket.on('chat message', (msg, callback) => { socket.on('playerReady', (data, callback) => {
// io.emit('chat message', msg); const response = sessionController.setPlayerReady(data);
// callback({ callback(response);
// status: 'ok', });
// message: 'Message received',
// }) this.pingClients()
// });
}); });
} }
private pingClients() {
setInterval(() => {
for (let [id, client] of this.clients.entries()) {
if (!client.alive) {
this.logger.debug(`Client ${id} did not respond. Disconnecting.`);
this.io.to(id).disconnectSockets(true); // Disconnect client
this.clients.delete(id);
} else {
client.alive = false; // Reset alive status for the next ping
this.io.to(id).emit('ping'); // Send ping message
}
}
}, 30000);
}
private socketIo(httpServer: HttpServer): Server { private socketIo(httpServer: HttpServer): Server {
return new Server(httpServer, { return new Server(httpServer, {
cors: { cors: {

View File

@ -0,0 +1,82 @@
import { UsersMongoManager } from "../db/mongo/UsersMongoManager";
import { SecurityManager } from "../managers/SecurityManager";
import { ServiceBase } from "./ServiceBase";
import { User } from "../db/interfaces";
import toObjectId from "../db/mongo/common/mongoUtils";
export class UsersService extends ServiceBase {
usersManager = new UsersMongoManager();
security = new SecurityManager();
listUsers(options?: any) {
if (options) {
return this.usersManager.getAvailableUsers(options.not);
}
return this.usersManager.getUsers();
}
getUser(id: string) {
return this.usersManager.getById(id);
}
getByUsername(username: string) {
return this.usersManager.getByUsername(username);
}
updateUser(user: any, id: string) {
const newUser = this._createUserObject(user, id);
const result = this.usersManager.updateUser(newUser);
return result;
}
updateUserNamespace(userId: string, namespaceId: string) {
return this.usersManager.updateUserNamespace(userId, namespaceId);
}
getById(id: string) {
return this.usersManager.getById(id);
}
createUser(user: any) {
const newUser = this._createUserObject(user);
return this.usersManager.addUser(newUser);
}
deleteUser(id: string) {
return this.usersManager.deleteUser(id);
}
_createUserObject(body: any, _id?: string) {
const { username, password, firstname, lastname, email, isadmin, namespaceId, character, roles } = body;
const id = this.security.generateId();
const createdAt = new Date().getTime();
const modifiedAt = createdAt
// const roles = isadmin ? ['admin', 'user'] : ['user'];
const user: User = {
id,
username,
password,
firstname,
lastname,
email,
roles,
createdAt,
modifiedAt,
namespaceId,
profileId: character,
};
this.logger.info(`${password === undefined}`);
if (_id !== undefined) {
user._id = toObjectId(_id);
}
if (password !== undefined && typeof password === 'string' && password.length > 0) {
user.hash = this.security.getHashedPassword(password);
delete user.password;
}
return user;
}
}

View File

@ -0,0 +1,75 @@
import nodemailer, { SentMessageInfo, Transporter } from 'nodemailer';
import hbs from 'nodemailer-express-handlebars';
import Mail from 'nodemailer/lib/mailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
export class MailerService {
transporter: Transporter;
host: string | undefined;
sender: string | undefined;
port: number | undefined;
user: string | undefined;
pass: string | undefined;
constructor() {
this.transporter = this._createTransporter();
this.sender = process.env.EMAIL_SENDER;
this.host = process.env.SMTP_HOST;
this.port = Number(process.env.SMTP_PORT || 587);
this.user = process.env.SMTP_USER;
this.pass = process.env.SMTP_PASS;
this._configureTemplates();
}
_createTransporter(): Transporter {
return nodemailer.createTransport({
host: this.host,
port: this.port,
secure: false,
auth: {
user: this.user,
pass: this.pass,
},
});
}
_configureTemplates() {
this.transporter.use('compile', hbs({
viewEngine: {
extname: '.hbs',
partialsDir: 'app/server/views/partials',
layoutsDir: 'app/server/views/layouts',
defaultLayout: 'email.hbs',
},
viewPath: 'app/server/views/emails',
extName: '.hbs',
}));
}
async sendRecoveryPasswordEmail(firstname: string = '', lastname: string = '', email: string, pin: string) {
const to = firstname ? `${firstname}${lastname ? ' ' + lastname : ''}} <${email}>` : email;
const mailOptions = {
from: this.sender,
to,
subject: 'Password Recovery',
template: 'passwordRecovery',
context: {
pin,
},
};
return this.send(mailOptions);
}
async send(mailOptions: any) {
return new Promise((resolve, reject) => {
this.transporter.sendMail(mailOptions, (error, info) => {
if (error) {
reject(error);
} else {
resolve(info);
}
});
});
}
}

11
src/server/types/environment.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export {};
declare global {
namespace NodeJS {
interface ProcessEnv {
DB_PORT: number;
DB_USER: string;
ENV: 'test' | 'dev' | 'prod';
}
}
}

8
src/server/types/express/index.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import 'express-serve-static-core';
declare module 'express-serve-static-core' {
interface Request {
user?: any;
token?: any;
}
}

View File

@ -31,7 +31,7 @@
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "typeRoots": ["./src/server/types"], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */