mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-11-01 00:06:04 +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
 |