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
COPY rel rel
COPY static static
COPY iframe/priv/static/iframe iframe/priv/static/iframe
COPY lib lib
# We need README.md during compilation
# (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
# 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
# you can mount a local directory into the container.
# Make sure to specify the user with "-u $(id -u):$(id -g)"
# 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,
# 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
@ -160,6 +160,9 @@ The following environment variables configure Livebook:
default path used on file selection screens and others. Defaults to the
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.
Must be a valid IPv4 or IPv6 address.

View file

@ -1,5 +1,5 @@
import { Socket } from "phoenix";
import { getAttributeOrThrow } from "../lib/attribute";
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
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
* 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 = {
mounted() {
@ -135,7 +140,7 @@ const JSOutput = {
// Load the iframe content
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
initializeIframeSource(iframe).then(() => {
initializeIframeSource(iframe, this.props.iframePort).then(() => {
iframesEl.appendChild(iframe);
});
@ -156,7 +161,10 @@ const JSOutput = {
const eventRef = channel.on(`event:${this.props.ref}`, (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 }) => {
@ -201,6 +209,11 @@ function getProps(hook) {
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
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
// 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
// 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
// (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=";
function initializeIframeSource(iframe) {
return verifyIframeSource().then(() => {
function getIframeUrl(iframePort) {
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 =
"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;
iframe.src = iframeUrl;
});
}
let iframeVerificationPromise = null;
function verifyIframeSource() {
function verifyIframeSource(iframeUrl) {
if (!iframeVerificationPromise) {
iframeVerificationPromise = fetch(IFRAME_URL)
iframeVerificationPromise = fetch(iframeUrl)
.then((response) => response.text())
.then((html) => {
if (sha256Base64(html) !== IFRAME_SHA256) {
@ -325,6 +352,8 @@ function verifyIframeSource() {
return iframeVerificationPromise;
}
// Encoding/decoding of channel payloads
function transportEncode(meta, payload) {
if (
Array.isArray(payload) &&

View file

@ -23,6 +23,8 @@ config :livebook, LivebookWeb.Endpoint,
]
]
config :livebook, :iframe_port, 4001
# ## SSL Support
#
# 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],
server: true
config :livebook, :iframe_port, 8081
# Set log level to warning by default to reduce output
config :logger, level: :warning

View file

@ -6,6 +6,8 @@ config :livebook, LivebookWeb.Endpoint,
http: [port: 4002],
server: false
config :livebook, :iframe_port, 4003
# Print only warnings and errors during test
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
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
config :livebook, :default_runtime, runtime
end

View file

@ -30,12 +30,14 @@ defmodule Livebook.Application do
# Start the Node Pool for managing node names
Livebook.Runtime.NodePool,
# Start the unique task dependencies
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}
Livebook.UniqueTask
] ++
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]
@ -181,4 +183,16 @@ defmodule Livebook.Application do
else
defp app_specs, do: []
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

View file

@ -66,6 +66,27 @@ defmodule Livebook.Config do
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
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
@doc """

View file

@ -394,7 +394,7 @@ defmodule Livebook.Utils do
"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")
"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
@ -458,4 +458,22 @@ defmodule Livebook.Utils do
defp memory_unit(:GB), do: 1024 * 1024 * 1024
defp memory_unit(:MB), do: 1024 * 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

View file

@ -86,34 +86,44 @@ defmodule LivebookCLI.Server do
config_entries = opts_to_config(opts, [])
put_config_entries(config_entries)
port = Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
base_url = "http://localhost:#{port}"
case Livebook.Config.port() do
0 ->
# When a random port is configured, we can assume no collision
start_server(extra_args)
case check_endpoint_availability(base_url) do
:livebook_running ->
IO.puts("Livebook already running on #{base_url}")
open_from_args(base_url, extra_args)
port ->
base_url = "http://localhost:#{port}"
:taken ->
print_error(
"Another application is already running on port #{port}." <>
" Either ensure this port is free or specify a different port using the --port option"
)
case check_endpoint_availability(base_url) do
:livebook_running ->
IO.puts("Livebook already running on #{base_url}")
open_from_args(base_url, extra_args)
:available ->
# 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)
:taken ->
print_error(
"Another application is already running on port #{port}." <>
" Either ensure this port is free or specify a different port using the --port option"
)
{:error, error} ->
print_error("Livebook failed to start with reason: #{inspect(error)}")
:available ->
start_server(extra_args)
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
# and overrides the current applications' configuration accordingly.
# 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 =
case struct_url() do
%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 | port: get_port(__MODULE__.HTTP, 80)}
%{uri | port: Livebook.Utils.get_port(__MODULE__.HTTP, 80)}
%URI{} = uri ->
uri
@ -109,15 +109,4 @@ defmodule LivebookWeb.Endpoint do
def access_url do
URI.to_string(access_struct_url())
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

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-js-path={@info.assets.js_path}
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>
"""
end

View file

@ -45,7 +45,7 @@ defmodule LivebookWeb.StaticPlug do
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
# behaviour, responsible for resolving file requests
#
# * `:at`, `:gzip` - same as `Plug.Static`
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
@behaviour Plug
@ -60,7 +60,8 @@ defmodule LivebookWeb.StaticPlug do
%{
file_provider: file_provider,
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
@ -95,6 +96,7 @@ defmodule LivebookWeb.StaticPlug do
|> put_resp_header("content-type", content_type)
|> maybe_add_encoding(content_encoding)
|> maybe_add_vary(options)
|> merge_resp_headers(options.headers)
|> send_resp(200, file.content)
|> halt()