From db3b09c5d8379f1686570d311ef7a13453394c38 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 17:01:52 +0200 Subject: [PATCH] handlers: a directory under /s/ answers 404, not a listing (closes #286) http.FileServer answered /s/ and /s/css/ with an HTML listing of the embedded files, and /s/css with a redirect to that listing. HandleStatic in internal/handlers now looks the name up in the embedded files first and answers 404 when it names a directory; files are served as before. The routes mount it under /s. Model: opus-5-5 --- README.md | 1 + TODO.md | 2 ++ internal/handlers/static.go | 36 ++++++++++++++++++++++++++++++ internal/server/routes.go | 10 +-------- internal/server/routes_test.go | 40 ++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 internal/handlers/static.go diff --git a/README.md b/README.md index 21758c0..49e220a 100644 --- a/README.md +++ b/README.md @@ -910,6 +910,7 @@ internal/ templates/dashboard.html Dashboard template (embedded) status.go /api/v1/status healthcheck.go Health check handler + static.go Static assets under /s/ server/ server.go HTTP server lifecycle routes.go Route definitions diff --git a/TODO.md b/TODO.md index 368aa6c..c6e8b06 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or + `/s/css`, answers 404 instead of a listing of its files (closes #286). - 2026-10-15: a notification endpoint that answers with a redirect, or any status other than 2xx, is a failed delivery, retried and logged (closes #287). - 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..5a7bc12 --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "io/fs" + "net/http" + "strings" + + "sneak.berlin/go/dnswatcher/static" +) + +// HandleStatic returns the handler for the embedded static assets. It +// is mounted under /s, with that prefix stripped from the path. A +// request that names a directory answers 404: http.FileServer would +// answer it with a listing of the directory, or with a redirect to +// that listing when the trailing slash is missing. +func (h *Handlers) HandleStatic() http.HandlerFunc { + files := http.FileServer(http.FS(static.Static)) + + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + // fs.Stat refuses a name that is empty or ends in a slash, + // so /s/ and /s/css/ answer 404 here along with /s/css. + name := strings.TrimPrefix(request.URL.Path, "/") + + info, err := fs.Stat(static.Static, name) + if err != nil || info.IsDir() { + http.NotFound(writer, request) + + return + } + + files.ServeHTTP(writer, request) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 805ff21..52778ee 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -8,8 +8,6 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" - - "sneak.berlin/go/dnswatcher/static" ) // requestTimeout is the maximum duration for handling a request. @@ -47,13 +45,7 @@ func (s *Server) SetupRoutes() { public.Get("/", s.handlers.HandleDashboard()) // Static assets (embedded CSS/JS) - public.Mount( - "/s", - http.StripPrefix( - "/s", - http.FileServer(http.FS(static.Static)), - ), - ) + public.Mount("/s", http.StripPrefix("/s", s.handlers.HandleStatic())) // Health check (standard well-known path) public.Get( diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f8f9c47..5513a9c 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1,6 +1,7 @@ package server_test import ( + "mime" "net/http" "net/http/httptest" "testing" @@ -288,3 +289,42 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) { t.Errorf("another address: status = %d, want 200", rec.Code) } } + +// TestStaticDirectoriesAnswerNotFound checks that a request naming a +// directory of the static assets, with or without the trailing slash, +// answers 404 rather than a listing of it or a redirect to one, and +// that the stylesheet is still served as CSS. +func TestStaticDirectoriesAnswerNotFound(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + + srv := routedServer(t) + + for _, path := range []string{"/s", "/s/", "/s/css", "/s/css/"} { + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, path, nil, + )) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", path, rec.Code) + } + } + + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/s/css/tailwind.min.css", nil, + )) + + if rec.Code != http.StatusOK { + t.Errorf("GET stylesheet: status = %d, want 200", rec.Code) + } + + contentType := rec.Header().Get("Content-Type") + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "text/css" { + t.Errorf( + "GET stylesheet: Content-Type = %q, want text/css", + contentType, + ) + } +}