Adding support for "readableBy" and "writableBy" in back

This means that we are now loading maps from server side.
This commit is contained in:
David Négrier 2021-07-19 10:16:43 +02:00
parent 3d76f76d3e
commit dbd5b80636
24 changed files with 768 additions and 132 deletions

View file

@ -0,0 +1,24 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios";
import { MapDetailsData } from "./AdminApi/MapDetailsData";
import { RoomRedirect } from "./AdminApi/RoomRedirect";
class AdminApi {
async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!"));
}
const params: { playUri: string } = {
playUri,
};
const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` },
params,
});
return res.data;
}
}
export const adminApi = new AdminApi();

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,21 @@
import * as tg from "generic-type-guard";
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({
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.withOptionalProperties({
roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated
})
.get();
export type MapDetailsData = tg.GuardedType<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

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

View file

@ -0,0 +1,64 @@
import Axios from "axios";
import ipaddr from 'ipaddr.js';
import { Resolver } from 'dns';
import { promisify } from 'util';
import {LocalUrlError} from "./LocalUrlError";
import {ITiledMap} from "@workadventure/tiled-map-type-guard";
import {isTiledMap} from "@workadventure/tiled-map-type-guard/dist";
class MapFetcher {
async fetchMap(mapUrl: string): Promise<ITiledMap> {
// Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map)
if (await this.isLocalUrl(mapUrl)) {
throw new LocalUrlError('URL for map "'+mapUrl+'" targets a local map');
}
// Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that
// returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially
// target to different servers (and one could trick Axios.get into loading resources on the internal network
// despite isLocalUrl checking that.
// We can deem this problem not that important because:
// - We make sure we are only passing "GET" requests
// - The result of the query is never displayed to the end user
const res = await Axios.get(mapUrl, {
maxContentLength: 50*1024*1024, // Max content length: 50MB. Maps should not be bigger
});
if (!isTiledMap(res.data)) {
throw new Error('Invalid map format for map '+mapUrl);
}
return res.data;
}
/**
* Returns true if the domain name is localhost of *.localhost
* Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x)
*/
private async isLocalUrl(url: string): Promise<boolean> {
const urlObj = new URL(url);
if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) {
return true;
}
let addresses = [];
if (!ipaddr.isValid(urlObj.hostname)) {
const resolver = new Resolver();
addresses = await promisify(resolver.resolve)(urlObj.hostname);
} else {
addresses = [urlObj.hostname];
}
for (const address of addresses) {
const addr = ipaddr.parse(address);
if (addr.range() !== 'unicast') {
return true;
}
}
return false;
}
}
export const mapFetcher = new MapFetcher();

View file

@ -1,5 +1,11 @@
import { ErrorMessage, ServerToClientMessage } from "../Messages/generated/messages_pb";
import {
BatchMessage,
BatchToPusherMessage, BatchToPusherRoomMessage,
ErrorMessage,
ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage
} from "../Messages/generated/messages_pb";
import { UserSocket } from "_Model/User";
import {RoomSocket, ZoneSocket} from "../RoomManager";
export function emitError(Client: UserSocket, message: string): void {
const errorMessage = new ErrorMessage();
@ -13,3 +19,39 @@ export function emitError(Client: UserSocket, message: string): void {
//}
console.warn(message);
}
export function emitErrorOnRoomSocket(Client: RoomSocket, message: string): void {
console.error(message);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherRoomMessage = new SubToPusherRoomMessage();
subToPusherRoomMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherRoomMessage();
batchToPusherMessage.addPayload(subToPusherRoomMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}
export function emitErrorOnZoneSocket(Client: ZoneSocket, message: string): void {
console.error(message);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherMessage = new SubToPusherMessage();
subToPusherMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherMessage();
batchToPusherMessage.addPayload(subToPusherMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}

View file

@ -68,7 +68,7 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo
}
export class SocketManager {
private rooms = new Map<string, GameRoom>();
//private rooms = new Map<string, GameRoom>();
// List of rooms in process of loading.
private roomsPromises = new Map<string, PromiseLike<GameRoom>>();
@ -106,7 +106,9 @@ export class SocketManager {
roomJoinedMessage.addItem(itemStateMessage);
}
for (const [name, value] of room.variables.entries()) {
const variables = await room.getVariablesForTags(user.tags);
for (const [name, value] of variables.entries()) {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
@ -198,12 +200,14 @@ export class SocketManager {
}
handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage) {
try {
room.setVariable(variableMessage.getName(), variableMessage.getValue());
} catch (e) {
console.error('An error occurred on "handleVariableEvent"');
console.error(e);
}
(async () => {
try {
await room.setVariable(variableMessage.getName(), variableMessage.getValue(), user);
} catch (e) {
console.error('An error occurred on "handleVariableEvent"');
console.error(e);
}
})();
}
emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void {
@ -272,7 +276,7 @@ export class SocketManager {
//user leave previous world
room.leave(user);
if (room.isEmpty()) {
this.rooms.delete(room.roomUrl);
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
@ -284,38 +288,34 @@ export class SocketManager {
async getOrCreateRoom(roomId: string): Promise<GameRoom> {
//check and create new room
let room = this.rooms.get(roomId);
if (room === undefined) {
let roomPromise = this.roomsPromises.get(roomId);
if (roomPromise) {
return roomPromise;
}
// Note: for now, the promise is useless (because this is synchronous, but soon, we will need to
// load the map server side.
room = new GameRoom(
roomId,
(user: User, group: Group) => this.joinWebRtcRoom(user, group),
(user: User, group: Group) => this.disConnectedUser(user, group),
MINIMUM_DISTANCE,
GROUP_RADIUS,
(thing: Movable, fromZone: Zone | null, listener: ZoneSocket) =>
this.onZoneEnter(thing, fromZone, listener),
(thing: Movable, position: PositionInterface, listener: ZoneSocket) =>
this.onClientMove(thing, position, listener),
(thing: Movable, newZone: Zone | null, listener: ZoneSocket) =>
this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener)
);
gaugeManager.incNbRoomGauge();
this.rooms.set(roomId, room);
// TODO: change this the to new Promise()... when the method becomes actually asynchronous
roomPromise = Promise.resolve(room);
let roomPromise = this.roomsPromises.get(roomId);
if (roomPromise === undefined) {
roomPromise = new Promise<GameRoom>((resolve, reject) => {
GameRoom.create(
roomId,
(user: User, group: Group) => this.joinWebRtcRoom(user, group),
(user: User, group: Group) => this.disConnectedUser(user, group),
MINIMUM_DISTANCE,
GROUP_RADIUS,
(thing: Movable, fromZone: Zone | null, listener: ZoneSocket) =>
this.onZoneEnter(thing, fromZone, listener),
(thing: Movable, position: PositionInterface, listener: ZoneSocket) =>
this.onClientMove(thing, position, listener),
(thing: Movable, newZone: Zone | null, listener: ZoneSocket) =>
this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener)
).then((gameRoom) => {
gaugeManager.incNbRoomGauge();
resolve(gameRoom);
}).catch((e) => {
this.roomsPromises.delete(roomId);
reject(e);
});
});
this.roomsPromises.set(roomId, roomPromise);
}
return Promise.resolve(room);
return roomPromise;
}
private async joinRoom(
@ -554,8 +554,8 @@ export class SocketManager {
}
}
public getWorlds(): Map<string, GameRoom> {
return this.rooms;
public getWorlds(): Map<string, PromiseLike<GameRoom>> {
return this.roomsPromises;
}
public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) {
@ -625,11 +625,10 @@ export class SocketManager {
}, 10000);
}
public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void {
const room = this.rooms.get(roomId);
public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error("In addZoneListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In addZoneListener, could not find room with id '" + roomId + "'");
}
const things = room.addZoneListener(call, x, y);
@ -670,11 +669,10 @@ export class SocketManager {
call.write(batchMessage);
}
removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) {
const room = this.rooms.get(roomId);
async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error("In removeZoneListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'");
}
room.removeZoneListener(call, x, y);
@ -683,8 +681,7 @@ export class SocketManager {
async addRoomListener(call: RoomSocket, roomId: string) {
const room = await this.getOrCreateRoom(roomId);
if (!room) {
console.error("In addRoomListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In addRoomListener, could not find room with id '" + roomId + "'");
}
room.addRoomListener(call);
@ -692,7 +689,10 @@ export class SocketManager {
const batchMessage = new BatchToPusherRoomMessage();
for (const [name, value] of room.variables.entries()) {
// Finally, no need to store variables in the pusher, let's only make it act as a relay
/*const variables = await room.getVariables();
for (const [name, value] of variables.entries()) {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
@ -701,16 +701,15 @@ export class SocketManager {
subMessage.setVariablemessage(variableMessage);
batchMessage.addPayload(subMessage);
}
}*/
call.write(batchMessage);
}
removeRoomListener(call: RoomSocket, roomId: string) {
const room = this.rooms.get(roomId);
async removeRoomListener(call: RoomSocket, roomId: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error("In removeRoomListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'");
}
room.removeRoomListener(call);
@ -727,14 +726,14 @@ export class SocketManager {
public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin);
if (room.isEmpty()) {
this.rooms.delete(room.roomUrl);
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
}
public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void {
const room = this.rooms.get(roomId);
public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In sendAdminMessage, could not find room with id '" +
@ -764,8 +763,8 @@ export class SocketManager {
recipient.socket.write(serverToClientMessage);
}
public banUser(roomId: string, recipientUuid: string, message: string): void {
const room = this.rooms.get(roomId);
public async banUser(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In banUser, could not find room with id '" +
@ -800,8 +799,8 @@ export class SocketManager {
recipient.socket.end();
}
sendAdminRoomMessage(roomId: string, message: string) {
const room = this.rooms.get(roomId);
async sendAdminRoomMessage(roomId: string, message: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
@ -824,8 +823,8 @@ export class SocketManager {
});
}
dispatchWorlFullWarning(roomId: string): void {
const room = this.rooms.get(roomId);
async dispatchWorldFullWarning(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
@ -846,8 +845,8 @@ export class SocketManager {
});
}
dispatchRoomRefresh(roomId: string): void {
const room = this.rooms.get(roomId);
async dispatchRoomRefresh(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
return;
}

View file

@ -0,0 +1,139 @@
/**
* Handles variables shared between the scripting API and the server.
*/
import {ITiledMap, ITiledMapObject, ITiledMapObjectLayer} from "@workadventure/tiled-map-type-guard/dist";
import {User} from "_Model/User";
interface Variable {
defaultValue?: string,
persist?: boolean,
readableBy?: string,
writableBy?: string,
}
export class VariablesManager {
/**
* The actual values of the variables for the current room
*/
private _variables = new Map<string, string>();
/**
* The list of variables that are allowed
*/
private variableObjects: Map<string, Variable> | undefined;
/**
* @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks.
*/
constructor(private map: ITiledMap | null) {
// We initialize the list of variable object at room start. The objects cannot be edited later
// (otherwise, this would cause a security issue if the scripting API can edit this list of objects)
if (map) {
this.variableObjects = VariablesManager.findVariablesInMap(map);
// Let's initialize default values
for (const [name, variableObject] of this.variableObjects.entries()) {
if (variableObject.defaultValue !== undefined) {
this._variables.set(name, variableObject.defaultValue);
}
}
}
}
private static findVariablesInMap(map: ITiledMap): Map<string, Variable> {
const objects = new Map<string, Variable>();
for (const layer of map.layers) {
if (layer.type === 'objectgroup') {
for (const object of (layer as ITiledMapObjectLayer).objects) {
if (object.type === 'variable') {
if (object.template) {
console.warn('Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.')
continue;
}
// We store a copy of the object (to make it immutable)
objects.set(object.name, this.iTiledObjectToVariable(object));
}
}
}
}
return objects;
}
private static iTiledObjectToVariable(object: ITiledMapObject): Variable {
const variable: Variable = {};
if (object.properties) {
for (const property of object.properties) {
const value = property.value;
switch (property.name) {
case 'default':
variable.defaultValue = JSON.stringify(value);
break;
case 'persist':
if (typeof value !== 'boolean') {
throw new Error('The persist property of variable "' + object.name + '" must be a boolean');
}
variable.persist = value;
break;
case 'writableBy':
if (typeof value !== 'string') {
throw new Error('The writableBy property of variable "' + object.name + '" must be a string');
}
if (value) {
variable.writableBy = value;
}
break;
case 'readableBy':
if (typeof value !== 'string') {
throw new Error('The readableBy property of variable "' + object.name + '" must be a string');
}
if (value) {
variable.readableBy = value;
}
break;
}
}
}
return variable;
}
setVariable(name: string, value: string, user: User): string | undefined {
let readableBy: string | undefined;
if (this.variableObjects) {
const variableObject = this.variableObjects.get(name);
if (variableObject === undefined) {
throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.');
}
if (variableObject.writableBy && user.tags.indexOf(variableObject.writableBy) === -1) {
throw new Error('Trying to set a variable "' + name + '". User "' + user.name + '" does not have sufficient permission. Required tag: "' + variableObject.writableBy + '". User tags: ' + user.tags.join(', ') + ".");
}
readableBy = variableObject.readableBy;
}
this._variables.set(name, value);
return readableBy;
}
public getVariablesForTags(tags: string[]): Map<string, string> {
if (this.variableObjects === undefined) {
return this._variables;
}
const readableVariables = new Map<string, string>();
for (const [key, value] of this._variables.entries()) {
const variableObject = this.variableObjects.get(key);
if (variableObject === undefined) {
throw new Error('Unexpected variable "'+key+'" found has no associated variableObject.');
}
if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) {
readableVariables.set(key, value);
}
}
return readableVariables;
}
}