diff --git a/.env.example b/.env.example index 3bc11dc83..9993dafb2 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:3011' 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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 075bf7dae..a770970ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,10 @@ on: required: false default: "https://staging-editor-static.raspberrypi.org" type: string + html_renderer_url: + required: false + default: "https://staging-editor-static.raspberrypi.org" + type: string react_app_api_endpoint: required: false default: "https://staging-editor-api.raspberrypi.org" @@ -62,7 +66,7 @@ on: required: false default: "https://staging-editor.raspberrypi.org,https://staging-editor-static.raspberrypi.org,https://test-editor.raspberrypi.org" type: string - + secrets: AWS_ACCESS_KEY_ID: required: false @@ -86,6 +90,7 @@ jobs: deploy_dir: ${{ steps.setup-environment.outputs.deploy_dir }} public_url: ${{ steps.setup-environment.outputs.public_url }} assets_url: ${{ steps.setup-environment.outputs.assets_url }} + html_renderer_url: ${{ steps.setup-environment.outputs.html_renderer_url }} react_app_base_url: ${{ steps.setup-environment.outputs.react_app_base_url }} steps: - id: setup-environment @@ -94,9 +99,11 @@ jobs: deploy_dir=${{inputs.prefix}}/$safe_ref_name public_url=${{inputs.base_url}}/$deploy_dir assets_url=${{inputs.assets_url}}/$deploy_dir + html_renderer_url=${{inputs.html_renderer_url}}/$deploy_dir echo "deploy_dir=$deploy_dir" >> $GITHUB_OUTPUT echo "public_url=$public_url" >> $GITHUB_OUTPUT echo "assets_url=$assets_url" >> $GITHUB_OUTPUT + echo "html_renderer_url=$html_renderer_url" >> $GITHUB_OUTPUT if [ "${{inputs.react_app_base_url}}" = "unspecified" ] ; then echo "react_app_base_url=$deploy_dir" >> $GITHUB_OUTPUT else @@ -134,11 +141,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build WC bundle + - name: Build WC and HTML renderer bundles run: yarn build env: PUBLIC_URL: ${{ needs.setup-environment.outputs.public_url }} ASSETS_URL: ${{ needs.setup-environment.outputs.assets_url }} + HTML_RENDERER_URL: ${{ needs.setup-environment.outputs.html_renderer_url }} REACT_APP_API_ENDPOINT: ${{ inputs.react_app_api_endpoint }} REACT_APP_AUTHENTICATION_CLIENT_ID: ${{ inputs.react_app_authentication_client_id }} REACT_APP_AUTHENTICATION_URL: ${{ inputs.react_app_authentication_url }} @@ -170,10 +178,6 @@ jobs: fi echo -n "${{ needs.setup-environment.outputs.deploy_dir }}" | aws s3 cp - s3://${{ secrets.AWS_S3_BUCKET }}/latest_version --endpoint ${{ secrets.AWS_ENDPOINT }} --content-type "text/plain" - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: ${{ secrets.AWS_REGION }} - name: Purge Cloudflare cache if: env.HAS_CLOUDFLARE_SECRETS == 'true' @@ -182,7 +186,7 @@ jobs: curl -sS --fail-with-body -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ -H "Content-Type: application/json" \ - --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js", "${{ needs.setup-environment.outputs.public_url }}/scratch.html", "${{ needs.setup-environment.outputs.public_url }}/scratch.js", "${{ inputs.base_url }}/latest_version"]}' + --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js", "${{ needs.setup-environment.outputs.public_url }}/scratch.html", "${{ needs.setup-environment.outputs.public_url }}/scratch.js", "${{ inputs.base_url }}/latest_version", "${{ needs.setup-environment.outputs.html_renderer_url}}/html-renderer.html", "${{ needs.setup-environment.outputs.html_renderer_url}}/html-renderer.js"]}' )" || { echo "Cloudflare purge request failed:" echo "$response" 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 deleted file mode 100644 index 1b358ad1b..000000000 --- a/cypress/e2e/spec-html.cy.js +++ /dev/null @@ -1,126 +0,0 @@ -import { - clickHtmlRunnerPreviewLink, - expectErrorModalToNotExist, - expectHtmlRunnerPreviewToContainText, - expectHtmlRunnerPreviewToNotContainText, - getEditorShadow, - getErrorModalTitle, - getHtmlRunnerContainer, - makeNewFile, - runProject, - setCodeEditorContent, -} from "../helpers/editor.js"; -import { defaultHtmlProject } from "../../src/utils/defaultProjects"; - -const baseUrl = - "http://localhost:3011/web-component.html?identifier=blank-html-starter"; - -beforeEach(() => { - cy.intercept( - "GET", - `${Cypress.env( - "REACT_APP_API_ENDPOINT", - )}/api/projects/blank-html-starter?locale=en`, - defaultHtmlProject, - ); - - localStorage.clear(); - cy.visit(baseUrl); -}); - -it("blocks access to localStorage authKey", () => { - setCodeEditorContent(`

authKey:

- `); - - runProject(); - - expectHtmlRunnerPreviewToContainText("authKey: null"); -}); - -it("blocks access to localStorage OIDC keys", () => { - setCodeEditorContent(`

oidcUser:

-`); - - runProject(); - - expectHtmlRunnerPreviewToContainText("oidcUser: null"); -}); - -it("allows access to other localStorage keys", () => { - setCodeEditorContent(`

foo:

-`); - - runProject(); - - expectHtmlRunnerPreviewToContainText("foo: bar"); -}); - -it("renders the html runner", () => { - runProject(); - getHtmlRunnerContainer().should("be.visible"); -}); - -it("can make a new file", () => { - makeNewFile("amazing.html"); - getEditorShadow() - .findByRole("button", { name: "amazing.html" }) - .should("be.visible"); -}); - -it("updates the preview after a change when you click run", () => { - runProject(); - expectHtmlRunnerPreviewToNotContainText("hello world"); - - setCodeEditorContent("

hello world

"); - runProject(); - - expectHtmlRunnerPreviewToContainText("hello world"); -}); - -it("blocks non-permitted external links", () => { - setCodeEditorContent( - 'some external link', - ); - - runProject(); - - clickHtmlRunnerPreviewLink("some external link"); - getErrorModalTitle().should("be.visible"); -}); - -it("allows permitted external links", () => { - setCodeEditorContent( - 'some external link', - ); - - runProject(); - - clickHtmlRunnerPreviewLink("some external link"); - cy.url().should("eq", baseUrl); - expectErrorModalToNotExist(); -}); - -it("allows internal links", () => { - setCodeEditorContent("

hello world

"); - runProject(); - - makeNewFile(); - - setCodeEditorContent('some internal link'); - runProject(); - - clickHtmlRunnerPreviewLink("some internal link"); - expectHtmlRunnerPreviewToContainText("hello world"); -}); diff --git a/cypress/e2e/spec-wc.cy.js b/cypress/e2e/spec-wc.cy.js index f9851b733..1d0cc8297 100644 --- a/cypress/e2e/spec-wc.cy.js +++ b/cypress/e2e/spec-wc.cy.js @@ -1,5 +1,4 @@ import { - expectHtmlRunnerPreviewToContainText, getCodeEditorInput, getEditorShadow, getRunButton, @@ -148,28 +147,4 @@ describe("when embedded, output_only & output_split_view are true", () => { getSkulptTabByName("Visual output").should("be.visible"); getSkulptTabByName("Visual output").should("have.length", 1); }); - - it("displays the embedded view for an HTML project", () => { - cy.visit(urlFor("anime-expressions-solution")); - - // Check HTML preview output panel is visible and has a run button - // Important to wait for this before making the negative assertions that follow - getEditorShadow().contains("index.html preview").should("be.visible"); - getRunButton().should("not.be.disabled"); - getRunButton().should("be.visible"); - - // Check that the code has automatically run i.e. the HTML has been rendered - expectHtmlRunnerPreviewToContainText("Draw anime with me"); - - // Check that the side bar is not displayed - getEditorShadow().should("not.contain.text", "Project files"); - // Check that the project bar is not displayed - getEditorShadow().should("not.contain.text", "Anime expressions solution"); - // Check that the editor input containing the code is not displayed - getEditorShadow().should("not.contain.text", "

Draw anime with me

"); - - // Run the code and check it executed without error - runProject(); - cy.get("#results").should("contain", '"errorDetails":{}'); - }); }); diff --git a/src/assets/stylesheets/HtmlRunner.scss b/src/assets/stylesheets/HtmlRunner.scss index 0a74a5736..515201a35 100644 --- a/src/assets/stylesheets/HtmlRunner.scss +++ b/src/assets/stylesheets/HtmlRunner.scss @@ -25,3 +25,18 @@ a.htmlrunner-link { block-size: 100%; inline-size: 100%; } + +.htmlrenderer-root { + box-sizing: border-box; + display: flex; + flex-direction: column; + min-block-size: 0; + block-size: 100%; + inline-size: 100%; + + .htmlrunner-iframe { + flex: 1 1 auto; + min-block-size: 0; + block-size: auto; + } +} diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx new file mode 100644 index 000000000..c575bbf46 --- /dev/null +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { parse } from "node-html-parser"; +import mimeTypes from "mime-types"; +import { + allowedExternalLinks, + allowedInternalLinks, + matchingRegexes, +} from "../../../../utils/externalLinkHelper"; +import htmlRunnerStyles from "../../../../assets/stylesheets/HtmlRunner.scss"; +import { + allowedIframeHost, + MSG_HTML_PREVIEW_EVENT, + MSG_HTML_PREVIEW_READY, + MSG_HTML_PROJECT_UPDATE, +} from "../../../../utils/iframeUtils"; + +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({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }}, '${process.env.HTML_RENDERER_URL}')`; + } + } 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({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'ERROR: External link'}, '${process.env.HTML_RENDERER_URL}')`; + } else if (matchingExternalHref) { + onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }}, '${process.env.HTML_RENDERER_URL}')`; + } + } + + 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 iframeHostOrigin = useRef(); + + const handlePreviewUpdateFromHost = useCallback( + (event) => { + const message = event.data; + if ( + allowedIframeHost(event.origin) && + message?.type === MSG_HTML_PROJECT_UPDATE && + message?.current + ) { + if (!iframeHostOrigin.current) { + // Record the host's origin, so we can use it as a target for future messages. + iframeHostOrigin.current = event.origin; + } + + 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) => { + const message = event.data; + if ( + message?.type === MSG_HTML_PREVIEW_EVENT && + !!iframeHostOrigin.current + ) { + // Forward events originating from the previewed code back to the host. + window.parent.postMessage(message, iframeHostOrigin.current); + } + }; + + useEffect(() => { + window.addEventListener("message", handlePreviewUpdateFromHost); + window.addEventListener("message", handleEventFromPreview); + + const source = window.opener || window.parent; + if (source) { + source.postMessage({ type: MSG_HTML_PREVIEW_READY }, "*"); + } + return () => { + window.removeEventListener("message", handlePreviewUpdateFromHost); + window.removeEventListener("message", handleEventFromPreview); + }; + }, [handlePreviewUpdateFromHost]); + + return ( + <> + +
+