Adding support to persist variables in Redis

This commit is contained in:
David Négrier 2021-07-19 15:57:50 +02:00
parent 18e4d2ba4e
commit d955ddfe82
24 changed files with 397 additions and 120 deletions

View File

@ -53,6 +53,7 @@
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"prom-client": "^12.0.0", "prom-client": "^12.0.0",
"query-string": "^6.13.3", "query-string": "^6.13.3",
"redis": "^3.1.2",
"systeminformation": "^4.31.1", "systeminformation": "^4.31.1",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",
"uuidv4": "^6.0.7" "uuidv4": "^6.0.7"
@ -66,6 +67,7 @@
"@types/jasmine": "^3.5.10", "@types/jasmine": "^3.5.10",
"@types/jsonwebtoken": "^8.3.8", "@types/jsonwebtoken": "^8.3.8",
"@types/mkdirp": "^1.0.1", "@types/mkdirp": "^1.0.1",
"@types/redis": "^2.8.31",
"@types/uuidv4": "^5.0.0", "@types/uuidv4": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0", "@typescript-eslint/parser": "^2.26.0",

View File

@ -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 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 TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || "";
export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); 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 { export {
MINIMUM_DISTANCE, MINIMUM_DISTANCE,

View File

@ -11,7 +11,8 @@ import {
EmoteEventMessage, EmoteEventMessage,
JoinRoomMessage, JoinRoomMessage,
SubToPusherRoomMessage, SubToPusherRoomMessage,
VariableMessage, VariableWithTagMessage, VariableMessage,
VariableWithTagMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { RoomSocket, ZoneSocket } from "src/RoomManager";
@ -71,7 +72,18 @@ export class GameRoom {
): Promise<GameRoom> { ): Promise<GameRoom> {
const mapDetails = await GameRoom.getMapDetails(roomUrl); 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; return gameRoom;
} }
@ -381,7 +393,7 @@ export class GameRoom {
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname);
if (!match) { if (!match) {
console.error('Unexpected room URL', roomUrl); console.error("Unexpected room URL", roomUrl);
throw new Error('Unexpected room URL "' + roomUrl + '"'); throw new Error('Unexpected room URL "' + roomUrl + '"');
} }
@ -392,13 +404,13 @@ export class GameRoom {
policy_type: 1, policy_type: 1,
textures: [], textures: [],
tags: [], tags: [],
} };
} }
const result = await adminApi.fetchMapDetails(roomUrl); const result = await adminApi.fetchMapDetails(roomUrl);
if (!isMapDetailsData(result)) { if (!isMapDetailsData(result)) {
console.error('Unexpected room details received from server', result); console.error("Unexpected room details received from server", result);
throw new Error('Unexpected room details received from server'); throw new Error("Unexpected room details received from server");
} }
return result; return result;
} }
@ -423,9 +435,19 @@ export class GameRoom {
private getVariableManager(): Promise<VariablesManager> { private getVariableManager(): Promise<VariablesManager> {
if (!this.variableManagerPromise) { if (!this.variableManagerPromise) {
this.variableManagerPromise = new Promise<VariablesManager>((resolve, reject) => { this.variableManagerPromise = new Promise<VariablesManager>((resolve, reject) => {
this.getMap().then((map) => { this.getMap()
resolve(new VariablesManager(map)); .then((map) => {
}).catch(e => { const variablesManager = new VariablesManager(this.roomUrl, map);
variablesManager
.init()
.then(() => {
resolve(variablesManager);
})
.catch((e) => {
reject(e);
});
})
.catch((e) => {
if (e instanceof LocalUrlError) { if (e instanceof LocalUrlError) {
// If we are trying to load a local URL, we are probably in test mode. // 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. // In this case, let's bypass the server-side checks completely.
@ -434,11 +456,19 @@ export class GameRoom {
// 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
// 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)); const variablesManager = new VariablesManager(this.roomUrl, null);
variablesManager
.init()
.then(() => {
resolve(variablesManager);
})
.catch((e) => {
reject(e);
});
} else { } else {
reject(e); reject(e);
} }
}) });
}); });
} }
return this.variableManagerPromise; return this.variableManagerPromise;

View File

@ -4,7 +4,9 @@ import {
AdminMessage, AdminMessage,
AdminPusherToBackMessage, AdminPusherToBackMessage,
AdminRoomMessage, AdminRoomMessage,
BanMessage, BatchToPusherMessage, BatchToPusherRoomMessage, BanMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmotePromptMessage, EmotePromptMessage,
EmptyMessage, EmptyMessage,
ItemEventMessage, ItemEventMessage,
@ -12,10 +14,12 @@ import {
PlayGlobalMessage, PlayGlobalMessage,
PusherToBackMessage, PusherToBackMessage,
QueryJitsiJwtMessage, QueryJitsiJwtMessage,
RefreshRoomPromptMessage, RoomMessage, RefreshRoomPromptMessage,
RoomMessage,
ServerToAdminClientMessage, ServerToAdminClientMessage,
SilentMessage, SilentMessage,
UserMovesMessage, VariableMessage, UserMovesMessage,
VariableMessage,
WebRtcSignalToServerMessage, WebRtcSignalToServerMessage,
WorldFullWarningToRoomMessage, WorldFullWarningToRoomMessage,
ZoneMessage, ZoneMessage,
@ -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. //Connexion may have been closed before the init was finished, so we have to manually disconnect the user.
socketManager.leaveRoom(gameRoom, myUser); socketManager.leaveRoom(gameRoom, myUser);
} }
}).catch(e => emitError(call, e)); })
.catch((e) => emitError(call, e));
} else { } else {
throw new Error("The first message sent MUST be of type JoinRoomMessage"); throw new Error("The first message sent MUST be of type JoinRoomMessage");
} }
@ -138,22 +143,30 @@ const roomManager: IRoomManagerServer = {
debug("listenZone called"); debug("listenZone called");
const zoneMessage = call.request; const zoneMessage = call.request;
socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => { socketManager
.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => {
emitErrorOnZoneSocket(call, e.toString()); emitErrorOnZoneSocket(call, e.toString());
}); });
call.on("cancelled", () => { call.on("cancelled", () => {
debug("listenZone 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.end();
}); });
call.on("close", () => { call.on("close", () => {
debug("listenZone connection closed"); 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) => { }).on("error", (e) => {
console.error("An error occurred in listenZone stream:", 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(); call.end();
}); });
}, },
@ -162,25 +175,24 @@ const roomManager: IRoomManagerServer = {
debug("listenRoom called"); debug("listenRoom called");
const roomMessage = call.request; const roomMessage = call.request;
socketManager.addRoomListener(call, roomMessage.getRoomid()).catch(e => { socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => {
emitErrorOnRoomSocket(call, e.toString()); emitErrorOnRoomSocket(call, e.toString());
}); });
call.on("cancelled", () => { call.on("cancelled", () => {
debug("listenRoom 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.end();
}); });
call.on("close", () => { call.on("close", () => {
debug("listenRoom connection closed"); 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) => { }).on("error", (e) => {
console.error("An error occurred in listenRoom stream:", 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(); call.end();
}); });
}, },
adminRoom(call: AdminSocket): void { adminRoom(call: AdminSocket): void {
@ -194,9 +206,12 @@ const roomManager: IRoomManagerServer = {
if (room === null) { if (room === null) {
if (message.hasSubscribetoroom()) { if (message.hasSubscribetoroom()) {
const roomId = message.getSubscribetoroom(); const roomId = message.getSubscribetoroom();
socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { socketManager
.handleJoinAdminRoom(admin, roomId)
.then((gameRoom: GameRoom) => {
room = gameRoom; room = gameRoom;
}).catch(e => console.error(e)); })
.catch((e) => console.error(e));
} else { } else {
throw new Error("The first message sent MUST be of type JoinRoomMessage"); throw new Error("The first message sent MUST be of type JoinRoomMessage");
} }
@ -221,11 +236,9 @@ const roomManager: IRoomManagerServer = {
}); });
}, },
sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminMessage( socketManager
call.request.getRoomid(), .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
call.request.getRecipientuuid(), .catch((e) => console.error(e));
call.request.getMessage()
).catch(e => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
@ -236,13 +249,17 @@ const roomManager: IRoomManagerServer = {
}, },
ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void { ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME Work in progress // 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()); callback(null, new EmptyMessage());
}, },
sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message // 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()); callback(null, new EmptyMessage());
}, },
sendWorldFullWarningToRoom( sendWorldFullWarningToRoom(
@ -250,7 +267,7 @@ const roomManager: IRoomManagerServer = {
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message // 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()); callback(null, new EmptyMessage());
}, },
sendRefreshRoomPrompt( sendRefreshRoomPrompt(
@ -258,9 +275,9 @@ const roomManager: IRoomManagerServer = {
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message // 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()); callback(null, new EmptyMessage());
} },
}; };
export { roomManager }; export { roomManager };

View File

@ -1,2 +1 @@
export class LocalUrlError extends Error { export class LocalUrlError extends Error {}
}

View File

@ -1,7 +1,7 @@
import Axios from "axios"; import Axios from "axios";
import ipaddr from 'ipaddr.js'; import ipaddr from "ipaddr.js";
import { Resolver } from 'dns'; import { Resolver } from "dns";
import { promisify } from 'util'; import { promisify } from "util";
import { LocalUrlError } from "./LocalUrlError"; import { LocalUrlError } from "./LocalUrlError";
import { ITiledMap } from "@workadventure/tiled-map-type-guard"; import { ITiledMap } from "@workadventure/tiled-map-type-guard";
import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist"; import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist";
@ -27,7 +27,7 @@ class MapFetcher {
}); });
if (!isTiledMap(res.data)) { 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; return res.data;
@ -39,7 +39,7 @@ class MapFetcher {
*/ */
private async isLocalUrl(url: string): Promise<boolean> { private async isLocalUrl(url: string): Promise<boolean> {
const urlObj = new URL(url); const urlObj = new URL(url);
if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) { if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) {
return true; return true;
} }
@ -53,7 +53,7 @@ class MapFetcher {
for (const address of addresses) { for (const address of addresses) {
const addr = ipaddr.parse(address); const addr = ipaddr.parse(address);
if (addr.range() !== 'unicast') { if (addr.range() !== "unicast") {
return true; return true;
} }
} }

View File

@ -1,8 +1,11 @@
import { import {
BatchMessage, BatchMessage,
BatchToPusherMessage, BatchToPusherRoomMessage, BatchToPusherMessage,
BatchToPusherRoomMessage,
ErrorMessage, ErrorMessage,
ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage ServerToClientMessage,
SubToPusherMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { UserSocket } from "_Model/User"; import { UserSocket } from "_Model/User";
import { RoomSocket, ZoneSocket } from "../RoomManager"; import { RoomSocket, ZoneSocket } from "../RoomManager";

View File

@ -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 };

View File

@ -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<number>>;
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<number> {
// 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);
}
}

View File

@ -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 };

View File

@ -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<number>;
}

View File

@ -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<number> {
return Promise.resolve(0);
}
}

View File

@ -305,10 +305,12 @@ export class SocketManager {
this.onClientLeave(thing, newZone, listener), this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener) this.onEmote(emoteEventMessage, listener)
).then((gameRoom) => { )
.then((gameRoom) => {
gaugeManager.incNbRoomGauge(); gaugeManager.incNbRoomGauge();
resolve(gameRoom); resolve(gameRoom);
}).catch((e) => { })
.catch((e) => {
this.roomsPromises.delete(roomId); this.roomsPromises.delete(roomId);
reject(e); reject(e);
}); });

View File

@ -3,12 +3,14 @@
*/ */
import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist"; import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist";
import { User } from "_Model/User"; import { User } from "_Model/User";
import { variablesRepository } from "./Repository/VariablesRepository";
import { redisClient } from "./RedisClient";
interface Variable { interface Variable {
defaultValue?: string, defaultValue?: string;
persist?: boolean, persist?: boolean;
readableBy?: string, readableBy?: string;
writableBy?: string, writableBy?: string;
} }
export class VariablesManager { 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. * @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 // 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) // (otherwise, this would cause a security issue if the scripting API can edit this list of objects)
if (map) { if (map) {
@ -40,14 +42,43 @@ export class VariablesManager {
} }
} }
/**
* Let's load data from the Redis backend.
*/
public async init(): Promise<void> {
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<string, Variable> { private static findVariablesInMap(map: ITiledMap): Map<string, Variable> {
const objects = new Map<string, Variable>(); const objects = new Map<string, Variable>();
for (const layer of map.layers) { for (const layer of map.layers) {
if (layer.type === 'objectgroup') { if (layer.type === "objectgroup") {
for (const object of (layer as ITiledMapObjectLayer).objects) { for (const object of (layer as ITiledMapObjectLayer).objects) {
if (object.type === 'variable') { if (object.type === "variable") {
if (object.template) { 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; continue;
} }
@ -67,26 +98,30 @@ export class VariablesManager {
for (const property of object.properties) { for (const property of object.properties) {
const value = property.value; const value = property.value;
switch (property.name) { switch (property.name) {
case 'default': case "default":
variable.defaultValue = JSON.stringify(value); variable.defaultValue = JSON.stringify(value);
break; break;
case 'persist': case "persist":
if (typeof value !== 'boolean') { if (typeof value !== "boolean") {
throw new Error('The persist property of variable "' + object.name + '" must be a boolean'); throw new Error('The persist property of variable "' + object.name + '" must be a boolean');
} }
variable.persist = value; variable.persist = value;
break; break;
case 'writableBy': case "writableBy":
if (typeof value !== 'string') { if (typeof value !== "string") {
throw new Error('The writableBy property of variable "' + object.name + '" must be a string'); throw new Error(
'The writableBy property of variable "' + object.name + '" must be a string'
);
} }
if (value) { if (value) {
variable.writableBy = value; variable.writableBy = value;
} }
break; break;
case 'readableBy': case "readableBy":
if (typeof value !== 'string') { if (typeof value !== "string") {
throw new Error('The readableBy property of variable "' + object.name + '" must be a string'); throw new Error(
'The readableBy property of variable "' + object.name + '" must be a string'
);
} }
if (value) { if (value) {
variable.readableBy = 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.'); 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) { 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(', ') + "."); 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; readableBy = variableObject.readableBy;
} }
this._variables.set(name, value); this._variables.set(name, value);
variablesRepository
.saveVariable(this.roomUrl, name, value)
.catch((e) => console.error("Error while saving variable in Redis:", e));
return readableBy; return readableBy;
} }
@ -130,7 +178,7 @@ export class VariablesManager {
if (variableObject === undefined) { 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); readableVariables.set(key, value);
} }
} }

View File

@ -37,7 +37,7 @@ function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMess
const emote: EmoteCallback = (emoteEventMessage, listener): void => {} const emote: EmoteCallback = (emoteEventMessage, listener): void => {}
describe("GameRoom", () => { describe("GameRoom", () => {
it("should connect user1 and user2", () => { it("should connect user1 and user2", async () => {
let connectCalledNumber: number = 0; let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalledNumber++; 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)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));
@ -67,7 +66,7 @@ describe("GameRoom", () => {
expect(connectCalledNumber).toBe(2); expect(connectCalledNumber).toBe(2);
}); });
it("should connect 3 users", () => { it("should connect 3 users", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true; 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)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));
@ -95,7 +94,7 @@ describe("GameRoom", () => {
expect(connectCalled).toBe(true); expect(connectCalled).toBe(true);
}); });
it("should disconnect user1 and user2", () => { it("should disconnect user1 and user2", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
let disconnectCallNumber: number = 0; let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
@ -105,7 +104,7 @@ describe("GameRoom", () => {
disconnectCallNumber++; 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)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));

View File

@ -122,6 +122,13 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== 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": "@types/strip-bom@^3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" 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" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= 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: detect-libc@^1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" 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" indent-string "^2.1.0"
strip-indent "^1.0.1" 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: regex-not@^1.0.0, regex-not@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"

View File

@ -22,6 +22,7 @@
"JITSI_URL": env.JITSI_URL, "JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET,
"REDIS_HOST": "redis",
} + (if adminUrl != null then { } + (if adminUrl != null then {
"ADMIN_API_URL": adminUrl, "ADMIN_API_URL": adminUrl,
} else {}) } else {})
@ -40,6 +41,7 @@
"JITSI_URL": env.JITSI_URL, "JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET,
"REDIS_HOST": "redis",
} + (if adminUrl != null then { } + (if adminUrl != null then {
"ADMIN_API_URL": adminUrl, "ADMIN_API_URL": adminUrl,
} else {}) } else {})
@ -97,6 +99,9 @@
}, },
"ports": [80] "ports": [80]
}, },
"redis": {
"image": "redis:6",
}
}, },
"config": { "config": {
k8sextension(k8sConf):: k8sextension(k8sConf)::

View File

@ -120,6 +120,8 @@ services:
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
MAX_PER_GROUP: "$MAX_PER_GROUP" MAX_PER_GROUP: "$MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes: volumes:
- ./back:/usr/src/app - ./back:/usr/src/app
labels: labels:
@ -168,6 +170,9 @@ services:
- ./front:/usr/src/front - ./front:/usr/src/front
- ./pusher:/usr/src/pusher - ./pusher:/usr/src/pusher
redis:
image: redis:6
# coturn: # coturn:
# image: coturn/coturn:4.5.2 # image: coturn/coturn:4.5.2
# command: # command:

View File

@ -115,6 +115,8 @@ services:
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret
MAX_PER_GROUP: "MAX_PER_GROUP" MAX_PER_GROUP: "MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes: volumes:
- ./back:/usr/src/app - ./back:/usr/src/app
labels: labels:
@ -157,6 +159,20 @@ services:
- ./front:/usr/src/front - ./front:/usr/src/front
- ./pusher:/usr/src/pusher - ./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: # coturn:
# image: coturn/coturn:4.5.2 # image: coturn/coturn:4.5.2
# command: # command:

View File

@ -82,6 +82,8 @@ The object **type** MUST be **variable**.
You can set a default value for the object in the `default` property. 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 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 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). server restarts).
@ -89,11 +91,13 @@ server restarts).
{.alert.alert-info} {.alert.alert-info}
Do not use `persist` for highly dynamic values that have a short life spawn. 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 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. representing a "tag". Anyone having this "tag" can read/write in the variable.
{.alert.alert-warning} {.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). 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. Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable.

View File

@ -7,7 +7,8 @@ import { apiClientRepository } from "../Services/ApiClientRepository";
import { import {
BatchToPusherMessage, BatchToPusherMessage,
BatchToPusherRoomMessage, BatchToPusherRoomMessage,
EmoteEventMessage, ErrorMessage, EmoteEventMessage,
ErrorMessage,
GroupLeftZoneMessage, GroupLeftZoneMessage,
GroupUpdateZoneMessage, GroupUpdateZoneMessage,
RoomMessage, RoomMessage,
@ -15,7 +16,8 @@ import {
UserJoinedZoneMessage, UserJoinedZoneMessage,
UserLeftZoneMessage, UserLeftZoneMessage,
UserMovedMessage, UserMovedMessage,
VariableMessage, VariableWithTagMessage, VariableMessage,
VariableWithTagMessage,
ZoneMessage, ZoneMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import Debug from "debug"; import Debug from "debug";
@ -99,7 +101,7 @@ export class PusherRoom {
// Let's dispatch this variable to all the listeners // Let's dispatch this variable to all the listeners
for (const listener of this.listeners) { for (const listener of this.listeners) {
const subMessage = new SubMessage(); const subMessage = new SubMessage();
if (!readableBy || listener.tags.indexOf(readableBy) !== -1) { if (!readableBy || listener.tags.includes(readableBy)) {
subMessage.setVariablemessage(variableMessage); subMessage.setVariablemessage(variableMessage);
} }
listener.emitInBatch(subMessage); listener.emitInBatch(subMessage);

View File

@ -14,7 +14,8 @@ import {
UserMovedMessage, UserMovedMessage,
ZoneMessage, ZoneMessage,
EmoteEventMessage, EmoteEventMessage,
CompanionMessage, ErrorMessage, CompanionMessage,
ErrorMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ClientReadableStream } from "grpc"; import { ClientReadableStream } from "grpc";
import { PositionDispatcher } from "_Model/PositionDispatcher"; import { PositionDispatcher } from "_Model/PositionDispatcher";

View File

@ -30,7 +30,8 @@ import {
BanMessage, BanMessage,
RefreshRoomMessage, RefreshRoomMessage,
EmotePromptMessage, EmotePromptMessage,
VariableMessage, ErrorMessage, VariableMessage,
ErrorMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";