From 3f4e85f1956827e4665b286455dd2171264947f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 9 Sep 2026 14:52:51 +0200 Subject: [PATCH] Serve a proper 404 error page for missing static files http.FileServer previously fell through to the default net/http 404 response for unmatched static-asset requests. Intercepts that response and redirects to Gokapi's own styled error page instead, undoing the cache headers that were speculatively set for the (nonexistent) file first. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0168YUwGEzEHTqd9CwzXE53d --- internal/webserver/Webserver.go | 35 +++++++++++++++++++++++++--- internal/webserver/Webserver_test.go | 8 +++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go index 6b707e5b..2f832351 100644 --- a/internal/webserver/Webserver.go +++ b/internal/webserver/Webserver.go @@ -50,8 +50,6 @@ import ( "github.com/forceu/gokapi/internal/webserver/ssl" ) -// TODO add 404 handler - // staticFolderEmbedded is the embedded version of the "static" folder // This contains JS files, CSS, images etc // @@ -175,8 +173,39 @@ func filesystemHandler(webserverDir fs.FS) http.HandlerFunc { return } addCacheHeader(w) - http.FileServer(http.FS(webserverDir)).ServeHTTP(w, r) + nfw := ¬FoundInterceptor{ResponseWriter: w} + http.FileServer(http.FS(webserverDir)).ServeHTTP(nfw, r) + if nfw.intercepted { + // The response was cached as servable content above; a 404 means that + // assumption was wrong, so undo it before showing the error page. + w.Header().Del("cdn-cache-control") + w.Header().Del("Cloudflare-CDN-Cache-Control") + w.Header().Del("cache-control") + errorHandling.RedirectGenericErrorPage(w, r, errorHandling.TypeFileNotFound) + } + } +} + +// notFoundInterceptor suppresses http.FileServer's built-in 404 response, so that a +// styled Gokapi error page can be shown instead of the plain stdlib fallback page. +type notFoundInterceptor struct { + http.ResponseWriter + intercepted bool +} + +func (nfw *notFoundInterceptor) WriteHeader(statusCode int) { + if statusCode == http.StatusNotFound { + nfw.intercepted = true + return + } + nfw.ResponseWriter.WriteHeader(statusCode) +} + +func (nfw *notFoundInterceptor) Write(b []byte) (int, error) { + if nfw.intercepted { + return len(b), nil } + return nfw.ResponseWriter.Write(b) } func handleFavicon(w http.ResponseWriter, r *http.Request) { diff --git a/internal/webserver/Webserver_test.go b/internal/webserver/Webserver_test.go index f5e2bf8a..e6acd3f0 100644 --- a/internal/webserver/Webserver_test.go +++ b/internal/webserver/Webserver_test.go @@ -74,6 +74,14 @@ func TestStaticDirs(t *testing.T) { RequiredContent: []string{".btn-secondary:hover"}, }) } +func TestStaticFileNotFound(t *testing.T) { + t.Parallel() + test.HttpPageResult(t, test.HttpTestConfig{ + Url: "http://localhost:53843/this/file/does/not/exist.xyz", + RedirectUrl: "error", + IgnoreRedirectParm: true, + }) +} func postValues(username, password, csrf string) []test.PostBody { return []test.PostBody{