Migrating away from the notion of public/private URL in WorkAdventure Github repository

The notion of public/private repositories (with /_/ and /@/ URLs) is specific to the SAAS version of WorkAdventure.
It would be better to avoid leaking the organization/world/room structure of the private SAAS URLs inside the WorkAdventure Github project.

Rather than sending http://admin_host/api/map?organizationSlug=...&worldSlug=...&roomSlug=...., we are now sending /api/map&playUri=...
where playUri is the full URL of the current game.
This allows the backend to act as a complete router.
The front (and the pusher) will be able to completely ignore the specifics of URL building (with /@/ and /_/ URLs, etc...)
Those details will live only in the admin server, which is way cleaner (and way more powerful).
This commit is contained in:
David Négrier 2021-07-13 19:09:07 +02:00
parent f2ca7b2b16
commit c9fa9b9a92
20 changed files with 292 additions and 343 deletions

View file

@ -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<OnConnectInterface> {
public connectToRoomSocket(roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise<OnConnectInterface> {
return new Promise<OnConnectInterface>((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) );
});
});

View file

@ -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<Room> {
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<MapDetail> {
return new Promise<MapDetail>((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<MapDetail | RoomRedirect> {
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;
}
}

View file

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

View file

@ -28,7 +28,7 @@ export class GameManager {
public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> {
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<void> {
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 (

View file

@ -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<number, Sprite>();
this.instance = room.getInstance();
this.MapUrlFile = MapUrlFile;
this.RoomId = room.id;
this.roomUrl = room.key;
this.createPromise = new Promise<void>((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<void> {
return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString()));
}
//todo: push that into the gameManager
private loadNextGame(exitSceneIdentifier: string): Promise<void> {
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<void> {
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?