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/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/IframeListener.ts b/front/src/Api/IframeListener.ts
index e91b92f3..66ea312f 100644
--- a/front/src/Api/IframeListener.ts
+++ b/front/src/Api/IframeListener.ts
@@ -2,6 +2,8 @@ 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";
@@ -14,6 +16,7 @@ class IframeListener {
public readonly chatStream = this._chatStream.asObservable();
private readonly iframes = new Set();
+ private readonly scripts = new Map();
init() {
window.addEventListener("message", (message) => {
@@ -54,6 +57,70 @@ class IframeListener {
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',
diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts
index 5ca3c749..e9725db0 100644
--- a/front/src/Phaser/Game/GameScene.ts
+++ b/front/src/Phaser/Game/GameScene.ts
@@ -72,6 +72,7 @@ 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";
export interface GameSceneInitInterface {
initPosition: PointInterface|null,
@@ -313,6 +314,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
@@ -744,6 +751,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.
@@ -829,8 +842,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;
}
@@ -841,6 +858,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/maps/tests/script.js b/maps/tests/script.js
new file mode 100644
index 00000000..dd608bca
--- /dev/null
+++ b/maps/tests/script.js
@@ -0,0 +1,8 @@
+console.log('SCRIPT LAUNCHED');
+WA.sendChatMessage('Hi, my name is Poly and I repeat what you say!', 'Poly Parrot');
+
+
+WA.onChatMessage((message => {
+ console.log('CHAT MESSAGE RECEIVED BY SCRIPT');
+ WA.sendChatMessage('Poly Parrot says: "'+message+'"', 'Poly Parrot');
+}));
diff --git a/maps/tests/script_api.json b/maps/tests/script_api.json
new file mode 100644
index 00000000..d578f4be
--- /dev/null
+++ b/maps/tests/script_api.json
@@ -0,0 +1,77 @@
+{ "compressionlevel":-1,
+ "editorsettings":
+ {
+ "export":
+ {
+ "target":"."
+ }
+ },
+ "height":10,
+ "infinite":false,
+ "layers":[
+ {
+ "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ "height":10,
+ "id":1,
+ "name":"floor",
+ "opacity":1,
+ "type":"tilelayer",
+ "visible":true,
+ "width":10,
+ "x":0,
+ "y":0
+ },
+ {
+ "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ "height":10,
+ "id":2,
+ "name":"start",
+ "opacity":1,
+ "type":"tilelayer",
+ "visible":true,
+ "width":10,
+ "x":0,
+ "y":0
+ },
+ {
+ "draworder":"topdown",
+ "id":3,
+ "name":"floorLayer",
+ "objects":[],
+ "opacity":1,
+ "type":"objectgroup",
+ "visible":true,
+ "x":0,
+ "y":0
+ }],
+ "nextlayerid":6,
+ "nextobjectid":1,
+ "orientation":"orthogonal",
+ "properties":[
+ {
+ "name":"script",
+ "type":"string",
+ "value":"script.js"
+ }],
+ "renderorder":"right-down",
+ "tiledversion":"1.3.3",
+ "tileheight":32,
+ "tilesets":[
+ {
+ "columns":11,
+ "firstgid":1,
+ "image":"tileset1.png",
+ "imageheight":352,
+ "imagewidth":352,
+ "margin":0,
+ "name":"tileset1",
+ "spacing":0,
+ "tilecount":121,
+ "tileheight":32,
+ "tilewidth":32
+ }],
+ "tilewidth":32,
+ "type":"map",
+ "version":1.2,
+ "width":10
+}
\ No newline at end of file