zf

zenflows testing
git clone https://s.sonu.ch/~srfsh/zf.git
Log | Files | Refs | Submodules | README | LICENSE

static.ex (14949B)


      1 defmodule Plug.Static do
      2   @moduledoc """
      3   A plug for serving static assets.
      4 
      5   It requires two options:
      6 
      7     * `:at` - the request path to reach for static assets.
      8       It must be a string.
      9 
     10     * `:from` - the file system path to read static assets from.
     11       It can be either: a string containing a file system path, an
     12       atom representing the application name (where assets will
     13       be served from `priv/static`), a tuple containing the
     14       application name and the directory to serve assets from (besides
     15       `priv/static`), or an MFA tuple.
     16 
     17   The preferred form is to use `:from` with an atom or tuple, since
     18   it will make your application independent from the starting directory.
     19   For example, if you pass:
     20 
     21       plug Plug.Static, from: "priv/app/path"
     22 
     23   Plug.Static will be unable to serve assets if you build releases
     24   or if you change the current directory. Instead do:
     25 
     26       plug Plug.Static, from: {:app_name, "priv/app/path"}
     27 
     28   If a static asset cannot be found, `Plug.Static` simply forwards
     29   the connection to the rest of the pipeline.
     30 
     31   ## Cache mechanisms
     32 
     33   `Plug.Static` uses etags for HTTP caching. This means browsers/clients
     34   should cache assets on the first request and validate the cache on
     35   following requests, not downloading the static asset once again if it
     36   has not changed. The cache-control for etags is specified by the
     37   `cache_control_for_etags` option and defaults to `"public"`.
     38 
     39   However, `Plug.Static` also supports direct cache control by using
     40   versioned query strings. If the request query string starts with
     41   "?vsn=", `Plug.Static` assumes the application is versioning assets
     42   and does not set the `ETag` header, meaning the cache behaviour will
     43   be specified solely by the `cache_control_for_vsn_requests` config,
     44   which defaults to `"public, max-age=31536000"`.
     45 
     46   ## Options
     47 
     48     * `:encodings` - list of 2-ary tuples where first value is value of
     49       the `Accept-Encoding` header and second is extension of the file to
     50       be served if given encoding is accepted by client. Entries will be tested
     51       in order in list, so entries higher in list will be preferred. Defaults
     52       to: `[]`.
     53 
     54       In addition to setting this value directly it supports 2 additional
     55       options for compatibility reasons:
     56 
     57         + `:brotli` - will append `{"br", ".br"}` to the encodings list.
     58         + `:gzip` - will append `{"gzip", ".gz"}` to the encodings list.
     59 
     60       Additional options will be added in the above order (Brotli takes
     61       preference over Gzip) to reflect older behaviour which was set due
     62       to fact that Brotli in general provides better compression ratio than
     63       Gzip.
     64 
     65     * `:cache_control_for_etags` - sets the cache header for requests
     66       that use etags. Defaults to `"public"`.
     67 
     68     * `:etag_generation` - specify a `{module, function, args}` to be used
     69       to generate   an etag. The `path` of the resource will be passed to
     70       the function, as well as the `args`. If this option is not supplied,
     71       etags will be generated based off of file size and modification time.
     72       Note it is [recommended for the etag value to be quoted](https://tools.ietf.org/html/rfc7232#section-2.3),
     73       which Plug won't do automatically.
     74 
     75     * `:cache_control_for_vsn_requests` - sets the cache header for
     76       requests starting with "?vsn=" in the query string. Defaults to
     77       `"public, max-age=31536000"`.
     78 
     79     * `:only` - filters which requests to serve. This is useful to avoid
     80       file system access on every request when this plug is mounted
     81       at `"/"`. For example, if `only: ["images", "favicon.ico"]` is
     82       specified, only files in the "images" directory and the
     83       "favicon.ico" file will be served by `Plug.Static`.
     84       Note that `Plug.Static` matches these filters against request
     85       uri and not against the filesystem. When requesting
     86       a file with name containing non-ascii or special characters,
     87       you should use urlencoded form. For example, you should write
     88       `only: ["file%20name"]` instead of `only: ["file name"]`.
     89       Defaults to `nil` (no filtering).
     90 
     91     * `:only_matching` - a relaxed version of `:only` that will
     92       serve any request as long as one of the given values matches the
     93       given path. For example, `only_matching: ["images", "favicon"]`
     94       will match any request that starts at "images" or "favicon",
     95       be it "/images/foo.png", "/images-high/foo.png", "/favicon.ico"
     96       or "/favicon-high.ico". Such matches are useful when serving
     97       digested files at the root. Defaults to `nil` (no filtering).
     98 
     99     * `:headers` - other headers to be set when serving static assets. Specify either
    100       an enum of key-value pairs or a `{module, function, args}` to return an enum. The
    101       `conn` will be passed to the function, as well as the `args`.
    102 
    103     * `:content_types` - custom MIME type mapping. As a map with filename as key
    104       and content type as value. For example:
    105       `content_types: %{"apple-app-site-association" => "application/json"}`.
    106 
    107   ## Examples
    108 
    109   This plug can be mounted in a `Plug.Builder` pipeline as follows:
    110 
    111       defmodule MyPlug do
    112         use Plug.Builder
    113 
    114         plug Plug.Static,
    115           at: "/public",
    116           from: :my_app,
    117           only: ~w(images robots.txt)
    118         plug :not_found
    119 
    120         def not_found(conn, _) do
    121           send_resp(conn, 404, "not found")
    122         end
    123       end
    124 
    125   """
    126 
    127   @behaviour Plug
    128   @allowed_methods ~w(GET HEAD)
    129 
    130   import Plug.Conn
    131   alias Plug.Conn
    132 
    133   # In this module, the `:prim_file` Erlang module along with the `:file_info`
    134   # record are used instead of the more common and Elixir-y `File` module and
    135   # `File.Stat` struct, respectively. The reason behind this is performance: all
    136   # the `File` operations pass through a single process in order to support node
    137   # operations that we simply don't need when serving assets.
    138 
    139   require Record
    140   Record.defrecordp(:file_info, Record.extract(:file_info, from_lib: "kernel/include/file.hrl"))
    141 
    142   defmodule InvalidPathError do
    143     defexception message: "invalid path for static asset", plug_status: 400
    144   end
    145 
    146   @impl true
    147   def init(opts) do
    148     from =
    149       case Keyword.fetch!(opts, :from) do
    150         {_, _} = from -> from
    151         {_, _, _} = from -> from
    152         from when is_atom(from) -> {from, "priv/static"}
    153         from when is_binary(from) -> from
    154         _ -> raise ArgumentError, ":from must be an atom, a binary or a tuple"
    155       end
    156 
    157     encodings =
    158       opts
    159       |> Keyword.get(:encodings, [])
    160       |> maybe_add("br", ".br", Keyword.get(opts, :brotli, false))
    161       |> maybe_add("gzip", ".gz", Keyword.get(opts, :gzip, false))
    162 
    163     %{
    164       encodings: encodings,
    165       only_rules: {Keyword.get(opts, :only, []), Keyword.get(opts, :only_matching, [])},
    166       qs_cache: Keyword.get(opts, :cache_control_for_vsn_requests, "public, max-age=31536000"),
    167       et_cache: Keyword.get(opts, :cache_control_for_etags, "public"),
    168       et_generation: Keyword.get(opts, :etag_generation, nil),
    169       headers: Keyword.get(opts, :headers, %{}),
    170       content_types: Keyword.get(opts, :content_types, %{}),
    171       from: from,
    172       at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split()
    173     }
    174   end
    175 
    176   @impl true
    177   def call(
    178         conn = %Conn{method: meth},
    179         %{at: at, only_rules: only_rules, from: from, encodings: encodings} = options
    180       )
    181       when meth in @allowed_methods do
    182     segments = subset(at, conn.path_info)
    183 
    184     if allowed?(only_rules, segments) do
    185       segments = Enum.map(segments, &uri_decode/1)
    186 
    187       if invalid_path?(segments) do
    188         raise InvalidPathError, "invalid path for static asset: #{conn.request_path}"
    189       end
    190 
    191       path = path(from, segments)
    192       range = get_req_header(conn, "range")
    193       encoding = file_encoding(conn, path, range, encodings)
    194       serve_static(encoding, conn, segments, range, options)
    195     else
    196       conn
    197     end
    198   end
    199 
    200   def call(conn, _options) do
    201     conn
    202   end
    203 
    204   defp uri_decode(path) do
    205     # TODO: Remove rescue as this can't fail from Elixir v1.13
    206     try do
    207       URI.decode(path)
    208     rescue
    209       ArgumentError ->
    210         raise InvalidPathError
    211     end
    212   end
    213 
    214   defp allowed?(_only_rules, []), do: false
    215   defp allowed?({[], []}, _list), do: true
    216 
    217   defp allowed?({full, prefix}, [h | _]) do
    218     h in full or (prefix != [] and match?({0, _}, :binary.match(h, prefix)))
    219   end
    220 
    221   defp serve_static({content_encoding, file_info, path}, conn, segments, range, options) do
    222     %{
    223       qs_cache: qs_cache,
    224       et_cache: et_cache,
    225       et_generation: et_generation,
    226       headers: headers,
    227       content_types: types
    228     } = options
    229 
    230     case put_cache_header(conn, qs_cache, et_cache, et_generation, file_info, path) do
    231       {:stale, conn} ->
    232         filename = List.last(segments)
    233         content_type = Map.get(types, filename) || MIME.from_path(filename)
    234 
    235         conn
    236         |> put_resp_header("content-type", content_type)
    237         |> put_resp_header("accept-ranges", "bytes")
    238         |> maybe_add_encoding(content_encoding)
    239         |> merge_headers(headers)
    240         |> serve_range(file_info, path, range, options)
    241 
    242       {:fresh, conn} ->
    243         conn
    244         |> maybe_add_vary(options)
    245         |> send_resp(304, "")
    246         |> halt()
    247     end
    248   end
    249 
    250   defp serve_static(:error, conn, _segments, _range, _options) do
    251     conn
    252   end
    253 
    254   defp serve_range(conn, file_info, path, [range], options) do
    255     file_info(size: file_size) = file_info
    256 
    257     with %{"bytes" => bytes} <- Plug.Conn.Utils.params(range),
    258          {range_start, range_end} <- start_and_end(bytes, file_size) do
    259       send_range(conn, path, range_start, range_end, file_size, options)
    260     else
    261       _ -> send_entire_file(conn, path, options)
    262     end
    263   end
    264 
    265   defp serve_range(conn, _file_info, path, _range, options) do
    266     send_entire_file(conn, path, options)
    267   end
    268 
    269   defp start_and_end("-" <> rest, file_size) do
    270     case Integer.parse(rest) do
    271       {last, ""} when last > 0 and last <= file_size -> {file_size - last, file_size - 1}
    272       _ -> :error
    273     end
    274   end
    275 
    276   defp start_and_end(range, file_size) do
    277     case Integer.parse(range) do
    278       {first, "-"} when first >= 0 ->
    279         {first, file_size - 1}
    280 
    281       {first, "-" <> rest} when first >= 0 ->
    282         case Integer.parse(rest) do
    283           {last, ""} when last >= first -> {first, min(last, file_size - 1)}
    284           _ -> :error
    285         end
    286 
    287       _ ->
    288         :error
    289     end
    290   end
    291 
    292   defp send_range(conn, path, 0, range_end, file_size, options) when range_end == file_size - 1 do
    293     send_entire_file(conn, path, options)
    294   end
    295 
    296   defp send_range(conn, path, range_start, range_end, file_size, _options) do
    297     length = range_end - range_start + 1
    298 
    299     conn
    300     |> put_resp_header("content-range", "bytes #{range_start}-#{range_end}/#{file_size}")
    301     |> send_file(206, path, range_start, length)
    302     |> halt()
    303   end
    304 
    305   defp send_entire_file(conn, path, options) do
    306     conn
    307     |> maybe_add_vary(options)
    308     |> send_file(200, path)
    309     |> halt()
    310   end
    311 
    312   defp maybe_add_encoding(conn, nil), do: conn
    313   defp maybe_add_encoding(conn, ce), do: put_resp_header(conn, "content-encoding", ce)
    314 
    315   defp maybe_add_vary(conn, %{encodings: encodings}) do
    316     # If we serve gzip or brotli at any moment, we need to set the proper vary
    317     # header regardless of whether we are serving gzip content right now.
    318     # See: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/
    319     if encodings != [] do
    320       update_in(conn.resp_headers, &[{"vary", "Accept-Encoding"} | &1])
    321     else
    322       conn
    323     end
    324   end
    325 
    326   defp put_cache_header(
    327          %Conn{query_string: "vsn=" <> _} = conn,
    328          qs_cache,
    329          _et_cache,
    330          _et_generation,
    331          _file_info,
    332          _path
    333        )
    334        when is_binary(qs_cache) do
    335     {:stale, put_resp_header(conn, "cache-control", qs_cache)}
    336   end
    337 
    338   defp put_cache_header(conn, _qs_cache, et_cache, et_generation, file_info, path)
    339        when is_binary(et_cache) do
    340     etag = etag_for_path(file_info, et_generation, path)
    341 
    342     conn =
    343       conn
    344       |> put_resp_header("cache-control", et_cache)
    345       |> put_resp_header("etag", etag)
    346 
    347     if etag in get_req_header(conn, "if-none-match") do
    348       {:fresh, conn}
    349     else
    350       {:stale, conn}
    351     end
    352   end
    353 
    354   defp put_cache_header(conn, _, _, _, _, _) do
    355     {:stale, conn}
    356   end
    357 
    358   defp etag_for_path(file_info, et_generation, path) do
    359     case et_generation do
    360       {module, function, args} ->
    361         apply(module, function, [path | args])
    362 
    363       nil ->
    364         file_info(size: size, mtime: mtime) = file_info
    365         <<?", {size, mtime} |> :erlang.phash2() |> Integer.to_string(16)::binary, ?">>
    366     end
    367   end
    368 
    369   defp file_encoding(conn, path, [_range], _encodings) do
    370     # We do not support compression for range queries.
    371     file_encoding(conn, path, nil, [])
    372   end
    373 
    374   defp file_encoding(conn, path, _range, encodings) do
    375     encoded =
    376       Enum.find_value(encodings, fn {encoding, ext} ->
    377         if file_info = accept_encoding?(conn, encoding) && regular_file_info(path <> ext) do
    378           {encoding, file_info, path <> ext}
    379         end
    380       end)
    381 
    382     cond do
    383       not is_nil(encoded) ->
    384         encoded
    385 
    386       file_info = regular_file_info(path) ->
    387         {nil, file_info, path}
    388 
    389       true ->
    390         :error
    391     end
    392   end
    393 
    394   defp regular_file_info(path) do
    395     case :prim_file.read_file_info(path) do
    396       {:ok, file_info(type: :regular) = file_info} ->
    397         file_info
    398 
    399       _ ->
    400         nil
    401     end
    402   end
    403 
    404   defp accept_encoding?(conn, encoding) do
    405     encoding? = &String.contains?(&1, [encoding, "*"])
    406 
    407     Enum.any?(get_req_header(conn, "accept-encoding"), fn accept ->
    408       accept |> Plug.Conn.Utils.list() |> Enum.any?(encoding?)
    409     end)
    410   end
    411 
    412   defp maybe_add(list, key, value, true), do: list ++ [{key, value}]
    413   defp maybe_add(list, _key, _value, false), do: list
    414 
    415   defp path({module, function, arguments}, segments)
    416        when is_atom(module) and is_atom(function) and is_list(arguments),
    417        do: Enum.join([apply(module, function, arguments) | segments], "/")
    418 
    419   defp path({app, from}, segments) when is_atom(app) and is_binary(from),
    420     do: Enum.join([Application.app_dir(app), from | segments], "/")
    421 
    422   defp path(from, segments),
    423     do: Enum.join([from | segments], "/")
    424 
    425   defp subset([h | expected], [h | actual]), do: subset(expected, actual)
    426   defp subset([], actual), do: actual
    427   defp subset(_, _), do: []
    428 
    429   defp invalid_path?(list) do
    430     invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"]))
    431   end
    432 
    433   defp invalid_path?([h | _], _match) when h in [".", "..", ""], do: true
    434   defp invalid_path?([h | t], match), do: String.contains?(h, match) or invalid_path?(t)
    435   defp invalid_path?([], _match), do: false
    436 
    437   defp merge_headers(conn, {module, function, args}) do
    438     merge_headers(conn, apply(module, function, [conn | args]))
    439   end
    440 
    441   defp merge_headers(conn, headers) do
    442     merge_resp_headers(conn, headers)
    443   end
    444 end