diff --git a/lib/plug/static.ex b/lib/plug/static.ex index b40aece9..d252937f 100644 --- a/lib/plug/static.ex +++ b/lib/plug/static.ex @@ -96,6 +96,12 @@ defmodule Plug.Static do or "/favicon-high.ico". Such matches are useful when serving digested files at the root. Defaults to `nil` (no filtering). + * `:raise_on_missing_only` - when `true`, raises an exception if a static + file exists but does not match the `:only` list. This is useful in + development to catch missing entries, especially for digested files. + For example, if `favicon.ico` is in `:only` but the actual file is + `favicon-deadbeef.ico`, this option will raise an error. Defaults to `false`. + * `:headers` - other headers to be set when serving static assets. Specify either an enum of key-value pairs or a `{module, function, args}` to return an enum. The `conn` will be passed to the function, as well as the `args`. @@ -147,6 +153,10 @@ defmodule Plug.Static do defexception message: "invalid path for static asset", plug_status: 400 end + defmodule MissingPathInOnlyFilterError do + defexception message: "static asset found but not specified in :only rule", plug_status: 400 + end + @impl true def init(opts) do from = @@ -167,6 +177,7 @@ defmodule Plug.Static do %{ encodings: encodings, only_rules: {Keyword.get(opts, :only, []), Keyword.get(opts, :only_matching, [])}, + raise_on_missing_only: Keyword.get(opts, :raise_on_missing_only, false), qs_cache: Keyword.get(opts, :cache_control_for_vsn_requests, "public, max-age=31536000, immutable"), et_cache: Keyword.get(opts, :cache_control_for_etags, "public"), @@ -198,6 +209,7 @@ defmodule Plug.Static do encoding = file_encoding(conn, path, range, encodings) serve_static(encoding, conn, segments, range, options) else + maybe_raise_on_missing_only(segments, from, options) conn end end @@ -213,6 +225,32 @@ defmodule Plug.Static do h in full or (prefix != [] and match?({0, _}, :binary.match(h, prefix))) end + defp maybe_raise_on_missing_only([], _from, _options), do: :ok + + defp maybe_raise_on_missing_only(segments, from, %{ + raise_on_missing_only: true, + only_rules: {only, _only_matching} + }) + when only != [] do + segments = Enum.map(segments, &URI.decode/1) + + if not invalid_path?(segments) do + path = path(from, segments) + + case :prim_file.read_file_info(path, [:posix]) do + {:ok, file_info(type: :regular)} -> + raise MissingPathInOnlyFilterError, + "static file exists but is not in the :only list: #{Enum.join(segments, "/")}. " <> + "Add it to the :only list or use :only_matching for prefix matching" + + _ -> + :ok + end + end + end + + defp maybe_raise_on_missing_only(_segments, _from, _options), do: :ok + defp maybe_put_content_type(conn, false, _), do: conn defp maybe_put_content_type(conn, types, filename) do diff --git a/test/plug/static_test.exs b/test/plug/static_test.exs index d4595a27..3b63a867 100644 --- a/test/plug/static_test.exs +++ b/test/plug/static_test.exs @@ -836,6 +836,34 @@ defmodule Plug.StaticTest do assert conn.status == 200 end + defmodule RaiseOnMissingOnlyPlug do + use Plug.Builder + + plug Plug.Static, + at: "/", + from: Path.expand("../fixtures", __DIR__), + only: ~w(static.txt), + raise_on_missing_only: true + + plug :passthrough + + defp passthrough(conn, _), do: Plug.Conn.send_resp(conn, 404, "Passthrough") + end + + test "raise_on_missing_only option validates static files against only list" do + assert_raise Plug.Static.MissingPathInOnlyFilterError, + ~r/static file exists but is not in the :only list: file-deadbeef.txt/, + fn -> + RaiseOnMissingOnlyPlug.call(conn(:get, "/file-deadbeef.txt"), []) + end + + conn = RaiseOnMissingOnlyPlug.call(conn(:get, "/static.txt"), []) + assert conn.status == 200 + + conn = RaiseOnMissingOnlyPlug.call(conn(:get, "/nonexistent.txt"), []) + assert conn.status == 404 + end + defmodule HeaderGenerator do def generate(_conn, header) do [header]