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",
"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",

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 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,

View File

@ -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<GameRoom> {
): Promise<GameRoom> {
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<ITiledMap>|undefined;
private mapPromise: Promise<ITiledMap> | undefined;
/**
* Returns a promise to the map file.
@ -418,27 +430,45 @@ export class GameRoom {
return this.mapPromise;
}
private variableManagerPromise: Promise<VariablesManager>|undefined;
private variableManagerPromise: Promise<VariablesManager> | undefined;
private getVariableManager(): Promise<VariablesManager> {
if (!this.variableManagerPromise) {
this.variableManagerPromise = new Promise<VariablesManager>((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;

View File

@ -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<AdminMessage>, callback: sendUnaryData<EmptyMessage>): 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<BanMessage>, callback: sendUnaryData<EmptyMessage>): 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<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): 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<EmptyMessage>
): 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<EmptyMessage>
): 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 };

View File

@ -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<typeof isMapDetailsData>;

View File

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

View File

@ -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<ITiledMap> {
// 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<boolean> {
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;
}
}

View File

@ -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();

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,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);
}

View File

@ -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<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> {
const objects = new Map<string, Variable>();
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);
}
}

View File

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

View File

@ -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"

View File

@ -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)::

View File

@ -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:

View File

@ -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:

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.
#### 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.

View File

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

View File

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

View File

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