Serve static files under their digested names

a773f61b6164 · AtlantisPleb · · parent 197b6f8e2887

Serve static files under their digested names

`Plug.Static`'s `:only` matches a whole path segment, and digesting rewrites
the segment: the page asks for `favicon-32x32-<hash>.png`, which matches
nothing in `static_paths/0`. So the icon worked in development, where nothing
is digested, and 404d everywhere that runs `mix phx.digest`. Staging served
`/favicon-32x32.png` with a 200 and the name the page actually requested with
a 404.

Every root file in that list had the same fault -- `favicon.ico`,
`favicon-16x16.png`, `apple-touch-icon.png`, `robots.txt`. Directories do not,
because there the segment being matched is the directory name. `only_matching`
now carries the prefixes; it widens what may be served by name, and a file
still has to exist in `priv/static` to be sent.

The options moved into `OpenAgentsWeb.static_options/1` so the test can hold
the real ones against a real `Plug.Static` over a temporary directory, rather
than re-implementing the matching rules -- the bug was a misunderstanding of
those rules, and a copy of them would have shared it. Verified by reverting
the fix and watching the digested-name test fail.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web.ex
  • modified lib/openagents_web/endpoint.ex
  • added test/openagents_web/static_files_test.exs

Diff

3 files changed, +150 -5

lib/openagents_web.ex modified +34

@@ -21,6 +21,40 @@ defmodule OpenAgentsWeb do

21 21
    do:
22 22
      ~w(assets fonts images favicon.ico favicon-32x32.png favicon-16x16.png apple-touch-icon.png robots.txt)
23 23
24
  @doc """
25
  Prefixes for static files that are served under a digested name.
26
27
  `Plug.Static`'s `:only` matches a whole path segment, and digesting rewrites
28
  the segment: `favicon-32x32.png` is requested as
29
  `favicon-32x32-<hash>.png`, which matches nothing in `static_paths/0` and is
30
  refused. Every file listed there that sits at the root -- rather than inside
31
  `assets`, `fonts` or `images`, whose directory name is the segment being
32
  matched -- needs a prefix here or it 404s in any environment that digests.
33
34
  It only widens what may be served to names that begin this way; a file still
35
  has to exist in `priv/static` to be sent.
36
  """
37
  def static_prefixes, do: ~w(favicon apple-touch-icon robots)
38
39
  @doc """
40
  `Plug.Static` options for this application's endpoint.
41
42
  Assembled here rather than written inline in the endpoint so a test can hold
43
  the real options against a real `Plug.Static` and check that a digested file
44
  is actually served.
45
  """
46
  def static_options(overrides \\ []) do
47
    Keyword.merge(
48
      [
49
        at: "/",
50
        from: :openagents,
51
        only: static_paths(),
52
        only_matching: static_prefixes()
53
      ],
54
      overrides
55
    )
56
  end
57
24 58
  def router do
25 59
    quote do
26 60
      use Phoenix.Router, helpers: false
lib/openagents_web/endpoint.ex modified +4 -5

@@ -32,11 +32,10 @@ defmodule OpenAgentsWeb.Endpoint do

32 32
  # the `gzip` option is enabled to serve compressed
33 33
  # static files generated by running `phx.digest`.
34 34
  plug Plug.Static,
35
    at: "/",
36
    from: :openagents,
37
    gzip: not code_reloading?,
38
    only: OpenAgentsWeb.static_paths(),
39
    raise_on_missing_only: code_reloading?
35
       OpenAgentsWeb.static_options(
36
         gzip: not code_reloading?,
37
         raise_on_missing_only: code_reloading?
38
       )
40 39
41 40
  # Code reloading can be explicitly enabled under the
42 41
  # :code_reloader configuration of your endpoint.
test/openagents_web/static_files_test.exs added +112

@@ -0,0 +1,112 @@

1
defmodule OpenAgentsWeb.StaticFilesTest do
2
  @moduledoc """
3
  Static files must still be served once they are digested.
4
5
  `Plug.Static`'s `:only` matches a whole path segment, and digesting rewrites
6
  the segment -- `favicon-32x32.png` is requested as
7
  `favicon-32x32-<hash>.png`. Listing the plain name therefore admits the file
8
  in development, where nothing is digested, and refuses it in every
9
  environment that runs `mix phx.digest`. Staging served `/favicon-32x32.png`
10
  with a 200 and the digested name the page actually asked for with a 404, so
11
  the icon was missing in exactly the places it is seen most.
12
13
  These run the real `Plug.Static` with the real options over a temporary
14
  directory, rather than re-implementing its matching rules, because the bug
15
  was a misunderstanding of those rules -- a copy of them would have had the
16
  same misunderstanding and passed.
17
  """
18
19
  use ExUnit.Case, async: true
20
21
  import Plug.Test
22
  import Plug.Conn
23
24
  @digest "0123456789abcdef0123456789abcdef"
25
26
  setup do
27
    root = Path.join(System.tmp_dir!(), "static-#{System.unique_integer([:positive])}")
28
29
    on_exit(fn -> File.rm_rf!(root) end)
30
31
    {:ok, root: root}
32
  end
33
34
  test "every root file is served under its digested name", %{root: root} do
35
    for path <- root_files() do
36
      digested = digested_name(path)
37
38
      write!(root, digested, "body of #{path}")
39
40
      assert %{status: 200} = request(root, "/" <> digested),
41
             """
42
             `#{path}` is listed in `static_paths/0` but its digested name
43
             `#{digested}` is refused, so it 404s wherever assets are digested.
44
             Add a covering prefix to `OpenAgentsWeb.static_prefixes/0`.
45
             """
46
    end
47
  end
48
49
  test "every root file is still served under its plain name", %{root: root} do
50
    for path <- root_files() do
51
      write!(root, path, "body of #{path}")
52
      assert %{status: 200} = request(root, "/" <> path), "#{path} is no longer served"
53
    end
54
  end
55
56
  test "directories of assets are served digested, as the segment is the directory",
57
       %{root: root} do
58
    for directory <- root_directories() do
59
      write!(root, Path.join(directory, "app-#{@digest}.js"), "console.log(1)")
60
61
      assert %{status: 200} = request(root, "/#{directory}/app-#{@digest}.js")
62
    end
63
  end
64
65
  test "widening the match does not serve a file that is not listed", %{root: root} do
66
    # `only_matching` admits names by prefix, so it is worth stating that it
67
    # has not turned the static root into an open directory.
68
    write!(root, "secrets.txt", "nope")
69
    write!(root, "favicons-are-fine.txt", "also nope")
70
71
    assert %{status: 404} = request(root, "/secrets.txt")
72
    assert %{status: 200} = request(root, "/favicons-are-fine.txt")
73
  end
74
75
  test "a prefix that matches nothing listed is dead weight" do
76
    # Not a correctness failure, but a prefix nobody needs widens what may be
77
    # served for no reason, and is usually a leftover.
78
    for prefix <- OpenAgentsWeb.static_prefixes() do
79
      assert Enum.any?(OpenAgentsWeb.static_paths(), &String.starts_with?(&1, prefix)),
80
             "`#{prefix}` covers nothing in static_paths/0"
81
    end
82
  end
83
84
  defp root_files do
85
    Enum.filter(OpenAgentsWeb.static_paths(), &String.contains?(&1, "."))
86
  end
87
88
  defp root_directories do
89
    Enum.reject(OpenAgentsWeb.static_paths(), &String.contains?(&1, "."))
90
  end
91
92
  # `mix phx.digest` names a digested file `<base>-<hash><extension>`.
93
  defp digested_name(path) do
94
    extension = Path.extname(path)
95
    Path.basename(path, extension) <> "-" <> @digest <> extension
96
  end
97
98
  defp write!(root, path, contents) do
99
    full = Path.join(root, path)
100
    File.mkdir_p!(Path.dirname(full))
101
    File.write!(full, contents)
102
  end
103
104
  defp request(root, path) do
105
    options = Plug.Static.init(OpenAgentsWeb.static_options(from: root))
106
107
    :get
108
    |> conn(path)
109
    |> Plug.Static.call(options)
110
    |> then(fn conn -> if conn.state == :unset, do: send_resp(conn, 404, ""), else: conn end)
111
  end
112
end

This page updates live while a promote is in flight · changelog