2021-03-17 08:53:44 +08:00
|
|
|
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
|
|
|
|
#
|
2022-02-08 21:45:58 +08:00
|
|
|
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
|
2021-03-17 08:53:44 +08:00
|
|
|
|
|
|
|
@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(),
|
2022-02-08 21:45:58 +08:00
|
|
|
gzip?: Keyword.get(opts, :gzip, false),
|
|
|
|
headers: Keyword.get(opts, :headers, [])
|
2021-03-17 08:53:44 +08:00
|
|
|
}
|
|
|
|
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)
|
2022-02-08 21:45:58 +08:00
|
|
|
|> merge_resp_headers(options.headers)
|
2021-03-17 08:53:44 +08:00
|
|
|
|> 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
|