mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-26 13:27:05 +08:00
Load output iframe from a different origin (#968)
* Load output iframe from a different origin * Update iframe source
This commit is contained in:
parent
731d95e4f0
commit
178df3dac9
3 changed files with 150 additions and 276 deletions
|
|
@ -1,191 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Output</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// Invoke the init function in a separate context for better isolation
|
||||
function applyInit(init, ctx, data) {
|
||||
init(ctx, data);
|
||||
}
|
||||
|
||||
(() => {
|
||||
const state = {
|
||||
token: null,
|
||||
importPromise: null,
|
||||
eventHandlers: {},
|
||||
eventQueue: [],
|
||||
};
|
||||
|
||||
function postMessage(message) {
|
||||
window.parent.postMessage({ token: state.token, ...message }, "*");
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
root: document.getElementById("root"),
|
||||
|
||||
handleEvent(event, callback) {
|
||||
if (state.eventHandlers[event]) {
|
||||
throw new Error(
|
||||
`Handler has already been defined for event "${event}"`
|
||||
);
|
||||
}
|
||||
|
||||
state.eventHandlers[event] = callback;
|
||||
|
||||
while (
|
||||
state.eventQueue.length > 0 &&
|
||||
state.eventHandlers[state.eventQueue[0].event]
|
||||
) {
|
||||
const { event, payload } = state.eventQueue.shift();
|
||||
const handler = state.eventHandlers[event];
|
||||
handler(payload);
|
||||
}
|
||||
},
|
||||
|
||||
pushEvent(event, payload = null) {
|
||||
postMessage({ type: "event", event, payload });
|
||||
},
|
||||
|
||||
importCSS(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.addEventListener(
|
||||
"load",
|
||||
(event) => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
linkEl.rel = "stylesheet";
|
||||
linkEl.href = url;
|
||||
document.head.appendChild(linkEl);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
handleParentMessage(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
function handleParentMessage(message) {
|
||||
if (message.type === "readyReply") {
|
||||
state.token = message.token;
|
||||
onReady();
|
||||
|
||||
// Set the base URL for relative URLs
|
||||
const baseUrlEl = document.createElement("base");
|
||||
baseUrlEl.href = message.baseUrl;
|
||||
document.head.appendChild(baseUrlEl);
|
||||
// We already entered the script and the base URL change
|
||||
// doesn't impact this import call, so we use the absolute
|
||||
// URL instead
|
||||
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
|
||||
} else if (message.type === "init") {
|
||||
state.importPromise.then((module) => {
|
||||
const init = module.init;
|
||||
|
||||
if (!init) {
|
||||
const fns = Object.keys(module);
|
||||
throw new Error(
|
||||
`Expected the widget JS module to export an init function, but found: ${fns.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
applyInit(init, ctx, message.data);
|
||||
});
|
||||
} else if (message.type === "event") {
|
||||
const { event, payload } = message;
|
||||
const handler = state.eventHandlers[event];
|
||||
|
||||
if (state.eventQueue.length === 0 && handler) {
|
||||
handler(payload);
|
||||
} else {
|
||||
state.eventQueue.push({ event, payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postMessage({ type: "ready" });
|
||||
|
||||
function onReady() {
|
||||
// Report height changes
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
postMessage({ type: "resize", height: document.body.scrollHeight });
|
||||
});
|
||||
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
// Forward relevant DOM events
|
||||
|
||||
window.addEventListener("mousedown", (event) => {
|
||||
postMessage({ type: "domEvent", event: { type: "mousedown" } });
|
||||
});
|
||||
|
||||
window.addEventListener("focus", (event) => {
|
||||
postMessage({ type: "domEvent", event: { type: "focus" } });
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!isEditableElement(event.target)) {
|
||||
postMessage({
|
||||
type: "domEvent",
|
||||
event: keyboardEventToPayload(event),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableElement(element) {
|
||||
return element.matches("input, textarea, [contenteditable]");
|
||||
}
|
||||
|
||||
function keyboardEventToPayload(event) {
|
||||
const {
|
||||
altKey,
|
||||
code,
|
||||
ctrlKey,
|
||||
isComposing,
|
||||
key,
|
||||
location,
|
||||
metaKey,
|
||||
repeat,
|
||||
shiftKey,
|
||||
} = event;
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
props: {
|
||||
altKey,
|
||||
code,
|
||||
ctrlKey,
|
||||
isComposing,
|
||||
key,
|
||||
location,
|
||||
metaKey,
|
||||
repeat,
|
||||
shiftKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,28 +1,5 @@
|
|||
import { getAttributeOrThrow } from "../lib/attribute";
|
||||
import { randomToken } from "../lib/utils";
|
||||
|
||||
import iframeHtml from "./iframe.html";
|
||||
|
||||
const global = {
|
||||
channel: null,
|
||||
};
|
||||
|
||||
// Returns channel responsible for JS communication in the current session
|
||||
function getChannel(socket, sessionId, { create = true } = {}) {
|
||||
if (!global.channel && create) {
|
||||
global.channel = socket.channel("js_output", { session_id: sessionId });
|
||||
global.channel.join();
|
||||
}
|
||||
|
||||
return global.channel;
|
||||
}
|
||||
|
||||
export function leaveChannel() {
|
||||
if (global.channel) {
|
||||
global.channel.leave();
|
||||
global.channel = null;
|
||||
}
|
||||
}
|
||||
import { randomToken, sha256Base64 } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* A hook used to render JS-enabled cell output.
|
||||
|
|
@ -69,10 +46,31 @@ const JSOutput = {
|
|||
this.props.sessionId
|
||||
);
|
||||
|
||||
// When cells/sections are reordered, morphdom detaches and attaches
|
||||
// the relevant elements in the DOM. Consequently the output element
|
||||
// becomes temporarily detached from the DOM and attaching it back
|
||||
// would cause the iframe to reload. This behaviour is expected, see
|
||||
// https://github.com/whatwg/html/issues/5484 for more details. Reloading
|
||||
// that frequently is inefficient and also clears the iframe state,
|
||||
// which makes is very undesired in our case. To solve this, we insert
|
||||
// the iframe higher in the DOM tree, so that it's never affected by
|
||||
// reordering. Then, we insert a placeholder element in the output to
|
||||
// take up the expected space and we use absolute positioning to place
|
||||
// the iframe exactly over that placeholder. We set up observers to
|
||||
// track the changes in placeholder's position/size and we keep the
|
||||
// absolute iframe in sync.
|
||||
|
||||
const iframePlaceholder = document.createElement("div");
|
||||
this.el.appendChild(iframePlaceholder);
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "w-full h-0 absolute z-[1]";
|
||||
this.state.iframe = iframe;
|
||||
|
||||
this.disconnectObservers = bindIframeSize(iframe, iframePlaceholder);
|
||||
|
||||
// Register message chandler to communicate with the iframe
|
||||
|
||||
function postMessage(message) {
|
||||
iframe.contentWindow.postMessage(message, "*");
|
||||
}
|
||||
|
|
@ -136,65 +134,11 @@ const JSOutput = {
|
|||
};
|
||||
});
|
||||
|
||||
// When cells/sections are reordered, morphdom detaches and attaches
|
||||
// the relevant elements in the DOM. Consequently the output element
|
||||
// becomes temporarily detached from the DOM and attaching it back
|
||||
// would cause the iframe to reload. This behaviour is expected, see
|
||||
// https://github.com/whatwg/html/issues/5484 for more details. Reloading
|
||||
// that frequently is inefficient and also clears the iframe state,
|
||||
// which makes is very undesired in our case. To solve this, we insert
|
||||
// the iframe higher in the DOM tree, so that it's never affected by
|
||||
// reordering. Then, we insert a placeholder element in the output to
|
||||
// take up the expected space and we use absolute positioning to place
|
||||
// the iframe exactly over that placeholder. We set up observers to
|
||||
// track the changes in placeholder's position/size and we keep the
|
||||
// absolute iframe in sync.
|
||||
|
||||
const notebookEl = document.querySelector(`[data-element="notebook"]`);
|
||||
const notebookContentEl = notebookEl.querySelector([
|
||||
`[data-element="notebook-content"]`,
|
||||
]);
|
||||
const iframesEl = notebookEl.querySelector(
|
||||
`[data-element="output-iframes"]`
|
||||
);
|
||||
|
||||
iframe.className = "w-full h-0 absolute z-[1]";
|
||||
// Note that we use `srcdoc`, so the iframe has the same origin as the
|
||||
// parent. For this reason we intentionally don't use allow-same-origin,
|
||||
// as it would allow the iframe to effectively access the parent window.
|
||||
iframe.sandbox = "allow-scripts allow-downloads";
|
||||
iframe.srcdoc = iframeHtml;
|
||||
|
||||
iframesEl.appendChild(iframe);
|
||||
this.el.appendChild(iframePlaceholder);
|
||||
|
||||
function repositionIframe() {
|
||||
const notebookBox = notebookEl.getBoundingClientRect();
|
||||
const placeholderBox = iframePlaceholder.getBoundingClientRect();
|
||||
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
|
||||
iframe.style.top = `${top}px`;
|
||||
const left =
|
||||
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
|
||||
iframe.style.left = `${left}px`;
|
||||
iframe.style.height = `${placeholderBox.height}px`;
|
||||
iframe.style.width = `${placeholderBox.width}px`;
|
||||
}
|
||||
|
||||
// Most output position changes are accompanied by changes to the
|
||||
// notebook content element (adding cells, inserting newlines in
|
||||
// the editor, etc)
|
||||
this.resizeObserver = new ResizeObserver((entries) => repositionIframe());
|
||||
this.resizeObserver.observe(notebookContentEl);
|
||||
|
||||
// On lower level cell/section reordering is applied as element
|
||||
// removal followed by insert, consequently the intersection
|
||||
// between the output and notebook content changes (becomes none
|
||||
// for a brief moment)
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => repositionIframe(),
|
||||
{ root: notebookContentEl }
|
||||
);
|
||||
this.intersectionObserver.observe(iframePlaceholder);
|
||||
// Load the iframe content
|
||||
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
|
||||
initializeIframeSource(iframe).then(() => {
|
||||
iframesEl.appendChild(iframe);
|
||||
});
|
||||
|
||||
// Event handlers
|
||||
|
||||
|
|
@ -241,8 +185,7 @@ const JSOutput = {
|
|||
|
||||
destroyed() {
|
||||
window.removeEventListener("message", this.handleWindowMessage);
|
||||
this.resizeObserver.disconnect();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.disconnectObservers();
|
||||
this.state.iframe.remove();
|
||||
|
||||
const channel = getChannel(
|
||||
|
|
@ -270,4 +213,117 @@ function getProps(hook) {
|
|||
};
|
||||
}
|
||||
|
||||
let channel = null;
|
||||
|
||||
/**
|
||||
* Returns channel used for all JS outputs in the current session.
|
||||
*/
|
||||
function getChannel(socket, sessionId, { create = true } = {}) {
|
||||
if (!channel && create) {
|
||||
channel = socket.channel("js_output", { session_id: sessionId });
|
||||
channel.join();
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the JS outputs channel tied to the current session.
|
||||
*/
|
||||
export function leaveChannel() {
|
||||
if (channel) {
|
||||
channel.leave();
|
||||
channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up observers to resize and reposition the iframe
|
||||
* whenever the placeholder moves.
|
||||
*/
|
||||
function bindIframeSize(iframe, iframePlaceholder) {
|
||||
const notebookEl = document.querySelector(`[data-element="notebook"]`);
|
||||
const notebookContentEl = notebookEl.querySelector(
|
||||
`[data-element="notebook-content"]`
|
||||
);
|
||||
|
||||
function repositionIframe() {
|
||||
const notebookBox = notebookEl.getBoundingClientRect();
|
||||
const placeholderBox = iframePlaceholder.getBoundingClientRect();
|
||||
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
|
||||
iframe.style.top = `${top}px`;
|
||||
const left = placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
|
||||
iframe.style.left = `${left}px`;
|
||||
iframe.style.height = `${placeholderBox.height}px`;
|
||||
iframe.style.width = `${placeholderBox.width}px`;
|
||||
}
|
||||
|
||||
// Most output position changes are accompanied by changes to the
|
||||
// notebook content element (adding cells, inserting newlines in
|
||||
// the editor, etc)
|
||||
const resizeObserver = new ResizeObserver((entries) => repositionIframe());
|
||||
resizeObserver.observe(notebookContentEl);
|
||||
|
||||
// On lower level cell/section reordering is applied as element
|
||||
// removal followed by insert, consequently the intersection
|
||||
// between the output and notebook content changes (becomes none
|
||||
// for a brief moment)
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => repositionIframe(),
|
||||
{ root: notebookContentEl }
|
||||
);
|
||||
intersectionObserver.observe(iframePlaceholder);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
// Loading iframe using `srcdoc` disables cookies and browser APIs,
|
||||
// such as camera and microphone (1), the same applies to `src` with
|
||||
// data URL, so we need to load the iframe through a regular request.
|
||||
// Since the iframe is sandboxed we also need `allow-same-origin`.
|
||||
// Additionally, we cannot load the iframe from the same origin as
|
||||
// the app, because using `allow-same-origin` together with `allow-scripts`
|
||||
// would be insecure (2). Consequently, we need to load the iframe
|
||||
// from a different origin.
|
||||
//
|
||||
// To ensure integrity of the loaded content we manually verify the
|
||||
// checksum against the expected value.
|
||||
//
|
||||
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
|
||||
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
|
||||
|
||||
const IFRAME_SHA256 = "9cYdQb4mocxzFoj1EryzubL1n7P+lQTeEdWAkeV4E0I=";
|
||||
const IFRAME_URL = "https://livebook.space/iframe/v1.html";
|
||||
|
||||
function initializeIframeSource(iframe) {
|
||||
return verifyIframeSource().then(() => {
|
||||
iframe.sandbox =
|
||||
"allow-scripts allow-same-origin allow-downloads allow-modals";
|
||||
iframe.allow =
|
||||
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
|
||||
iframe.src = IFRAME_URL;
|
||||
});
|
||||
}
|
||||
|
||||
let iframeVerificationPromise = null;
|
||||
|
||||
function verifyIframeSource() {
|
||||
if (!iframeVerificationPromise) {
|
||||
iframeVerificationPromise = fetch(IFRAME_URL)
|
||||
.then((response) => response.text())
|
||||
.then((html) => {
|
||||
if (sha256Base64(html) !== IFRAME_SHA256) {
|
||||
throw new Error(
|
||||
"The loaded iframe content doesn't have the expected checksum"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return iframeVerificationPromise;
|
||||
}
|
||||
|
||||
export default JSOutput;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import md5 from "crypto-js/md5";
|
||||
import sha256 from "crypto-js/sha256";
|
||||
import encBase64 from "crypto-js/enc-base64";
|
||||
|
||||
export function isMacOS() {
|
||||
|
|
@ -95,6 +96,14 @@ export function md5Base64(string) {
|
|||
return md5(string).toString(encBase64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates SHA256 of the given string and returns
|
||||
* the base64 encoded binary.
|
||||
*/
|
||||
export function sha256Base64(string) {
|
||||
return sha256(string).toString(encBase64);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple throttle version that ensures
|
||||
* the given function is called at most once
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue