Merge branch 'develop' of github.com:thecodingmachine/workadventure into GlobalMessageToWorld

This commit is contained in:
GRL 2021-07-19 10:01:13 +02:00
commit 0d3c697add
43 changed files with 422 additions and 553 deletions

View file

@ -23,6 +23,10 @@
- The chat allows your to see the visit card of users - The chat allows your to see the visit card of users
- You can close the chat window with the escape key - You can close the chat window with the escape key
- Added a 'Enable notifications' button in the menu. - 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 ## Version 1.4.3 - 1.4.4 - 1.4.5

View file

@ -5,8 +5,6 @@ import { PositionInterface } from "_Model/PositionInterface";
import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone";
import { PositionNotifier } from "./PositionNotifier"; import { PositionNotifier } from "./PositionNotifier";
import { Movable } from "_Model/Movable"; 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 { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { ZoneSocket } from "src/RoomManager"; import { ZoneSocket } from "src/RoomManager";
@ -31,15 +29,12 @@ export class GameRoom {
private itemsState: Map<number, unknown> = new Map<number, unknown>(); private itemsState: Map<number, unknown> = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier; private readonly positionNotifier: PositionNotifier;
public readonly roomId: string; public readonly roomUrl: string;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: string = "";
private versionNumber: number = 1; private versionNumber: number = 1;
private nextUserId: number = 1; private nextUserId: number = 1;
constructor( constructor(
roomId: string, roomUrl: string,
connectCallback: ConnectCallback, connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback, disconnectCallback: DisconnectCallback,
minDistance: number, minDistance: number,
@ -49,16 +44,7 @@ export class GameRoom {
onLeaves: LeavesCallback, onLeaves: LeavesCallback,
onEmote: EmoteCallback onEmote: EmoteCallback
) { ) {
this.roomId = roomId; this.roomUrl = roomUrl;
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.users = new Map<number, User>(); this.users = new Map<number, User>();
this.usersByUuid = new Map<string, User>(); this.usersByUuid = new Map<string, User>();
@ -177,7 +163,7 @@ export class GameRoom {
} else { } else {
const closestUser: User = closestItem; const closestUser: User = closestItem;
const group: Group = new Group( const group: Group = new Group(
this.roomId, this.roomUrl,
[user, closestUser], [user, closestUser],
this.connectCallback, this.connectCallback,
this.disconnectCallback, this.disconnectCallback,

View file

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

View file

@ -250,12 +250,12 @@ export class SocketManager {
//user leave previous world //user leave previous world
room.leave(user); room.leave(user);
if (room.isEmpty()) { if (room.isEmpty()) {
this.rooms.delete(room.roomId); this.rooms.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId); debug('Room is empty. Deleting room "%s"', room.roomUrl);
} }
} finally { } finally {
clientEventsEmitter.emitClientLeave(user.uuid, room.roomId); clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl);
console.log("A user left"); console.log("A user left");
} }
} }
@ -658,9 +658,9 @@ export class SocketManager {
public leaveAdminRoom(room: GameRoom, admin: Admin) { public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin); room.adminLeave(admin);
if (room.isEmpty()) { if (room.isEmpty()) {
this.rooms.delete(room.roomId); this.rooms.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId); debug('Room is empty. Deleting room "%s"', room.roomUrl);
} }
} }

View file

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

View file

@ -1,23 +1,6 @@
{.section-title.accent.text-primary} {.section-title.accent.text-primary}
# API Player functions Reference # API Player functions Reference
### Getting information on the current user
```
WA.player.getCurrentUser(): Promise<User>
```
Return a promise that resolves to a `User` object with the following attributes :
* **id (string) :** ID of the current user
* **nickName (string) :** name displayed above the current user
* **tags (string[]) :** list of all the tags of the current user
Example :
```javascript
WA.player.getCurrentUser().then((user) => {
if (user.nickName === 'ABC') {
console.log(user.tags);
}
})
```
### Listen to player movement ### Listen to player movement
``` ```

View file

@ -79,26 +79,6 @@ Example :
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
``` ```
### Getting information on the current room
```
WA.room.getCurrentRoom(): Promise<Room>
```
Return a promise that resolves to a `Room` object with the following attributes :
* **id (string) :** ID of the current room
* **map (ITiledMap) :** contains the JSON map file with the properties that were set by the script if `setProperty` was called.
* **mapUrl (string) :** Url of the JSON map file
* **startLayer (string | null) :** Name of the layer where the current user started, only if different from `start` layer
Example :
```javascript
WA.room.getCurrentRoom((room) => {
if (room.id === '42') {
console.log(room.map);
window.open(room.mapUrl, '_blank');
}
})
```
### Changing tiles ### Changing tiles
``` ```
WA.room.setTiles(tiles: TileDescriptor[]): void WA.room.setTiles(tiles: TileDescriptor[]): void

View file

@ -39,7 +39,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/press-start-2p": "^4.3.0", "@fontsource/press-start-2p": "^4.3.0",
"@types/simple-peer": "^9.6.0", "@types/simple-peer": "^9.11.1",
"@types/socket.io-client": "^1.4.32", "@types/socket.io-client": "^1.4.32",
"axios": "^0.21.1", "axios": "^0.21.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -51,7 +51,7 @@
"queue-typescript": "^1.0.1", "queue-typescript": "^1.0.1",
"quill": "1.3.6", "quill": "1.3.6",
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"simple-peer": "^9.6.2", "simple-peer": "^9.11.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"standardized-audio-context": "^25.2.4" "standardized-audio-context": "^25.2.4"
}, },

View file

@ -30,12 +30,10 @@
<aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}"> <aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}">
<section class="chatWindowTitle"> <i class="close-icon" on:click={closeChat}>&times</i>
<h1>Your chat history <span class="float-right" on:click={closeChat}>&times</span></h1>
</section>
<section class="messagesList" bind:this={listDom}> <section class="messagesList" bind:this={listDom}>
<ul> <ul>
<li><p class="system-text">Here is your chat history: </p></li>
{#each $chatMessagesStore as message, i} {#each $chatMessagesStore as message, i}
<li><ChatElement message={message} line={i}></ChatElement></li> <li><ChatElement message={message} line={i}></ChatElement></li>
{/each} {/each}
@ -47,16 +45,24 @@
</aside> </aside>
<style lang="scss"> <style lang="scss">
h1 { i.close-icon {
font-family: 'Whiteney'; position: absolute;
padding: 4px;
span.float-right { right: 12px;
font-size: 30px; font-size: 30px;
line-height: 25px; line-height: 25px;
font-weight: bold; font-weight: bold;
float: right; cursor: pointer;
cursor: pointer; }
}
p.system-text {
border-radius: 8px;
margin-bottom: 10px;
padding:6px;
overflow-wrap: break-word;
max-width: 100%;
background: gray;
display: inline-block;
} }
aside.chatWindow { aside.chatWindow {
@ -78,16 +84,8 @@
border-bottom-right-radius: 16px; border-bottom-right-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
h1 {
background-color: #5f5f5f;
border-radius: 8px;
padding: 2px;
}
.chatWindowTitle {
flex: 0 100px;
}
.messagesList { .messagesList {
margin-top: 35px;
overflow-y: auto; overflow-y: auto;
flex: auto; flex: auto;
@ -98,7 +96,7 @@
} }
.messageForm { .messageForm {
flex: 0 70px; flex: 0 70px;
padding-top: 20px; padding-top: 15px;
} }
} }
</style> </style>

View file

@ -29,7 +29,7 @@
<div class="chatElement"> <div class="chatElement">
<div class="messagePart"> <div class="messagePart">
{#if message.type === ChatMessageTypes.userIncoming} {#if message.type === ChatMessageTypes.userIncoming}
&gt;&gt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} enter <span class="date">({renderDate(message.date)})</span> &gt;&gt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} entered <span class="date">({renderDate(message.date)})</span>
{:else if message.type === ChatMessageTypes.userOutcoming} {:else if message.type === ChatMessageTypes.userOutcoming}
&lt;&lt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} left <span class="date">({renderDate(message.date)})</span> &lt;&lt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} left <span class="date">({renderDate(message.date)})</span>
{:else if message.type === ChatMessageTypes.me} {:else if message.type === ChatMessageTypes.me}
@ -48,7 +48,7 @@
<style lang="scss"> <style lang="scss">
h4, p { h4, p {
font-family: 'Whiteney'; font-family: Lato;
} }
div.chatElement { div.chatElement {
display: flex; display: flex;

View file

@ -32,26 +32,25 @@
input { input {
flex: auto; flex: auto;
background-color: #42464d; background-color: #254560;
color: white; color: white;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border: none; border: none;
font-size: 22px; font-size: 22px;
font-family: Whiteney; font-family: Lato;
padding-left: 6px;
min-width: 0; //Needed so that the input doesn't overflow the container in firefox min-width: 0; //Needed so that the input doesn't overflow the container in firefox
outline: none; outline: none;
} }
button { button {
background-color: #42464d; background-color: #254560;
color: white;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
border: none; border: none;
border-left: solid black 1px; border-left: solid white 1px;
font-size: 16px; font-size: 16px;
font-family: Whiteney;
} }
} }
</style> </style>

View file

@ -37,9 +37,7 @@
<img alt="Report this user" src={reportImg}> <img alt="Report this user" src={reportImg}>
<span>Report/Block</span> <span>Report/Block</span>
</button> </button>
{#if $streamStore }
<video use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video> <video use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video>
{/if}
<img src={blockSignImg} class="block-logo" alt="Block" /> <img src={blockSignImg} class="block-logo" alt="Block" />
{#if $constraintStore && $constraintStore.audio !== false} {#if $constraintStore && $constraintStore.audio !== false}
<SoundMeterWidget stream={$streamStore}></SoundMeterWidget> <SoundMeterWidget stream={$streamStore}></SoundMeterWidget>

View file

@ -1,3 +1,6 @@
import type { UserSimplePeerInterface } from "../../WebRtc/SimplePeer";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../../Enum/EnvironmentVariable";
export function getColorByString(str: string): string | null { export function getColorByString(str: string): string | null {
let hash = 0; let hash = 0;
if (str.length === 0) { if (str.length === 0) {
@ -15,7 +18,7 @@ export function getColorByString(str: string): string | null {
return color; return color;
} }
export function srcObject(node: HTMLVideoElement, stream: MediaStream) { export function srcObject(node: HTMLVideoElement, stream: MediaStream | null) {
node.srcObject = stream; node.srcObject = stream;
return { return {
update(newStream: MediaStream) { update(newStream: MediaStream) {
@ -25,3 +28,19 @@ export function srcObject(node: HTMLVideoElement, stream: MediaStream) {
}, },
}; };
} }
export function getIceServersConfig(user: UserSimplePeerInterface): RTCIceServer[] {
const config: RTCIceServer[] = [
{
urls: STUN_SERVER.split(","),
},
];
if (TURN_SERVER !== "") {
config.push({
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
});
}
return config;
}

View file

@ -38,11 +38,9 @@ class ConnectionManager {
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures);
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
const organizationSlug = data.organizationSlug; const roomUrl = data.roomUrl;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
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); urlManager.pushRoomIdToUrl(room);
return Promise.resolve(room); return Promise.resolve(room);
} else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { } else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) {
@ -66,22 +64,21 @@ class ConnectionManager {
throw "Error to store local user data"; throw "Error to store local user data";
} }
let roomId: string; let roomPath: string;
if (connexionType === GameConnexionTypes.empty) { if (connexionType === GameConnexionTypes.empty) {
roomId = START_ROOM_URL; roomPath = window.location.protocol + '//' + window.location.host + START_ROOM_URL;
} else { } 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 //get detail map for anonymous login and set texture in local storage
const room = new Room(roomId); const room = await Room.createRoom(new URL(roomPath));
const mapDetail = await room.getMapDetail(); if(room.textures != undefined && room.textures.length > 0) {
if(mapDetail.textures != undefined && mapDetail.textures.length > 0) {
//check if texture was changed //check if texture was changed
if(localUser.textures.length === 0){ if(localUser.textures.length === 0){
localUser.textures = mapDetail.textures; localUser.textures = room.textures;
}else{ }else{
mapDetail.textures.forEach((newTexture) => { room.textures.forEach((newTexture) => {
const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id);
if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){
return; return;
@ -114,9 +111,9 @@ class ConnectionManager {
this.localUser = new LocalUser('', 'test', []); 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) => { 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) => { connection.onConnectError((error: object) => {
console.log('An error occurred while connecting to socket server. Retrying'); console.log('An error occurred while connecting to socket server. Retrying');
reject(error); reject(error);
@ -137,7 +134,7 @@ class ConnectionManager {
this.reconnectingTimeout = setTimeout(() => { this.reconnectingTimeout = setTimeout(() => {
//todo: allow a way to break recursion? //todo: allow a way to break recursion?
//todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. //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) ); }, 4000 + Math.floor(Math.random() * 2000) );
}); });
}); });

View file

@ -3,91 +3,103 @@ import { PUSHER_URL } from "../Enum/EnvironmentVariable";
import type { CharacterTexture } from "./LocalUser"; import type { CharacterTexture } from "./LocalUser";
export class MapDetail { 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 { export class Room {
public readonly id: string; public readonly id: string;
public readonly isPublic: boolean; public readonly isPublic: boolean;
private mapUrl: string | undefined; private _mapUrl: string | undefined;
private textures: CharacterTexture[] | undefined; private _textures: CharacterTexture[] | undefined;
private instance: string | undefined; private instance: string | undefined;
private _search: URLSearchParams; private readonly _search: URLSearchParams;
constructor(id: string) { private constructor(private roomUrl: URL) {
const url = new URL(id, 'https://example.com'); this.id = roomUrl.pathname;
this.id = url.pathname; if (this.id.startsWith("/")) {
if (this.id.startsWith('/')) {
this.id = this.id.substr(1); this.id = this.id.substr(1);
} }
if (this.id.startsWith('_/')) { if (this.id.startsWith("_/")) {
this.isPublic = true; this.isPublic = true;
} else if (this.id.startsWith('@/')) { } else if (this.id.startsWith("@/")) {
this.isPublic = false; this.isPublic = false;
} else { } 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.
let hash = null; * This method will follow room redirects if necessary, so the instance returned is a "real" room.
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. public static async createRoom(roomUrl: URL): Promise<Room> {
//We instead use 'workadventure' as a dummy base value. let redirectCount = 0;
const baseUrlObject = new URL(baseUrl); while (redirectCount < 32) {
const absoluteExitSceneUrl = new URL(identifier, 'http://workadventure/_/' + currentInstance + '/' + baseUrlObject.hostname + baseUrlObject.pathname); const room = new Room(roomUrl);
roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId const result = await room.getMapDetail();
roomId = roomId.substring(1); //remove the leading slash if (result instanceof MapDetail) {
hash = absoluteExitSceneUrl.hash; return room;
hash = hash.substring(1); //remove the leading diese
if (!hash.length) {
hash = null
}
} 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]
} }
redirectCount++;
roomUrl = new URL(result.redirectUrl);
} }
return { roomId, hash } throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts");
} }
public async getMapDetail(): Promise<MapDetail> { public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL {
return new Promise<MapDetail>((resolve, reject) => { const url = new URL(exitUrl, currentRoomUrl);
if (this.mapUrl !== undefined && this.textures != undefined) { return url;
resolve(new MapDetail(this.mapUrl, this.textures)); }
return;
}
if (this.isPublic) { /**
const match = /_\/[^/]+\/(.+)/.exec(this.id); * @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too.
if (!match) throw new Error('Could not extract url from "' + this.id + '"'); */
this.mapUrl = window.location.protocol + '//' + match[1]; public static getRoomPathFromExitSceneUrl(
resolve(new MapDetail(this.mapUrl, this.textures)); exitSceneUrl: string,
return; currentRoomUrl: string,
} else { currentMapUrl: string
// We have a private ID, we need to query the map URL from the server. ): URL {
const urlParts = this.parsePrivateUrl(this.id); const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl);
const baseUrl = new URL(currentRoomUrl);
Axios.get(`${PUSHER_URL}/map`, { const currentRoom = new Room(baseUrl);
params: urlParts let instance: string = "global";
}).then(({ data }) => { if (currentRoom.isPublic) {
console.log('Map ', this.id, ' resolves to URL ', data.mapUrl); instance = currentRoom.instance as string;
resolve(data); }
return;
}).catch((reason) => { baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname;
reject(reason); if (absoluteExitSceneUrl.hash) {
}); baseUrl.hash = absoluteExitSceneUrl.hash;
} }
return baseUrl;
}
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 +120,24 @@ export class Room {
} else { } else {
const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id); const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id);
if (!match) throw new Error('Could not extract instance from "' + 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; 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 regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm;
const match = regex.exec(url); const match = regex.exec(url);
if (!match) { 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], organizationSlug: match[1],
worldSlug: match[2], worldSlug: match[2],
} };
if (match[3] !== undefined) { if (match[3] !== undefined) {
results.roomSlug = match[3]; results.roomSlug = match[3];
} }
@ -130,8 +145,8 @@ export class Room {
} }
public isDisconnected(): boolean { public isDisconnected(): boolean {
const alone = this._search.get('alone'); const alone = this._search.get("alone");
if (alone && alone !== '0' && alone.toLowerCase() !== 'false') { if (alone && alone !== "0" && alone.toLowerCase() !== "false") {
return true; return true;
} }
return false; return false;
@ -140,4 +155,32 @@ export class Room {
public get search(): URLSearchParams { public get search(): URLSearchParams {
return this._search; 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 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( public constructor(
token: string | null, token: string | null,
roomId: string, roomUrl: string,
name: string, name: string,
characterLayers: string[], characterLayers: string[],
position: PositionInterface, position: PositionInterface,
@ -92,7 +92,7 @@ export class RoomConnection implements RoomConnection {
url += "/"; url += "/";
} }
url += "room"; url += "room";
url += "?roomId=" + (roomId ? encodeURIComponent(roomId) : ""); url += "?roomId=" + encodeURIComponent(roomUrl);
url += "&token=" + (token ? encodeURIComponent(token) : ""); url += "&token=" + (token ? encodeURIComponent(token) : "");
url += "&name=" + encodeURIComponent(name); url += "&name=" + encodeURIComponent(name);
for (const layer of characterLayers) { for (const layer of characterLayers) {

View file

@ -28,7 +28,7 @@ export class GameManager {
public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> { public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> {
this.startRoom = await connectionManager.initGameConnexion(); this.startRoom = await connectionManager.initGameConnexion();
await this.loadMap(this.startRoom, scenePlugin); this.loadMap(this.startRoom, scenePlugin);
if (!this.playerName) { if (!this.playerName) {
return LoginSceneName; return LoginSceneName;
@ -68,20 +68,19 @@ export class GameManager {
return this.companion; return this.companion;
} }
public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise<void> { public loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin) {
const roomID = room.id; const roomID = room.key;
const mapDetail = await room.getMapDetail();
const gameIndex = scenePlugin.getIndex(roomID); const gameIndex = scenePlugin.getIndex(roomID);
if (gameIndex === -1) { 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); scenePlugin.add(roomID, game, false);
} }
} }
public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void { public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void {
console.log("starting " + (this.currentGameSceneName || this.startRoom.id)); console.log("starting " + (this.currentGameSceneName || this.startRoom.key));
scenePlugin.start(this.currentGameSceneName || this.startRoom.id); scenePlugin.start(this.currentGameSceneName || this.startRoom.key);
scenePlugin.launch(MenuSceneName); scenePlugin.launch(MenuSceneName);
if ( if (

View file

@ -173,7 +173,7 @@ export class GameScene extends DirtyScene {
private chatVisibilityUnsubscribe!: () => void; private chatVisibilityUnsubscribe!: () => void;
private biggestAvailableAreaStoreUnsubscribe!: () => void; private biggestAvailableAreaStoreUnsubscribe!: () => void;
MapUrlFile: string; MapUrlFile: string;
RoomId: string; roomUrl: string;
instance: string; instance: string;
currentTick!: number; currentTick!: number;
@ -206,14 +206,14 @@ export class GameScene extends DirtyScene {
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({ super({
key: customKey ?? room.id, key: customKey ?? room.key,
}); });
this.Terrains = []; this.Terrains = [];
this.groups = new Map<number, Sprite>(); this.groups = new Map<number, Sprite>();
this.instance = room.getInstance(); this.instance = room.getInstance();
this.MapUrlFile = MapUrlFile; this.MapUrlFile = MapUrlFile;
this.RoomId = room.id; this.roomUrl = room.key;
this.createPromise = new Promise<void>((resolve, reject): void => { this.createPromise = new Promise<void>((resolve, reject): void => {
this.createPromiseResolve = resolve; this.createPromiseResolve = resolve;
@ -465,11 +465,13 @@ export class GameScene extends DirtyScene {
if (layer.type === "tilelayer") { if (layer.type === "tilelayer") {
const exitSceneUrl = this.getExitSceneUrl(layer); const exitSceneUrl = this.getExitSceneUrl(layer);
if (exitSceneUrl !== undefined) { if (exitSceneUrl !== undefined) {
this.loadNextGame(exitSceneUrl); this.loadNextGame(
Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile)
);
} }
const exitUrl = this.getExitUrl(layer); const exitUrl = this.getExitUrl(layer);
if (exitUrl !== undefined) { if (exitUrl !== undefined) {
this.loadNextGame(exitUrl); this.loadNextGameFromExitUrl(exitUrl);
} }
} }
if (layer.type === "objectgroup") { if (layer.type === "objectgroup") {
@ -482,7 +484,7 @@ export class GameScene extends DirtyScene {
} }
this.gameMap.exitUrls.forEach((exitUrl) => { this.gameMap.exitUrls.forEach((exitUrl) => {
this.loadNextGame(exitUrl); this.loadNextGameFromExitUrl(exitUrl);
}); });
this.startPositionCalculator = new StartPositionCalculator( this.startPositionCalculator = new StartPositionCalculator(
@ -587,7 +589,7 @@ export class GameScene extends DirtyScene {
connectionManager connectionManager
.connectToRoomSocket( .connectToRoomSocket(
this.RoomId, this.roomUrl,
this.playerName, this.playerName,
this.characterLayers, this.characterLayers,
{ {
@ -775,10 +777,13 @@ export class GameScene extends DirtyScene {
private triggerOnMapLayerPropertyChange() { private triggerOnMapLayerPropertyChange() {
this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => { 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) => { 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) => { this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => {
if (newValue === undefined) { if (newValue === undefined) {
@ -1003,9 +1008,9 @@ ${escapedMessage}
); );
this.iframeSubscriptionList.push( this.iframeSubscriptionList.push(
iframeListener.loadPageStream.subscribe((url: string) => { iframeListener.loadPageStream.subscribe((url: string) => {
this.loadNextGame(url).then(() => { this.loadNextGameFromExitUrl(url).then(() => {
this.events.once(EVENT_TYPE.POST_UPDATE, () => { 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, startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid, uuid: localUserStore.getLocalUser()?.uuid,
nickname: localUserStore.getName(), nickname: localUserStore.getName(),
roomId: this.RoomId, roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [], tags: this.connection ? this.connection.getAllTags() : [],
}; };
}); });
@ -1084,7 +1089,7 @@ ${escapedMessage}
return; return;
} }
if (propertyName === "exitUrl" && typeof propertyValue === "string") { if (propertyName === "exitUrl" && typeof propertyValue === "string") {
this.loadNextGame(propertyValue); this.loadNextGameFromExitUrl(propertyValue);
} }
if (layer.properties === undefined) { if (layer.properties === undefined) {
layer.properties = []; layer.properties = [];
@ -1131,28 +1136,38 @@ ${escapedMessage}
return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/"));
} }
private onMapExit(exitKey: string) { private async onMapExit(roomUrl: URL) {
if (this.mapTransitioning) return; if (this.mapTransitioning) return;
this.mapTransitioning = true; 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); let targetRoom: Room;
if (hash) { try {
urlManager.pushStartLayerNameToUrl(hash); 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; const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene;
menuScene.reset(); menuScene.reset();
if (roomId !== this.scene.key) {
if (this.scene.get(roomId) === null) { if (!targetRoom.isEqual(this.room)) {
console.error("next room not loaded", exitKey); if (this.scene.get(targetRoom.key) === null) {
console.error("next room not loaded", targetRoom.key);
return; return;
} }
this.cleanupClosingScene(); this.cleanupClosingScene();
this.scene.stop(); this.scene.stop();
this.scene.start(targetRoom.key);
this.scene.remove(this.scene.key); this.scene.remove(this.scene.key);
this.scene.start(roomId);
} else { } else {
//if the exit points to the current map, we simply teleport the user back to the startLayer //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.x = this.startPositionCalculator.startPosition.x;
this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y; this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y;
setTimeout(() => (this.mapTransitioning = false), 500); setTimeout(() => (this.mapTransitioning = false), 500);
@ -1244,11 +1259,18 @@ ${escapedMessage}
.map((property) => property.value); .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 //todo: push that into the gameManager
private loadNextGame(exitSceneIdentifier: string): Promise<void> { private async loadNextGame(exitRoomPath: URL): Promise<void> {
const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance); try {
const room = new Room(roomId); const room = await Room.createRoom(exitRoomPath);
return gameManager.loadMap(room, this.scene).catch(() => {}); 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? //todo: in a dedicated class/function?

View file

@ -20,6 +20,7 @@ import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlo
import { get } from "svelte/store"; import { get } from "svelte/store";
import { playersStore } from "../../Stores/PlayersStore"; import { playersStore } from "../../Stores/PlayersStore";
import { mediaManager } from "../../WebRtc/MediaManager"; import { mediaManager } from "../../WebRtc/MediaManager";
import { chatVisibilityStore } from "../../Stores/ChatStore";
export const MenuSceneName = "MenuScene"; export const MenuSceneName = "MenuScene";
const gameMenuKey = "gameMenu"; const gameMenuKey = "gameMenu";
@ -147,6 +148,9 @@ export class MenuScene extends Phaser.Scene {
this.menuElement.on("click", this.onMenuClick.bind(this)); this.menuElement.on("click", this.onMenuClick.bind(this));
worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning()); worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning());
chatVisibilityStore.subscribe((v) => {
this.menuButton.setVisible(!v);
});
} }
//todo put this method in a parent menuElement class //todo put this method in a parent menuElement class

View file

@ -96,6 +96,7 @@ function createChatMessagesStore() {
} }
return list; return list;
}); });
chatVisibilityStore.set(true);
}, },
}; };
} }

View file

@ -3,6 +3,8 @@ import type { PlayerInterface } from "../Phaser/Game/PlayerInterface";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { getRandomColor } from "../WebRtc/ColorGenerator"; import { getRandomColor } from "../WebRtc/ColorGenerator";
let idCount = 0;
/** /**
* A store that contains the list of players currently known. * A store that contains the list of players currently known.
*/ */
@ -40,6 +42,27 @@ function createPlayersStore() {
getPlayerById(userId: number): PlayerInterface | undefined { getPlayerById(userId: number): PlayerInterface | undefined {
return players.get(userId); return players.get(userId);
}, },
addFacticePlayer(name: string): number {
let userId: number | null = null;
players.forEach((p) => {
if (p.name === name) userId = p.userId;
});
if (userId) return userId;
const newUserId = idCount--;
update((users) => {
users.set(newUserId, {
userId: newUserId,
name,
characterLayers: [],
visitCardUrl: null,
companion: null,
userUuid: "dummy",
color: getRandomColor(),
});
return users;
});
return newUserId;
},
}; };
} }

View file

@ -1,11 +1,12 @@
import { iframeListener } from "../Api/IframeListener"; import { iframeListener } from "../Api/IframeListener";
import { chatMessagesStore, chatVisibilityStore } from "../Stores/ChatStore"; import { chatMessagesStore } from "../Stores/ChatStore";
import { playersStore } from "../Stores/PlayersStore";
export class DiscussionManager { export class DiscussionManager {
constructor() { constructor() {
iframeListener.chatStream.subscribe((chatEvent) => { iframeListener.chatStream.subscribe((chatEvent) => {
chatMessagesStore.addExternalMessage(parseInt(chatEvent.author), chatEvent.message); const userId = playersStore.addFacticePlayer(chatEvent.author);
chatVisibilityStore.set(true); chatMessagesStore.addExternalMessage(userId, chatEvent.message);
}); });
} }
} }

View file

@ -1,11 +1,10 @@
import type * as SimplePeerNamespace from "simple-peer"; import type * as SimplePeerNamespace from "simple-peer";
import { mediaManager } from "./MediaManager";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer"; import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer";
import type { UserSimplePeerInterface } from "./SimplePeer"; import type { UserSimplePeerInterface } from "./SimplePeer";
import { Readable, readable, writable, Writable } from "svelte/store"; import { Readable, readable } from "svelte/store";
import { videoFocusStore } from "../Stores/VideoFocusStore"; import { videoFocusStore } from "../Stores/VideoFocusStore";
import { getIceServersConfig } from "../Components/Video/utils";
const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer");
@ -32,21 +31,9 @@ export class ScreenSharingPeer extends Peer {
stream: MediaStream | null stream: MediaStream | null
) { ) {
super({ super({
initiator: initiator ? initiator : false, initiator,
//reconnectTimer: 10000,
config: { config: {
iceServers: [ iceServers: getIceServersConfig(user),
{
urls: STUN_SERVER.split(","),
},
TURN_SERVER !== ""
? {
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
}
: undefined,
].filter((value) => value !== undefined),
}, },
}); });

View file

@ -1,15 +1,14 @@
import type * as SimplePeerNamespace from "simple-peer"; import type * as SimplePeerNamespace from "simple-peer";
import { mediaManager } from "./MediaManager"; import { mediaManager } from "./MediaManager";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { blackListManager } from "./BlackListManager"; import { blackListManager } from "./BlackListManager";
import type { Subscription } from "rxjs"; import type { Subscription } from "rxjs";
import type { UserSimplePeerInterface } from "./SimplePeer"; import type { UserSimplePeerInterface } from "./SimplePeer";
import { get, readable, Readable, Unsubscriber } from "svelte/store"; import { get, readable, Readable, Unsubscriber } from "svelte/store";
import { obtainedMediaConstraintStore } from "../Stores/MediaStore"; import { obtainedMediaConstraintStore } from "../Stores/MediaStore";
import { discussionManager } from "./DiscussionManager";
import { playersStore } from "../Stores/PlayersStore"; import { playersStore } from "../Stores/PlayersStore";
import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore"; import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore";
import { getIceServersConfig } from "../Components/Video/utils";
const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer");
@ -36,6 +35,7 @@ export class VideoPeer extends Peer {
public readonly statusStore: Readable<PeerStatus>; public readonly statusStore: Readable<PeerStatus>;
public readonly constraintsStore: Readable<MediaStreamConstraints | null>; public readonly constraintsStore: Readable<MediaStreamConstraints | null>;
private newMessageunsubscriber: Unsubscriber | null = null; private newMessageunsubscriber: Unsubscriber | null = null;
private closing: Boolean = false; //this is used to prevent destroy() from being called twice
constructor( constructor(
public user: UserSimplePeerInterface, public user: UserSimplePeerInterface,
@ -45,21 +45,9 @@ export class VideoPeer extends Peer {
localStream: MediaStream | null localStream: MediaStream | null
) { ) {
super({ super({
initiator: initiator ? initiator : false, initiator,
//reconnectTimer: 10000,
config: { config: {
iceServers: [ iceServers: getIceServersConfig(user),
{
urls: STUN_SERVER.split(","),
},
TURN_SERVER !== ""
? {
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
}
: undefined,
].filter((value) => value !== undefined),
}, },
}); });
@ -182,7 +170,6 @@ export class VideoPeer extends Peer {
} else if (message.type === MESSAGE_TYPE_MESSAGE) { } else if (message.type === MESSAGE_TYPE_MESSAGE) {
if (!blackListManager.isBlackListed(this.userUuid)) { if (!blackListManager.isBlackListed(this.userUuid)) {
chatMessagesStore.addExternalMessage(this.userId, message.message); chatMessagesStore.addExternalMessage(this.userId, message.message);
chatVisibilityStore.set(true);
} }
} else if (message.type === MESSAGE_TYPE_BLOCKED) { } else if (message.type === MESSAGE_TYPE_BLOCKED) {
//FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream. //FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream.
@ -262,20 +249,18 @@ export class VideoPeer extends Peer {
/** /**
* This is triggered twice. Once by the server, and once by a remote client disconnecting * This is triggered twice. Once by the server, and once by a remote client disconnecting
*/ */
public destroy(error?: Error): void { public destroy(): void {
try { try {
this._connected = false; this._connected = false;
if (!this.toClose) { if (!this.toClose || this.closing) {
return; return;
} }
this.closing = true;
this.onBlockSubscribe.unsubscribe(); this.onBlockSubscribe.unsubscribe();
this.onUnBlockSubscribe.unsubscribe(); this.onUnBlockSubscribe.unsubscribe();
if (this.newMessageunsubscriber) this.newMessageunsubscriber(); if (this.newMessageunsubscriber) this.newMessageunsubscriber();
chatMessagesStore.addOutcomingUser(this.userId); chatMessagesStore.addOutcomingUser(this.userId);
//discussionManager.removeParticipant(this.userId); super.destroy();
// FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray"
// I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel.
super.destroy(error);
} catch (err) { } catch (err) {
console.error("VideoPeer::destroy", err); console.error("VideoPeer::destroy", err);
} }

View file

@ -1,5 +1,5 @@
*{ *{
font-family: 'Open Sans', sans-serif; font-family: Lato;
cursor: url('./images/cursor_normal.png'), auto; cursor: url('./images/cursor_normal.png'), auto;
} }
* a, button, select{ * a, button, select{

View file

@ -1,58 +0,0 @@
import "jasmine";
import { Room } from "../../../src/Connexion/Room";
describe("Room getIdFromIdentifier()", () => {
it("should work with an absolute room id and no hash as parameter", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with an absolute room id and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json#start', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
it("should work with an absolute room id, regardless of baseUrl or instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', 'https://another.domain/_/global/test.json', 'lol');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link and no hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link with no dot", () => {
const { roomId, hash } = Room.getIdFromIdentifier('test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link two levels deep", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../floor1/Floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/floor1/Floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map domain", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../maps.workadventure.localhost/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventure.localhost/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../notglobal/maps.workadventu.re/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/notglobal/maps.workadventu.re/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that change the map type", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../../@/tcm/is/great', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('@/tcm/is/great');
expect(hash).toEqual(null);
});
it("should work with a relative file link and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json#start', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
});

View file

@ -262,10 +262,10 @@
"@types/mime" "^1" "@types/mime" "^1"
"@types/node" "*" "@types/node" "*"
"@types/simple-peer@^9.6.0": "@types/simple-peer@^9.11.1":
version "9.6.3" version "9.11.1"
resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.6.3.tgz#aa118a57e036f4ce2059a7e25367526a4764206d" resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.11.1.tgz#bef6ff1e75178d83438e33aa6a4df2fd98fded1d"
integrity sha512-zrXEBch9tF4NgkZDsGR3c1D0kq99M1bBCjzEyL0PVfEWzCIXrK64TuxRz3XKOx1B0KoEQ9kTs+AhMDuQaHy5RQ== integrity sha512-Pzqbau/WlivSXdRC0He2Wz/ANj2wbi4gzJrtysZz93jvOyI2jo/ibMjUe6AvPllFl/UO6QXT/A0Rcp44bDQB5A==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
@ -5008,7 +5008,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
simple-peer@^9.6.2: simple-peer@^9.11.0:
version "9.11.0" version "9.11.0"
resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571" resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571"
integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg== integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg==

View file

@ -1,11 +1,4 @@
{ "compressionlevel":-1, { "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":26, "height":26,
"infinite":false, "infinite":false,
"layers":[ "layers":[
@ -101,7 +94,7 @@
"opacity":1, "opacity":1,
"properties":[ "properties":[
{ {
"name":"exitSceneUrl", "name":"exitUrl",
"type":"string", "type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs" "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs"
}], }],
@ -119,7 +112,7 @@
"opacity":1, "opacity":1,
"properties":[ "properties":[
{ {
"name":"exitSceneUrl", "name":"exitUrl",
"type":"string", "type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours" "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours"
}], }],
@ -264,7 +257,7 @@
"nextobjectid":1, "nextobjectid":1,
"orientation":"orthogonal", "orientation":"orthogonal",
"renderorder":"right-down", "renderorder":"right-down",
"tiledversion":"1.3.3", "tiledversion":"2021.03.23",
"tileheight":32, "tileheight":32,
"tilesets":[ "tilesets":[
{ {
@ -1959,6 +1952,6 @@
}], }],
"tilewidth":32, "tilewidth":32,
"type":"map", "type":"map",
"version":1.2, "version":1.5,
"width":46 "width":46
} }

View file

@ -188,7 +188,7 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<input type="radio" name="test-cowebsite-allowAPI"> Success <input type="radio" name="test-cowebsite-allowAPI"> Failure <input type="radio" name="test-cowebsite-allowAPI" checked> Pending <input type="radio" name="test-cowebsite-allowAPI2"> Success <input type="radio" name="test-cowebsite-allowAPI2"> Failure <input type="radio" name="test-cowebsite-allowAPI2" checked> Pending
</td> </td>
<td> <td>
<a href="#" class="testLink" data-testmap="Metadata/cowebsiteAllowApi.json" target="_blank">Test cowebsite opened by script is allowed to use IFrame API</a> <a href="#" class="testLink" data-testmap="Metadata/cowebsiteAllowApi.json" target="_blank">Test cowebsite opened by script is allowed to use IFrame API</a>

View file

@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController {
if (typeof organizationMemberToken != "string") throw new Error("No organization token"); if (typeof organizationMemberToken != "string") throw new Error("No organization token");
const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken);
const userUuid = data.userUuid; const userUuid = data.userUuid;
const organizationSlug = data.organizationSlug; const roomUrl = data.roomUrl;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const mapUrlStart = data.mapUrlStart; const mapUrlStart = data.mapUrlStart;
const textures = data.textures; const textures = data.textures;
@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController {
JSON.stringify({ JSON.stringify({
authToken, authToken,
userUuid, userUuid,
organizationSlug, roomUrl,
worldSlug,
roomSlug,
mapUrlStart, mapUrlStart,
organizationMemberToken, organizationMemberToken,
textures, textures,

View file

@ -22,13 +22,14 @@ import { UserMovesMessage } from "../Messages/generated/messages_pb";
import { TemplatedApp } from "uWebSockets.js"; import { TemplatedApp } from "uWebSockets.js";
import { parse } from "query-string"; import { parse } from "query-string";
import { jwtTokenManager } from "../Services/JWTTokenManager"; import { jwtTokenManager } from "../Services/JWTTokenManager";
import { adminApi, CharacterTexture, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi";
import { SocketManager, socketManager } from "../Services/SocketManager"; import { SocketManager, socketManager } from "../Services/SocketManager";
import { emitInBatch } from "../Services/IoSocketHelpers"; import { emitInBatch } from "../Services/IoSocketHelpers";
import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable"; import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { CharacterTexture } from "../Services/AdminApi/CharacterTexture";
export class IoSocketController { export class IoSocketController {
private nextUserId: number = 1; private nextUserId: number = 1;
@ -221,14 +222,12 @@ export class IoSocketController {
memberVisitCardUrl = userData.visitCardUrl; memberVisitCardUrl = userData.visitCardUrl;
memberTextures = userData.textures; memberTextures = userData.textures;
if ( if (
!room.public &&
room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY &&
(userData.anonymous === true || !room.canAccess(memberTags)) (userData.anonymous === true || !room.canAccess(memberTags))
) { ) {
throw new Error("Insufficient privileges to access this room"); throw new Error("Insufficient privileges to access this room");
} }
if ( if (
!room.public &&
room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY && room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY &&
userData.anonymous === true userData.anonymous === true
) { ) {

View file

@ -2,6 +2,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js";
import { BaseController } from "./BaseController"; import { BaseController } from "./BaseController";
import { parse } from "query-string"; import { parse } from "query-string";
import { adminApi } from "../Services/AdminApi"; import { adminApi } from "../Services/AdminApi";
import { ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import { GameRoomPolicyTypes } from "../Model/PusherRoom";
import { MapDetailsData } from "../Services/AdminApi/MapDetailsData";
export class MapController extends BaseController { export class MapController extends BaseController {
constructor(private App: TemplatedApp) { constructor(private App: TemplatedApp) {
@ -25,35 +28,46 @@ export class MapController extends BaseController {
const query = parse(req.getQuery()); const query = parse(req.getQuery());
if (typeof query.organizationSlug !== "string") { if (typeof query.playUri !== "string") {
console.error("Expected organizationSlug parameter"); console.error("Expected playUri parameter in /map endpoint");
res.writeStatus("400 Bad request"); res.writeStatus("400 Bad request");
this.addCorsHeaders(res); this.addCorsHeaders(res);
res.end("Expected organizationSlug parameter"); res.end("Expected playUri parameter");
return; return;
} }
if (typeof query.worldSlug !== "string") {
console.error("Expected worldSlug parameter"); // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs
res.writeStatus("400 Bad request"); 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); this.addCorsHeaders(res);
res.end("Expected worldSlug parameter"); res.end(
return; JSON.stringify({
} mapUrl,
if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) { policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY,
console.error("Expected only one roomSlug parameter"); roomSlug: "", // Deprecated
res.writeStatus("400 Bad request"); tags: [],
this.addCorsHeaders(res); textures: [],
res.end("Expected only one roomSlug parameter"); } as MapDetailsData)
);
return; return;
} }
(async () => { (async () => {
try { try {
const mapDetails = await adminApi.fetchMapDetails( const mapDetails = await adminApi.fetchMapDetails(query.playUri as string);
query.organizationSlug as string,
query.worldSlug as string,
query.roomSlug as string | undefined
);
res.writeStatus("200 OK"); res.writeStatus("200 OK");
this.addCorsHeaders(res); this.addCorsHeaders(res);

View file

@ -1,42 +1,27 @@
import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface"; import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface";
import { PositionDispatcher } from "./PositionDispatcher"; import { PositionDispatcher } from "./PositionDispatcher";
import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage";
import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier";
import { arrayIntersect } from "../Services/ArrayHelper"; import { arrayIntersect } from "../Services/ArrayHelper";
import { ZoneEventListener } from "_Model/Zone"; import { ZoneEventListener } from "_Model/Zone";
export enum GameRoomPolicyTypes { export enum GameRoomPolicyTypes {
ANONYMUS_POLICY = 1, ANONYMOUS_POLICY = 1,
MEMBERS_ONLY_POLICY, MEMBERS_ONLY_POLICY,
USE_TAGS_POLICY, USE_TAGS_POLICY,
} }
export class PusherRoom { export class PusherRoom {
private readonly positionNotifier: PositionDispatcher; private readonly positionNotifier: PositionDispatcher;
public readonly public: boolean;
public tags: string[]; public tags: string[];
public policyType: GameRoomPolicyTypes; public policyType: GameRoomPolicyTypes;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: string = "";
private versionNumber: number = 1; private versionNumber: number = 1;
constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) {
this.public = isRoomAnonymous(roomId);
this.tags = []; this.tags = [];
this.policyType = GameRoomPolicyTypes.ANONYMUS_POLICY; this.policyType = GameRoomPolicyTypes.ANONYMOUS_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;
}
// A zone is 10 sprites wide. // 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 { public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void {

View file

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

View file

@ -10,7 +10,6 @@ import {
SubMessage, SubMessage,
} from "../../Messages/generated/messages_pb"; } from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc"; import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";

View file

@ -9,9 +9,9 @@ import {
SubMessage, SubMessage,
} from "../../Messages/generated/messages_pb"; } from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc"; import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import { CharacterTexture } from "../../Services/AdminApi/CharacterTexture";
export type BackConnection = ClientDuplexStream<PusherToBackMessage, ServerToClientMessage>; export type BackConnection = ClientDuplexStream<PusherToBackMessage, ServerToClientMessage>;

View file

@ -1,11 +1,12 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios"; import Axios from "axios";
import { GameRoomPolicyTypes } from "_Model/PusherRoom"; import { GameRoomPolicyTypes } from "_Model/PusherRoom";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
import { MapDetailsData } from "./AdminApi/MapDetailsData";
import { RoomRedirect } from "./AdminApi/RoomRedirect";
export interface AdminApiData { export interface AdminApiData {
organizationSlug: string; roomUrl: string;
worldSlug: string;
roomSlug: string;
mapUrlStart: string; mapUrlStart: string;
tags: string[]; tags: string[];
policy_type: number; policy_type: number;
@ -14,25 +15,11 @@ export interface AdminApiData {
textures: CharacterTexture[]; textures: CharacterTexture[];
} }
export interface MapDetailsData {
roomSlug: string;
mapUrl: string;
policy_type: GameRoomPolicyTypes;
tags: string[];
}
export interface AdminBannedData { export interface AdminBannedData {
is_banned: boolean; is_banned: boolean;
message: string; message: string;
} }
export interface CharacterTexture {
id: number;
level: number;
url: string;
rights: string;
}
export interface FetchMemberDataByUuidResponse { export interface FetchMemberDataByUuidResponse {
uuid: string; uuid: string;
tags: string[]; tags: string[];
@ -43,24 +30,15 @@ export interface FetchMemberDataByUuidResponse {
} }
class AdminApi { class AdminApi {
async fetchMapDetails( async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
organizationSlug: string,
worldSlug: string,
roomSlug: string | undefined
): Promise<MapDetailsData> {
if (!ADMIN_API_URL) { if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!")); return Promise.reject(new Error("No admin backoffice set!"));
} }
const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { const params: { playUri: string } = {
organizationSlug, playUri,
worldSlug,
}; };
if (roomSlug) {
params.roomSlug = roomSlug;
}
const res = await Axios.get(ADMIN_API_URL + "/api/map", { const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` }, headers: { Authorization: `${ADMIN_API_TOKEN}` },
params, params,
@ -121,26 +99,20 @@ class AdminApi {
); );
} }
async verifyBanUser( async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
organizationMemberToken: string,
ipAddress: string,
organization: string,
world: string
): Promise<AdminBannedData> {
if (!ADMIN_API_URL) { if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!")); 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. //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case.
return Axios.get( return Axios.get(
ADMIN_API_URL + ADMIN_API_URL +
"/api/check-moderate-user/" + "/api/ban" +
organization +
"/" +
world +
"?ipAddress=" + "?ipAddress=" +
ipAddress + encodeURIComponent(ipAddress) +
"&token=" + "&token=" +
organizationMemberToken, encodeURIComponent(userUuid) +
"&roomUrl=" +
encodeURIComponent(roomUrl),
{ headers: { Authorization: `${ADMIN_API_TOKEN}` } } { headers: { Authorization: `${ADMIN_API_TOKEN}` } }
).then((data) => { ).then((data) => {
return data.data; return data.data;

View file

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

View file

@ -0,0 +1,20 @@
import * as tg from "generic-type-guard";
import { GameRoomPolicyTypes } from "_Model/PusherRoom";
import { isCharacterTexture } from "./CharacterTexture";
import { isAny, isNumber } from "generic-type-guard";
/*const isNumericEnum =
<T extends { [n: number]: string }>(vs: T) =>
(v: any): v is T =>
typeof v === "number" && v in vs;*/
export const isMapDetailsData = new tg.IsInterface()
.withProperties({
roomSlug: tg.isOptional(tg.isString), // deprecated
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.get();
export type MapDetailsData = tg.GuardedType<typeof isMapDetailsData>;

View file

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

View file

@ -9,7 +9,7 @@ class JWTTokenManager {
return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token 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<string> { public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise<string> {
if (!token) { if (!token) {
throw new Error("An authentication error happened, a user tried to connect without a 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) { if (ADMIN_API_URL) {
//verify user in admin //verify user in admin
let promise = new Promise((resolve) => resolve()); let promise = new Promise((resolve) => resolve());
if (ipAddress && room) { if (ipAddress && roomUrl) {
promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room); promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl);
} }
promise promise
.then(() => { .then(() => {
@ -79,19 +79,9 @@ class JWTTokenManager {
}); });
} }
private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise<AdminBannedData> { private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
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];
return adminApi return adminApi
.verifyBanUser(userUuid, ipAddress, organization, world) .verifyBanUser(userUuid, ipAddress, roomUrl)
.then((data: AdminBannedData) => { .then((data: AdminBannedData) => {
if (data && data.is_banned) { if (data && data.is_banned) {
throw new Error("User was banned"); throw new Error("User was banned");

View file

@ -32,8 +32,8 @@ import {
EmotePromptMessage, EmotePromptMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; 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 { adminApi } from "./AdminApi";
import { emitInBatch } from "./IoSocketHelpers"; import { emitInBatch } from "./IoSocketHelpers";
import Jwt from "jsonwebtoken"; import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { JITSI_URL } from "../Enum/EnvironmentVariable";
@ -44,6 +44,8 @@ import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone"
import Debug from "debug"; import Debug from "debug";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { isRoomRedirect } from "./AdminApi/RoomRedirect";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
const debug = Debug("socket"); const debug = Debug("socket");
@ -358,23 +360,30 @@ export class SocketManager implements ZoneEventListener {
} }
} }
async getOrCreateRoom(roomId: string): Promise<PusherRoom> { async getOrCreateRoom(roomUrl: string): Promise<PusherRoom> {
//check and create new world for a room //check and create new world for a room
let world = this.rooms.get(roomId); let room = this.rooms.get(roomUrl);
if (world === undefined) { if (room === undefined) {
world = new PusherRoom(roomId, this); room = new PusherRoom(roomUrl, this);
if (!world.public) { if (ADMIN_API_URL) {
await this.updateRoomWithAdminData(world); 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<void> { public async updateRoomWithAdminData(room: PusherRoom): Promise<void> {
const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug); const data = await adminApi.fetchMapDetails(room.roomUrl);
world.tags = data.tags;
world.policyType = Number(data.policy_type); if (isRoomRedirect(data)) {
// TODO: if the updated room data is actually a redirect, we need to take everybody on the map
// and redirect everybody to the new location (so we need to close the connection for everybody)
} else {
room.tags = data.tags;
room.policyType = Number(data.policy_type);
}
} }
emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) { emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) {

View file

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