From 3734420eec485bca8c18243741e1ad2a683515b7 Mon Sep 17 00:00:00 2001 From: Shulhan Date: Thu, 5 Feb 2026 19:26:29 +0700 Subject: lib/http: handle file system with canonical directory end with slash "/" Previously, if request to directory does not end with "/", the HTTP server will return the index.html of that directory. This cause relative link inside the index.html broken when visited from browser. This changes make the request to directory always end with "/" by redirecting the request with status 303 Found. --- lib/http/server.go | 107 ++++++++++------------- lib/http/server_test.go | 90 ++++++++++++++----- lib/http/testdata/Server_HandleFS/b.html | 6 ++ lib/http/testdata/Server_HandleFS/dir/index.html | 6 ++ lib/http/testdata/Server_HandleFS/index.html | 6 ++ lib/http/testdata/Server_HandleFS_test.txt | 98 ++++++++++++++++++--- 6 files changed, 216 insertions(+), 97 deletions(-) create mode 100644 lib/http/testdata/Server_HandleFS/b.html create mode 100644 lib/http/testdata/Server_HandleFS/dir/index.html create mode 100644 lib/http/testdata/Server_HandleFS/index.html diff --git a/lib/http/server.go b/lib/http/server.go index 76713308..edc99cf7 100644 --- a/lib/http/server.go +++ b/lib/http/server.go @@ -12,6 +12,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path" "sort" @@ -384,60 +385,67 @@ func (srv *Server) Stop(wait time.Duration) (err error) { // getFSNode get the memfs Node based on the request path. // -// If the path is not exist, try path with index.html; -// if it still not exist try path with suffix .html. +// If the path is not exist, try path with ".html". // -// If the path is directory and contains index.html, the node for index.html -// with true will be returned. +// If the path is directory and contains index.html, the node content for +// index.html will be returned. // // If the path is directory and does not contains index.html and // [ServerOptions.EnableIndexHTML] is true, server will generate list of // content for index.html. -func (srv *Server) getFSNode(reqPath string) (node *memfs.Node) { +// +// The redirectPath return whether the server should redirect first before +// serving the content to provide consistent canonical path that end with "/" +// for directory that contains "index.html" or EnableIndexHTML is on. +func (srv *Server) getFSNode(reqURL url.URL) (node *memfs.Node, redirectURL *url.URL) { if srv.Options.Memfs == nil { - return nil + return nil, nil } + reqPath := reqURL.Path + endWithSlash := reqPath[len(reqPath)-1] == '/' var err error + // The [MemFS.Get] method check reqPath with trailing `/` removed, so + // "path" or "path/" will return the same node. node, err = srv.Options.Memfs.Get(reqPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { - return nil + return nil, nil } - - var pathHTML = path.Join(reqPath, `index.html`) - - node, err = srv.Options.Memfs.Get(pathHTML) - if err != nil { - pathHTML = reqPath + `.html` + if !endWithSlash { + pathHTML := reqPath + `.html` node, err = srv.Options.Memfs.Get(pathHTML) if err != nil { - return nil + return nil, nil } - return node + return node, nil } + return nil, nil + } + if !node.IsDir() { + return node, nil } - if node.IsDir() { - var ( - pathHTML = path.Join(reqPath, `index.html`) - nodeIndexHTML *memfs.Node - ) - - nodeIndexHTML, err = srv.Options.Memfs.Get(pathHTML) - if err == nil { - return nodeIndexHTML - } - - if !srv.Options.EnableIndexHTML { - return node + pathIndexHTML := path.Join(reqPath, `index.html`) + nodeIndexHTML, err := srv.Options.Memfs.Get(pathIndexHTML) + if err != nil { + if srv.Options.EnableIndexHTML { + if !endWithSlash { + redirectURL = reqURL.JoinPath(`/`) + return nil, redirectURL + } + node.GenerateIndexHTML() + return node, nil } - - node.GenerateIndexHTML() + return nil, nil + } + if !endWithSlash { + redirectURL = reqURL.JoinPath(`/`) + return nil, redirectURL } - return node + return nodeIndexHTML, nil } // handleDelete handle the DELETE request by searching the registered route @@ -468,32 +476,14 @@ func (srv *Server) handleDelete(res http.ResponseWriter, req *http.Request) { // // If the request Path is not exist it will return 404 Not Found. func (srv *Server) HandleFS(res http.ResponseWriter, req *http.Request) { - var ( - logp = "HandleFS" - - node *memfs.Node - err error - ) - - node = srv.getFSNode(req.URL.Path) - if node == nil { - if srv.Options.HandleFS == nil { - res.WriteHeader(http.StatusNotFound) - return - } - // Fallthrough, call HandleFS below. - } else if node.IsDir() && req.URL.Path[len(req.URL.Path)-1] != '/' { + node, redirectURL := srv.getFSNode(*req.URL) + if redirectURL != nil { // If request path is a directory and it is not end with // slash, redirect request to location with slash to allow // relative links works inside the HTML content. - var redirectPath = req.URL.Path + "/" - if len(req.URL.RawQuery) > 0 { - redirectPath += "?" + req.URL.RawQuery - } - http.Redirect(res, req, redirectPath, http.StatusFound) + http.Redirect(res, req, redirectURL.String(), http.StatusFound) return } - if srv.Options.HandleFS != nil { var statusCode int node, statusCode = srv.Options.HandleFS(node, res, req) @@ -522,9 +512,7 @@ func (srv *Server) HandleFS(res http.ResponseWriter, req *http.Request) { var ifModifiedSince = req.Header.Get(HeaderIfModifiedSince) if len(ifModifiedSince) != 0 { - var timeModsince time.Time - - timeModsince, err = time.Parse(time.RFC1123, ifModifiedSince) + timeModsince, err := time.Parse(time.RFC1123, ifModifiedSince) if err == nil { if nodeModtime <= timeModsince.Unix() { res.WriteHeader(http.StatusNotModified) @@ -542,8 +530,7 @@ func (srv *Server) HandleFS(res http.ResponseWriter, req *http.Request) { bodyReader = bytes.NewReader(node.Content) size = node.Size() } else { - var f *os.File - f, err = os.Open(node.SysPath) + f, err := os.Open(node.SysPath) if err != nil { res.WriteHeader(http.StatusInternalServerError) return @@ -577,7 +564,7 @@ func (srv *Server) HandleFS(res http.ResponseWriter, req *http.Request) { return } - responseWrite(logp, res, req, bodyReader) + responseWrite(`HandleFS`, res, req, bodyReader) } // handleGet handle the GET request by searching the registered route and @@ -653,8 +640,8 @@ func (srv *Server) handleHead(res http.ResponseWriter, req *http.Request) { func (srv *Server) handleOptions(res http.ResponseWriter, req *http.Request) { methods := make(map[string]bool) - var node = srv.getFSNode(req.URL.Path) - if node != nil { + node, redirectURL := srv.getFSNode(*req.URL) + if node != nil || redirectURL != nil { methods[http.MethodGet] = true methods[http.MethodHead] = true } diff --git a/lib/http/server_test.go b/lib/http/server_test.go index 6542d833..a333628c 100644 --- a/lib/http/server_test.go +++ b/lib/http/server_test.go @@ -1,6 +1,5 @@ -// SPDX-FileCopyrightText: 2018 M. Shulhan -// // SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2018 M. Shulhan package http @@ -869,7 +868,8 @@ func TestServer_HandleFS(t *testing.T) { t.Fatal(err) } - var root = t.TempDir() + root := `testdata/Server_HandleFS` + var mfsOpts = memfs.Options{ Root: root, MaxFileSize: -1, @@ -892,48 +892,90 @@ func TestServer_HandleFS(t *testing.T) { t.Fatal(err) } - var redactTime = regexp.MustCompile(`\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d`) - var redactedTime = []byte(`0000-00-00T00:00:00`) - var simreq = libhttptest.SimulateRequest{ - Path: `/`, - } - var simres *libhttptest.SimulateResult - var exp string - var got []byte + listCase := []struct { + path string + expResponse string + }{{ + path: `/`, + expResponse: string(tdata.Output[`/`]), + }, { + path: `/index`, + expResponse: string(tdata.Output[`/`]), + }, { + path: `/dir`, + expResponse: string(tdata.Output[`/dir`]), + }, { + path: `/dir?q=abc`, + expResponse: string(tdata.Output[`/dir?q=abc`]), + }, { + path: `/dir?q=abc#fgh`, + expResponse: string(tdata.Output[`/dir?q=abc#fgh`]), + }, { + path: `/dir#fgh`, + expResponse: string(tdata.Output[`/dir/#fgh`]), + }, { + path: `/dir/#fgh`, + expResponse: string(tdata.Output[`/dir/#fgh`]), + }, { + path: `/dir/`, + expResponse: string(tdata.Output[`/dir/`]), + }, { + path: `/b`, + expResponse: string(tdata.Output[`/b`]), + }, { + path: `/b?q=abc`, + expResponse: string(tdata.Output[`/b`]), + }, { + path: `/b?q=abc#fgh`, + expResponse: string(tdata.Output[`/b`]), + }} + + for _, tc := range listCase { + simreq := libhttptest.SimulateRequest{ + Path: tc.path, + } - t.Run(`OnEmptyRoot`, func(t *testing.T) { + var simres *libhttptest.SimulateResult simres, err = libhttptest.Simulate(httpd.ServeHTTP, &simreq) if err != nil { t.Fatal(err) } - exp = string(tdata.Output[t.Name()]) + var got []byte got, err = simres.DumpResponse([]string{HeaderETag}) if err != nil { t.Fatal(err) } - test.Assert(t, `response`, exp, string(got)) - }) + test.Assert(t, tc.path, tc.expResponse, string(got)) + } + + newDir := filepath.Join(root, `newDir`) + _ = os.RemoveAll(newDir) + + t.Run(`OnNewDirectory`, func(tt *testing.T) { + redactTime := regexp.MustCompile(`\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d`) + redactedTime := []byte(`0000-00-00T00:00:00`) - t.Run(`OnNewDirectory`, func(t *testing.T) { - var newDir = filepath.Join(root, `dirA`) err = os.MkdirAll(newDir, 0755) if err != nil { - t.Fatal(err) + tt.Fatal(err) } - simres, err = libhttptest.Simulate(httpd.ServeHTTP, &simreq) + simreq := libhttptest.SimulateRequest{ + Path: `/newDir/`, + } + simres, err := libhttptest.Simulate(httpd.ServeHTTP, &simreq) if err != nil { - t.Fatal(err) + tt.Fatal(err) } - exp = string(tdata.Output[t.Name()]) - got, err = simres.DumpResponse([]string{HeaderETag}) + exp := string(tdata.Output[tt.Name()]) + got, err := simres.DumpResponse([]string{HeaderETag}) if err != nil { - t.Fatal(err) + tt.Fatal(err) } got = redactTime.ReplaceAll(got, redactedTime) - test.Assert(t, `response`, exp, string(got)) + test.Assert(tt, tt.Name(), exp, string(got)) }) } diff --git a/lib/http/testdata/Server_HandleFS/b.html b/lib/http/testdata/Server_HandleFS/b.html new file mode 100644 index 00000000..aa8d367a --- /dev/null +++ b/lib/http/testdata/Server_HandleFS/b.html @@ -0,0 +1,6 @@ + + + + /b.html + + diff --git a/lib/http/testdata/Server_HandleFS/dir/index.html b/lib/http/testdata/Server_HandleFS/dir/index.html new file mode 100644 index 00000000..245518a0 --- /dev/null +++ b/lib/http/testdata/Server_HandleFS/dir/index.html @@ -0,0 +1,6 @@ + + + + /dir/index.html + + diff --git a/lib/http/testdata/Server_HandleFS/index.html b/lib/http/testdata/Server_HandleFS/index.html new file mode 100644 index 00000000..73b9ab05 --- /dev/null +++ b/lib/http/testdata/Server_HandleFS/index.html @@ -0,0 +1,6 @@ + + + + /index.html + + diff --git a/lib/http/testdata/Server_HandleFS_test.txt b/lib/http/testdata/Server_HandleFS_test.txt index 1089121a..8b2173ef 100644 --- a/lib/http/testdata/Server_HandleFS_test.txt +++ b/lib/http/testdata/Server_HandleFS_test.txt @@ -1,21 +1,93 @@ // SPDX-License-Identifier: BSD-3-Clause // SPDX-FileCopyrightText: 2025 M. Shulhan -<<< TestServer_HandleFS/OnEmptyRoot +<<< / HTTP/1.1 200 OK Connection: close Content-Type: text/html; charset=utf-8 - - - - - - -

Index of /

- + + + + /index.html + + + +<<< /dir +HTTP/1.1 302 Found +Connection: close +Content-Type: text/html; charset=utf-8 +Location: /dir/ + +Found. + + + +<<< /dir?q=abc +HTTP/1.1 302 Found +Connection: close +Content-Type: text/html; charset=utf-8 +Location: /dir/?q=abc + +Found. + + + +<<< /dir?q=abc#fgh +HTTP/1.1 302 Found +Connection: close +Content-Type: text/html; charset=utf-8 +Location: /dir/?q=abc#fgh + +Found. + + + +<<< /dir/#fgh +HTTP/1.1 404 Not Found +Connection: close + + + +<<< /dir/ +HTTP/1.1 200 OK +Connection: close +Content-Type: text/html; charset=utf-8 + + + + + /dir/index.html + + + + +<<< /b +HTTP/1.1 200 OK +Connection: close +Content-Type: text/html; charset=utf-8 + + + + + /b.html + + + + +<<< /b?q=abc +HTTP/1.1 200 OK +Connection: close +Content-Type: text/html; charset=utf-8 + + + + + /b.html + + + + <<< TestServer_HandleFS/OnNewDirectory HTTP/1.1 200 OK @@ -30,6 +102,6 @@ body{font-family:monospace; white-space:pre;} -

Index of /

-
drwxr-xr-x 0 0000-00-00T00:00:00Z dirA

+

Index of newDir

+
..

-- cgit v1.3