neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Give errors a page instead of the words "Not Found"
8d41fd5 · Gabriel Morell · 2026-09-14 14:48
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