mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-06 15:07:52 +08:00
343 lines
10 KiB
Elixir
343 lines
10 KiB
Elixir
defmodule LivebookWeb.SessionController do
|
|
use LivebookWeb, :controller
|
|
|
|
alias Livebook.{Sessions, Session, FileSystem}
|
|
alias LivebookWeb.CodecHelpers
|
|
|
|
def show_file(conn, %{"id" => id, "name" => name}) do
|
|
with {:ok, session} <- Sessions.fetch_session(id),
|
|
{:ok, file_entry} <- fetch_file_entry(session, name),
|
|
true <- file_entry.type == :attachment do
|
|
file = FileSystem.File.resolve(session.files_dir, file_entry.name)
|
|
serve_static(conn, file)
|
|
else
|
|
_ ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
defp fetch_file_entry(session, name) do
|
|
file_entries = Session.get_notebook_file_entries(session.pid)
|
|
|
|
Enum.find_value(file_entries, :error, fn file_entry ->
|
|
if file_entry.name == name do
|
|
{:ok, file_entry}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def download_file(conn, %{"id" => id, "name" => name}) do
|
|
with {:ok, session} <- Sessions.fetch_session(id),
|
|
{:ok, path} <- Session.fetch_file_entry_path(session.pid, name) do
|
|
send_download(conn, {:file, path}, filename: name)
|
|
else
|
|
_ ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
def download_source(conn, %{"id" => id, "format" => format}) do
|
|
case Sessions.fetch_session(id) do
|
|
{:ok, session} ->
|
|
notebook = Session.get_notebook(session.pid)
|
|
file_name = Session.file_name_for_download(session)
|
|
|
|
send_notebook_source(conn, notebook, file_name, format)
|
|
|
|
{:error, _} ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
defp send_notebook_source(conn, notebook, file_name, "livemd" = format) do
|
|
opts = [include_outputs: conn.params["include_outputs"] == "true"]
|
|
{source, _warnings} = Livebook.LiveMarkdown.notebook_to_livemd(notebook, opts)
|
|
|
|
send_download(conn, {:binary, source},
|
|
filename: file_name <> "." <> format,
|
|
content_type: "text/plain"
|
|
)
|
|
end
|
|
|
|
defp send_notebook_source(conn, notebook, file_name, "exs" = format) do
|
|
source = Livebook.Notebook.Export.Elixir.notebook_to_elixir(notebook)
|
|
|
|
send_download(conn, {:binary, source},
|
|
filename: file_name <> "." <> format,
|
|
content_type: "text/plain"
|
|
)
|
|
end
|
|
|
|
defp send_notebook_source(conn, _notebook, _file_name, _format) do
|
|
send_resp(conn, 400, "Invalid format, supported formats: livemd, exs")
|
|
end
|
|
|
|
defp serve_static(conn, file) do
|
|
with {:ok, cache_state, conn} <- put_cache_header(conn, file),
|
|
{:ok, conn} <- serve_with_cache(conn, file, cache_state) do
|
|
conn
|
|
else
|
|
{:error, message} -> send_resp(conn, 404, Livebook.Utils.upcase_first(message))
|
|
end
|
|
end
|
|
|
|
defp put_cache_header(conn, file) do
|
|
with {:ok, etag} <- FileSystem.File.etag_for(file) do
|
|
conn =
|
|
conn
|
|
|> put_resp_header("cache-control", "public")
|
|
|> put_resp_header("etag", etag)
|
|
|
|
if etag in get_req_header(conn, "if-none-match") do
|
|
{:ok, :fresh, conn}
|
|
else
|
|
{:ok, :stale, conn}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp serve_with_cache(conn, file, :stale) do
|
|
filename = FileSystem.File.name(file)
|
|
|
|
with {:ok, content} <- FileSystem.File.read(file) do
|
|
conn
|
|
|> put_content_type(filename)
|
|
|> send_resp(200, content)
|
|
|> then(&{:ok, &1})
|
|
end
|
|
end
|
|
|
|
defp serve_with_cache(conn, _file, :fresh) do
|
|
{:ok, send_resp(conn, 304, "")}
|
|
end
|
|
|
|
def show_asset(conn, %{"id" => id, "hash" => hash, "file_parts" => file_parts}) do
|
|
asset_path = Path.join(file_parts)
|
|
|
|
# The request comes from a cross-origin iframe
|
|
conn = allow_cors(conn)
|
|
|
|
# This route include session id, while we want the browser to
|
|
# cache assets across sessions, so we only ensure the asset
|
|
# is available and redirect to the corresponding route without
|
|
# session id
|
|
if ensure_asset?(id, hash, asset_path) do
|
|
conn
|
|
|> cache_permanently()
|
|
|> put_status(:moved_permanently)
|
|
|> redirect(to: ~p"/public/sessions/assets/#{hash}/#{file_parts}")
|
|
else
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
def show_cached_asset(conn, %{"hash" => hash, "file_parts" => file_parts}) do
|
|
asset_path = Path.join(file_parts)
|
|
|
|
# The request comes from a cross-origin iframe
|
|
conn = allow_cors(conn)
|
|
|
|
case lookup_asset(hash, asset_path) do
|
|
{:ok, local_asset_path} ->
|
|
conn =
|
|
conn
|
|
|> put_content_type(asset_path)
|
|
|> cache_permanently()
|
|
|
|
local_asset_path_gz = local_asset_path <> ".gz"
|
|
|
|
if accept_encoding?(conn, "gzip") and File.exists?(local_asset_path_gz) do
|
|
conn
|
|
|> put_resp_header("content-encoding", "gzip")
|
|
|> put_resp_header("vary", "Accept-Encoding")
|
|
|> send_file(200, local_asset_path_gz)
|
|
else
|
|
send_file(conn, 200, local_asset_path)
|
|
end
|
|
|
|
:error ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
def show_input_audio(conn, %{"token" => token}) do
|
|
{live_view_pid, input_id} = LivebookWeb.SessionHelpers.verify_input_token!(token)
|
|
|
|
case GenServer.call(live_view_pid, {:get_input_value, input_id}) do
|
|
{:ok, session_id, value} ->
|
|
path = Livebook.Session.registered_file_path(session_id, value.file_ref)
|
|
|
|
conn =
|
|
conn
|
|
|> cache_permanently()
|
|
|> put_resp_header("accept-ranges", "bytes")
|
|
|
|
case value.format do
|
|
:pcm_f32 ->
|
|
%{size: file_size} = File.stat!(path)
|
|
|
|
total_size = CodecHelpers.pcm_as_wav_size(file_size)
|
|
|
|
case parse_byte_range(conn, total_size) do
|
|
{range_start, range_end} when range_start > 0 or range_end < total_size - 1 ->
|
|
stream =
|
|
CodecHelpers.encode_pcm_as_wav_stream!(
|
|
path,
|
|
file_size,
|
|
value.num_channels,
|
|
value.sampling_rate,
|
|
range_start,
|
|
range_end - range_start + 1
|
|
)
|
|
|
|
conn
|
|
|> put_content_range(range_start, range_end, total_size)
|
|
|> send_stream(206, stream)
|
|
|
|
_ ->
|
|
stream =
|
|
CodecHelpers.encode_pcm_as_wav_stream!(
|
|
path,
|
|
file_size,
|
|
value.num_channels,
|
|
value.sampling_rate,
|
|
0,
|
|
total_size
|
|
)
|
|
|
|
conn
|
|
|> put_resp_header("content-length", Integer.to_string(total_size))
|
|
|> send_stream(200, stream)
|
|
end
|
|
|
|
:wav ->
|
|
%{size: total_size} = File.stat!(path)
|
|
|
|
case parse_byte_range(conn, total_size) do
|
|
{range_start, range_end} when range_start > 0 or range_end < total_size - 1 ->
|
|
conn
|
|
|> put_content_range(range_start, range_end, total_size)
|
|
|> send_file(206, path, range_start, range_end - range_start + 1)
|
|
|
|
_ ->
|
|
send_file(conn, 200, path)
|
|
end
|
|
end
|
|
|
|
:error ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
def show_input_image(conn, %{"token" => token}) do
|
|
{live_view_pid, input_id} = LivebookWeb.SessionHelpers.verify_input_token!(token)
|
|
|
|
case GenServer.call(live_view_pid, {:get_input_value, input_id}) do
|
|
{:ok, session_id, value} ->
|
|
path = Livebook.Session.registered_file_path(session_id, value.file_ref)
|
|
|
|
conn
|
|
|> cache_permanently()
|
|
|> send_file(200, path)
|
|
|
|
:error ->
|
|
send_resp(conn, 404, "Not found")
|
|
end
|
|
end
|
|
|
|
defp accept_encoding?(conn, encoding) do
|
|
encoding? = &String.contains?(&1, [encoding, "*"])
|
|
|
|
Enum.any?(get_req_header(conn, "accept-encoding"), fn accept ->
|
|
accept |> Plug.Conn.Utils.list() |> Enum.any?(encoding?)
|
|
end)
|
|
end
|
|
|
|
defp ensure_asset?(session_id, hash, asset_path) do
|
|
case lookup_asset(hash, asset_path) do
|
|
{:ok, _local_asset_path} ->
|
|
true
|
|
|
|
:error ->
|
|
with {:ok, session} <- Sessions.fetch_session(session_id),
|
|
:ok <- Session.fetch_assets(session.pid, hash) do
|
|
true
|
|
else
|
|
_ -> false
|
|
end
|
|
end
|
|
end
|
|
|
|
defp lookup_asset(hash, asset_path) do
|
|
with {:ok, local_asset_path} <- Session.local_asset_path(hash, asset_path),
|
|
true <- File.exists?(local_asset_path) do
|
|
{:ok, local_asset_path}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp allow_cors(conn) do
|
|
put_resp_header(conn, "access-control-allow-origin", "*")
|
|
end
|
|
|
|
defp cache_permanently(conn) do
|
|
put_resp_header(conn, "cache-control", "public, max-age=31536000")
|
|
end
|
|
|
|
defp put_content_type(conn, path) do
|
|
content_type = MIME.from_path(path)
|
|
put_resp_header(conn, "content-type", content_type)
|
|
end
|
|
|
|
defp parse_byte_range(conn, total_size) do
|
|
with [range] <- get_req_header(conn, "range"),
|
|
%{"bytes" => bytes} <- Plug.Conn.Utils.params(range),
|
|
{range_start, range_end} <- start_and_end(bytes, total_size) do
|
|
{range_start, range_end}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp start_and_end("-" <> rest, total_size) do
|
|
case Integer.parse(rest) do
|
|
{last, ""} when last > 0 and last <= total_size -> {total_size - last, total_size - 1}
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp start_and_end(range, total_size) do
|
|
case Integer.parse(range) do
|
|
{first, "-"} when first >= 0 ->
|
|
{first, total_size - 1}
|
|
|
|
{first, "-" <> rest} when first >= 0 ->
|
|
case Integer.parse(rest) do
|
|
{last, ""} when last >= first -> {first, min(last, total_size - 1)}
|
|
_ -> :error
|
|
end
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
defp put_content_range(conn, range_start, range_end, total_size) do
|
|
put_resp_header(conn, "content-range", "bytes #{range_start}-#{range_end}/#{total_size}")
|
|
end
|
|
|
|
defp send_stream(conn, status, stream) do
|
|
conn = send_chunked(conn, status)
|
|
|
|
Enum.reduce_while(stream, conn, fn chunk, conn ->
|
|
case Plug.Conn.chunk(conn, chunk) do
|
|
{:ok, conn} ->
|
|
{:cont, conn}
|
|
|
|
{:error, :closed} ->
|
|
{:halt, conn}
|
|
end
|
|
end)
|
|
end
|
|
end
|