From dbd5b80636a5a2c5fa22641f222864af0df56e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 19 Jul 2021 10:16:43 +0200 Subject: [PATCH] Adding support for "readableBy" and "writableBy" in back This means that we are now loading maps from server side. --- back/package.json | 2 + back/src/Model/GameRoom.ts | 150 ++++++++++++++---- back/src/RoomManager.ts | 44 ++--- back/src/Services/AdminApi.ts | 24 +++ .../src/Services/AdminApi/CharacterTexture.ts | 11 ++ back/src/Services/AdminApi/MapDetailsData.ts | 21 +++ back/src/Services/AdminApi/RoomRedirect.ts | 8 + back/src/Services/LocalUrlError.ts | 2 + back/src/Services/MapFetcher.ts | 64 ++++++++ back/src/Services/MessageHelpers.ts | 44 ++++- back/src/Services/SocketManager.ts | 137 ++++++++-------- back/src/Services/VariablesManager.ts | 139 ++++++++++++++++ back/tsconfig.json | 2 +- back/yarn.lock | 17 ++ front/src/Connexion/RoomConnection.ts | 5 +- .../src/Phaser/Game/SharedVariablesManager.ts | 6 +- maps/tests/Variables/script.js | 7 + maps/tests/Variables/shared_variables.json | 131 +++++++++++++++ maps/tests/Variables/variables.json | 25 ++- messages/protos/messages.proto | 14 +- pusher/src/Model/PusherRoom.ts | 24 ++- pusher/src/Model/Zone.ts | 12 +- pusher/src/Services/SocketManager.ts | 9 +- pusher/tsconfig.json | 2 +- 24 files changed, 768 insertions(+), 132 deletions(-) create mode 100644 back/src/Services/AdminApi.ts create mode 100644 back/src/Services/AdminApi/CharacterTexture.ts create mode 100644 back/src/Services/AdminApi/MapDetailsData.ts create mode 100644 back/src/Services/AdminApi/RoomRedirect.ts create mode 100644 back/src/Services/LocalUrlError.ts create mode 100644 back/src/Services/MapFetcher.ts create mode 100644 back/src/Services/VariablesManager.ts create mode 100644 maps/tests/Variables/shared_variables.json diff --git a/back/package.json b/back/package.json index 7015b9b8..a532f5cd 100644 --- a/back/package.json +++ b/back/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/thecodingmachine/workadventure#readme", "dependencies": { + "@workadventure/tiled-map-type-guard": "^1.0.0", "axios": "^0.21.1", "busboy": "^0.3.1", "circular-json": "^0.5.9", @@ -47,6 +48,7 @@ "generic-type-guard": "^3.2.0", "google-protobuf": "^3.13.0", "grpc": "^1.24.4", + "ipaddr.js": "^2.0.1", "jsonwebtoken": "^8.5.1", "mkdirp": "^1.0.4", "prom-client": "^12.0.0", diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index f26fd459..fd711ae8 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -11,39 +11,54 @@ import { EmoteEventMessage, JoinRoomMessage, SubToPusherRoomMessage, - VariableMessage, + 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"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; export class GameRoom { - private readonly minDistance: number; - private readonly groupRadius: number; - // Users, sorted by ID - private readonly users: Map; - private readonly usersByUuid: Map; - private readonly groups: Set; - private readonly admins: Set; - - private readonly connectCallback: ConnectCallback; - private readonly disconnectCallback: DisconnectCallback; + private readonly users = new Map(); + private readonly usersByUuid = new Map(); + private readonly groups = new Set(); + private readonly admins = new Set(); private itemsState = new Map(); - public readonly variables = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; private roomListeners: Set = new Set(); - constructor( + private constructor( + public readonly roomUrl: string, + private mapUrl: string, + private readonly connectCallback: ConnectCallback, + private readonly disconnectCallback: DisconnectCallback, + private readonly minDistance: number, + private readonly groupRadius: number, + onEnters: EntersCallback, + onMoves: MovesCallback, + onLeaves: LeavesCallback, + onEmote: EmoteCallback + ) { + // A zone is 10 sprites wide. + this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + } + + public static async create( roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, @@ -53,19 +68,12 @@ export class GameRoom { onMoves: MovesCallback, onLeaves: LeavesCallback, onEmote: EmoteCallback - ) { - this.roomUrl = roomUrl; + ) : Promise { + const mapDetails = await GameRoom.getMapDetails(roomUrl); - this.users = new Map(); - this.usersByUuid = new Map(); - this.admins = new Set(); - this.groups = new Set(); - this.connectCallback = connectCallback; - this.disconnectCallback = disconnectCallback; - this.minDistance = minDistance; - this.groupRadius = groupRadius; - // A zone is 10 sprites wide. - this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + const gameRoom = new GameRoom(roomUrl, mapDetails.mapUrl, connectCallback, disconnectCallback, minDistance, groupRadius, onEnters, onMoves, onLeaves, onEmote); + + return gameRoom; } public getGroups(): Group[] { @@ -299,13 +307,19 @@ export class GameRoom { return this.itemsState; } - public setVariable(name: string, value: string): void { - this.variables.set(name, value); + public async setVariable(name: string, value: string, user: User): Promise { + // First, let's check if "user" is allowed to modify the variable. + const variableManager = await this.getVariableManager(); + + const readableBy = variableManager.setVariable(name, value, user); // TODO: should we batch those every 100ms? - const variableMessage = new VariableMessage(); + const variableMessage = new VariableWithTagMessage(); variableMessage.setName(name); variableMessage.setValue(value); + if (readableBy) { + variableMessage.setReadableby(readableBy); + } const subMessage = new SubToPusherRoomMessage(); subMessage.setVariablemessage(variableMessage); @@ -356,4 +370,82 @@ export class GameRoom { public removeRoomListener(socket: RoomSocket) { this.roomListeners.delete(socket); } + + /** + * Connects to the admin server to fetch map details. + * If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url) + */ + private static async getMapDetails(roomUrl: string): Promise { + if (!ADMIN_API_URL) { + const roomUrlObj = new URL(roomUrl); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); + if (!match) { + console.error('Unexpected room URL', roomUrl); + throw new Error('Unexpected room URL "' + roomUrl + '"'); + } + + const mapUrl = roomUrlObj.protocol + "//" + match[1]; + + return { + mapUrl, + 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'); + } + return result; + } + + private mapPromise: Promise|undefined; + + /** + * Returns a promise to the map file. + * @throws LocalUrlError if the map we are trying to load is hosted on a local network + * @throws Error + */ + private getMap(): Promise { + if (!this.mapPromise) { + this.mapPromise = mapFetcher.fetchMap(this.mapUrl); + } + + return this.mapPromise; + } + + 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. + + // 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); + } + }) + }); + } + return this.variableManagerPromise; + } + + public async getVariablesForTags(tags: string[]): Promise> { + const variablesManager = await this.getVariableManager(); + return variablesManager.getVariablesForTags(tags); + } } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index d4dcc6d4..a6a99993 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -4,7 +4,7 @@ import { AdminMessage, AdminPusherToBackMessage, AdminRoomMessage, - BanMessage, BatchToPusherRoomMessage, + BanMessage, BatchToPusherMessage, BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -14,7 +14,6 @@ import { QueryJitsiJwtMessage, RefreshRoomPromptMessage, RoomMessage, ServerToAdminClientMessage, - ServerToClientMessage, SilentMessage, UserMovesMessage, VariableMessage, WebRtcSignalToServerMessage, @@ -23,7 +22,7 @@ import { } from "./Messages/generated/messages_pb"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { socketManager } from "./Services/SocketManager"; -import { emitError } 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"; @@ -32,7 +31,7 @@ import { Admin } from "./Model/Admin"; const debug = Debug("roommanager"); export type AdminSocket = ServerDuplexStream; -export type ZoneSocket = ServerWritableStream; +export type ZoneSocket = ServerWritableStream; export type RoomSocket = ServerWritableStream; const roomManager: IRoomManagerServer = { @@ -56,7 +55,7 @@ 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)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -139,20 +138,22 @@ const roomManager: IRoomManagerServer = { debug("listenZone called"); const zoneMessage = call.request; - socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + 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()); + 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()); + 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()); + socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()).catch(e => console.error(e)); call.end(); }); }, @@ -161,20 +162,22 @@ const roomManager: IRoomManagerServer = { debug("listenRoom called"); const roomMessage = call.request; - socketManager.addRoomListener(call, roomMessage.getRoomid()); + socketManager.addRoomListener(call, roomMessage.getRoomid()).catch(e => { + emitErrorOnRoomSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenRoom cancelled"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); call.end(); }); call.on("close", () => { debug("listenRoom connection closed"); - socketManager.removeRoomListener(call, roomMessage.getRoomid()); + 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()); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch(e => console.error(e)); call.end(); }); @@ -193,7 +196,7 @@ const roomManager: IRoomManagerServer = { const roomId = message.getSubscribetoroom(); 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"); } @@ -222,7 +225,7 @@ const roomManager: IRoomManagerServer = { call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage() - ); + ).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, @@ -233,26 +236,29 @@ const roomManager: IRoomManagerServer = { }, ban(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME Work in progress - socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()); + 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 { - socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()); + // 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)); callback(null, new EmptyMessage()); }, sendWorldFullWarningToRoom( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchWorlFullWarning(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch(e => console.error(e)); callback(null, new EmptyMessage()); }, sendRefreshRoomPrompt( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchRoomRefresh(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch(e => console.error(e)); callback(null, new EmptyMessage()); } }; diff --git a/back/src/Services/AdminApi.ts b/back/src/Services/AdminApi.ts new file mode 100644 index 00000000..158a47c1 --- /dev/null +++ b/back/src/Services/AdminApi.ts @@ -0,0 +1,24 @@ +import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import Axios from "axios"; +import { MapDetailsData } from "./AdminApi/MapDetailsData"; +import { RoomRedirect } from "./AdminApi/RoomRedirect"; + +class AdminApi { + async fetchMapDetails(playUri: string): Promise { + if (!ADMIN_API_URL) { + return Promise.reject(new Error("No admin backoffice set!")); + } + + const params: { playUri: string } = { + playUri, + }; + + const res = await Axios.get(ADMIN_API_URL + "/api/map", { + headers: { Authorization: `${ADMIN_API_TOKEN}` }, + params, + }); + return res.data; + } +} + +export const adminApi = new AdminApi(); diff --git a/back/src/Services/AdminApi/CharacterTexture.ts b/back/src/Services/AdminApi/CharacterTexture.ts new file mode 100644 index 00000000..055b3033 --- /dev/null +++ b/back/src/Services/AdminApi/CharacterTexture.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isCharacterTexture = new tg.IsInterface() + .withProperties({ + id: tg.isNumber, + level: tg.isNumber, + url: tg.isString, + rights: tg.isString, + }) + .get(); +export type CharacterTexture = tg.GuardedType; diff --git a/back/src/Services/AdminApi/MapDetailsData.ts b/back/src/Services/AdminApi/MapDetailsData.ts new file mode 100644 index 00000000..54320791 --- /dev/null +++ b/back/src/Services/AdminApi/MapDetailsData.ts @@ -0,0 +1,21 @@ +import * as tg from "generic-type-guard"; +import { isCharacterTexture } from "./CharacterTexture"; +import { isAny, isNumber } from "generic-type-guard"; + +/*const isNumericEnum = + (vs: T) => + (v: any): v is T => + typeof v === "number" && v in vs;*/ + +export const isMapDetailsData = new tg.IsInterface() + .withProperties({ + mapUrl: tg.isString, + policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes), + tags: tg.isArray(tg.isString), + textures: tg.isArray(isCharacterTexture), + }) + .withOptionalProperties({ + roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated + }) + .get(); +export type MapDetailsData = tg.GuardedType; diff --git a/back/src/Services/AdminApi/RoomRedirect.ts b/back/src/Services/AdminApi/RoomRedirect.ts new file mode 100644 index 00000000..7257ebd3 --- /dev/null +++ b/back/src/Services/AdminApi/RoomRedirect.ts @@ -0,0 +1,8 @@ +import * as tg from "generic-type-guard"; + +export const isRoomRedirect = new tg.IsInterface() + .withProperties({ + redirectUrl: tg.isString, + }) + .get(); +export type RoomRedirect = tg.GuardedType; diff --git a/back/src/Services/LocalUrlError.ts b/back/src/Services/LocalUrlError.ts new file mode 100644 index 00000000..fc3fa617 --- /dev/null +++ b/back/src/Services/LocalUrlError.ts @@ -0,0 +1,2 @@ +export class LocalUrlError extends Error { +} diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts new file mode 100644 index 00000000..fa1a831e --- /dev/null +++ b/back/src/Services/MapFetcher.ts @@ -0,0 +1,64 @@ +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"; + +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'); + } + + // Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that + // returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially + // target to different servers (and one could trick Axios.get into loading resources on the internal network + // despite isLocalUrl checking that. + // We can deem this problem not that important because: + // - 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 + }); + + if (!isTiledMap(res.data)) { + throw new Error('Invalid map format for map '+mapUrl); + } + + return res.data; + } + + /** + * Returns true if the domain name is localhost of *.localhost + * Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x) + */ + private async isLocalUrl(url: string): Promise { + const urlObj = new URL(url); + if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) { + return true; + } + + let addresses = []; + if (!ipaddr.isValid(urlObj.hostname)) { + const resolver = new Resolver(); + addresses = await promisify(resolver.resolve)(urlObj.hostname); + } else { + addresses = [urlObj.hostname]; + } + + for (const address of addresses) { + const addr = ipaddr.parse(address); + if (addr.range() !== 'unicast') { + return true; + } + } + + return false; + } +} + +export const mapFetcher = new MapFetcher(); diff --git a/back/src/Services/MessageHelpers.ts b/back/src/Services/MessageHelpers.ts index 493f7173..069d3c78 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,5 +1,11 @@ -import { ErrorMessage, ServerToClientMessage } from "../Messages/generated/messages_pb"; +import { + BatchMessage, + BatchToPusherMessage, BatchToPusherRoomMessage, + ErrorMessage, + ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage +} from "../Messages/generated/messages_pb"; import { UserSocket } from "_Model/User"; +import {RoomSocket, ZoneSocket} from "../RoomManager"; export function emitError(Client: UserSocket, message: string): void { const errorMessage = new ErrorMessage(); @@ -13,3 +19,39 @@ export function emitError(Client: UserSocket, message: string): void { //} console.warn(message); } + +export function emitErrorOnRoomSocket(Client: RoomSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherRoomMessage = new SubToPusherRoomMessage(); + subToPusherRoomMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherRoomMessage(); + batchToPusherMessage.addPayload(subToPusherRoomMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} + +export function emitErrorOnZoneSocket(Client: ZoneSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherMessage = new SubToPusherMessage(); + subToPusherMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherMessage(); + batchToPusherMessage.addPayload(subToPusherMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 82efc71b..35494b2c 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -68,7 +68,7 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo } export class SocketManager { - private rooms = new Map(); + //private rooms = new Map(); // List of rooms in process of loading. private roomsPromises = new Map>(); @@ -106,7 +106,9 @@ export class SocketManager { roomJoinedMessage.addItem(itemStateMessage); } - for (const [name, value] of room.variables.entries()) { + const variables = await room.getVariablesForTags(user.tags); + + for (const [name, value] of variables.entries()) { const variableMessage = new VariableMessage(); variableMessage.setName(name); variableMessage.setValue(value); @@ -198,12 +200,14 @@ export class SocketManager { } handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) { - try { - room.setVariable(variableMessage.getName(), variableMessage.getValue()); - } catch (e) { - console.error('An error occurred on "handleVariableEvent"'); - console.error(e); - } + (async () => { + try { + await room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); + } catch (e) { + console.error('An error occurred on "handleVariableEvent"'); + console.error(e); + } + })(); } emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { @@ -272,7 +276,7 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomUrl); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); debug('Room is empty. Deleting room "%s"', room.roomUrl); } @@ -284,38 +288,34 @@ export class SocketManager { async getOrCreateRoom(roomId: string): Promise { //check and create new room - let room = this.rooms.get(roomId); - if (room === undefined) { - let roomPromise = this.roomsPromises.get(roomId); - if (roomPromise) { - return roomPromise; - } - - // Note: for now, the promise is useless (because this is synchronous, but soon, we will need to - // load the map server side. - - room = new GameRoom( - roomId, - (user: User, group: Group) => this.joinWebRtcRoom(user, group), - (user: User, group: Group) => this.disConnectedUser(user, group), - MINIMUM_DISTANCE, - GROUP_RADIUS, - (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => - this.onZoneEnter(thing, fromZone, listener), - (thing: Movable, position: PositionInterface, listener: ZoneSocket) => - this.onClientMove(thing, position, listener), - (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => - this.onClientLeave(thing, newZone, listener), - (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => - this.onEmote(emoteEventMessage, listener) - ); - gaugeManager.incNbRoomGauge(); - this.rooms.set(roomId, room); - - // TODO: change this the to new Promise()... when the method becomes actually asynchronous - roomPromise = Promise.resolve(room); + let roomPromise = this.roomsPromises.get(roomId); + if (roomPromise === undefined) { + roomPromise = new Promise((resolve, reject) => { + GameRoom.create( + roomId, + (user: User, group: Group) => this.joinWebRtcRoom(user, group), + (user: User, group: Group) => this.disConnectedUser(user, group), + MINIMUM_DISTANCE, + GROUP_RADIUS, + (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => + this.onZoneEnter(thing, fromZone, listener), + (thing: Movable, position: PositionInterface, listener: ZoneSocket) => + this.onClientMove(thing, position, listener), + (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => + 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); + }); + }); + this.roomsPromises.set(roomId, roomPromise); } - return Promise.resolve(room); + return roomPromise; } private async joinRoom( @@ -554,8 +554,8 @@ export class SocketManager { } } - public getWorlds(): Map { - return this.rooms; + public getWorlds(): Map> { + return this.roomsPromises; } public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) { @@ -625,11 +625,10 @@ export class SocketManager { }, 10000); } - public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void { - const room = this.rooms.get(roomId); + public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In addZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In addZoneListener, could not find room with id '" + roomId + "'"); } const things = room.addZoneListener(call, x, y); @@ -670,11 +669,10 @@ export class SocketManager { call.write(batchMessage); } - removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) { - const room = this.rooms.get(roomId); + async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In removeZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'"); } room.removeZoneListener(call, x, y); @@ -683,8 +681,7 @@ export class SocketManager { async addRoomListener(call: RoomSocket, roomId: string) { const room = await this.getOrCreateRoom(roomId); if (!room) { - console.error("In addRoomListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In addRoomListener, could not find room with id '" + roomId + "'"); } room.addRoomListener(call); @@ -692,7 +689,10 @@ export class SocketManager { const batchMessage = new BatchToPusherRoomMessage(); - for (const [name, value] of room.variables.entries()) { + // Finally, no need to store variables in the pusher, let's only make it act as a relay + /*const variables = await room.getVariables(); + + for (const [name, value] of variables.entries()) { const variableMessage = new VariableMessage(); variableMessage.setName(name); variableMessage.setValue(value); @@ -701,16 +701,15 @@ export class SocketManager { subMessage.setVariablemessage(variableMessage); batchMessage.addPayload(subMessage); - } + }*/ call.write(batchMessage); } - removeRoomListener(call: RoomSocket, roomId: string) { - const room = this.rooms.get(roomId); + async removeRoomListener(call: RoomSocket, roomId: string) { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In removeRoomListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'"); } room.removeRoomListener(call); @@ -727,14 +726,14 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomUrl); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); debug('Room is empty. Deleting room "%s"', room.roomUrl); } } - public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In sendAdminMessage, could not find room with id '" + @@ -764,8 +763,8 @@ export class SocketManager { recipient.socket.write(serverToClientMessage); } - public banUser(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async banUser(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In banUser, could not find room with id '" + @@ -800,8 +799,8 @@ export class SocketManager { recipient.socket.end(); } - sendAdminRoomMessage(roomId: string, message: string) { - const room = this.rooms.get(roomId); + async sendAdminRoomMessage(roomId: string, message: string) { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -824,8 +823,8 @@ export class SocketManager { }); } - dispatchWorlFullWarning(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchWorldFullWarning(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -846,8 +845,8 @@ export class SocketManager { }); } - dispatchRoomRefresh(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchRoomRefresh(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { return; } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts new file mode 100644 index 00000000..36acd9e4 --- /dev/null +++ b/back/src/Services/VariablesManager.ts @@ -0,0 +1,139 @@ +/** + * 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"; + +interface Variable { + defaultValue?: string, + persist?: boolean, + readableBy?: string, + writableBy?: string, +} + +export class VariablesManager { + /** + * The actual values of the variables for the current room + */ + private _variables = new Map(); + + /** + * The list of variables that are allowed + */ + private variableObjects: Map | undefined; + + /** + * @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) { + // 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) { + this.variableObjects = VariablesManager.findVariablesInMap(map); + + // Let's initialize default values + for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.defaultValue !== undefined) { + this._variables.set(name, variableObject.defaultValue); + } + } + } + } + + private static findVariablesInMap(map: ITiledMap): Map { + const objects = new Map(); + for (const layer of map.layers) { + if (layer.type === 'objectgroup') { + for (const object of (layer as ITiledMapObjectLayer).objects) { + 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.') + continue; + } + + // We store a copy of the object (to make it immutable) + objects.set(object.name, this.iTiledObjectToVariable(object)); + } + } + } + } + return objects; + } + + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = {}; + + if (object.properties) { + for (const property of object.properties) { + const value = property.value; + switch (property.name) { + case 'default': + variable.defaultValue = JSON.stringify(value); + break; + 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'); + } + 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'); + } + if (value) { + variable.readableBy = value; + } + break; + } + } + } + + return variable; + } + + setVariable(name: string, value: string, user: User): string | undefined { + let readableBy: string | undefined; + if (this.variableObjects) { + const variableObject = this.variableObjects.get(name); + if (variableObject === undefined) { + 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(', ') + "."); + } + + readableBy = variableObject.readableBy; + } + + this._variables.set(name, value); + return readableBy; + } + + public getVariablesForTags(tags: string[]): Map { + if (this.variableObjects === undefined) { + return this._variables; + } + + const readableVariables = new Map(); + + 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.'); + } + if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) { + readableVariables.set(key, value); + } + } + return readableVariables; + } +} diff --git a/back/tsconfig.json b/back/tsconfig.json index 6972715f..e149d304 100644 --- a/back/tsconfig.json +++ b/back/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "downlevelIteration": true, "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ diff --git a/back/yarn.lock b/back/yarn.lock index 242728db..54833963 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -187,6 +187,13 @@ semver "^7.3.2" tsutils "^3.17.1" +"@workadventure/tiled-map-type-guard@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@workadventure/tiled-map-type-guard/-/tiled-map-type-guard-1.0.0.tgz#02524602ee8b2688429a1f56df1d04da3fc171ba" + integrity sha512-Mc0SE128otQnYlScQWVaQVyu1+CkailU/FTBh09UTrVnBAhyMO+jIn9vT9+Dv244xq+uzgQDpXmiVdjgrYFQ+A== + dependencies: + generic-type-guard "^3.4.1" + abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -1181,6 +1188,11 @@ generic-type-guard@^3.2.0: resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.3.3.tgz#954b846fecff91047cadb0dcc28930811fcb9dc1" integrity sha512-SXraZvNW/uTfHVgB48iEwWaD1XFJ1nvZ8QP6qy9pSgaScEyQqFHYN5E6d6rCsJgrvlWKygPrNum7QeJHegzNuQ== +generic-type-guard@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.4.1.tgz#0896dc018de915c890562a34763858076e4676da" + integrity sha512-sXce0Lz3Wfy2rR1W8O8kUemgEriTeG1x8shqSJeWGb0FwJu2qBEkB1M2qXbdSLmpgDnHcIXo0Dj/1VLNJkK/QA== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -1417,6 +1429,11 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= +ipaddr.js@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" + integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 04ef6619..b23f9549 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -32,7 +32,7 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, - VariableMessage, + VariableMessage, ErrorMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -165,6 +165,9 @@ export class RoomConnection implements RoomConnection { } else if (subMessage.hasEmoteeventmessage()) { const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); + } else if (subMessage.hasErrormessage()) { + const errorMessage = subMessage.getErrormessage() as ErrorMessage; + console.error('An error occurred server side: '+errorMessage.getMessage()); } else if (subMessage.hasVariablemessage()) { event = EventMessage.SET_VARIABLE; payload = subMessage.getVariablemessage(); diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index c73ffac4..f177438d 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -26,6 +26,11 @@ export class SharedVariablesManager { // Let's initialize default values for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.readableBy && !this.roomConnection.hasTag(variableObject.readableBy)) { + // Do not initialize default value for variables that are not readable + continue; + } + this._variables.set(name, variableObject.defaultValue); } @@ -35,7 +40,6 @@ export class SharedVariablesManager { } roomConnection.onSetVariable((name, value) => { - console.log('Set Variable received from server'); this._variables.set(name, value); // On server change, let's notify the iframes diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js index ae663cc9..1ab1b2e5 100644 --- a/maps/tests/Variables/script.js +++ b/maps/tests/Variables/script.js @@ -23,4 +23,11 @@ WA.onInit().then(() => { WA.state.saveVariable('config', {'foo': 'bar'}).catch(e => { console.log('Successfully caught error because variable "config" is not writable: ', e); }); + + console.log('Trying to read variable "readableByAdmin" that can only be read by "admin". We are not admin so we should not get the default value.'); + if (WA.state.readableByAdmin === true) { + console.error('Failed test: readableByAdmin can be read.'); + } else { + console.log('Success test: readableByAdmin was not read.'); + } }); diff --git a/maps/tests/Variables/shared_variables.json b/maps/tests/Variables/shared_variables.json new file mode 100644 index 00000000..2de5e4c0 --- /dev/null +++ b/maps/tests/Variables/shared_variables.json @@ -0,0 +1,131 @@ +{ "compressionlevel":-1, + "height":10, + "infinite":false, + "layers":[ + { + "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "height":10, + "id":1, + "name":"floor", + "opacity":1, + "properties":[ + { + "name":"openWebsite", + "type":"string", + "value":"shared_variables.html" + }, + { + "name":"openWebsiteAllowApi", + "type":"bool", + "value":true + }], + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":2, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":67, + "id":3, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"Test:\nChange the form\nConnect with another user\n\nResult:\nThe form should open in the same state for the other user\nAlso, a change on one user is directly propagated to the other user", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "height":0, + "id":5, + "name":"textField", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"default value" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":57.5, + "y":111 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":10, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":11, + "firstgid":1, + "image":"..\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json index 79ca591b..b0f5b5b0 100644 --- a/maps/tests/Variables/variables.json +++ b/maps/tests/Variables/variables.json @@ -142,6 +142,29 @@ "width":0, "x":88.8149900876127, "y":147.75212636695 + }, + { + "height":0, + "id":10, + "name":"readableByAdmin", + "point":true, + "properties":[ + { + "name":"default", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"admin" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":182.132122529897, + "y":157.984268082113 }], "opacity":1, "type":"objectgroup", @@ -150,7 +173,7 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":10, + "nextobjectid":11, "orientation":"orthogonal", "properties":[ { diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index c352a324..12caf32d 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -113,6 +113,15 @@ message VariableMessage { string value = 2; } +/** + * A variable, along the tag describing who it is targeted at + */ +message VariableWithTagMessage { + string name = 1; + string value = 2; + string readableBy = 3; +} + message PlayGlobalMessage { string id = 1; string type = 2; @@ -140,6 +149,7 @@ message SubMessage { ItemEventMessage itemEventMessage = 6; EmoteEventMessage emoteEventMessage = 7; VariableMessage variableMessage = 8; + ErrorMessage errorMessage = 9; } } @@ -365,6 +375,7 @@ message SubToPusherMessage { SendUserMessage sendUserMessage = 7; BanUserMessage banUserMessage = 8; EmoteEventMessage emoteEventMessage = 9; + ErrorMessage errorMessage = 10; } } @@ -374,7 +385,8 @@ message BatchToPusherRoomMessage { message SubToPusherRoomMessage { oneof message { - VariableMessage variableMessage = 1; + VariableWithTagMessage variableMessage = 1; + ErrorMessage errorMessage = 2; } } diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index 713e9d25..d7cfffe4 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -7,7 +7,7 @@ import { apiClientRepository } from "../Services/ApiClientRepository"; import { BatchToPusherMessage, BatchToPusherRoomMessage, - EmoteEventMessage, + EmoteEventMessage, ErrorMessage, GroupLeftZoneMessage, GroupUpdateZoneMessage, RoomMessage, @@ -15,7 +15,7 @@ import { UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, - VariableMessage, + VariableMessage, VariableWithTagMessage, ZoneMessage, } from "../Messages/generated/messages_pb"; import Debug from "debug"; @@ -38,7 +38,7 @@ export class PusherRoom { private backConnection!: ClientReadableStream; private isClosing: boolean = false; private listeners: Set = new Set(); - public readonly variables = new Map(); + //public readonly variables = new Map(); constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) { this.tags = []; @@ -90,15 +90,27 @@ export class PusherRoom { this.backConnection.on("data", (batch: BatchToPusherRoomMessage) => { for (const message of batch.getPayloadList()) { if (message.hasVariablemessage()) { - const variableMessage = message.getVariablemessage() as VariableMessage; + const variableMessage = message.getVariablemessage() as VariableWithTagMessage; + const readableBy = variableMessage.getReadableby(); // We need to store all variables to dispatch variables later to the listeners - this.variables.set(variableMessage.getName(), variableMessage.getValue()); + //this.variables.set(variableMessage.getName(), variableMessage.getValue(), readableBy); // Let's dispatch this variable to all the listeners for (const listener of this.listeners) { const subMessage = new SubMessage(); - subMessage.setVariablemessage(variableMessage); + if (!readableBy || listener.tags.indexOf(readableBy) !== -1) { + subMessage.setVariablemessage(variableMessage); + } + listener.emitInBatch(subMessage); + } + } else if (message.hasErrormessage()) { + const errorMessage = message.getErrormessage() as ErrorMessage; + + // Let's dispatch this error to all the listeners + for (const listener of this.listeners) { + const subMessage = new SubMessage(); + subMessage.setErrormessage(errorMessage); listener.emitInBatch(subMessage); } } else { diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index 501a2541..d116bb79 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -14,7 +14,7 @@ import { UserMovedMessage, ZoneMessage, EmoteEventMessage, - CompanionMessage, + CompanionMessage, ErrorMessage, } from "../Messages/generated/messages_pb"; import { ClientReadableStream } from "grpc"; import { PositionDispatcher } from "_Model/PositionDispatcher"; @@ -30,6 +30,7 @@ export interface ZoneEventListener { onGroupMoves(group: GroupDescriptor, listener: ExSocketInterface): void; onGroupLeaves(groupId: number, listener: ExSocketInterface): void; onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void; + onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void; } /*export type EntersCallback = (thing: Movable, listener: User) => void; @@ -217,6 +218,9 @@ export class Zone { } else if (message.hasEmoteeventmessage()) { const emoteEventMessage = message.getEmoteeventmessage() as EmoteEventMessage; this.notifyEmote(emoteEventMessage); + } else if (message.hasErrormessage()) { + const errorMessage = message.getErrormessage() as ErrorMessage; + this.notifyError(errorMessage); } else { throw new Error("Unexpected message"); } @@ -303,6 +307,12 @@ export class Zone { } } + private notifyError(errorMessage: ErrorMessage) { + for (const listener of this.listeners) { + this.socketListener.onError(errorMessage, listener); + } + } + /** * Notify listeners of this zone that this group left */ diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 5a544966..dfd9c15a 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -30,7 +30,7 @@ import { BanMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, + 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"; @@ -281,6 +281,13 @@ export class SocketManager implements ZoneEventListener { emitInBatch(listener, subMessage); } + onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void { + const subMessage = new SubMessage(); + subMessage.setErrormessage(errorMessage); + + emitInBatch(listener, subMessage); + } + // Useless now, will be useful again if we allow editing details in game handleSetPlayerDetails(client: ExSocketInterface, playerDetailsMessage: SetPlayerDetailsMessage) { const pusherToBackMessage = new PusherToBackMessage(); diff --git a/pusher/tsconfig.json b/pusher/tsconfig.json index 6972715f..e149d304 100644 --- a/pusher/tsconfig.json +++ b/pusher/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "downlevelIteration": true, "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */