diff --git a/.env.example b/.env.example index 3bc11dc83..5744a3957 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ REACT_APP_SENTRY_DSN='' REACT_APP_SENTRY_ENV='local' PUBLIC_URL='http://localhost:3011' ASSETS_URL='http://localhost:3011' +HTML_RENDERER_URL='http://localhost:3003' REACT_APP_GOOGLE_TAG_MANAGER_ID='' REACT_APP_API_ENDPOINT='http://localhost:3009' REACT_APP_PLAUSIBLE_DATA_DOMAIN='' REACT_APP_PLAUSIBLE_SOURCE='' -REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012' \ No newline at end of file +REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012' diff --git a/.gitignore b/.gitignore index 56ae9ffea..e6cd150ab 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ node_modules # production /build +/build-html-renderer /public/storybook # misc @@ -33,3 +34,4 @@ yarn-error.log* .yarn/install-state.gz .vscode/settings.json +.idea/* diff --git a/cypress/e2e/spec-html.cy.js b/cypress/e2e/spec-html.cy.js index 7f3041aa9..b182de879 100644 --- a/cypress/e2e/spec-html.cy.js +++ b/cypress/e2e/spec-html.cy.js @@ -9,6 +9,8 @@ const getIframeDocument = () => { .shadow() .find("iframe[class=htmlrunner-iframe]") .its("0.contentDocument") + .find("iframe[title=preview-sandbox]") + .its("0.contentDocument") .should("exist"); }; @@ -30,6 +32,14 @@ const makeNewFile = (filename = "new.html") => { .click(); }; +const getEditorInput = () => { + return cy.get("editor-wc").shadow().find("div[class=cm-content]"); +}; + +const getRunButton = () => { + return cy.get("editor-wc").shadow().find(".btn--run"); +}; + beforeEach(() => { // intercept request to editor api cy.intercept( @@ -41,60 +51,51 @@ beforeEach(() => { ); }); -it("blocks access to localStorage authKey", () => { +it("blocks access to parent localStorage", () => { + // Arrange localStorage.clear(); - cy.visit(baseUrl); - cy.get("editor-wc") - .shadow() - .find("div[class=cm-content]") - .invoke( - "text", - `

authKey:

-`, - ); - cy.get("editor-wc").shadow().find(".btn--run").click(); - getIframeBody().find("p").should("include.text", "authKey: null"); -}); + localStorage.setItem("parentKey", "secretValue"); -it("blocks access to localStorage OIDC keys", () => { - localStorage.clear(); cy.visit(baseUrl); - cy.get("editor-wc") - .shadow() - .find("div[class=cm-content]") - .invoke( - "text", - `

oidcUser:

+ + // Act + const input = getEditorInput(); + + input.invoke( + "text", + `

parentKey:

`, - ); - cy.get("editor-wc").shadow().find(".btn--run").click(); - getIframeBody().find("p").should("include.text", "oidcUser: null"); + ); + getRunButton().click(); + + // Assert + getIframeBody().find("p").should("include.text", "parentKey: null"); }); -it("allows access to other localStorage keys", () => { +it("allows access to localStorage", () => { + // Arrange localStorage.clear(); cy.visit(baseUrl); - cy.get("editor-wc") - .shadow() - .find("div[class=cm-content]") - .invoke( - "text", - `

foo:

+ + // Act + const input = getEditorInput(); + + input.invoke( + "text", + `

foo:

`, - ); - cy.get("editor-wc").shadow().find(".btn--run").click(); + ); + + getRunButton().click(); + + // Assert getIframeBody().find("p").should("include.text", "foo: bar"); }); @@ -130,17 +131,19 @@ it("updates the preview after a change when you click run", () => { }); it("blocks non-permitted external links", () => { + // Arrange localStorage.clear(); cy.visit(baseUrl); - cy.get("editor-wc") - .shadow() - .find("div[class=cm-content]") - .invoke( - "text", - 'some external link', - ); - cy.get("editor-wc").shadow().find(".btn--run").click(); + + // Act + getEditorInput().invoke( + "text", + 'some external link', + ); + getRunButton().click(); getIframeBody().find("a").click(); + + // Assert cy.get("editor-wc") .shadow() .find("div[class=modal-content__header]") diff --git a/package.json b/package.json index 8ccb2d281..fdaeeab5d 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ }, "scripts": { "start": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.config.js", + "start-html-renderer": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.html-renderer.config.js", "build": "NODE_ENV=production BABEL_ENV=production webpack build -c ./webpack.config.js", "analyze": "ANALYZE_WEBPACK_BUNDLE=true yarn build", "lint": "eslint 'src/**/*.{js,jsx}' cypress/**/*.js", diff --git a/public/index-html-renderer.html b/public/index-html-renderer.html new file mode 100644 index 000000000..2f66c6e74 --- /dev/null +++ b/public/index-html-renderer.html @@ -0,0 +1,15 @@ + + + + + + + + Editor HTML preview + + +
+
+ + diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx new file mode 100644 index 000000000..2cbc4f8fa --- /dev/null +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { parse } from "node-html-parser"; +import mimeTypes from "mime-types"; +import { + allowedExternalLinks, + allowedInternalLinks, + matchingRegexes, +} from "../../../../utils/externalLinkHelper"; +import "../../../../assets/stylesheets/HtmlRunner.scss"; + +const parentTag = (node, tag) => + node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag; + +const cssProjectImgs = (projectFile, projectMedia) => { + let updatedProjectFile = { ...projectFile }; + if (projectFile.extension === "css") { + projectMedia.forEach((media_file) => { + const find = new RegExp(`['"]${media_file.filename}['"]`, "g"); // prevent substring matches + const replace = `"${media_file.url}"`; + updatedProjectFile.content = updatedProjectFile.content.replaceAll( + find, + replace, + ); + }); + } + return updatedProjectFile; +}; + +const getBlobURL = (code, type) => { + const blob = new Blob([code], { type }); + return URL.createObjectURL(blob); +}; + +const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { + const hrefNodes = indexPage.querySelectorAll("[href]"); + + hrefNodes.forEach((hrefNode) => { + const projectFile = projectCode.find( + (file) => `${file.name}.${file.extension}` === hrefNode.attrs.href, + ); + + if (hrefNode.attrs?.target === "_blank") { + hrefNode.removeAttribute("target"); + } + + let onClick; + + if (!!projectFile) { + if (parentTag(hrefNode, "head")) { + const projectFileBlob = getBlobURL( + cssProjectImgs(projectFile, projectMedia).content, + mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`), + ); + hrefNode.setAttribute("href", projectFileBlob); + } else { + // eslint-disable-next-line no-script-url + hrefNode.setAttribute("href", "javascript:void(0)"); + onClick = `window.parent.postMessage({msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }})`; + } + } else { + const matchingExternalHref = matchingRegexes( + allowedExternalLinks, + hrefNode.attrs.href, + ); + const matchingInternalHref = matchingRegexes( + allowedInternalLinks, + hrefNode.attrs.href, + ); + if ( + !matchingInternalHref && + !matchingExternalHref && + !parentTag(hrefNode, "head") + ) { + // eslint-disable-next-line no-script-url + hrefNode.setAttribute("href", "javascript:void(0)"); + onClick = "window.parent.postMessage({msg: 'ERROR: External link'})"; + } else if (matchingExternalHref) { + onClick = `window.parent.postMessage({msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }})`; + } + } + + if (onClick) { + hrefNode.removeAttribute("target"); + hrefNode.setAttribute("onclick", onClick); + } + }); +}; + +const replaceSrcNodes = ( + indexPage, + projectMedia, + projectCode, + attr = "src", +) => { + const srcNodes = indexPage.querySelectorAll(`[${attr}]`); + + srcNodes.forEach((srcNode) => { + const projectMediaFile = projectMedia.find( + (component) => component.filename === srcNode.attrs[attr], + ); + const projectTextFile = projectCode.find( + (file) => `${file.name}.${file.extension}` === srcNode.attrs[attr], + ); + + let src = ""; + if (!!projectMediaFile) { + src = projectMediaFile.url; + } else if (!!projectTextFile) { + src = getBlobURL( + projectTextFile.content, + mimeTypes.lookup( + `${projectTextFile.name}.${projectTextFile.extension}`, + ), + ); + } else if (matchingRegexes(allowedExternalLinks, srcNode.attrs[attr])) { + src = srcNode.attrs[attr]; + } + srcNode.setAttribute(attr, src); + srcNode.setAttribute("crossorigin", true); + }); +}; + +export function HtmlRenderer() { + const [previewHtml, setPreviewHtml] = useState(); + + const handlePreviewUpdateFromHost = useCallback( + (event) => { + // todo: validate message origin + // todo: use "type" to check what kind of message this is + const message = event.data; + if (message?.current) { + const transformedHtml = parse(message.current); + + replaceHrefNodes(transformedHtml, message.media, message.code); + replaceSrcNodes(transformedHtml, message.media, message.code); + replaceSrcNodes( + transformedHtml, + message.media, + message.code, + "data-src", + ); + + setPreviewHtml(transformedHtml); + } + }, + [setPreviewHtml], + ); + + const handleEventFromPreview = (event) => { + // todo: validate message origin + const message = event.data; + // todo: use "type" to check what kind of message this is + if (typeof event.data?.msg === "string") { + // Forward events originating from the previewed code back to the host + // todo: set appropriate target origin + window.parent.postMessage(message, "*"); + } + }; + + useEffect(() => { + window.addEventListener("message", handlePreviewUpdateFromHost); + window.addEventListener("message", handleEventFromPreview); + + const source = window.opener || window.parent; + if (source) { + // todo: set appropriate target origin + source.postMessage({ ready: true }, "*"); + } + return () => { + window.removeEventListener("message", handlePreviewUpdateFromHost); + window.removeEventListener("message", handleEventFromPreview); + }; + }, [handlePreviewUpdateFromHost]); + + return previewHtml ? ( +