livebook/lib/livebook_web/plugs/static_plug.ex
Jonatan Kłosko e5e13d86c2
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
2022-02-08 14:45:58 +01:00

166 lines
4.4 KiB
Elixir

defmodule LivebookWeb.StaticPlug.File do
@moduledoc false
defstruct [:content, :digest]
@type t :: %__MODULE__{content: binary(), digest: String.t()}
end
defmodule LivebookWeb.StaticPlug.Provider do
@moduledoc false
@type segments :: list(String.t())
@type compression :: :gzip | nil
@doc """
Returns file data for the given path (given as list of segments) and compression type.
"""
@callback get_file(segments(), compression()) :: LivebookWeb.StaticPlug.File.t() | nil
@doc """
Parses static files location usually passed as the `:from` option
when configuring provider.
See `Plug.Static` for more details.
"""
@spec static_path({atom(), binary()} | atom() | binary()) :: binary()
def static_path(from)
def static_path({app, path}) when is_atom(app) and is_binary(path) do
Path.join(Application.app_dir(app), path)
end
def static_path(path) when is_binary(path), do: path
def static_path(app) when is_atom(app), do: static_path({app, "priv/static"})
end
defmodule LivebookWeb.StaticPlug do
@moduledoc false
# This is a simplified version of `Plug.Static` meant
# to serve static files using the given provider.
#
# ## Options
#
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
# behaviour, responsible for resolving file requests
#
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
@behaviour Plug
import Plug.Conn
@allowed_methods ~w(GET HEAD)
@impl true
def init(opts) do
file_provider = Keyword.fetch!(opts, :file_provider)
%{
file_provider: file_provider,
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(),
gzip?: Keyword.get(opts, :gzip, false),
headers: Keyword.get(opts, :headers, [])
}
end
@impl true
def call(
%Plug.Conn{method: method} = conn,
%{file_provider: file_provider, at: at, gzip?: gzip?} = options
)
when method in @allowed_methods do
segments = subset(at, conn.path_info)
case encoding_with_file(conn, file_provider, segments, gzip?) do
{encoding, file} ->
serve_static(conn, encoding, file, segments, options)
:error ->
conn
end
end
def call(conn, _options) do
conn
end
defp serve_static(conn, content_encoding, file, segments, options) do
case put_cache_header(conn, file) do
{:stale, conn} ->
filename = List.last(segments)
content_type = MIME.from_path(filename)
conn
|> 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()
{:fresh, conn} ->
conn
|> maybe_add_vary(options)
|> send_resp(304, "")
|> halt()
end
end
defp maybe_add_encoding(conn, nil), do: conn
defp maybe_add_encoding(conn, ce), do: put_resp_header(conn, "content-encoding", ce)
# If we serve gzip at any moment, we need to set the proper vary
# header regardless of whether we are serving gzip content right now.
# See: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/
defp maybe_add_vary(conn, %{gzip?: true}) do
update_in(conn.resp_headers, &[{"vary", "Accept-Encoding"} | &1])
end
defp maybe_add_vary(conn, _options), do: conn
defp put_cache_header(conn, file) do
etag = etag_for_file(file)
conn =
conn
|> put_resp_header("cache-control", "public")
|> put_resp_header("etag", etag)
if etag in get_req_header(conn, "if-none-match") do
{:fresh, conn}
else
{:stale, conn}
end
end
defp etag_for_file(file) do
<<?", file.digest::binary, ?">>
end
defp encoding_with_file(conn, file_provider, segments, gzip?) do
cond do
file = gzip? and accept_encoding?(conn, "gzip") && file_provider.get_file(segments, :gzip) ->
{"gzip", file}
file = file_provider.get_file(segments, nil) ->
{nil, file}
true ->
:error
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 subset([h | expected], [h | actual]), do: subset(expected, actual)
defp subset([], actual), do: actual
defp subset(_, _), do: []
end