Adding support to persist variables in Redis
This commit is contained in:
parent
18e4d2ba4e
commit
d955ddfe82
24 changed files with 397 additions and 120 deletions
|
@ -15,7 +15,7 @@ export const isMapDetailsData = new tg.IsInterface()
|
|||
textures: tg.isArray(isCharacterTexture),
|
||||
})
|
||||
.withOptionalProperties({
|
||||
roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated
|
||||
roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated
|
||||
})
|
||||
.get();
|
||||
export type MapDetailsData = tg.GuardedType<typeof isMapDetailsData>;
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export class LocalUrlError extends Error {
|
||||
}
|
||||
export class LocalUrlError extends Error {}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
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";
|
||||
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');
|
||||
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
|
||||
|
@ -22,12 +22,12 @@ class MapFetcher {
|
|||
// - 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
|
||||
maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger
|
||||
timeout: 10000, // Timeout after 10 seconds
|
||||
});
|
||||
|
||||
if (!isTiledMap(res.data)) {
|
||||
throw new Error('Invalid map format for map '+mapUrl);
|
||||
throw new Error("Invalid map format for map " + mapUrl);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
|
@ -39,7 +39,7 @@ class MapFetcher {
|
|||
*/
|
||||
private async isLocalUrl(url: string): Promise<boolean> {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.hostname === 'localhost' || urlObj.hostname.endsWith('.localhost')) {
|
||||
if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -53,7 +53,7 @@ class MapFetcher {
|
|||
|
||||
for (const address of addresses) {
|
||||
const addr = ipaddr.parse(address);
|
||||
if (addr.range() !== 'unicast') {
|
||||
if (addr.range() !== "unicast") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import {
|
||||
BatchMessage,
|
||||
BatchToPusherMessage, BatchToPusherRoomMessage,
|
||||
BatchToPusherMessage,
|
||||
BatchToPusherRoomMessage,
|
||||
ErrorMessage,
|
||||
ServerToClientMessage, SubToPusherMessage, SubToPusherRoomMessage
|
||||
ServerToClientMessage,
|
||||
SubToPusherMessage,
|
||||
SubToPusherRoomMessage,
|
||||
} from "../Messages/generated/messages_pb";
|
||||
import { UserSocket } from "_Model/User";
|
||||
import {RoomSocket, ZoneSocket} from "../RoomManager";
|
||||
import { RoomSocket, ZoneSocket } from "../RoomManager";
|
||||
|
||||
export function emitError(Client: UserSocket, message: string): void {
|
||||
const errorMessage = new ErrorMessage();
|
||||
|
|
23
back/src/Services/RedisClient.ts
Normal file
23
back/src/Services/RedisClient.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { ClientOpts, createClient, RedisClient } from "redis";
|
||||
import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable";
|
||||
|
||||
let redisClient: RedisClient | null = null;
|
||||
|
||||
if (REDIS_HOST !== undefined) {
|
||||
const config: ClientOpts = {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
};
|
||||
|
||||
if (REDIS_PASSWORD) {
|
||||
config.password = REDIS_PASSWORD;
|
||||
}
|
||||
|
||||
redisClient = createClient(config);
|
||||
|
||||
redisClient.on("error", (err) => {
|
||||
console.error("Error connecting to Redis:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export { redisClient };
|
40
back/src/Services/Repository/RedisVariablesRepository.ts
Normal file
40
back/src/Services/Repository/RedisVariablesRepository.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { promisify } from "util";
|
||||
import { RedisClient } from "redis";
|
||||
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
|
||||
|
||||
/**
|
||||
* Class in charge of saving/loading variables from the data store
|
||||
*/
|
||||
export class RedisVariablesRepository implements VariablesRepositoryInterface {
|
||||
private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>;
|
||||
private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise<number>>;
|
||||
|
||||
constructor(redisClient: RedisClient) {
|
||||
// @eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.hgetall = promisify(redisClient.hgetall).bind(redisClient);
|
||||
// @eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.hset = promisify(redisClient.hset).bind(redisClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all variables for a room.
|
||||
*
|
||||
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
|
||||
*/
|
||||
async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
|
||||
return this.hgetall(roomUrl);
|
||||
}
|
||||
|
||||
async saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
|
||||
// TODO: handle the case for "undefined"
|
||||
// TODO: handle the case for "undefined"
|
||||
// TODO: handle the case for "undefined"
|
||||
// TODO: handle the case for "undefined"
|
||||
// TODO: handle the case for "undefined"
|
||||
|
||||
// TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT
|
||||
|
||||
// @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify
|
||||
return this.hset(roomUrl, key, value);
|
||||
}
|
||||
}
|
14
back/src/Services/Repository/VariablesRepository.ts
Normal file
14
back/src/Services/Repository/VariablesRepository.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { RedisVariablesRepository } from "./RedisVariablesRepository";
|
||||
import { redisClient } from "../RedisClient";
|
||||
import { VoidVariablesRepository } from "./VoidVariablesRepository";
|
||||
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
|
||||
|
||||
let variablesRepository: VariablesRepositoryInterface;
|
||||
if (!redisClient) {
|
||||
console.warn("WARNING: Redis isnot configured. No variables will be persisted.");
|
||||
variablesRepository = new VoidVariablesRepository();
|
||||
} else {
|
||||
variablesRepository = new RedisVariablesRepository(redisClient);
|
||||
}
|
||||
|
||||
export { variablesRepository };
|
10
back/src/Services/Repository/VariablesRepositoryInterface.ts
Normal file
10
back/src/Services/Repository/VariablesRepositoryInterface.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface VariablesRepositoryInterface {
|
||||
/**
|
||||
* Load all variables for a room.
|
||||
*
|
||||
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
|
||||
*/
|
||||
loadVariables(roomUrl: string): Promise<{ [key: string]: string }>;
|
||||
|
||||
saveVariable(roomUrl: string, key: string, value: string): Promise<number>;
|
||||
}
|
14
back/src/Services/Repository/VoidVariablesRepository.ts
Normal file
14
back/src/Services/Repository/VoidVariablesRepository.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
|
||||
|
||||
/**
|
||||
* Mock class in charge of NOT saving/loading variables from the data store
|
||||
*/
|
||||
export class VoidVariablesRepository implements VariablesRepositoryInterface {
|
||||
loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
}
|
|
@ -305,13 +305,15 @@ export class SocketManager {
|
|||
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);
|
||||
});
|
||||
)
|
||||
.then((gameRoom) => {
|
||||
gaugeManager.incNbRoomGauge();
|
||||
resolve(gameRoom);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.roomsPromises.delete(roomId);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
this.roomsPromises.set(roomId, roomPromise);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
/**
|
||||
* 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";
|
||||
import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist";
|
||||
import { User } from "_Model/User";
|
||||
import { variablesRepository } from "./Repository/VariablesRepository";
|
||||
import { redisClient } from "./RedisClient";
|
||||
|
||||
interface Variable {
|
||||
defaultValue?: string,
|
||||
persist?: boolean,
|
||||
readableBy?: string,
|
||||
writableBy?: string,
|
||||
defaultValue?: string;
|
||||
persist?: boolean;
|
||||
readableBy?: string;
|
||||
writableBy?: string;
|
||||
}
|
||||
|
||||
export class VariablesManager {
|
||||
|
@ -25,7 +27,7 @@ export class VariablesManager {
|
|||
/**
|
||||
* @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) {
|
||||
constructor(private roomUrl: string, 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) {
|
||||
|
@ -40,14 +42,43 @@ export class VariablesManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let's load data from the Redis backend.
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
if (!this.shouldPersist()) {
|
||||
return;
|
||||
}
|
||||
const variables = await variablesRepository.loadVariables(this.roomUrl);
|
||||
console.error("LIST OF VARIABLES FETCHED", variables);
|
||||
for (const key in variables) {
|
||||
this._variables.set(key, variables[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if saving should be enabled, and false otherwise.
|
||||
*
|
||||
* Saving is enabled if REDIS_HOST is set
|
||||
* unless we are editing a local map
|
||||
* unless we are in dev mode in which case it is ok to save
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private shouldPersist(): boolean {
|
||||
return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development");
|
||||
}
|
||||
|
||||
private static findVariablesInMap(map: ITiledMap): Map<string, Variable> {
|
||||
const objects = new Map<string, Variable>();
|
||||
for (const layer of map.layers) {
|
||||
if (layer.type === 'objectgroup') {
|
||||
if (layer.type === "objectgroup") {
|
||||
for (const object of (layer as ITiledMapObjectLayer).objects) {
|
||||
if (object.type === 'variable') {
|
||||
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.')
|
||||
console.warn(
|
||||
'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -67,26 +98,30 @@ export class VariablesManager {
|
|||
for (const property of object.properties) {
|
||||
const value = property.value;
|
||||
switch (property.name) {
|
||||
case 'default':
|
||||
case "default":
|
||||
variable.defaultValue = JSON.stringify(value);
|
||||
break;
|
||||
case 'persist':
|
||||
if (typeof value !== 'boolean') {
|
||||
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');
|
||||
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');
|
||||
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;
|
||||
|
@ -107,14 +142,27 @@ export class VariablesManager {
|
|||
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(', ') + ".");
|
||||
if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) {
|
||||
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);
|
||||
variablesRepository
|
||||
.saveVariable(this.roomUrl, name, value)
|
||||
.catch((e) => console.error("Error while saving variable in Redis:", e));
|
||||
return readableBy;
|
||||
}
|
||||
|
||||
|
@ -128,9 +176,9 @@ export class VariablesManager {
|
|||
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.');
|
||||
throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.');
|
||||
}
|
||||
if (!variableObject.readableBy || tags.indexOf(variableObject.readableBy) !== -1) {
|
||||
if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) {
|
||||
readableVariables.set(key, value);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue