diff --git a/pkg/http/file_server.go b/pkg/http/file_server.go new file mode 100644 index 000000000..ffc9976fc --- /dev/null +++ b/pkg/http/file_server.go @@ -0,0 +1,145 @@ +package http + +import ( + "io/fs" + "net/http" + "path" + "strings" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +type ( + // special file-server meant to be used for serving single page applications + // or anything that needs a bit of extra attention like fallback handling + // 404 handler, error handler and URL prefixing + fileServer struct { + // fileServer files + files http.FileSystem + + urlPrefix string + + fallbacks []string + + // Final not-found handler + notFound http.HandlerFunc + + // how errors are handled + errHandler func(w http.ResponseWriter, error string, code int) + + logger *zap.Logger + } + + configurator func(*fileServer) error +) + +// MountSPA helper function, preconfigures FileServer for SPA serving +// and mounts it to chi Router +func MountSPA(r chi.Router, path string, root fs.FS, cc ...configurator) error { + path = "/" + strings.Trim(strings.TrimRight(path, "*"), "/") + "/" + handler, err := FileServer(root, append(cc, UrlPrefix(path), Fallbacks("index.html"))...) + if err != nil { + return err + } + + r.Handle( + strings.TrimRight(path, "/"), + http.RedirectHandler("."+path, http.StatusTemporaryRedirect), + ) + + r.Handle(path+"*", handler) + return nil +} + +func FileServer(files fs.FS, cc ...configurator) (h *fileServer, err error) { + h = &fileServer{ + files: http.FS(files), + notFound: http.NotFound, + errHandler: http.Error, + logger: zap.NewNop(), + } + + for _, configure := range cc { + if err = configure(h); err != nil { + return + } + } + + return +} + +func UrlPrefix(prefix string) configurator { + return func(s *fileServer) error { + s.urlPrefix = prefix + return nil + } +} + +func Fallbacks(ff ...string) configurator { + return func(s *fileServer) error { + s.fallbacks = ff + return nil + } +} + +func NotFound(h http.HandlerFunc) configurator { + return func(s *fileServer) error { + s.notFound = h + return nil + } +} + +func Logger(l *zap.Logger) configurator { + return func(s *fileServer) error { + s.logger = l + return nil + } +} + +// Serves the single-page-application +// +// This is file-server with some special logic for handling missing +// files (404s) and directories. +// In both cases we serve index file directly +func (h *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // catch requests for non-existing files and redirect to index.html + if h.files == nil { + h.errHandler(w, "configured without files", http.StatusInternalServerError) + return + } + + trimmed := path.Clean(strings.TrimPrefix(r.URL.Path, h.urlPrefix)) + h.logger.Debug(r.URL.Path, zap.String("trimmed", trimmed), zap.String("urlPrefix", h.urlPrefix)) + r.URL.Path = trimmed + + var ( + err error + fh http.File + st fs.FileInfo + ) + + for _, candidate := range append([]string{r.URL.Path}, h.fallbacks...) { + if len(candidate) == 0 { + continue + } + + if fh, err = h.files.Open(candidate); err != nil { + continue + } else if st, err = fh.Stat(); err != nil { + continue + } else if st.IsDir() { + // index + continue + } + + break + } + + if fh == nil || st == nil { + h.notFound(w, r) + return + } + + http.ServeContent(w, r, st.Name(), st.ModTime(), fh) +} diff --git a/pkg/http/file_server_test.go b/pkg/http/file_server_test.go new file mode 100644 index 000000000..86fc58978 --- /dev/null +++ b/pkg/http/file_server_test.go @@ -0,0 +1,133 @@ +package http + +import ( + "embed" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + //go:embed test_data/file_server + testAssets embed.FS +) + +func TestSPA(t *testing.T) { + testAssetsSub, err := fs.Sub(testAssets, "test_data/file_server") + if err != nil { + t.Errorf("failed to dive into subdirectory: %w", err) + } + + testDirectAssets := os.DirFS("test_data/file_server") + + var ( + cases = []struct { + name string + fs fs.FS + cc []configurator + url string + rsp string + }{ + { + name: "no special config", + fs: testAssetsSub, + url: "index.html", + rsp: "index html\n", + }, + + { + name: "empty path", + fs: testAssetsSub, + cc: []configurator{Fallbacks("index.html")}, + url: "", + rsp: "index html\n", + }, + { + name: "slash", + fs: testAssetsSub, + cc: []configurator{Fallbacks("index.html")}, + url: "/", + rsp: "index html\n", + }, + + { + name: "prefix, no slash", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/my-spa", + rsp: "index html\n", + }, + + { + name: "sub dir with url prefix", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/my-spa/index.html", + rsp: "index html\n", + }, + { + name: "sub dir with url prefix, none existing file", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/not-here", + rsp: "index html\n", + }, + { + name: "sub dir with url prefix, dir", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/my-spa/sub", + rsp: "index html\n", + }, + { + name: "sub dir with url prefix, sub index", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/my-spa/sub/index.html", + rsp: "sub index html\n", + }, + { + name: "sub dir with url prefix, sub file", + fs: testAssetsSub, + cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")}, + url: "/my-spa/sub/test.html", + rsp: "sub test html\n", + }, + } + ) + + // another set of test cases for direct fs + total := len(cases) + for c := 0; c < total; c++ { + direct := cases[c] + cases[c].name += "; embedded" + direct.name += "; direct" + direct.fs = testDirectAssets + + cases = append(cases, direct) + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var ( + handler http.Handler + req = require.New(t) + w = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodGet, c.url, nil) + ) + + handler, err = FileServer(c.fs, c.cc...) + + req.NoError(err) + handler.ServeHTTP(w, r) + + req.Equal(c.rsp, w.Body.String()) + req.Equal(http.StatusOK, w.Result().StatusCode) + }) + } + +} diff --git a/pkg/http/test_data/file_server/index.html b/pkg/http/test_data/file_server/index.html new file mode 100644 index 000000000..11e3e65c7 --- /dev/null +++ b/pkg/http/test_data/file_server/index.html @@ -0,0 +1 @@ +index html diff --git a/pkg/http/test_data/file_server/sub/index.html b/pkg/http/test_data/file_server/sub/index.html new file mode 100644 index 000000000..d15502162 --- /dev/null +++ b/pkg/http/test_data/file_server/sub/index.html @@ -0,0 +1 @@ +sub index html diff --git a/pkg/http/test_data/file_server/sub/test.html b/pkg/http/test_data/file_server/sub/test.html new file mode 100644 index 000000000..61a1bcffe --- /dev/null +++ b/pkg/http/test_data/file_server/sub/test.html @@ -0,0 +1 @@ +sub test html diff --git a/pkg/http/test_data/file_server/test.html b/pkg/http/test_data/file_server/test.html new file mode 100644 index 000000000..87fd3abcc --- /dev/null +++ b/pkg/http/test_data/file_server/test.html @@ -0,0 +1 @@ + test html