Give errors a page instead of the words "Not Found"

8d41fd5 · Gabriel Morell · 2026-09-14 14:48

10 files +178 -20
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/controllers/error_html.ex
+59 −15
@@ -1,24 +1,68 @@
1 1 defmodule GitGudWeb.ErrorHTML do
2 2 @moduledoc """
3 − This module is invoked by your endpoint in case of errors on HTML requests.
3 + The page a browser gets when something went wrong.
4 4
5 − See config/config.exs.
5 + It used to be `Phoenix.Controller.status_message_from_template/1` and
6 + nothing else — a white page with the words "Not Found" on it. This
7 + renders the forge's own colours instead, including the theme the
8 + reader picked, since an error page is the one page you meet when
9 + you're already annoyed.
10 +
11 + ## One template for every status
12 +
13 + Rather than a `404.html.heex` and a `500.html.heex` that drift, there
14 + is one template and the status fills it in. A status nobody wrote
15 + prose for still renders a real page, because `detail/1` falls through
16 + to a sentence that is true of any of them.
17 +
18 + ## What it deliberately does not say
19 +
20 + Nothing about the request. The 404 a private repository produces has
21 + to be byte-identical to the 404 a misspelt URL produces, or the page
22 + becomes a way to enumerate private repositories — which is exactly
23 + what `GitGudWeb.RepoAuth` exists to prevent, and it would be a poor
24 + trade to hand it back here for a friendlier message.
25 +
26 + ## Why it is self-contained
27 +
28 + No layout, no `@conn`, no CSRF token, no Sentry snippet. This renders
29 + when something is already broken, and every assign it reaches for is
30 + another thing that can be missing at exactly the wrong moment. The
31 + stylesheet and the two theme names are the whole dependency list.
6 32 """
7 33 use GitGudWeb, :html
8 34
9 − # If you want to customize your error pages,
10 − # uncomment the embed_templates/1 call below
11 − # and add pages to the error directory:
12 − #
13 − # * lib/git_gud_web/controllers/error_html/404.html.heex
14 − # * lib/git_gud_web/controllers/error_html/500.html.heex
15 − #
16 − # embed_templates "error_html/*"
17 −
18 − # The default is to render a plain text page based on
19 − # the template name. For example, "404.html" becomes
20 − # "Not Found".
35 + embed_templates "error_html/*"
36 +
37 + @doc """
38 + Render `template` — "404.html", "500.html", anything Phoenix hands us.
39 +
40 + Falls back to the status message for a template we can't parse, which
41 + keeps the old behaviour as the floor rather than raising from inside
42 + the error handler.
43 + """
21 44 def render(template, _assigns) do
22 − Phoenix.Controller.status_message_from_template(template)
45 + message = Phoenix.Controller.status_message_from_template(template)
46 +
47 + case Integer.parse(template) do
48 + {status, _} -> error(%{status: status, message: message, detail: detail(status)})
49 + :error -> message
50 + end
23 51 end
52 +
53 + # Deliberately vague about *why* something is missing: "or you may not
54 + # have access to it" covers a private repository without confirming
55 + # one exists, and is honest for a mistyped URL too.
56 + defp detail(404),
57 + do: "There's nothing at this address. It may have moved, or you may not have access to it."
58 +
59 + defp detail(403), do: "You're signed in, but this isn't yours to see."
60 + defp detail(401), do: "You'll need to sign in to see this."
61 + defp detail(413), do: "That upload is larger than this forge accepts."
62 + defp detail(429), do: "Too many requests for now. Give it a moment and try again."
63 +
64 + defp detail(status) when status >= 500,
65 + do: "Something broke on our side. The forge logged it; trying again may well work."
66 +
67 + defp detail(_status), do: "That request couldn't be completed."
24 68 end
added lib/git_gud_web/controllers/error_html/error.html.heex
+52 −0
@@ -0,0 +1,52 @@
1 +<!DOCTYPE html>
2 +<html
3 + lang="en"
4 + data-system-light={GitGud.Themes.system_light()}
5 + data-system-dark={GitGud.Themes.system_dark()}
6 +>
7 + <head>
8 + <meta charset="utf-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1" />
10 + <title>{@status} {@message} · forge</title>
11 + <link rel="stylesheet" href={GitGudWeb.Endpoint.static_path("/assets/css/app.css")} />
12 + <script>
13 + // The root layout's bootstrap, minus the parts that need a live
14 + // socket. Read-only: this page has no theme picker, so it never
15 + // writes phx:theme — but it does listen, so switching the theme
16 + // in another tab recolours this one.
17 + //
18 + // Read off <html> rather than written here: HEEx does not
19 + // interpolate inside a <script>, it emits the expression verbatim.
20 + (() => {
21 + const root = document.documentElement;
22 + const mq = window.matchMedia("(prefers-color-scheme: dark)");
23 + const systemTheme = () => mq.matches ? root.dataset.systemDark : root.dataset.systemLight;
24 + const apply = (theme) => root.setAttribute("data-theme", theme || systemTheme());
25 + apply(localStorage.getItem("phx:theme"));
26 + mq.addEventListener("change", () => {
27 + if (!localStorage.getItem("phx:theme")) apply(null);
28 + });
29 + window.addEventListener("storage", (e) => {
30 + if (e.key === "phx:theme") apply(e.newValue);
31 + });
32 + })();
33 + </script>
34 + </head>
35 + <body class="min-h-screen bg-base-100 text-base-content">
36 + <main class="min-h-screen flex flex-col items-center justify-center gap-6 px-6 py-16 text-center">
37 + <a href="/" class="font-mono font-bold tracking-[0.16em] text-accent text-xl">GITGUD</a>
38 +
39 + <div class="flex flex-col items-center gap-2">
40 + <p class="font-mono text-7xl font-bold leading-none opacity-20">{@status}</p>
41 + <h1 class="text-xl font-semibold">{@message}</h1>
42 + </div>
43 +
44 + <p class="max-w-md text-sm opacity-60">{@detail}</p>
45 +
46 + <div class="flex flex-wrap items-center justify-center gap-2">
47 + <a href="/" class="btn btn-primary btn-sm">Home</a>
48 + <a href="/repositories" class="btn btn-ghost btn-sm">Repositories</a>
49 + </div>
50 + </main>
51 + </body>
52 +</html>
modified test/git_gud_web/controllers/error_html_test.exs
+67 −5
@@ -1,14 +1,76 @@
1 1 defmodule GitGudWeb.ErrorHTMLTest do
2 + @moduledoc """
3 + The error page.
4 +
5 + Two of these are about security rather than looks: the 404 must not
6 + describe the request, because `GitGudWeb.RepoAuth` relies on a private
7 + repository's 404 being indistinguishable from a missing page's, and
8 + handing that back here for a friendlier message would be a poor trade.
9 + """
2 10 use GitGudWeb.ConnCase, async: true
3 11
4 − # Bring render_to_string/4 for testing custom views
5 12 import Phoenix.Template, only: [render_to_string: 4]
6 13
7 − test "renders 404.html" do
8 − assert render_to_string(GitGudWeb.ErrorHTML, "404", "html", []) == "Not Found"
14 + defp render(status), do: render_to_string(GitGudWeb.ErrorHTML, status, "html", [])
15 +
16 + test "404 is a real page, not the words 'Not Found'" do
17 + html = render("404")
18 +
19 + assert html =~ "<!DOCTYPE html>"
20 + assert html =~ "Not Found"
21 + assert html =~ "404"
22 + # Themed: the stylesheet and the theme bootstrap the rest of the
23 + # forge uses, so the page arrives in the colours the reader picked.
24 + assert html =~ "/assets/css/app.css"
25 + assert html =~ "data-system-dark"
26 + assert html =~ ~s(class="btn btn-primary btn-sm")
27 + end
28 +
29 + test "500 renders the same page with its own wording" do
30 + html = render("500")
31 +
32 + assert html =~ "Internal Server Error"
33 + assert html =~ "500"
34 + assert html =~ "broke on our side"
35 + end
36 +
37 + test "a status nobody wrote prose for still renders a page" do
38 + # One template and a fallthrough, rather than a file per status that
39 + # drifts — a 422 should not be a blank page just because nobody
40 + # anticipated it.
41 + html = render("422")
42 +
43 + assert html =~ "<!DOCTYPE html>"
44 + assert html =~ "422"
9 45 end
10 46
11 − test "renders 500.html" do
12 − assert render_to_string(GitGudWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
47 + test "a real unmatched request gets the themed page", %{conn: conn} do
48 + # Through the endpoint, not just the view: an error page that raises
49 + # while rendering turns every 404 into a 500, and rendering it in
50 + # isolation would never show that.
51 + body = conn |> get("/definitely-not-a-route-here") |> response(404)
52 +
53 + assert body =~ "<!DOCTYPE html>"
54 + assert body =~ "Not Found"
55 + assert body =~ "/assets/css/app.css"
56 + end
57 +
58 + test "the 404 does not vary with the request" do
59 + # A private repository's 404 and a misspelt URL's 404 have to be the
60 + # same bytes, or this page becomes a way to enumerate private
61 + # repositories. Asserted by rendering with wildly different assigns
62 + # rather than by grepping for strings — the page is allowed to
63 + # contain the word "repositories" in a nav link, and it is not
64 + # allowed to contain anything that came in with the request.
65 + plain = render_to_string(GitGudWeb.ErrorHTML, "404", "html", [])
66 +
67 + with_context =
68 + render_to_string(GitGudWeb.ErrorHTML, "404", "html",
69 + conn: Phoenix.ConnTest.build_conn(:get, "/r/someone/a-secret-repo"),
70 + reason: %RuntimeError{message: "a-secret-repo"}
71 + )
72 +
73 + assert plain == with_context
74 + refute with_context =~ "a-secret-repo"
13 75 end
14 76 end

Parents: 466a390