Serve iframes from another local port when running on http (#989)

* Serve iframes from another local port when running on http

* Use relative hostname in local iframe URL

* Simplify server start check

* Use random iframe port when Livebook runs on a random port

* Rename space/ to iframe/

* LivebookWeb.IframePlug -> LivebookWeb.IframeEndpoint
This commit is contained in:
Jonatan Kłosko 2022-02-08 14:45:58 +01:00 committed by GitHub
parent 93592c1f89
commit e5e13d86c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 835 additions and 58 deletions

View file

@ -26,6 +26,7 @@ RUN mix do deps.get, deps.compile
# Compile and build the release # Compile and build the release
COPY rel rel COPY rel rel
COPY static static COPY static static
COPY iframe/priv/static/iframe iframe/priv/static/iframe
COPY lib lib COPY lib lib
# We need README.md during compilation # We need README.md during compilation
# (look for @external_resource "README.md") # (look for @external_resource "README.md")

View file

@ -93,17 +93,17 @@ and also for local usage in case you don't have Elixir installed.
```shell ```shell
# Running with the default configuration # Running with the default configuration
docker run -p 8080:8080 --pull always livebook/livebook docker run -p 8080:8080 -p 8081:8081 --pull always livebook/livebook
# In order to access and save notebooks directly to your machine # In order to access and save notebooks directly to your machine
# you can mount a local directory into the container. # you can mount a local directory into the container.
# Make sure to specify the user with "-u $(id -u):$(id -g)" # Make sure to specify the user with "-u $(id -u):$(id -g)"
# so that the created files have proper permissions # so that the created files have proper permissions
docker run -p 8080:8080 --pull always -u $(id -u):$(id -g) -v $(pwd):/data livebook/livebook docker run -p 8080:8080 -p 8081:8081 --pull always -u $(id -u):$(id -g) -v $(pwd):/data livebook/livebook
# You can configure Livebook using environment variables, # You can configure Livebook using environment variables,
# for all options see the dedicated "Environment variables" section below # for all options see the dedicated "Environment variables" section below
docker run -p 8080:8080 --pull always -e LIVEBOOK_PASSWORD="securesecret" livebook/livebook docker run -p 8080:8080 -p 8081:8081 --pull always -e LIVEBOOK_PASSWORD="securesecret" livebook/livebook
``` ```
To try out features from the main branch you can alternatively To try out features from the main branch you can alternatively
@ -160,6 +160,9 @@ The following environment variables configure Livebook:
default path used on file selection screens and others. Defaults to the default path used on file selection screens and others. Defaults to the
user's operating system home. user's operating system home.
* LIVEBOOK_IFRAME_PORT - sets the port that Livebook serves iframes at.
This is relevant only when running Livebook without TLS. Defaults to 8081.
* LIVEBOOK_IP - sets the ip address to start the web application on. * LIVEBOOK_IP - sets the ip address to start the web application on.
Must be a valid IPv4 or IPv6 address. Must be a valid IPv4 or IPv6 address.

View file

@ -1,5 +1,5 @@
import { Socket } from "phoenix"; import { Socket } from "phoenix";
import { getAttributeOrThrow } from "../lib/attribute"; import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { randomToken, sha256Base64 } from "../lib/utils"; import { randomToken, sha256Base64 } from "../lib/utils";
/** /**
@ -29,6 +29,11 @@ import { randomToken, sha256Base64 } from "../lib/utils";
* * `data-session-token` - token is sent in the "connect" message to * * `data-session-token` - token is sent in the "connect" message to
* the channel * the channel
* *
* * `data-session-id` - the identifier of the session that this output
* belongs go
*
* * `data-iframe-local-port` - the local port where the iframe is served
*
*/ */
const JSOutput = { const JSOutput = {
mounted() { mounted() {
@ -135,7 +140,7 @@ const JSOutput = {
// Load the iframe content // Load the iframe content
const iframesEl = document.querySelector(`[data-element="output-iframes"]`); const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
initializeIframeSource(iframe).then(() => { initializeIframeSource(iframe, this.props.iframePort).then(() => {
iframesEl.appendChild(iframe); iframesEl.appendChild(iframe);
}); });
@ -156,7 +161,10 @@ const JSOutput = {
const eventRef = channel.on(`event:${this.props.ref}`, (raw) => { const eventRef = channel.on(`event:${this.props.ref}`, (raw) => {
const [[event], payload] = transportDecode(raw); const [[event], payload] = transportDecode(raw);
postMessage({ type: "event", event, payload });
this.state.childReadyPromise.then(() => {
postMessage({ type: "event", event, payload });
});
}); });
const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => { const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => {
@ -201,6 +209,11 @@ function getProps(hook) {
jsPath: getAttributeOrThrow(hook.el, "data-js-path"), jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"), sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
sessionId: getAttributeOrThrow(hook.el, "data-session-id"), sessionId: getAttributeOrThrow(hook.el, "data-session-id"),
iframePort: getAttributeOrThrow(
hook.el,
"data-iframe-local-port",
parseInteger
),
}; };
} }
@ -284,34 +297,48 @@ function bindIframeSize(iframe, iframePlaceholder) {
// would be insecure (2). Consequently, we need to load the iframe // would be insecure (2). Consequently, we need to load the iframe
// from a different origin. // from a different origin.
// //
// When running Livebook on https:// we load the iframe from another
// https:// origin. On the other hand, when running on http:// we want
// to load the iframe from http:// as well, otherwise the browser could
// block asset requests from the https:// iframe to http:// Livebook.
// However, external http:// content is not considered a secure context (3),
// which implies no access to user media. Therefore, instead of using
// http://livebook.space we use another localhost endpoint. Note that
// this endpoint has a different port than the Livebook web app, that's
// because we need separate origins, as outlined above.
//
// To ensure integrity of the loaded content we manually verify the // To ensure integrity of the loaded content we manually verify the
// checksum against the expected value. // checksum against the expected value.
// //
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security // (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 // (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
// When running Livebook on http:// we want to load the iframe from
// http:// as well, otherwise the browser could block asset requests
// from the https:// iframe to http:// Livebook. Both protocols are
// supported by livebook.space
const IFRAME_URL = `${window.location.protocol}//livebook.space/iframe/v2.html`;
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc="; const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
function initializeIframeSource(iframe) { function getIframeUrl(iframePort) {
return verifyIframeSource().then(() => { return window.location.protocol === "https:"
? "https://livebook.space/iframe/v2.html"
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
}
function initializeIframeSource(iframe, iframePort) {
const iframeUrl = getIframeUrl(iframePort);
return verifyIframeSource(iframeUrl).then(() => {
iframe.sandbox = iframe.sandbox =
"allow-scripts allow-same-origin allow-downloads allow-modals"; "allow-scripts allow-same-origin allow-downloads allow-modals";
iframe.allow = iframe.allow =
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking"; "accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
iframe.src = IFRAME_URL; iframe.src = iframeUrl;
}); });
} }
let iframeVerificationPromise = null; let iframeVerificationPromise = null;
function verifyIframeSource() { function verifyIframeSource(iframeUrl) {
if (!iframeVerificationPromise) { if (!iframeVerificationPromise) {
iframeVerificationPromise = fetch(IFRAME_URL) iframeVerificationPromise = fetch(iframeUrl)
.then((response) => response.text()) .then((response) => response.text())
.then((html) => { .then((html) => {
if (sha256Base64(html) !== IFRAME_SHA256) { if (sha256Base64(html) !== IFRAME_SHA256) {
@ -325,6 +352,8 @@ function verifyIframeSource() {
return iframeVerificationPromise; return iframeVerificationPromise;
} }
// Encoding/decoding of channel payloads
function transportEncode(meta, payload) { function transportEncode(meta, payload) {
if ( if (
Array.isArray(payload) && Array.isArray(payload) &&

View file

@ -23,6 +23,8 @@ config :livebook, LivebookWeb.Endpoint,
] ]
] ]
config :livebook, :iframe_port, 4001
# ## SSL Support # ## SSL Support
# #
# In order to use HTTPS in development, a self-signed # In order to use HTTPS in development, a self-signed

View file

@ -5,6 +5,8 @@ config :livebook, LivebookWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 8080], http: [ip: {127, 0, 0, 1}, port: 8080],
server: true server: true
config :livebook, :iframe_port, 8081
# Set log level to warning by default to reduce output # Set log level to warning by default to reduce output
config :logger, level: :warning config :logger, level: :warning

View file

@ -6,6 +6,8 @@ config :livebook, LivebookWeb.Endpoint,
http: [port: 4002], http: [port: 4002],
server: false server: false
config :livebook, :iframe_port, 4003
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warn config :logger, level: :warn

4
iframe/.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
[
import_deps: [:plug],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
iframe/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
livebook_space-*.tar
# Temporary files, for example, from tests.
/tmp/

38
iframe/Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# Stage 1: build
FROM hexpm/elixir:1.13.2-erlang-24.1.7-alpine-3.15.0 AS build
# Install build dependencies
RUN apk add --no-cache build-base git
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
# Set build ENV
ENV MIX_ENV=prod
# Install mix dependencies
COPY mix.exs mix.lock ./
RUN mix do deps.get, deps.compile
# Compile and build release
COPY priv priv
COPY lib lib
RUN mix do compile, release
# Stage 2: release image
FROM alpine:3.15.0
# Install runtime dependencies
RUN apk add --no-cache openssl ncurses-libs libstdc++
WORKDIR /app
# Copy the release build from the previous stage.
COPY --from=build /app/_build/prod/rel/livebook_space ./
ENV HOME=/app
CMD [ "/app/bin/livebook_space", "start" ]

5
iframe/README.md Normal file
View file

@ -0,0 +1,5 @@
# Livebook iframes
Livebook executes custom JavaScript inside iframes. When running on http,
they are served on a separate port. For https, they are safely served by
[livebook.space](https://livebook.space), which runs this application.

24
iframe/fly.toml Normal file
View file

@ -0,0 +1,24 @@
app = "livebook-space"
kill_signal = "SIGTERM"
kill_timeout = 5
[env]
[[services]]
internal_port = 4000
protocol = "tcp"
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "30s"
interval = "15s"
restart_limit = 6
timeout = "2s"

View file

@ -0,0 +1,14 @@
defmodule LivebookSpace.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
{Plug.Cowboy, scheme: :http, plug: LivebookSpaceWeb.Plug, options: [port: 4000]}
]
opts = [strategy: :one_for_one, name: LivebookSpace.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

@ -0,0 +1,26 @@
defmodule LivebookSpaceWeb.Plug do
use Plug.Builder
plug Plug.Static,
from: {:livebook_space, "priv/static/iframe"},
at: "/iframe",
# Iframes are versioned, so we cache them for long
cache_control_for_etags: "public, max-age=31536000",
headers: [
# Enable CORS to allow Livebook fetch the content and verify its integrity
{"access-control-allow-origin", "*"},
{"content-type", "text/html; charset=utf-8"}
]
plug Plug.Static, from: :livebook_space, at: "/"
plug :not_found
defp not_found(%{path_info: [], method: "GET"} = conn, _) do
call(%{conn | path_info: ["index.html"], request_path: "/index.html"}, [])
end
defp not_found(conn, _) do
send_resp(conn, 404, "not found")
end
end

26
iframe/mix.exs Normal file
View file

@ -0,0 +1,26 @@
defmodule LivebookSpace.MixProject do
use Mix.Project
def project do
[
app: :livebook_space,
version: "0.1.0",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {LivebookSpace.Application, []}
]
end
defp deps do
[
{:plug_cowboy, "~> 2.0"}
]
end
end

11
iframe/mix.lock Normal file
View file

@ -0,0 +1,11 @@
%{
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
"plug": {:hex, :plug, "1.13.2", "33aba8e2b43ddd68d9d49b818ed2fb46da85f4ec3229bc4bcd0c981a640a4e71", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a95cdfe599e3524b98684376c3f3494cbfbc1f41fcddefc380cac3138dd7619d"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
}

View file

@ -0,0 +1,191 @@
<!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>

View file

@ -0,0 +1,206 @@
<!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 module to export an init function, but found: ${fns.join(
", "
)}`
);
}
applyInit(init, ctx, message.data);
})
.catch((error) => {
renderErrorMessage(
`Failed to load the widget JS module, got the following error:\n\n ${error.message}\n\nSee the browser console for more details. If running behind an authentication proxy, make sure the /public/* routes are publicly accessible.`
);
throw error;
});
} 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,
},
};
}
function renderErrorMessage(message) {
ctx.root.innerHTML = `
<div style="color: #FF3E38; white-space: pre-wrap; word-break: break-word;">${message}</div>
`;
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1 @@
../../../../static/favicon.svg

View file

@ -0,0 +1 @@
../../../../static/images/logo.png

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Livebook.space</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body {
margin: 0;
padding: 0;
font-family: "Inter";
}
.root {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #0d1829;
}
.lead {
display: flex;
flex-direction: column;
align-items: center;
}
.disclaimer {
margin-top: 16px;
color: #f8fafc;
font-size: 1.25rem;
}
a {
font-weight: 500;
text-decoration-line: underline;
color: #f8fafc;
}
</style>
</head>
<body>
<div class="root">
<div class="lead">
<a href="https://livebook.dev">
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
</a>
<div class="disclaimer">
This website serves iframes for Livebook apps. See
<a href="https://livebook.dev">livebook.dev</a>. See
<a href="https://github.com/livebook-dev/livebook.space">source</a>.
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View file

@ -42,6 +42,10 @@ defmodule Livebook do
config :livebook, authentication_mode: :disabled config :livebook, authentication_mode: :disabled
end end
if port = Livebook.Config.port!("LIVEBOOK_IFRAME_PORT") do
config :livebook, :iframe_port, port
end
if runtime = Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") do if runtime = Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") do
config :livebook, :default_runtime, runtime config :livebook, :default_runtime, runtime
end end

View file

@ -30,12 +30,14 @@ defmodule Livebook.Application do
# Start the Node Pool for managing node names # Start the Node Pool for managing node names
Livebook.Runtime.NodePool, Livebook.Runtime.NodePool,
# Start the unique task dependencies # Start the unique task dependencies
Livebook.UniqueTask, Livebook.UniqueTask
# Start the Endpoint (http/https)
# We skip the access url as we do our own logging below
{LivebookWeb.Endpoint, log_access_url: false}
] ++ ] ++
app_specs() iframe_server_specs() ++
[
# Start the Endpoint (http/https)
# We skip the access url as we do our own logging below
{LivebookWeb.Endpoint, log_access_url: false}
] ++ app_specs()
opts = [strategy: :one_for_one, name: Livebook.Supervisor] opts = [strategy: :one_for_one, name: Livebook.Supervisor]
@ -181,4 +183,16 @@ defmodule Livebook.Application do
else else
defp app_specs, do: [] defp app_specs, do: []
end end
defp iframe_server_specs() do
server? = Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint)
port = Livebook.Config.iframe_port()
if server? do
# Start the iframe endpoint on a different port
[{Plug.Cowboy, scheme: :http, plug: LivebookWeb.IframeEndpoint, options: [port: port]}]
else
[]
end
end
end end

View file

@ -66,6 +66,27 @@ defmodule Livebook.Config do
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook") Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
end end
@doc """
Returns the configured port for the Livebook endpoint.
Note that the value may be `0`.
"""
@spec port() :: pos_integer() | 0
def port() do
Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
end
@doc """
Returns the configured port for the iframe endpoint.
"""
@spec iframe_port() :: pos_integer() | 0
def iframe_port() do
case port() do
0 -> 0
_ -> Application.fetch_env!(:livebook, :iframe_port)
end
end
## Parsing ## Parsing
@doc """ @doc """

View file

@ -394,7 +394,7 @@ defmodule Livebook.Utils do
"http://localhost:4002/open?path=https%3A%2F%2Fexample.com%2Ffoo.livemd" "http://localhost:4002/open?path=https%3A%2F%2Fexample.com%2Ffoo.livemd"
iex> Livebook.Utils.notebook_open_url("https://my_host", "https://example.com/foo.livemd") iex> Livebook.Utils.notebook_open_url("https://my_host", "https://example.com/foo.livemd")
"https://my_host/open?path=https%3A%2F%2Fexample.com%2Ffoo.livemd" "https://my_host/open?path=https%3A%2F%2Fexample.com%2Ffoo.livemd"
""" """
def notebook_open_url(base_url \\ LivebookWeb.Endpoint.access_struct_url(), url) do def notebook_open_url(base_url \\ LivebookWeb.Endpoint.access_struct_url(), url) do
@ -458,4 +458,22 @@ defmodule Livebook.Utils do
defp memory_unit(:GB), do: 1024 * 1024 * 1024 defp memory_unit(:GB), do: 1024 * 1024 * 1024
defp memory_unit(:MB), do: 1024 * 1024 defp memory_unit(:MB), do: 1024 * 1024
defp memory_unit(:KB), do: 1024 defp memory_unit(:KB), do: 1024
@doc """
Gets the port for an existing listener.
The listener references usually follow the pattern `plug.HTTP`
and `plug.HTTPS`.
"""
@spec get_port(:ranch.ref(), :inet.port_number()) :: :inet.port_number()
def get_port(ref, default) do
try do
:ranch.get_addr(ref)
rescue
_ -> default
else
{_, port} when is_integer(port) -> port
_ -> default
end
end
end end

View file

@ -86,34 +86,44 @@ defmodule LivebookCLI.Server do
config_entries = opts_to_config(opts, []) config_entries = opts_to_config(opts, [])
put_config_entries(config_entries) put_config_entries(config_entries)
port = Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port] case Livebook.Config.port() do
base_url = "http://localhost:#{port}" 0 ->
# When a random port is configured, we can assume no collision
start_server(extra_args)
case check_endpoint_availability(base_url) do port ->
:livebook_running -> base_url = "http://localhost:#{port}"
IO.puts("Livebook already running on #{base_url}")
open_from_args(base_url, extra_args)
:taken -> case check_endpoint_availability(base_url) do
print_error( :livebook_running ->
"Another application is already running on port #{port}." <> IO.puts("Livebook already running on #{base_url}")
" Either ensure this port is free or specify a different port using the --port option" open_from_args(base_url, extra_args)
)
:available -> :taken ->
# We configure the endpoint with `server: true`, print_error(
# so it's gonna start listening "Another application is already running on port #{port}." <>
case Application.ensure_all_started(:livebook) do " Either ensure this port is free or specify a different port using the --port option"
{:ok, _} -> )
open_from_args(LivebookWeb.Endpoint.access_url(), extra_args)
Process.sleep(:infinity)
{:error, error} -> :available ->
print_error("Livebook failed to start with reason: #{inspect(error)}") start_server(extra_args)
end end
end end
end end
defp start_server(extra_args) do
# We configure the endpoint with `server: true`,
# so it's gonna start listening
case Application.ensure_all_started(:livebook) do
{:ok, _} ->
open_from_args(LivebookWeb.Endpoint.access_url(), extra_args)
Process.sleep(:infinity)
{:error, error} ->
print_error("Livebook failed to start with reason: #{inspect(error)}")
end
end
# Takes a list of {app, key, value} config entries # Takes a list of {app, key, value} config entries
# and overrides the current applications' configuration accordingly. # and overrides the current applications' configuration accordingly.
# Multiple values for the same key are deeply merged (provided they are keyword lists). # Multiple values for the same key are deeply merged (provided they are keyword lists).

View file

@ -87,10 +87,10 @@ defmodule LivebookWeb.Endpoint do
base = base =
case struct_url() do case struct_url() do
%URI{scheme: "https", port: 0} = uri -> %URI{scheme: "https", port: 0} = uri ->
%{uri | port: get_port(__MODULE__.HTTPS, 433)} %{uri | port: Livebook.Utils.get_port(__MODULE__.HTTPS, 433)}
%URI{scheme: "http", port: 0} = uri -> %URI{scheme: "http", port: 0} = uri ->
%{uri | port: get_port(__MODULE__.HTTP, 80)} %{uri | port: Livebook.Utils.get_port(__MODULE__.HTTP, 80)}
%URI{} = uri -> %URI{} = uri ->
uri uri
@ -109,15 +109,4 @@ defmodule LivebookWeb.Endpoint do
def access_url do def access_url do
URI.to_string(access_struct_url()) URI.to_string(access_struct_url())
end end
defp get_port(ref, default) do
try do
:ranch.get_addr(ref)
rescue
_ -> default
else
{_, port} when is_integer(port) -> port
_ -> default
end
end
end end

View file

@ -0,0 +1,42 @@
defmodule LivebookWeb.IframeEndpoint do
use Plug.Builder
defmodule AssetsMemoryProvider do
use LivebookWeb.MemoryProvider,
from: Path.expand("../../iframe/priv/static/iframe", __DIR__),
gzip: true
end
plug LivebookWeb.StaticPlug,
at: "/iframe",
file_provider: AssetsMemoryProvider,
gzip: true,
headers: [
# Enable CORS to allow Livebook fetch the content and verify its integrity
{"access-control-allow-origin", "*"},
# Iframes are versioned, so we cache them for long
{"cache-control", "public, max-age=31536000"},
# Specify the charset
{"content-type", "text/html; charset=utf-8"}
]
plug :not_found
defp not_found(conn, _) do
send_resp(conn, 404, "not found")
end
@doc """
Returns the listening port of the iframe endpoint.
"""
@spec port() :: pos_integer()
def port() do
livebook_port = Livebook.Config.port()
iframe_port = Livebook.Config.iframe_port()
case livebook_port do
0 -> Livebook.Utils.get_port(__MODULE__.HTTP, iframe_port)
_ -> iframe_port
end
end
end

View file

@ -11,7 +11,8 @@ defmodule LivebookWeb.Output.JSComponent do
data-assets-base-path={Routes.session_path(@socket, :show_asset, @session_id, @info.assets.hash, [])} data-assets-base-path={Routes.session_path(@socket, :show_asset, @session_id, @info.assets.hash, [])}
data-js-path={@info.assets.js_path} data-js-path={@info.assets.js_path}
data-session-token={session_token(@info.pid)} data-session-token={session_token(@info.pid)}
data-session-id={@session_id}> data-session-id={@session_id}
data-iframe-local-port={LivebookWeb.IframeEndpoint.port()}>
</div> </div>
""" """
end end

View file

@ -45,7 +45,7 @@ defmodule LivebookWeb.StaticPlug do
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider` # * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
# behaviour, responsible for resolving file requests # behaviour, responsible for resolving file requests
# #
# * `:at`, `:gzip` - same as `Plug.Static` # * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
@behaviour Plug @behaviour Plug
@ -60,7 +60,8 @@ defmodule LivebookWeb.StaticPlug do
%{ %{
file_provider: file_provider, file_provider: file_provider,
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(), at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(),
gzip?: Keyword.get(opts, :gzip, false) gzip?: Keyword.get(opts, :gzip, false),
headers: Keyword.get(opts, :headers, [])
} }
end end
@ -95,6 +96,7 @@ defmodule LivebookWeb.StaticPlug do
|> put_resp_header("content-type", content_type) |> put_resp_header("content-type", content_type)
|> maybe_add_encoding(content_encoding) |> maybe_add_encoding(content_encoding)
|> maybe_add_vary(options) |> maybe_add_vary(options)
|> merge_resp_headers(options.headers)
|> send_resp(200, file.content) |> send_resp(200, file.content)
|> halt() |> halt()