author | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-10-13 02:47:33 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-10-13 02:47:33 UTC |
parent | 0d6bb380f8b88500c2157ffb426458d8b79b664e |
server/fileserver.go | +234 | -0 |
server/http.go | +1 | -1 |
test/test.sh | +6 | -0 |
test/testdata/dir/#anchor | +1 | -0 |
test/testdata/dir/<a> | +1 | -0 |
test/testdata/dir/?query | +1 | -0 |
diff --git a/server/fileserver.go b/server/fileserver.go new file mode 100644 index 0000000..3ebfc1b --- /dev/null +++ b/server/fileserver.go @@ -0,0 +1,234 @@ +package server + +import ( + "html" + "net/http" + "net/url" + "os" + "path" + "sort" + "strconv" + "strings" + "text/template" +) + +// FileServer implements an equivalent of http.FileServer, but with custom +// directory listing. +func FileServer(root http.FileSystem) http.Handler { + return &fileServer{ + root: root, + upsrv: http.FileServer(root), + } +} + +type fileServer struct { + root http.FileSystem + upsrv http.Handler +} + +func (fsrv *fileServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // Ensure the URL path begins with a /. + { + upath := req.URL.Path + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + req.URL.Path = upath + } + } + + // Redirect x/index.html to x/ + const indexhtml = "/index.html" + if strings.HasSuffix(req.URL.Path, indexhtml) { + localRedirect(w, req, "./") + return + } + + // Clean the path up. Removes the .., and it will end in a slash only if + // it is the root. + cleanPath := path.Clean(req.URL.Path) + + // Open and stat the path. + f, err := fsrv.root.Open(cleanPath) + if err != nil { + toHTTPErrror(w, err) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + toHTTPErrror(w, err) + return + } + + // Redirect directories to ending with "/". + if fi.IsDir() && !strings.HasSuffix(req.URL.Path, "/") { + localRedirect(w, req, path.Base(req.URL.Path)+"/") + return + } + + // Strip "/" from regular files. + if !fi.IsDir() && strings.HasSuffix(req.URL.Path, "/") { + localRedirect(w, req, "../"+path.Base(req.URL.Path)) + return + } + + // Serve the directory. + if fi.IsDir() { + // Try to serve from index.html first. + idxPath := path.Join(cleanPath, indexhtml) + idxf, err := fsrv.root.Open(idxPath) + if err == nil { + defer idxf.Close() + + idxfi, err := idxf.Stat() + if err == nil { + http.ServeContent(w, req, idxPath, idxfi.ModTime(), idxf) + return + } + } + + // Fall back to listing. + dirList(w, req, f) + return + } + + // Serve the file. + http.ServeContent(w, req, cleanPath, fi.ModTime(), f) +} + +// Local redirect, which keeps relative paths. +func localRedirect(w http.ResponseWriter, req *http.Request, dst string) { + u := url.URL{ + Path: dst, + RawQuery: req.URL.RawQuery, + } + + w.Header().Set("Location", u.String()) + w.WriteHeader(http.StatusMovedPermanently) +} + +func toHTTPErrror(w http.ResponseWriter, err error) { + if os.IsNotExist(err) { + http.Error(w, "404 Not found", http.StatusNotFound) + return + } + if os.IsPermission(err) { + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + + http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) + return +} + +func dirList(w http.ResponseWriter, req *http.Request, f http.File) { + dirs, err := f.Readdir(-1) + if err != nil { + http.Error(w, "Error reading directory", http.StatusInternalServerError) + return + } + + // Sort the entries by name. + sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + data := struct { + Dirs []os.FileInfo + }{ + Dirs: dirs, + } + dirListTmpl.Execute(w, data) +} + +var tmplFuncs = template.FuncMap{ + "humanize": humanizeInt, + "pathEscape": pathEscape, + "htmlEscape": html.EscapeString, +} + +var dirListTmpl = template.Must( + template.New("dirList"). + Funcs(tmplFuncs). + Parse(`<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> + +<style> +table { + border-collapse: collapse; + text-align: left; +} + +thead { + background-color: #e2eef4; + text-align: center; +} + +th, td { + padding-top: 0.3em; + padding-bottom: 0.3em; + padding-left: 0.3em; + padding-right: 1em; +} + +tbody tr:nth-child(odd) { + background-color: #f8f8f8; +} + +tbody tr:nth-child(even) { + background-color: white; +} +</style> + +</head> +<body> + +<table> +<thead> + <tr> + <th>Name</th> + <th>Size</th> + <th>Last modified</th> + </tr> +</thead> + +<tbody> +{{range .Dirs -}} + <tr> + <td><code> + <a href="{{.Name | pathEscape}}{{if .IsDir}}/{{end}}"> + {{- .Name | htmlEscape}}{{if .IsDir}}/{{end}}</a> + </code></td> + <td>{{if not .IsDir}}{{.Size | humanize}}{{end}}</td> + <td>{{.ModTime.Format "2006-01-02 15:04:05"}}</td> +</tr> +{{- end}} +</tbody> +</table> + +</body> +</html> +`)) + +func humanizeInt(i int64) string { + if i > 1024*1024*1024 { + return strconv.FormatInt(i/(1024*1024*1024), 10) + "G" + } + if i > 1024*1024 { + return strconv.FormatInt(i/(1024*1024), 10) + "M" + } + if i > 1024 { + return strconv.FormatInt(i/1024, 10) + "K" + } + return strconv.FormatInt(i, 10) +} + +func pathEscape(path string) string { + // This ensure that paths containing # and ? are escaped properly. + u := url.URL{Path: path} + return u.String() +} diff --git a/server/http.go b/server/http.go index 4a060ea..f42db99 100644 --- a/server/http.go +++ b/server/http.go @@ -220,7 +220,7 @@ func adjustPath(req string, from string, to string) string { } func makeDir(path string, dir string, opts config.DirOpts) http.Handler { - fs := http.FileServer(NewFS(http.Dir(dir), opts)) + fs := FileServer(NewFS(http.Dir(dir), opts)) path = stripDomain(path) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/test/test.sh b/test/test.sh index a0c3d3d..c0099b2 100755 --- a/test/test.sh +++ b/test/test.sh @@ -67,6 +67,9 @@ do exp $base/dir -status 301 -redir /dir/ exp $base/dir/ -bodyre '<a href="%C3%B1aca">ñaca</a>' + exp $base/dir/ -bodyre '<a href="%23anchor">#anchor</a>' + exp $base/dir/ -bodyre '<a href="%3Fquery">\?query</a>' + exp $base/dir/ -bodyre '<a href="%3Ca%3E"><a></a>' exp $base/dir/ -bodyre '>withindex/<' exp $base/dir/ -bodyre '>withoutindex/<' exp $base/dir/ -bodynotre 'ignored' @@ -74,8 +77,11 @@ do exp $base/dir/hola -body 'hola marola\n' exp $base/dir/ñaca -body "tracañaca\n" exp $base/dir/ignored.file -status 404 + exp $base/dir/ñaca/ -status 301 -redir '../%C3%B1aca' + exp "$base/dir/%23anchor/?abc" -status 301 -redir '../%23anchor?abc' exp $base/dir/withindex -status 301 -redir withindex/ + exp $base/dir/withindex/index.html -status 301 -redir ./ exp $base/dir/withindex/ -bodyre 'This is the index.' exp $base/dir/withoutindex -status 404 exp $base/dir/withoutindex/ -status 404 diff --git a/test/testdata/dir/#anchor b/test/testdata/dir/#anchor new file mode 100644 index 0000000..a2b8ae6 --- /dev/null +++ b/test/testdata/dir/#anchor @@ -0,0 +1 @@ +to the seas diff --git a/test/testdata/dir/<a> b/test/testdata/dir/<a> new file mode 100644 index 0000000..3ba2f49 --- /dev/null +++ b/test/testdata/dir/<a> @@ -0,0 +1 @@ +a ver que pasa aca diff --git a/test/testdata/dir/?query b/test/testdata/dir/?query new file mode 100644 index 0000000..c868fd6 --- /dev/null +++ b/test/testdata/dir/?query @@ -0,0 +1 @@ +¿de que estamos hablando?