diff --git a/deeployer.libsonnet b/deeployer.libsonnet
index 07f5f491..52cea293 100644
--- a/deeployer.libsonnet
+++ b/deeployer.libsonnet
@@ -88,8 +88,6 @@
"JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_SERVER": "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443",
- "TURN_USER": "workadventure",
- "TURN_PASSWORD": "WorkAdventure123",
"JITSI_PRIVATE_MODE": if env.SECRET_JITSI_KEY != '' then "true" else "false",
"START_ROOM_URL": "/_/global/maps."+url+"/Floor0/floor0.json"
//"GA_TRACKING_ID": "UA-10196481-11"
diff --git a/front/Dockerfile b/front/Dockerfile
index b0d17877..51734535 100644
--- a/front/Dockerfile
+++ b/front/Dockerfile
@@ -8,6 +8,11 @@ FROM thecodingmachine/nodejs:14-apache
COPY --chown=docker:docker front .
COPY --from=builder --chown=docker:docker /var/www/messages/generated /var/www/html/src/Messages/generated
+
+# Removing the iframe.html file from the final image as this adds a XSS attack.
+# iframe.html is only in dev mode to circumvent a limitation
+RUN rm dist/iframe.html
+
RUN yarn install
ENV NODE_ENV=production
diff --git a/front/dist/.gitignore b/front/dist/.gitignore
index dd1fae3d..a60c53be 100644
--- a/front/dist/.gitignore
+++ b/front/dist/.gitignore
@@ -1,2 +1,3 @@
index.html
index.tmpl.html.tmp
+/js/
diff --git a/front/dist/iframe.html b/front/dist/iframe.html
new file mode 100644
index 00000000..c8fafb4b
--- /dev/null
+++ b/front/dist/iframe.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/front/src/Api/Events/ChatEvent.ts b/front/src/Api/Events/ChatEvent.ts
new file mode 100644
index 00000000..5729a120
--- /dev/null
+++ b/front/src/Api/Events/ChatEvent.ts
@@ -0,0 +1,11 @@
+import * as tg from "generic-type-guard";
+
+export const isChatEvent =
+ new tg.IsInterface().withProperties({
+ message: tg.isString,
+ author: tg.isString,
+ }).get();
+/**
+ * A message sent from the iFrame to the game to add a message in the chat.
+ */
+export type ChatEvent = tg.GuardedType;
diff --git a/front/src/Api/Events/EnterLeaveEvent.ts b/front/src/Api/Events/EnterLeaveEvent.ts
new file mode 100644
index 00000000..0c0cb4ff
--- /dev/null
+++ b/front/src/Api/Events/EnterLeaveEvent.ts
@@ -0,0 +1,10 @@
+import * as tg from "generic-type-guard";
+
+export const isEnterLeaveEvent =
+ new tg.IsInterface().withProperties({
+ name: tg.isString,
+ }).get();
+/**
+ * A message sent from the game to the iFrame when a user enters or leaves a zone marked with the "zone" property.
+ */
+export type EnterLeaveEvent = tg.GuardedType;
diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts
new file mode 100644
index 00000000..65d2b443
--- /dev/null
+++ b/front/src/Api/Events/IframeEvent.ts
@@ -0,0 +1,7 @@
+export interface IframeEvent {
+ type: string;
+ data: unknown;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIframeEventWrapper = (event: any): event is IframeEvent => typeof event.type === 'string' && typeof event.data === 'object';
diff --git a/front/src/Api/Events/OpenPopupEvent.ts b/front/src/Api/Events/OpenPopupEvent.ts
new file mode 100644
index 00000000..bbfc12bf
--- /dev/null
+++ b/front/src/Api/Events/OpenPopupEvent.ts
@@ -0,0 +1,22 @@
+import * as tg from "generic-type-guard";
+
+const isButtonDescriptor =
+ new tg.IsInterface().withProperties({
+ label: tg.isString,
+ className: tg.isOptional(tg.isString),
+ closeOnClick: tg.isOptional(tg.isBoolean)
+ }).get();
+type ButtonDescriptor = tg.GuardedType;
+
+export const isOpenPopupEvent =
+ new tg.IsInterface().withProperties({
+ popupId: tg.isNumber,
+ targetObject: tg.isString,
+ message: tg.isString,
+ buttons: tg.isAny //tg.isArray,
+ }).get();
+
+/**
+ * A message sent from the iFrame to the game to add a message in the chat.
+ */
+export type OpenPopupEvent = tg.GuardedType;
diff --git a/front/src/Api/Events/UserInputChatEvent.ts b/front/src/Api/Events/UserInputChatEvent.ts
new file mode 100644
index 00000000..de21ff6e
--- /dev/null
+++ b/front/src/Api/Events/UserInputChatEvent.ts
@@ -0,0 +1,10 @@
+import * as tg from "generic-type-guard";
+
+export const isUserInputChatEvent =
+ new tg.IsInterface().withProperties({
+ message: tg.isString,
+ }).get();
+/**
+ * A message sent from the game to the iFrame when a user types a message in the chat.
+ */
+export type UserInputChatEvent = tg.GuardedType;
diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts
new file mode 100644
index 00000000..1a6a0ea7
--- /dev/null
+++ b/front/src/Api/IframeListener.ts
@@ -0,0 +1,171 @@
+import {Subject} from "rxjs";
+import {ChatEvent, isChatEvent} from "./Events/ChatEvent";
+import {IframeEvent, isIframeEventWrapper} from "./Events/IframeEvent";
+import {UserInputChatEvent} from "./Events/UserInputChatEvent";
+import * as crypto from "crypto";
+import {HtmlUtils} from "../WebRtc/HtmlUtils";
+import {EnterLeaveEvent} from "./Events/EnterLeaveEvent";
+import {isOpenPopupEvent, OpenPopupEvent} from "./Events/OpenPopupEvent";
+
+
+
+/**
+ * Listens to messages from iframes and turn those messages into easy to use observables.
+ * Also allows to send messages to those iframes.
+ */
+class IframeListener {
+ private readonly _chatStream: Subject = new Subject();
+ public readonly chatStream = this._chatStream.asObservable();
+
+ private readonly _openPopupStream: Subject = new Subject();
+ public readonly openPopupStream = this._openPopupStream.asObservable();
+
+ private readonly iframes = new Set();
+ private readonly scripts = new Map();
+
+ init() {
+ window.addEventListener("message", (message) => {
+ // Do we trust the sender of this message?
+ // Let's only accept messages from the iframe that are allowed.
+ // Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain).
+ let found = false;
+ for (const iframe of this.iframes) {
+ if (iframe.contentWindow === message.source) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return;
+ }
+
+ const payload = message.data;
+ console.log('FOO');
+ if (isIframeEventWrapper(payload)) {
+ console.log('FOOBAR', payload);
+ if (payload.type === 'chat' && isChatEvent(payload.data)) {
+ this._chatStream.next(payload.data);
+ } else if (payload.type === 'openPopup' && isOpenPopupEvent(payload.data)) {
+ console.log('OPENPOPUP called');
+ this._openPopupStream.next(payload.data);
+ }
+ }
+
+
+ }, false);
+
+ }
+
+ /**
+ * Allows the passed iFrame to send/receive messages via the API.
+ */
+ registerIframe(iframe: HTMLIFrameElement): void {
+ this.iframes.add(iframe);
+ }
+
+ unregisterIframe(iframe: HTMLIFrameElement): void {
+ this.iframes.delete(iframe);
+ }
+
+ registerScript(scriptUrl: string): void {
+ console.log('Loading map related script at ', scriptUrl)
+
+ if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
+ // Using external iframe mode (
+ const iframe = document.createElement('iframe');
+ iframe.id = this.getIFrameId(scriptUrl);
+ iframe.style.display = 'none';
+ iframe.src = '/iframe.html?script='+encodeURIComponent(scriptUrl);
+
+ // We are putting a sandbox on this script because it will run in the same domain as the main website.
+ iframe.sandbox.add('allow-scripts');
+ iframe.sandbox.add('allow-top-navigation-by-user-activation');
+
+ document.body.prepend(iframe);
+
+ this.scripts.set(scriptUrl, iframe);
+ this.registerIframe(iframe);
+ } else {
+ // production code
+ const iframe = document.createElement('iframe');
+ iframe.id = this.getIFrameId(scriptUrl);
+
+ // We are putting a sandbox on this script because it will run in the same domain as the main website.
+ iframe.sandbox.add('allow-scripts');
+ iframe.sandbox.add('allow-top-navigation-by-user-activation');
+
+ const html = '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n';
+
+ //iframe.src = "data:text/html;charset=utf-8," + escape(html);
+ iframe.srcdoc = html;
+
+ document.body.prepend(iframe);
+
+ this.scripts.set(scriptUrl, iframe);
+ this.registerIframe(iframe);
+ }
+
+
+ }
+
+ private getIFrameId(scriptUrl: string): string {
+ return 'script'+crypto.createHash('md5').update(scriptUrl).digest("hex");
+ }
+
+ unregisterScript(scriptUrl: string): void {
+ const iFrameId = this.getIFrameId(scriptUrl);
+ const iframe = HtmlUtils.getElementByIdOrFail(iFrameId);
+ if (!iframe) {
+ throw new Error('Unknown iframe for script "'+scriptUrl+'"');
+ }
+ this.unregisterIframe(iframe);
+ iframe.remove();
+
+ this.scripts.delete(scriptUrl);
+ }
+
+ sendUserInputChat(message: string) {
+ this.postMessage({
+ 'type': 'userInputChat',
+ 'data': {
+ 'message': message,
+ } as UserInputChatEvent
+ });
+ }
+
+ sendEnterEvent(name: string) {
+ this.postMessage({
+ 'type': 'enterEvent',
+ 'data': {
+ "name": name
+ } as EnterLeaveEvent
+ });
+ }
+
+ sendLeaveEvent(name: string) {
+ this.postMessage({
+ 'type': 'leaveEvent',
+ 'data': {
+ "name": name
+ } as EnterLeaveEvent
+ });
+ }
+
+ /**
+ * Sends the message... to all allowed iframes.
+ */
+ private postMessage(message: IframeEvent) {
+ for (const iframe of this.iframes) {
+ iframe.contentWindow?.postMessage(message, '*');
+ }
+ }
+}
+
+export const iframeListener = new IframeListener();
diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts
index e97899bf..0a953363 100644
--- a/front/src/Phaser/Game/GameScene.ts
+++ b/front/src/Phaser/Game/GameScene.ts
@@ -72,8 +72,10 @@ import {TextureError} from "../../Exception/TextureError";
import {addLoader} from "../Components/Loader";
import {ErrorSceneName} from "../Reconnecting/ErrorScene";
import {localUserStore} from "../../Connexion/LocalUserStore";
+import {iframeListener} from "../../Api/IframeListener";
import DOMElement = Phaser.GameObjects.DOMElement;
import Tween = Phaser.Tweens.Tween;
+import {HtmlUtils} from "../../WebRtc/HtmlUtils";
export interface GameSceneInitInterface {
initPosition: PointInterface|null,
@@ -164,8 +166,8 @@ export class GameScene extends ResizableScene implements CenterListener {
private openChatIcon!: OpenChatIcon;
private playerName!: string;
private characterLayers!: string[];
+ private popUpElements : Map = new Map();
- private popUpElement : DOMElement| undefined;
constructor(private room: Room, MapUrlFile: string, customKey?: string|undefined) {
super({
key: customKey ?? room.id
@@ -316,6 +318,12 @@ export class GameScene extends ResizableScene implements CenterListener {
// });
// });
}
+
+ // Now, let's load the script, if any
+ const scripts = this.getScriptUrls(this.mapFile);
+ for (const script of scripts) {
+ iframeListener.registerScript(script);
+ }
}
//hook initialisation
@@ -435,6 +443,7 @@ export class GameScene extends ResizableScene implements CenterListener {
// From now, this game scene will be notified of reposition events
layoutManager.setListener(this);
this.triggerOnMapLayerPropertyChange();
+ this.listenToIframeEvents();
const camera = this.cameras.main;
@@ -648,33 +657,6 @@ export class GameScene extends ResizableScene implements CenterListener {
this.gameMap.onPropertyChange('exitSceneUrl', (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string);
});
- this.gameMap.onPropertyChange('inGameConsoleMessage', (newValue, oldValue, allProps) => {
- if (newValue !== undefined) {
- this.popUpElement?.destroy();
- this.popUpElement = this.add.dom(2100, 150).createFromHTML(newValue as string);
- this.popUpElement.scale = 0;
- this.tweens.add({
- targets : this.popUpElement ,
- scale : 1,
- ease : "EaseOut",
- duration : 400,
- });
-
- this.popUpElement.setClassName("popUpElement");
-
- } else {
- this.tweens.add({
- targets : this.popUpElement ,
- scale : 0,
- ease : "EaseOut",
- duration : 400,
- onComplete : () => {
- this.popUpElement?.destroy();
- this.popUpElement = undefined;
- },
- });
- }
- });
this.gameMap.onPropertyChange('exitUrl', (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string);
});
@@ -684,7 +666,7 @@ export class GameScene extends ResizableScene implements CenterListener {
coWebsiteManager.closeCoWebsite();
}else{
const openWebsiteFunction = () => {
- coWebsiteManager.loadCoWebsite(newValue as string, this.MapUrlFile, allProps.get('openWebsitePolicy') as string | undefined);
+ coWebsiteManager.loadCoWebsite(newValue as string, this.MapUrlFile, allProps.get('openWebsiteAllowApi') as boolean | undefined, allProps.get('openWebsitePolicy') as string | undefined);
layoutManager.removeActionButton('openWebsite', this.userInputManager);
};
@@ -748,6 +730,64 @@ export class GameScene extends ResizableScene implements CenterListener {
this.playAudio(newValue, true);
});
+ this.gameMap.onPropertyChange('zone', (newValue, oldValue) => {
+ if (newValue === undefined || newValue === false || newValue === '') {
+ iframeListener.sendLeaveEvent(oldValue as string);
+ } else {
+ iframeListener.sendEnterEvent(newValue as string);
+ }
+ });
+
+ }
+
+ private listenToIframeEvents(): void {
+ iframeListener.openPopupStream.subscribe((openPopupEvent) => {
+ const escapedMessage = HtmlUtils.escapeHtml(openPopupEvent.message);
+
+ let html = `
+${escapedMessage}
+
`;
+
+ const domElement = this.add.dom(150, 150).createFromHTML(html);
+ domElement.scale = 0;
+ domElement.setClassName('popUpElement');
+ this.tweens.add({
+ targets : domElement ,
+ scale : 1,
+ ease : "EaseOut",
+ duration : 400,
+ });
+
+ this.popUpElements.set(openPopupEvent.popupId, domElement);
+ });
+ /*this.gameMap.onPropertyChange('inGameConsoleMessage', (newValue, oldValue, allProps) => {
+ if (newValue !== undefined) {
+ this.popUpElement?.destroy();
+ this.popUpElement = this.add.dom(2100, 150).createFromHTML(newValue as string);
+ this.popUpElement.scale = 0;
+ this.tweens.add({
+ targets : this.popUpElement ,
+ scale : 1,
+ ease : "EaseOut",
+ duration : 400,
+ });
+
+ this.popUpElement.setClassName("popUpElement");
+
+ } else {
+ this.tweens.add({
+ targets : this.popUpElement ,
+ scale : 0,
+ ease : "EaseOut",
+ duration : 400,
+ onComplete : () => {
+ this.popUpElement?.destroy();
+ this.popUpElement = undefined;
+ },
+ });
+ }
+ });*/
+
}
private onMapExit(exitKey: string) {
@@ -774,6 +814,12 @@ export class GameScene extends ResizableScene implements CenterListener {
public cleanupClosingScene(): void {
// stop playing audio, close any open website, stop any open Jitsi
coWebsiteManager.closeCoWebsite();
+ // Stop the script, if any
+ const scripts = this.getScriptUrls(this.mapFile);
+ for (const script of scripts) {
+ iframeListener.unregisterScript(script);
+ }
+
this.stopJitsi();
this.playAudio(undefined);
// We are completely destroying the current scene to avoid using a half-backed instance when coming back to the same map.
@@ -859,8 +905,12 @@ export class GameScene extends ResizableScene implements CenterListener {
return this.getProperty(layer, "startLayer") == true;
}
- private getProperty(layer: ITiledMapLayer, name: string): string|boolean|number|undefined {
- const properties = layer.properties;
+ private getScriptUrls(map: ITiledMap): string[] {
+ return (this.getProperties(map, "script") as string[]).map((script) => (new URL(script, this.MapUrlFile)).toString());
+ }
+
+ private getProperty(layer: ITiledMapLayer|ITiledMap, name: string): string|boolean|number|undefined {
+ const properties: ITiledMapLayerProperty[] = layer.properties;
if (!properties) {
return undefined;
}
@@ -871,6 +921,14 @@ export class GameScene extends ResizableScene implements CenterListener {
return obj.value;
}
+ private getProperties(layer: ITiledMapLayer|ITiledMap, name: string): (string|number|boolean|undefined)[] {
+ const properties: ITiledMapLayerProperty[] = layer.properties;
+ if (!properties) {
+ return [];
+ }
+ return properties.filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()).map((property) => property.value);
+ }
+
//todo: push that into the gameManager
private async loadNextGame(exitSceneIdentifier: string){
const {roomId, hash} = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance);
diff --git a/front/src/Phaser/Map/ITiledMap.ts b/front/src/Phaser/Map/ITiledMap.ts
index 2a82e93a..39e0a1f5 100644
--- a/front/src/Phaser/Map/ITiledMap.ts
+++ b/front/src/Phaser/Map/ITiledMap.ts
@@ -14,7 +14,7 @@ export interface ITiledMap {
* Map orientation (orthogonal)
*/
orientation: string;
- properties: {[key: string]: string};
+ properties: ITiledMapLayerProperty[];
/**
* Render order (right-down)
diff --git a/front/src/WebRtc/CoWebsiteManager.ts b/front/src/WebRtc/CoWebsiteManager.ts
index 4e74c4a7..472e7a13 100644
--- a/front/src/WebRtc/CoWebsiteManager.ts
+++ b/front/src/WebRtc/CoWebsiteManager.ts
@@ -1,4 +1,5 @@
import {HtmlUtils} from "./HtmlUtils";
+import {iframeListener} from "../Api/IframeListener";
export type CoWebsiteStateChangedCallback = () => void;
@@ -12,8 +13,8 @@ const cowebsiteDivId = "cowebsite"; // the id of the parent div of the iframe.
const animationTime = 500; //time used by the css transitions, in ms.
class CoWebsiteManager {
-
- private opened: iframeStates = iframeStates.closed;
+
+ private opened: iframeStates = iframeStates.closed;
private observers = new Array();
/**
@@ -21,12 +22,12 @@ class CoWebsiteManager {
* So we use this promise to queue up every cowebsite state transition
*/
private currentOperationPromise: Promise = Promise.resolve();
- private cowebsiteDiv: HTMLDivElement;
-
+ private cowebsiteDiv: HTMLDivElement;
+
constructor() {
this.cowebsiteDiv = HtmlUtils.getElementByIdOrFail(cowebsiteDivId);
}
-
+
private close(): void {
this.cowebsiteDiv.classList.remove('loaded'); //edit the css class to trigger the transition
this.cowebsiteDiv.classList.add('hidden');
@@ -42,7 +43,7 @@ class CoWebsiteManager {
this.opened = iframeStates.opened;
}
- public loadCoWebsite(url: string, base: string, allowPolicy?: string): void {
+ public loadCoWebsite(url: string, base: string, allowApi?: boolean, allowPolicy?: string): void {
this.load();
this.cowebsiteDiv.innerHTML = `