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