diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cc0cb7..46034e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ - The chat allows your to see the visit card of users - You can close the chat window with the escape key - Added a 'Enable notifications' button in the menu. +- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed: + - `/api/map`: now accepts a complete room URL instead of organization/world/room slugs + - `/api/ban`: new endpoint to report users + - as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing ## Version 1.4.3 - 1.4.4 - 1.4.5 diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 71d2124e..f2b736c6 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -5,8 +5,6 @@ import { PositionInterface } from "_Model/PositionInterface"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; -import { arrayIntersect } from "../Services/ArrayHelper"; import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ZoneSocket } from "src/RoomManager"; @@ -31,15 +29,12 @@ export class GameRoom { private itemsState: Map = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomId: string; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; + public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; constructor( - roomId: string, + roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, minDistance: number, @@ -49,16 +44,7 @@ export class GameRoom { onLeaves: LeavesCallback, onEmote: EmoteCallback ) { - this.roomId = roomId; - - if (isRoomAnonymous(roomId)) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } + this.roomUrl = roomUrl; this.users = new Map(); this.usersByUuid = new Map(); @@ -177,7 +163,7 @@ export class GameRoom { } else { const closestUser: User = closestItem; const group: Group = new Group( - this.roomId, + this.roomUrl, [user, closestUser], this.connectCallback, this.disconnectCallback, diff --git a/back/src/Model/RoomIdentifier.ts b/back/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/back/src/Model/RoomIdentifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -//helper functions to parse room IDs - -export const isRoomAnonymous = (roomID: string): boolean => { - if (roomID.startsWith("_/")) { - return true; - } else if (roomID.startsWith("@/")) { - return false; - } else { - throw new Error("Incorrect room ID: " + roomID); - } -}; - -export const extractRoomSlugPublicRoomId = (roomId: string): string => { - const idParts = roomId.split("/"); - if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId); - return idParts.slice(2).join("/"); -}; -export interface extractDataFromPrivateRoomIdResponse { - organizationSlug: string; - worldSlug: string; - roomSlug: string; -} -export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => { - const idParts = roomId.split("/"); - if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId); - const organizationSlug = idParts[1]; - const worldSlug = idParts[2]; - const roomSlug = idParts[3]; - return { organizationSlug, worldSlug, roomSlug }; -}; diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 8d1659df..5e45c975 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -250,12 +250,12 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } finally { - clientEventsEmitter.emitClientLeave(user.uuid, room.roomId); + clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl); console.log("A user left"); } } @@ -658,9 +658,9 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } diff --git a/back/tests/RoomIdentifierTest.ts b/back/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/back/tests/RoomIdentifierTest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier"; - -describe("RoomIdentifier", () => { - it("should flag public id as anonymous", () => { - expect(isRoomAnonymous('_/global/test')).toBe(true); - }); - it("should flag public id as not anonymous", () => { - expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false); - }); - it("should extract roomSlug from public ID", () => { - expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json'); - }); - it("should extract correct from private ID", () => { - const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor'); - expect(organizationSlug).toBe('afup'); - expect(worldSlug).toBe('afup2020'); - expect(roomSlug).toBe('1floor'); - }); -}) \ No newline at end of file diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 8112ba17..379b161f 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -38,11 +38,9 @@ class ConnectionManager { this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); localUserStore.saveUser(this.localUser); - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; - const room = new Room('/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug + window.location.search + window.location.hash); + const room = await Room.createRoom(new URL(window.location.protocol + '//' + window.location.host + roomUrl + window.location.search + window.location.hash)); urlManager.pushRoomIdToUrl(room); return Promise.resolve(room); } else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { @@ -66,22 +64,21 @@ class ConnectionManager { throw "Error to store local user data"; } - let roomId: string; + let roomPath: string; if (connexionType === GameConnexionTypes.empty) { - roomId = START_ROOM_URL; + roomPath = window.location.protocol + '//' + window.location.host + START_ROOM_URL; } else { - roomId = window.location.pathname + window.location.search + window.location.hash; + roomPath = window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search + window.location.hash; } //get detail map for anonymous login and set texture in local storage - const room = new Room(roomId); - const mapDetail = await room.getMapDetail(); - if(mapDetail.textures != undefined && mapDetail.textures.length > 0) { + const room = await Room.createRoom(new URL(roomPath)); + if(room.textures != undefined && room.textures.length > 0) { //check if texture was changed if(localUser.textures.length === 0){ - localUser.textures = mapDetail.textures; + localUser.textures = room.textures; }else{ - mapDetail.textures.forEach((newTexture) => { + room.textures.forEach((newTexture) => { const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ return; @@ -114,9 +111,9 @@ class ConnectionManager { this.localUser = new LocalUser('', 'test', []); } - public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise { + public connectToRoomSocket(roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise { return new Promise((resolve, reject) => { - const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport, companion); + const connection = new RoomConnection(this.localUser.jwtToken, roomUrl, name, characterLayers, position, viewport, companion); connection.onConnectError((error: object) => { console.log('An error occurred while connecting to socket server. Retrying'); reject(error); @@ -137,7 +134,7 @@ class ConnectionManager { this.reconnectingTimeout = setTimeout(() => { //todo: allow a way to break recursion? //todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. - this.connectToRoomSocket(roomId, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); + this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); }, 4000 + Math.floor(Math.random() * 2000) ); }); }); diff --git a/front/src/Connexion/Room.ts b/front/src/Connexion/Room.ts index 434f9060..3eec8099 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -3,91 +3,141 @@ import { PUSHER_URL } from "../Enum/EnvironmentVariable"; import type { CharacterTexture } from "./LocalUser"; export class MapDetail { - constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) { - } + constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {} +} + +export interface RoomRedirect { + redirectUrl: string; } export class Room { public readonly id: string; public readonly isPublic: boolean; - private mapUrl: string | undefined; - private textures: CharacterTexture[] | undefined; + private _mapUrl: string | undefined; + private _textures: CharacterTexture[] | undefined; private instance: string | undefined; - private _search: URLSearchParams; + private readonly _search: URLSearchParams; - constructor(id: string) { - const url = new URL(id, 'https://example.com'); + private constructor(private roomUrl: URL) { + this.id = roomUrl.pathname; - this.id = url.pathname; - - if (this.id.startsWith('/')) { + if (this.id.startsWith("/")) { this.id = this.id.substr(1); } - if (this.id.startsWith('_/')) { + if (this.id.startsWith("_/")) { this.isPublic = true; - } else if (this.id.startsWith('@/')) { + } else if (this.id.startsWith("@/")) { this.isPublic = false; } else { - throw new Error('Invalid room ID'); + throw new Error("Invalid room ID"); } - this._search = new URLSearchParams(url.search); + this._search = new URLSearchParams(roomUrl.search); } - public static getIdFromIdentifier(identifier: string, baseUrl: string, currentInstance: string): { roomId: string, hash: string | null } { - let roomId = ''; + /** + * Creates a "Room" object representing the room. + * This method will follow room redirects if necessary, so the instance returned is a "real" room. + */ + public static async createRoom(roomUrl: URL): Promise { + let redirectCount = 0; + while (redirectCount < 32) { + const room = new Room(roomUrl); + const result = await room.getMapDetail(); + if (result instanceof MapDetail) { + return room; + } + redirectCount++; + roomUrl = new URL(result.redirectUrl); + } + throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts"); + } + + public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL { + const url = new URL(exitUrl, currentRoomUrl); + return url; + } + + /** + * @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too. + */ + public static getRoomPathFromExitSceneUrl( + exitSceneUrl: string, + currentRoomUrl: string, + currentMapUrl: string + ): URL { + const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl); + const baseUrl = new URL(currentRoomUrl); + + const currentRoom = new Room(baseUrl); + let instance: string = "global"; + if (currentRoom.isPublic) { + instance = currentRoom.instance as string; + } + + baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname; + if (absoluteExitSceneUrl.hash) { + baseUrl.hash = absoluteExitSceneUrl.hash; + } + + return baseUrl; + } + + /** + * @deprecated + */ + public static getIdFromIdentifier( + identifier: string, + baseUrl: string, + currentInstance: string + ): { roomId: string; hash: string | null } { + let roomId = ""; let hash = null; - if (!identifier.startsWith('/_/') && !identifier.startsWith('/@/')) { //relative file link + if (!identifier.startsWith("/_/") && !identifier.startsWith("/@/")) { + //relative file link //Relative identifier can be deep enough to rewrite the base domain, so we cannot use the variable 'baseUrl' as the actual base url for the URL objects. //We instead use 'workadventure' as a dummy base value. const baseUrlObject = new URL(baseUrl); - const absoluteExitSceneUrl = new URL(identifier, 'http://workadventure/_/' + currentInstance + '/' + baseUrlObject.hostname + baseUrlObject.pathname); + const absoluteExitSceneUrl = new URL( + identifier, + "http://workadventure/_/" + currentInstance + "/" + baseUrlObject.hostname + baseUrlObject.pathname + ); roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId roomId = roomId.substring(1); //remove the leading slash hash = absoluteExitSceneUrl.hash; hash = hash.substring(1); //remove the leading diese if (!hash.length) { - hash = null + hash = null; } - } else { //absolute room Id - const parts = identifier.split('#'); + } else { + //absolute room Id + const parts = identifier.split("#"); roomId = parts[0]; roomId = roomId.substring(1); //remove the leading slash if (parts.length > 1) { - hash = parts[1] + hash = parts[1]; } } - return { roomId, hash } + return { roomId, hash }; } - public async getMapDetail(): Promise { - return new Promise((resolve, reject) => { - if (this.mapUrl !== undefined && this.textures != undefined) { - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } - - if (this.isPublic) { - const match = /_\/[^/]+\/(.+)/.exec(this.id); - if (!match) throw new Error('Could not extract url from "' + this.id + '"'); - this.mapUrl = window.location.protocol + '//' + match[1]; - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } else { - // We have a private ID, we need to query the map URL from the server. - const urlParts = this.parsePrivateUrl(this.id); - - Axios.get(`${PUSHER_URL}/map`, { - params: urlParts - }).then(({ data }) => { - console.log('Map ', this.id, ' resolves to URL ', data.mapUrl); - resolve(data); - return; - }).catch((reason) => { - reject(reason); - }); - } + private async getMapDetail(): Promise { + const result = await Axios.get(`${PUSHER_URL}/map`, { + params: { + playUri: this.roomUrl.toString(), + }, }); + + const data = result.data; + if (data.redirectUrl) { + return { + redirectUrl: data.redirectUrl as string, + }; + } + console.log("Map ", this.id, " resolves to URL ", data.mapUrl); + this._mapUrl = data.mapUrl; + this._textures = data.textures; + return new MapDetail(data.mapUrl, data.textures); } /** @@ -108,21 +158,24 @@ export class Room { } else { const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id); if (!match) throw new Error('Could not extract instance from "' + this.id + '"'); - this.instance = match[1] + '/' + match[2]; + this.instance = match[1] + "/" + match[2]; return this.instance; } } - private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } { + /** + * @deprecated + */ + private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } { const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm; const match = regex.exec(url); if (!match) { - throw new Error('Invalid URL ' + url); + throw new Error("Invalid URL " + url); } - const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = { + const results: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { organizationSlug: match[1], worldSlug: match[2], - } + }; if (match[3] !== undefined) { results.roomSlug = match[3]; } @@ -130,8 +183,8 @@ export class Room { } public isDisconnected(): boolean { - const alone = this._search.get('alone'); - if (alone && alone !== '0' && alone.toLowerCase() !== 'false') { + const alone = this._search.get("alone"); + if (alone && alone !== "0" && alone.toLowerCase() !== "false") { return true; } return false; @@ -140,4 +193,32 @@ export class Room { public get search(): URLSearchParams { return this._search; } + + /** + * 2 rooms are equal if they share the same path (but not necessarily the same hash) + * @param room + */ + public isEqual(room: Room): boolean { + return room.key === this.key; + } + + /** + * A key representing this room + */ + public get key(): string { + const newUrl = new URL(this.roomUrl.toString()); + newUrl.hash = ""; + return newUrl.toString(); + } + + get textures(): CharacterTexture[] | undefined { + return this._textures; + } + + get mapUrl(): string { + if (!this._mapUrl) { + throw new Error("Map URL not fetched yet"); + } + return this._mapUrl; + } } diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 189eabba..c121e4b7 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -75,11 +75,11 @@ export class RoomConnection implements RoomConnection { /** * * @param token A JWT token containing the UUID of the user - * @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]" + * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" */ public constructor( token: string | null, - roomId: string, + roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, @@ -92,7 +92,7 @@ export class RoomConnection implements RoomConnection { url += "/"; } url += "room"; - url += "?roomId=" + (roomId ? encodeURIComponent(roomId) : ""); + url += "?roomId=" + (roomUrl ? encodeURIComponent(roomUrl) : ""); url += "&token=" + (token ? encodeURIComponent(token) : ""); url += "&name=" + encodeURIComponent(name); for (const layer of characterLayers) { diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index 3e39de9a..7f0b2061 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -28,7 +28,7 @@ export class GameManager { public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise { this.startRoom = await connectionManager.initGameConnexion(); - await this.loadMap(this.startRoom, scenePlugin); + this.loadMap(this.startRoom, scenePlugin); if (!this.playerName) { return LoginSceneName; @@ -68,20 +68,19 @@ export class GameManager { return this.companion; } - public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise { - const roomID = room.id; - const mapDetail = await room.getMapDetail(); + public loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin) { + const roomID = room.key; const gameIndex = scenePlugin.getIndex(roomID); if (gameIndex === -1) { - const game: Phaser.Scene = new GameScene(room, mapDetail.mapUrl); + const game: Phaser.Scene = new GameScene(room, room.mapUrl); scenePlugin.add(roomID, game, false); } } public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void { - console.log("starting " + (this.currentGameSceneName || this.startRoom.id)); - scenePlugin.start(this.currentGameSceneName || this.startRoom.id); + console.log("starting " + (this.currentGameSceneName || this.startRoom.key)); + scenePlugin.start(this.currentGameSceneName || this.startRoom.key); scenePlugin.launch(MenuSceneName); if ( diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 3bf69846..dfecc0c8 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -173,7 +173,7 @@ export class GameScene extends DirtyScene { private chatVisibilityUnsubscribe!: () => void; private biggestAvailableAreaStoreUnsubscribe!: () => void; MapUrlFile: string; - RoomId: string; + roomUrl: string; instance: string; currentTick!: number; @@ -206,14 +206,14 @@ export class GameScene extends DirtyScene { constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ - key: customKey ?? room.id, + key: customKey ?? room.key, }); this.Terrains = []; this.groups = new Map(); this.instance = room.getInstance(); this.MapUrlFile = MapUrlFile; - this.RoomId = room.id; + this.roomUrl = room.key; this.createPromise = new Promise((resolve, reject): void => { this.createPromiseResolve = resolve; @@ -465,11 +465,13 @@ export class GameScene extends DirtyScene { if (layer.type === "tilelayer") { const exitSceneUrl = this.getExitSceneUrl(layer); if (exitSceneUrl !== undefined) { - this.loadNextGame(exitSceneUrl); + this.loadNextGame( + Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile) + ); } const exitUrl = this.getExitUrl(layer); if (exitUrl !== undefined) { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); } } if (layer.type === "objectgroup") { @@ -482,7 +484,7 @@ export class GameScene extends DirtyScene { } this.gameMap.exitUrls.forEach((exitUrl) => { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); }); this.startPositionCalculator = new StartPositionCalculator( @@ -587,7 +589,7 @@ export class GameScene extends DirtyScene { connectionManager .connectToRoomSocket( - this.RoomId, + this.roomUrl, this.playerName, this.characterLayers, { @@ -775,10 +777,13 @@ export class GameScene extends DirtyScene { private triggerOnMapLayerPropertyChange() { this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) + this.onMapExit( + Room.getRoomPathFromExitSceneUrl(newValue as string, window.location.toString(), this.MapUrlFile) + ); }); this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) this.onMapExit(Room.getRoomPathFromExitUrl(newValue as string, window.location.toString())); }); this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => { if (newValue === undefined) { @@ -1003,9 +1008,9 @@ ${escapedMessage} ); this.iframeSubscriptionList.push( iframeListener.loadPageStream.subscribe((url: string) => { - this.loadNextGame(url).then(() => { + this.loadNextGameFromExitUrl(url).then(() => { this.events.once(EVENT_TYPE.POST_UPDATE, () => { - this.onMapExit(url); + this.onMapExit(Room.getRoomPathFromExitUrl(url, window.location.toString())); }); }); }) @@ -1060,7 +1065,7 @@ ${escapedMessage} startLayerName: this.startPositionCalculator.startLayerName, uuid: localUserStore.getLocalUser()?.uuid, nickname: localUserStore.getName(), - roomId: this.RoomId, + roomId: this.roomUrl, tags: this.connection ? this.connection.getAllTags() : [], }; }); @@ -1084,7 +1089,7 @@ ${escapedMessage} return; } if (propertyName === "exitUrl" && typeof propertyValue === "string") { - this.loadNextGame(propertyValue); + this.loadNextGameFromExitUrl(propertyValue); } if (layer.properties === undefined) { layer.properties = []; @@ -1131,28 +1136,38 @@ ${escapedMessage} return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); } - private onMapExit(exitKey: string) { + private async onMapExit(roomUrl: URL) { if (this.mapTransitioning) return; this.mapTransitioning = true; - const { roomId, hash } = Room.getIdFromIdentifier(exitKey, this.MapUrlFile, this.instance); - if (!roomId) throw new Error("Could not find the room from its exit key: " + exitKey); - if (hash) { - urlManager.pushStartLayerNameToUrl(hash); + + let targetRoom: Room; + try { + targetRoom = await Room.createRoom(roomUrl); + } catch (e: unknown) { + console.error('Error while fetching new room "' + roomUrl.toString() + '"', e); + this.mapTransitioning = false; + return; } + + if (roomUrl.hash) { + urlManager.pushStartLayerNameToUrl(roomUrl.hash); + } + const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene; menuScene.reset(); - if (roomId !== this.scene.key) { - if (this.scene.get(roomId) === null) { - console.error("next room not loaded", exitKey); + + if (!targetRoom.isEqual(this.room)) { + if (this.scene.get(targetRoom.key) === null) { + console.error("next room not loaded", targetRoom.key); return; } this.cleanupClosingScene(); this.scene.stop(); + this.scene.start(targetRoom.key); this.scene.remove(this.scene.key); - this.scene.start(roomId); } else { //if the exit points to the current map, we simply teleport the user back to the startLayer - this.startPositionCalculator.initPositionFromLayerName(hash, hash); + this.startPositionCalculator.initPositionFromLayerName(roomUrl.hash, roomUrl.hash); this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x; this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y; setTimeout(() => (this.mapTransitioning = false), 500); @@ -1244,11 +1259,18 @@ ${escapedMessage} .map((property) => property.value); } + private loadNextGameFromExitUrl(exitUrl: string): Promise { + return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString())); + } + //todo: push that into the gameManager - private loadNextGame(exitSceneIdentifier: string): Promise { - const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance); - const room = new Room(roomId); - return gameManager.loadMap(room, this.scene).catch(() => {}); + private async loadNextGame(exitRoomPath: URL): Promise { + try { + const room = await Room.createRoom(exitRoomPath); + return gameManager.loadMap(room, this.scene); + } catch (e: unknown) { + console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e); + } } //todo: in a dedicated class/function? diff --git a/maps/Floor1/floor1.json b/maps/Floor1/floor1.json index 1894ed42..fec5937e 100644 --- a/maps/Floor1/floor1.json +++ b/maps/Floor1/floor1.json @@ -1,11 +1,4 @@ { "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, "height":26, "infinite":false, "layers":[ @@ -101,7 +94,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs" }], @@ -119,7 +112,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours" }], @@ -264,7 +257,7 @@ "nextobjectid":1, "orientation":"orthogonal", "renderorder":"right-down", - "tiledversion":"1.3.3", + "tiledversion":"2021.03.23", "tileheight":32, "tilesets":[ { @@ -1959,6 +1952,6 @@ }], "tilewidth":32, "type":"map", - "version":1.2, + "version":1.5, "width":46 } \ No newline at end of file diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index 3012e275..e937ece9 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController { if (typeof organizationMemberToken != "string") throw new Error("No organization token"); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const userUuid = data.userUuid; - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; const mapUrlStart = data.mapUrlStart; const textures = data.textures; @@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController { JSON.stringify({ authToken, userUuid, - organizationSlug, - worldSlug, - roomSlug, + roomUrl, mapUrlStart, organizationMemberToken, textures, diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 1af9d917..e526b47e 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -221,14 +221,12 @@ export class IoSocketController { memberVisitCardUrl = userData.visitCardUrl; memberTextures = userData.textures; if ( - !room.public && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && (userData.anonymous === true || !room.canAccess(memberTags)) ) { throw new Error("Insufficient privileges to access this room"); } if ( - !room.public && room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY && userData.anonymous === true ) { diff --git a/pusher/src/Controller/MapController.ts b/pusher/src/Controller/MapController.ts index 1df828d4..b7643fee 100644 --- a/pusher/src/Controller/MapController.ts +++ b/pusher/src/Controller/MapController.ts @@ -1,7 +1,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js"; import { BaseController } from "./BaseController"; import { parse } from "query-string"; -import { adminApi } from "../Services/AdminApi"; +import { adminApi, MapDetailsData } from "../Services/AdminApi"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { GameRoomPolicyTypes } from "../Model/PusherRoom"; export class MapController extends BaseController { constructor(private App: TemplatedApp) { @@ -25,35 +27,45 @@ export class MapController extends BaseController { const query = parse(req.getQuery()); - if (typeof query.organizationSlug !== "string") { - console.error("Expected organizationSlug parameter"); + if (typeof query.playUri !== "string") { + console.error("Expected playUri parameter in /map endpoint"); res.writeStatus("400 Bad request"); this.addCorsHeaders(res); - res.end("Expected organizationSlug parameter"); + res.end("Expected playUri parameter"); return; } - if (typeof query.worldSlug !== "string") { - console.error("Expected worldSlug parameter"); - res.writeStatus("400 Bad request"); + + // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs + if (!ADMIN_API_URL) { + const roomUrl = new URL(query.playUri); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname); + if (!match) { + res.writeStatus("404 Not Found"); + this.addCorsHeaders(res); + res.end(JSON.stringify({})); + return; + } + + const mapUrl = roomUrl.protocol + "//" + match[1]; + + res.writeStatus("200 OK"); this.addCorsHeaders(res); - res.end("Expected worldSlug parameter"); - return; - } - if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) { - console.error("Expected only one roomSlug parameter"); - res.writeStatus("400 Bad request"); - this.addCorsHeaders(res); - res.end("Expected only one roomSlug parameter"); + res.end( + JSON.stringify({ + mapUrl, + policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY, + roomSlug: "", // Deprecated + tags: [], + } as MapDetailsData) + ); + return; } (async () => { try { - const mapDetails = await adminApi.fetchMapDetails( - query.organizationSlug as string, - query.worldSlug as string, - query.roomSlug as string | undefined - ); + const mapDetails = await adminApi.fetchMapDetails(query.playUri as string); res.writeStatus("200 OK"); this.addCorsHeaders(res); diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index a49fce3e..72625108 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -1,42 +1,27 @@ import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface"; import { PositionDispatcher } from "./PositionDispatcher"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import { arrayIntersect } from "../Services/ArrayHelper"; import { ZoneEventListener } from "_Model/Zone"; export enum GameRoomPolicyTypes { - ANONYMUS_POLICY = 1, + ANONYMOUS_POLICY = 1, MEMBERS_ONLY_POLICY, USE_TAGS_POLICY, } export class PusherRoom { private readonly positionNotifier: PositionDispatcher; - public readonly public: boolean; public tags: string[]; public policyType: GameRoomPolicyTypes; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; private versionNumber: number = 1; - constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { - this.public = isRoomAnonymous(roomId); + constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) { this.tags = []; - this.policyType = GameRoomPolicyTypes.ANONYMUS_POLICY; - - if (this.public) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } + this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; // A zone is 10 sprites wide. - this.positionNotifier = new PositionDispatcher(this.roomId, 320, 320, this.socketListener); + this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener); } public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void { diff --git a/pusher/src/Model/RoomIdentifier.ts b/pusher/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/pusher/src/Model/RoomIdentifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -//helper functions to parse room IDs - -export const isRoomAnonymous = (roomID: string): boolean => { - if (roomID.startsWith("_/")) { - return true; - } else if (roomID.startsWith("@/")) { - return false; - } else { - throw new Error("Incorrect room ID: " + roomID); - } -}; - -export const extractRoomSlugPublicRoomId = (roomId: string): string => { - const idParts = roomId.split("/"); - if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId); - return idParts.slice(2).join("/"); -}; -export interface extractDataFromPrivateRoomIdResponse { - organizationSlug: string; - worldSlug: string; - roomSlug: string; -} -export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => { - const idParts = roomId.split("/"); - if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId); - const organizationSlug = idParts[1]; - const worldSlug = idParts[2]; - const roomSlug = idParts[3]; - return { organizationSlug, worldSlug, roomSlug }; -}; diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 2cbac52c..5a3abef2 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -3,9 +3,7 @@ import Axios from "axios"; import { GameRoomPolicyTypes } from "_Model/PusherRoom"; export interface AdminApiData { - organizationSlug: string; - worldSlug: string; - roomSlug: string; + roomUrl: string; mapUrlStart: string; tags: string[]; policy_type: number; @@ -43,24 +41,15 @@ export interface FetchMemberDataByUuidResponse { } class AdminApi { - async fetchMapDetails( - organizationSlug: string, - worldSlug: string, - roomSlug: string | undefined - ): Promise { + async fetchMapDetails(playUri: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } - const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { - organizationSlug, - worldSlug, + const params: { playUri: string } = { + playUri, }; - if (roomSlug) { - params.roomSlug = roomSlug; - } - const res = await Axios.get(ADMIN_API_URL + "/api/map", { headers: { Authorization: `${ADMIN_API_TOKEN}` }, params, @@ -121,26 +110,20 @@ class AdminApi { ); } - async verifyBanUser( - organizationMemberToken: string, - ipAddress: string, - organization: string, - world: string - ): Promise { + async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. return Axios.get( ADMIN_API_URL + - "/api/check-moderate-user/" + - organization + - "/" + - world + + "/api/ban" + "?ipAddress=" + - ipAddress + + encodeURIComponent(ipAddress) + "&token=" + - organizationMemberToken, + encodeURIComponent(userUuid) + + "&roomUrl=" + + encodeURIComponent(roomUrl), { headers: { Authorization: `${ADMIN_API_TOKEN}` } } ).then((data) => { return data.data; diff --git a/pusher/src/Services/JWTTokenManager.ts b/pusher/src/Services/JWTTokenManager.ts index 2e82929e..cc44fc08 100644 --- a/pusher/src/Services/JWTTokenManager.ts +++ b/pusher/src/Services/JWTTokenManager.ts @@ -9,7 +9,7 @@ class JWTTokenManager { return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token } - public async getUserUuidFromToken(token: unknown, ipAddress?: string, room?: string): Promise { + public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise { if (!token) { throw new Error("An authentication error happened, a user tried to connect without a token."); } @@ -50,8 +50,8 @@ class JWTTokenManager { if (ADMIN_API_URL) { //verify user in admin let promise = new Promise((resolve) => resolve()); - if (ipAddress && room) { - promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room); + if (ipAddress && roomUrl) { + promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl); } promise .then(() => { @@ -79,19 +79,9 @@ class JWTTokenManager { }); } - private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise { - const parts = room.split("/"); - if (parts.length < 3 || parts[0] !== "@") { - return Promise.resolve({ - is_banned: false, - message: "", - }); - } - - const organization = parts[1]; - const world = parts[2]; + private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { return adminApi - .verifyBanUser(userUuid, ipAddress, organization, world) + .verifyBanUser(userUuid, ipAddress, roomUrl) .then((data: AdminBannedData) => { if (data && data.is_banned) { throw new Error("User was banned"); diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index cfac5946..8dd8abb9 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -32,7 +32,7 @@ import { EmotePromptMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; +import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; import { adminApi, CharacterTexture } from "./AdminApi"; import { emitInBatch } from "./IoSocketHelpers"; import Jwt from "jsonwebtoken"; @@ -358,23 +358,24 @@ export class SocketManager implements ZoneEventListener { } } - async getOrCreateRoom(roomId: string): Promise { + async getOrCreateRoom(roomUrl: string): Promise { //check and create new world for a room - let world = this.rooms.get(roomId); - if (world === undefined) { - world = new PusherRoom(roomId, this); - if (!world.public) { - await this.updateRoomWithAdminData(world); + let room = this.rooms.get(roomUrl); + if (room === undefined) { + room = new PusherRoom(roomUrl, this); + if (ADMIN_API_URL) { + await this.updateRoomWithAdminData(room); } - this.rooms.set(roomId, world); + + this.rooms.set(roomUrl, room); } - return Promise.resolve(world); + return room; } - public async updateRoomWithAdminData(world: PusherRoom): Promise { - const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug); - world.tags = data.tags; - world.policyType = Number(data.policy_type); + public async updateRoomWithAdminData(room: PusherRoom): Promise { + const data = await adminApi.fetchMapDetails(room.roomUrl); + room.tags = data.tags; + room.policyType = Number(data.policy_type); } emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) { diff --git a/pusher/tests/RoomIdentifierTest.ts b/pusher/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/pusher/tests/RoomIdentifierTest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier"; - -describe("RoomIdentifier", () => { - it("should flag public id as anonymous", () => { - expect(isRoomAnonymous('_/global/test')).toBe(true); - }); - it("should flag public id as not anonymous", () => { - expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false); - }); - it("should extract roomSlug from public ID", () => { - expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json'); - }); - it("should extract correct from private ID", () => { - const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor'); - expect(organizationSlug).toBe('afup'); - expect(worldSlug).toBe('afup2020'); - expect(roomSlug).toBe('1floor'); - }); -}) \ No newline at end of file