From d955ddfe821691324818c7127615bd3655ed27ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 15:57:50 +0200 Subject: [PATCH] Adding support to persist variables in Redis --- back/package.json | 2 + back/src/Enum/EnvironmentVariable.ts | 3 + back/src/Model/GameRoom.ts | 92 ++++++++++++------ back/src/RoomManager.ts | 75 +++++++++------ back/src/Services/AdminApi/MapDetailsData.ts | 2 +- back/src/Services/LocalUrlError.ts | 3 +- back/src/Services/MapFetcher.ts | 22 ++--- back/src/Services/MessageHelpers.ts | 9 +- back/src/Services/RedisClient.ts | 23 +++++ .../Repository/RedisVariablesRepository.ts | 40 ++++++++ .../Repository/VariablesRepository.ts | 14 +++ .../VariablesRepositoryInterface.ts | 10 ++ .../Repository/VoidVariablesRepository.ts | 14 +++ back/src/Services/SocketManager.ts | 16 ++-- back/src/Services/VariablesManager.ts | 94 ++++++++++++++----- back/tests/GameRoomTest.ts | 13 ++- back/yarn.lock | 39 ++++++++ deeployer.libsonnet | 5 + docker-compose.single-domain.yaml | 5 + docker-compose.yaml | 16 ++++ docs/maps/api-state.md | 6 +- pusher/src/Model/PusherRoom.ts | 8 +- pusher/src/Model/Zone.ts | 3 +- pusher/src/Services/SocketManager.ts | 3 +- 24 files changed, 397 insertions(+), 120 deletions(-) create mode 100644 back/src/Services/RedisClient.ts create mode 100644 back/src/Services/Repository/RedisVariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepositoryInterface.ts create mode 100644 back/src/Services/Repository/VoidVariablesRepository.ts diff --git a/back/package.json b/back/package.json index a532f5cd..8a1e445e 100644 --- a/back/package.json +++ b/back/package.json @@ -53,6 +53,7 @@ "mkdirp": "^1.0.4", "prom-client": "^12.0.0", "query-string": "^6.13.3", + "redis": "^3.1.2", "systeminformation": "^4.31.1", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uuidv4": "^6.0.7" @@ -66,6 +67,7 @@ "@types/jasmine": "^3.5.10", "@types/jsonwebtoken": "^8.3.8", "@types/mkdirp": "^1.0.1", + "@types/redis": "^2.8.31", "@types/uuidv4": "^5.0.0", "@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/parser": "^2.26.0", diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 19eddd3e..92f62b0b 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -12,6 +12,9 @@ const GRPC_PORT = parseInt(process.env.GRPC_PORT || "50051") || 50051; export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || ""; export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); +export const REDIS_HOST = process.env.REDIS_HOST || undefined; +export const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379") || 6379; +export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined; export { MINIMUM_DISTANCE, diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index fd711ae8..2e30bf52 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -11,18 +11,19 @@ import { EmoteEventMessage, JoinRoomMessage, SubToPusherRoomMessage, - VariableMessage, VariableWithTagMessage, + VariableMessage, + VariableWithTagMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { Admin } from "../Model/Admin"; -import {adminApi} from "../Services/AdminApi"; -import {isMapDetailsData, MapDetailsData} from "../Services/AdminApi/MapDetailsData"; -import {ITiledMap} from "@workadventure/tiled-map-type-guard/dist"; -import {mapFetcher} from "../Services/MapFetcher"; -import {VariablesManager} from "../Services/VariablesManager"; -import {ADMIN_API_URL} from "../Enum/EnvironmentVariable"; -import {LocalUrlError} from "../Services/LocalUrlError"; +import { adminApi } from "../Services/AdminApi"; +import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetailsData"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard/dist"; +import { mapFetcher } from "../Services/MapFetcher"; +import { VariablesManager } from "../Services/VariablesManager"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { LocalUrlError } from "../Services/LocalUrlError"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; @@ -68,10 +69,21 @@ export class GameRoom { onMoves: MovesCallback, onLeaves: LeavesCallback, onEmote: EmoteCallback - ) : Promise { + ): Promise { const mapDetails = await GameRoom.getMapDetails(roomUrl); - const gameRoom = new GameRoom(roomUrl, mapDetails.mapUrl, connectCallback, disconnectCallback, minDistance, groupRadius, onEnters, onMoves, onLeaves, onEmote); + const gameRoom = new GameRoom( + roomUrl, + mapDetails.mapUrl, + connectCallback, + disconnectCallback, + minDistance, + groupRadius, + onEnters, + onMoves, + onLeaves, + onEmote + ); return gameRoom; } @@ -381,7 +393,7 @@ export class GameRoom { const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); if (!match) { - console.error('Unexpected room URL', roomUrl); + console.error("Unexpected room URL", roomUrl); throw new Error('Unexpected room URL "' + roomUrl + '"'); } @@ -392,18 +404,18 @@ export class GameRoom { policy_type: 1, textures: [], tags: [], - } + }; } const result = await adminApi.fetchMapDetails(roomUrl); if (!isMapDetailsData(result)) { - console.error('Unexpected room details received from server', result); - throw new Error('Unexpected room details received from server'); + console.error("Unexpected room details received from server", result); + throw new Error("Unexpected room details received from server"); } return result; } - private mapPromise: Promise|undefined; + private mapPromise: Promise | undefined; /** * Returns a promise to the map file. @@ -418,27 +430,45 @@ export class GameRoom { return this.mapPromise; } - private variableManagerPromise: Promise|undefined; + private variableManagerPromise: Promise | undefined; private getVariableManager(): Promise { if (!this.variableManagerPromise) { this.variableManagerPromise = new Promise((resolve, reject) => { - this.getMap().then((map) => { - resolve(new VariablesManager(map)); - }).catch(e => { - if (e instanceof LocalUrlError) { - // If we are trying to load a local URL, we are probably in test mode. - // In this case, let's bypass the server-side checks completely. + this.getMap() + .then((map) => { + const variablesManager = new VariablesManager(this.roomUrl, map); + variablesManager + .init() + .then(() => { + resolve(variablesManager); + }) + .catch((e) => { + reject(e); + }); + }) + .catch((e) => { + if (e instanceof LocalUrlError) { + // If we are trying to load a local URL, we are probably in test mode. + // In this case, let's bypass the server-side checks completely. - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - // FIXME: find a way to send a warning to the client side - resolve(new VariablesManager(null)); - } else { - reject(e); - } - }) + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + // FIXME: find a way to send a warning to the client side + const variablesManager = new VariablesManager(this.roomUrl, null); + variablesManager + .init() + .then(() => { + resolve(variablesManager); + }) + .catch((e) => { + reject(e); + }); + } else { + reject(e); + } + }); }); } return this.variableManagerPromise; diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index a6a99993..6a879202 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -4,7 +4,9 @@ import { AdminMessage, AdminPusherToBackMessage, AdminRoomMessage, - BanMessage, BatchToPusherMessage, BatchToPusherRoomMessage, + BanMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -12,17 +14,19 @@ import { PlayGlobalMessage, PusherToBackMessage, QueryJitsiJwtMessage, - RefreshRoomPromptMessage, RoomMessage, + RefreshRoomPromptMessage, + RoomMessage, ServerToAdminClientMessage, SilentMessage, - UserMovesMessage, VariableMessage, + UserMovesMessage, + VariableMessage, WebRtcSignalToServerMessage, WorldFullWarningToRoomMessage, ZoneMessage, } from "./Messages/generated/messages_pb"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { socketManager } from "./Services/SocketManager"; -import {emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket} from "./Services/MessageHelpers"; +import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers"; import { User, UserSocket } from "./Model/User"; import { GameRoom } from "./Model/GameRoom"; import Debug from "debug"; @@ -55,7 +59,8 @@ const roomManager: IRoomManagerServer = { //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. socketManager.leaveRoom(gameRoom, myUser); } - }).catch(e => emitError(call, e)); + }) + .catch((e) => emitError(call, e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -138,22 +143,30 @@ const roomManager: IRoomManagerServer = { debug("listenZone called"); const zoneMessage = call.request; - socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => { - emitErrorOnZoneSocket(call, e.toString()); - }); + socketManager + .addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => { + emitErrorOnZoneSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenZone cancelled"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); call.end(); }); call.on("close", () => { debug("listenZone connection closed"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenZone stream:", e); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); call.end(); }); }, @@ -162,25 +175,24 @@ const roomManager: IRoomManagerServer = { debug("listenRoom called"); const roomMessage = call.request; - socketManager.addRoomListener(call, roomMessage.getRoomid()).catch(e => { + socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => { emitErrorOnRoomSocket(call, e.toString()); }); call.on("cancelled", () => { debug("listenRoom cancelled"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); call.end(); }); call.on("close", () => { debug("listenRoom connection closed"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenRoom stream:", e); - socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); call.end(); }); - }, adminRoom(call: AdminSocket): void { @@ -194,9 +206,12 @@ const roomManager: IRoomManagerServer = { if (room === null) { if (message.hasSubscribetoroom()) { const roomId = message.getSubscribetoroom(); - socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { - room = gameRoom; - }).catch(e => console.error(e)); + socketManager + .handleJoinAdminRoom(admin, roomId) + .then((gameRoom: GameRoom) => { + room = gameRoom; + }) + .catch((e) => console.error(e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -221,11 +236,9 @@ const roomManager: IRoomManagerServer = { }); }, sendAdminMessage(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminMessage( - call.request.getRoomid(), - call.request.getRecipientuuid(), - call.request.getMessage() - ).catch(e => console.error(e)); + socketManager + .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, @@ -236,13 +249,17 @@ const roomManager: IRoomManagerServer = { }, ban(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME Work in progress - socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()).catch(e => console.error(e)); + socketManager + .banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendAdminMessageToRoom(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()).catch(e => console.error(e)); + socketManager + .sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendWorldFullWarningToRoom( @@ -250,7 +267,7 @@ const roomManager: IRoomManagerServer = { callback: sendUnaryData ): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch(e => console.error(e)); + socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendRefreshRoomPrompt( @@ -258,9 +275,9 @@ const roomManager: IRoomManagerServer = { callback: sendUnaryData ): void { // FIXME: we could improve return message by returning a Success|ErrorMessage message - socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch(e => console.error(e)); + socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); - } + }, }; export { roomManager }; diff --git a/back/src/Services/AdminApi/MapDetailsData.ts b/back/src/Services/AdminApi/MapDetailsData.ts index 54320791..d3402b92 100644 --- a/back/src/Services/AdminApi/MapDetailsData.ts +++ b/back/src/Services/AdminApi/MapDetailsData.ts @@ -15,7 +15,7 @@ export const isMapDetailsData = new tg.IsInterface() textures: tg.isArray(isCharacterTexture), }) .withOptionalProperties({ - roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated + roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated }) .get(); export type MapDetailsData = tg.GuardedType; diff --git a/back/src/Services/LocalUrlError.ts b/back/src/Services/LocalUrlError.ts index fc3fa617..a4984fdd 100644 --- a/back/src/Services/LocalUrlError.ts +++ b/back/src/Services/LocalUrlError.ts @@ -1,2 +1 @@ -export class LocalUrlError extends Error { -} +export class LocalUrlError extends Error {} diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts index 9869d26a..99465ac4 100644 --- a/back/src/Services/MapFetcher.ts +++ b/back/src/Services/MapFetcher.ts @@ -1,17 +1,17 @@ import Axios from "axios"; -import ipaddr from 'ipaddr.js'; -import { Resolver } from 'dns'; -import { promisify } from 'util'; -import {LocalUrlError} from "./LocalUrlError"; -import {ITiledMap} from "@workadventure/tiled-map-type-guard"; -import {isTiledMap} from "@workadventure/tiled-map-type-guard/dist"; +import ipaddr from "ipaddr.js"; +import { Resolver } from "dns"; +import { promisify } from "util"; +import { LocalUrlError } from "./LocalUrlError"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard"; +import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist"; class MapFetcher { async fetchMap(mapUrl: string): Promise { // Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map) if (await this.isLocalUrl(mapUrl)) { - throw new LocalUrlError('URL for map "'+mapUrl+'" targets a local map'); + throw new LocalUrlError('URL for map "' + mapUrl + '" targets a local map'); } // Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that @@ -22,12 +22,12 @@ class MapFetcher { // - We make sure we are only passing "GET" requests // - The result of the query is never displayed to the end user const res = await Axios.get(mapUrl, { - maxContentLength: 50*1024*1024, // Max content length: 50MB. Maps should not be bigger + maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger timeout: 10000, // Timeout after 10 seconds }); if (!isTiledMap(res.data)) { - throw new Error('Invalid map format for map '+mapUrl); + throw new Error("Invalid map format for map " + mapUrl); } return res.data; @@ -39,7 +39,7 @@ class MapFetcher { */ private async isLocalUrl(url: string): Promise { const urlObj = new URL(url); - if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) { + if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) { return true; } @@ -53,7 +53,7 @@ class MapFetcher { for (const address of addresses) { const addr = ipaddr.parse(address); - if (addr.range() !== 'unicast') { + if (addr.range() !== "unicast") { return true; } } diff --git a/back/src/Services/MessageHelpers.ts b/back/src/Services/MessageHelpers.ts index 069d3c78..606374be 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,11 +1,14 @@ import { BatchMessage, - BatchToPusherMessage, BatchToPusherRoomMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, ErrorMessage, - ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage + ServerToClientMessage, + SubToPusherMessage, + SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { UserSocket } from "_Model/User"; -import {RoomSocket, ZoneSocket} from "../RoomManager"; +import { RoomSocket, ZoneSocket } from "../RoomManager"; export function emitError(Client: UserSocket, message: string): void { const errorMessage = new ErrorMessage(); diff --git a/back/src/Services/RedisClient.ts b/back/src/Services/RedisClient.ts new file mode 100644 index 00000000..1f8c1ecd --- /dev/null +++ b/back/src/Services/RedisClient.ts @@ -0,0 +1,23 @@ +import { ClientOpts, createClient, RedisClient } from "redis"; +import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable"; + +let redisClient: RedisClient | null = null; + +if (REDIS_HOST !== undefined) { + const config: ClientOpts = { + host: REDIS_HOST, + port: REDIS_PORT, + }; + + if (REDIS_PASSWORD) { + config.password = REDIS_PASSWORD; + } + + redisClient = createClient(config); + + redisClient.on("error", (err) => { + console.error("Error connecting to Redis:", err); + }); +} + +export { redisClient }; diff --git a/back/src/Services/Repository/RedisVariablesRepository.ts b/back/src/Services/Repository/RedisVariablesRepository.ts new file mode 100644 index 00000000..f59e37ab --- /dev/null +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -0,0 +1,40 @@ +import { promisify } from "util"; +import { RedisClient } from "redis"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Class in charge of saving/loading variables from the data store + */ +export class RedisVariablesRepository implements VariablesRepositoryInterface { + private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>; + private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise>; + + constructor(redisClient: RedisClient) { + // @eslint-disable-next-line @typescript-eslint/unbound-method + this.hgetall = promisify(redisClient.hgetall).bind(redisClient); + // @eslint-disable-next-line @typescript-eslint/unbound-method + this.hset = promisify(redisClient.hset).bind(redisClient); + } + + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return this.hgetall(roomUrl); + } + + async saveVariable(roomUrl: string, key: string, value: string): Promise { + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + // TODO: handle the case for "undefined" + + // TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT + + // @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify + return this.hset(roomUrl, key, value); + } +} diff --git a/back/src/Services/Repository/VariablesRepository.ts b/back/src/Services/Repository/VariablesRepository.ts new file mode 100644 index 00000000..9f668bcf --- /dev/null +++ b/back/src/Services/Repository/VariablesRepository.ts @@ -0,0 +1,14 @@ +import { RedisVariablesRepository } from "./RedisVariablesRepository"; +import { redisClient } from "../RedisClient"; +import { VoidVariablesRepository } from "./VoidVariablesRepository"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +let variablesRepository: VariablesRepositoryInterface; +if (!redisClient) { + console.warn("WARNING: Redis isnot configured. No variables will be persisted."); + variablesRepository = new VoidVariablesRepository(); +} else { + variablesRepository = new RedisVariablesRepository(redisClient); +} + +export { variablesRepository }; diff --git a/back/src/Services/Repository/VariablesRepositoryInterface.ts b/back/src/Services/Repository/VariablesRepositoryInterface.ts new file mode 100644 index 00000000..d927f5ff --- /dev/null +++ b/back/src/Services/Repository/VariablesRepositoryInterface.ts @@ -0,0 +1,10 @@ +export interface VariablesRepositoryInterface { + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + loadVariables(roomUrl: string): Promise<{ [key: string]: string }>; + + saveVariable(roomUrl: string, key: string, value: string): Promise; +} diff --git a/back/src/Services/Repository/VoidVariablesRepository.ts b/back/src/Services/Repository/VoidVariablesRepository.ts new file mode 100644 index 00000000..0a2664e8 --- /dev/null +++ b/back/src/Services/Repository/VoidVariablesRepository.ts @@ -0,0 +1,14 @@ +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Mock class in charge of NOT saving/loading variables from the data store + */ +export class VoidVariablesRepository implements VariablesRepositoryInterface { + loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return Promise.resolve({}); + } + + saveVariable(roomUrl: string, key: string, value: string): Promise { + return Promise.resolve(0); + } +} diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 35494b2c..4f02b6ca 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -305,13 +305,15 @@ export class SocketManager { this.onClientLeave(thing, newZone, listener), (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => this.onEmote(emoteEventMessage, listener) - ).then((gameRoom) => { - gaugeManager.incNbRoomGauge(); - resolve(gameRoom); - }).catch((e) => { - this.roomsPromises.delete(roomId); - reject(e); - }); + ) + .then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + resolve(gameRoom); + }) + .catch((e) => { + this.roomsPromises.delete(roomId); + reject(e); + }); }); this.roomsPromises.set(roomId, roomPromise); } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 36acd9e4..a900894c 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -1,14 +1,16 @@ /** * Handles variables shared between the scripting API and the server. */ -import {ITiledMap, ITiledMapObject, ITiledMapObjectLayer} from "@workadventure/tiled-map-type-guard/dist"; -import {User} from "_Model/User"; +import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist"; +import { User } from "_Model/User"; +import { variablesRepository } from "./Repository/VariablesRepository"; +import { redisClient } from "./RedisClient"; interface Variable { - defaultValue?: string, - persist?: boolean, - readableBy?: string, - writableBy?: string, + defaultValue?: string; + persist?: boolean; + readableBy?: string; + writableBy?: string; } export class VariablesManager { @@ -25,7 +27,7 @@ export class VariablesManager { /** * @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks. */ - constructor(private map: ITiledMap | null) { + constructor(private roomUrl: string, private map: ITiledMap | null) { // We initialize the list of variable object at room start. The objects cannot be edited later // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) if (map) { @@ -40,14 +42,43 @@ export class VariablesManager { } } + /** + * Let's load data from the Redis backend. + */ + public async init(): Promise { + if (!this.shouldPersist()) { + return; + } + const variables = await variablesRepository.loadVariables(this.roomUrl); + console.error("LIST OF VARIABLES FETCHED", variables); + for (const key in variables) { + this._variables.set(key, variables[key]); + } + } + + /** + * Returns true if saving should be enabled, and false otherwise. + * + * Saving is enabled if REDIS_HOST is set + * unless we are editing a local map + * unless we are in dev mode in which case it is ok to save + * + * @private + */ + private shouldPersist(): boolean { + return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development"); + } + private static findVariablesInMap(map: ITiledMap): Map { const objects = new Map(); for (const layer of map.layers) { - if (layer.type === 'objectgroup') { + if (layer.type === "objectgroup") { for (const object of (layer as ITiledMapObjectLayer).objects) { - if (object.type === 'variable') { + if (object.type === "variable") { if (object.template) { - console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.') + console.warn( + 'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.' + ); continue; } @@ -67,26 +98,30 @@ export class VariablesManager { for (const property of object.properties) { const value = property.value; switch (property.name) { - case 'default': + case "default": variable.defaultValue = JSON.stringify(value); break; - case 'persist': - if (typeof value !== 'boolean') { + case "persist": + if (typeof value !== "boolean") { throw new Error('The persist property of variable "' + object.name + '" must be a boolean'); } variable.persist = value; break; - case 'writableBy': - if (typeof value !== 'string') { - throw new Error('The writableBy property of variable "' + object.name + '" must be a string'); + case "writableBy": + if (typeof value !== "string") { + throw new Error( + 'The writableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.writableBy = value; } break; - case 'readableBy': - if (typeof value !== 'string') { - throw new Error('The readableBy property of variable "' + object.name + '" must be a string'); + case "readableBy": + if (typeof value !== "string") { + throw new Error( + 'The readableBy property of variable "' + object.name + '" must be a string' + ); } if (value) { variable.readableBy = value; @@ -107,14 +142,27 @@ export class VariablesManager { throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.'); } - if (variableObject.writableBy && user.tags.indexOf(variableObject.writableBy) === -1) { - throw new Error('Trying to set a variable "' + name + '". User "' + user.name + '" does not have sufficient permission. Required tag: "' + variableObject.writableBy + '". User tags: ' + user.tags.join(', ') + "."); + if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) { + throw new Error( + 'Trying to set a variable "' + + name + + '". User "' + + user.name + + '" does not have sufficient permission. Required tag: "' + + variableObject.writableBy + + '". User tags: ' + + user.tags.join(", ") + + "." + ); } readableBy = variableObject.readableBy; } this._variables.set(name, value); + variablesRepository + .saveVariable(this.roomUrl, name, value) + .catch((e) => console.error("Error while saving variable in Redis:", e)); return readableBy; } @@ -128,9 +176,9 @@ export class VariablesManager { for (const [key, value] of this._variables.entries()) { const variableObject = this.variableObjects.get(key); if (variableObject === undefined) { - throw new Error('Unexpected variable "'+key+'" found has no associated variableObject.'); + throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.'); } - if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) { + if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) { readableVariables.set(key, value); } } diff --git a/back/tests/GameRoomTest.ts b/back/tests/GameRoomTest.ts index 6bdc6912..4b1b519a 100644 --- a/back/tests/GameRoomTest.ts +++ b/back/tests/GameRoomTest.ts @@ -37,7 +37,7 @@ function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMess const emote: EmoteCallback = (emoteEventMessage, listener): void => {} describe("GameRoom", () => { - it("should connect user1 and user2", () => { + it("should connect user1 and user2", async () => { let connectCalledNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalledNumber++; @@ -47,8 +47,7 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); - + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); @@ -67,7 +66,7 @@ describe("GameRoom", () => { expect(connectCalledNumber).toBe(2); }); - it("should connect 3 users", () => { + it("should connect 3 users", async () => { let connectCalled: boolean = false; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalled = true; @@ -76,7 +75,7 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); @@ -95,7 +94,7 @@ describe("GameRoom", () => { expect(connectCalled).toBe(true); }); - it("should disconnect user1 and user2", () => { + it("should disconnect user1 and user2", async () => { let connectCalled: boolean = false; let disconnectCallNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { @@ -105,7 +104,7 @@ describe("GameRoom", () => { disconnectCallNumber++; } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); diff --git a/back/yarn.lock b/back/yarn.lock index 54833963..98d675ee 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -122,6 +122,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/redis@^2.8.31": + version "2.8.31" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.31.tgz#c11c1b269fec132ac2ec9eb891edf72fc549149e" + integrity sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA== + dependencies: + "@types/node" "*" + "@types/strip-bom@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" @@ -804,6 +811,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" + integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== + detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -2441,6 +2453,33 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" +redis-commands@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +redis@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c" + integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw== + dependencies: + denque "^1.5.0" + redis-commands "^1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" diff --git a/deeployer.libsonnet b/deeployer.libsonnet index 8d9c2bfd..494c72b8 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -22,6 +22,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -40,6 +41,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -97,6 +99,9 @@ }, "ports": [80] }, + "redis": { + "image": "redis:6", + } }, "config": { k8sextension(k8sConf):: diff --git a/docker-compose.single-domain.yaml b/docker-compose.single-domain.yaml index 345ccf8d..b2e9b7c8 100644 --- a/docker-compose.single-domain.yaml +++ b/docker-compose.single-domain.yaml @@ -120,6 +120,8 @@ services: JITSI_URL: $JITSI_URL JITSI_ISS: $JITSI_ISS MAX_PER_GROUP: "$MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -168,6 +170,9 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docker-compose.yaml b/docker-compose.yaml index 1c1bcb8f..d0254d21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -115,6 +115,8 @@ services: JITSI_ISS: $JITSI_ISS TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret MAX_PER_GROUP: "MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -157,6 +159,20 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + + redisinsight: + image: redislabs/redisinsight:latest + labels: + - "traefik.http.routers.redisinsight.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight.entryPoints=web" + - "traefik.http.services.redisinsight.loadbalancer.server.port=8001" + - "traefik.http.routers.redisinsight-ssl.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight-ssl.entryPoints=websecure" + - "traefik.http.routers.redisinsight-ssl.tls=true" + - "traefik.http.routers.redisinsight-ssl.service=redisinsight" + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md index 6b74389b..38352861 100644 --- a/docs/maps/api-state.md +++ b/docs/maps/api-state.md @@ -82,6 +82,8 @@ The object **type** MUST be **variable**. You can set a default value for the object in the `default` property. +#### Persisting variables state + Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the server restarts). @@ -89,11 +91,13 @@ server restarts). {.alert.alert-info} Do not use `persist` for highly dynamic values that have a short life spawn. +#### Managing access rights to variables + With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string representing a "tag". Anyone having this "tag" can read/write in the variable. {.alert.alert-warning} -`readableBy` and `writableBy` are specific to the public version of WorkAdventure because the notion of tags +`readableBy` and `writableBy` are specific to the "online" version of WorkAdventure because the notion of tags is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index d7cfffe4..89ed772a 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -7,7 +7,8 @@ import { apiClientRepository } from "../Services/ApiClientRepository"; import { BatchToPusherMessage, BatchToPusherRoomMessage, - EmoteEventMessage, ErrorMessage, + EmoteEventMessage, + ErrorMessage, GroupLeftZoneMessage, GroupUpdateZoneMessage, RoomMessage, @@ -15,7 +16,8 @@ import { UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, - VariableMessage, VariableWithTagMessage, + VariableMessage, + VariableWithTagMessage, ZoneMessage, } from "../Messages/generated/messages_pb"; import Debug from "debug"; @@ -99,7 +101,7 @@ export class PusherRoom { // Let's dispatch this variable to all the listeners for (const listener of this.listeners) { const subMessage = new SubMessage(); - if (!readableBy || listener.tags.indexOf(readableBy) !== -1) { + if (!readableBy || listener.tags.includes(readableBy)) { subMessage.setVariablemessage(variableMessage); } listener.emitInBatch(subMessage); diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index d116bb79..d5a6058f 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -14,7 +14,8 @@ import { UserMovedMessage, ZoneMessage, EmoteEventMessage, - CompanionMessage, ErrorMessage, + CompanionMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import { ClientReadableStream } from "grpc"; import { PositionDispatcher } from "_Model/PositionDispatcher"; diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index dfd9c15a..bd3e2cad 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -30,7 +30,8 @@ import { BanMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, ErrorMessage, + VariableMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";