Skip to content

Commit 33644c2

Browse files
authored
Add :raise_on_missing_only option to Plug.Static (#1286)
Add development-time validation to catch files that exist but aren't declared in the :only list. When enabled, raises MissingPathInOnlyFilterError with helpful guidance to either add the file to :only or use :only_matching for prefix-based matching. This prevents silent 404s when developers add static files (like favicon.svg) but forget to update the static file configuration, which is a common trap especially for digested files. Part of the fix for: phoenixframework/phoenix#6203
1 parent a109030 commit 33644c2

File tree

2 files changed

+66
-0
lines changed

2 files changed

+66
-0
lines changed

lib/plug/static.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ defmodule Plug.Static do
9696
or "/favicon-high.ico". Such matches are useful when serving
9797
digested files at the root. Defaults to `nil` (no filtering).
9898
99+
* `:raise_on_missing_only` - when `true`, raises an exception if a static
100+
file exists but does not match the `:only` list. This is useful in
101+
development to catch missing entries, especially for digested files.
102+
For example, if `favicon.ico` is in `:only` but the actual file is
103+
`favicon-deadbeef.ico`, this option will raise an error. Defaults to `false`.
104+
99105
* `:headers` - other headers to be set when serving static assets. Specify either
100106
an enum of key-value pairs or a `{module, function, args}` to return an enum. The
101107
`conn` will be passed to the function, as well as the `args`.
@@ -147,6 +153,10 @@ defmodule Plug.Static do
147153
defexception message: "invalid path for static asset", plug_status: 400
148154
end
149155

156+
defmodule MissingPathInOnlyFilterError do
157+
defexception message: "static asset found but not specified in :only rule", plug_status: 400
158+
end
159+
150160
@impl true
151161
def init(opts) do
152162
from =
@@ -167,6 +177,7 @@ defmodule Plug.Static do
167177
%{
168178
encodings: encodings,
169179
only_rules: {Keyword.get(opts, :only, []), Keyword.get(opts, :only_matching, [])},
180+
raise_on_missing_only: Keyword.get(opts, :raise_on_missing_only, false),
170181
qs_cache:
171182
Keyword.get(opts, :cache_control_for_vsn_requests, "public, max-age=31536000, immutable"),
172183
et_cache: Keyword.get(opts, :cache_control_for_etags, "public"),
@@ -198,6 +209,7 @@ defmodule Plug.Static do
198209
encoding = file_encoding(conn, path, range, encodings)
199210
serve_static(encoding, conn, segments, range, options)
200211
else
212+
maybe_raise_on_missing_only(segments, from, options)
201213
conn
202214
end
203215
end
@@ -213,6 +225,32 @@ defmodule Plug.Static do
213225
h in full or (prefix != [] and match?({0, _}, :binary.match(h, prefix)))
214226
end
215227

228+
defp maybe_raise_on_missing_only([], _from, _options), do: :ok
229+
230+
defp maybe_raise_on_missing_only(segments, from, %{
231+
raise_on_missing_only: true,
232+
only_rules: {only, _only_matching}
233+
})
234+
when only != [] do
235+
segments = Enum.map(segments, &URI.decode/1)
236+
237+
if not invalid_path?(segments) do
238+
path = path(from, segments)
239+
240+
case :prim_file.read_file_info(path, [:posix]) do
241+
{:ok, file_info(type: :regular)} ->
242+
raise MissingPathInOnlyFilterError,
243+
"static file exists but is not in the :only list: #{Enum.join(segments, "/")}. " <>
244+
"Add it to the :only list or use :only_matching for prefix matching"
245+
246+
_ ->
247+
:ok
248+
end
249+
end
250+
end
251+
252+
defp maybe_raise_on_missing_only(_segments, _from, _options), do: :ok
253+
216254
defp maybe_put_content_type(conn, false, _), do: conn
217255

218256
defp maybe_put_content_type(conn, types, filename) do

test/plug/static_test.exs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,34 @@ defmodule Plug.StaticTest do
836836
assert conn.status == 200
837837
end
838838

839+
defmodule RaiseOnMissingOnlyPlug do
840+
use Plug.Builder
841+
842+
plug Plug.Static,
843+
at: "/",
844+
from: Path.expand("../fixtures", __DIR__),
845+
only: ~w(static.txt),
846+
raise_on_missing_only: true
847+
848+
plug :passthrough
849+
850+
defp passthrough(conn, _), do: Plug.Conn.send_resp(conn, 404, "Passthrough")
851+
end
852+
853+
test "raise_on_missing_only option validates static files against only list" do
854+
assert_raise Plug.Static.MissingPathInOnlyFilterError,
855+
~r/static file exists but is not in the :only list: file-deadbeef.txt/,
856+
fn ->
857+
RaiseOnMissingOnlyPlug.call(conn(:get, "/file-deadbeef.txt"), [])
858+
end
859+
860+
conn = RaiseOnMissingOnlyPlug.call(conn(:get, "/static.txt"), [])
861+
assert conn.status == 200
862+
863+
conn = RaiseOnMissingOnlyPlug.call(conn(:get, "/nonexistent.txt"), [])
864+
assert conn.status == 404
865+
end
866+
839867
defmodule HeaderGenerator do
840868
def generate(_conn, header) do
841869
[header]

0 commit comments

Comments
 (0)