git » gofer » commit 40eb3c8

http: Custom directory listing

author Alberto Bertogli
2020-10-13 02:47:33 UTC
committer Alberto Bertogli
2020-10-13 02:47:33 UTC
parent 0d6bb380f8b88500c2157ffb426458d8b79b664e

http: Custom directory listing

This patch introduces our own FileServer, which is similar to
http.FileServer but allows us to customize the directory listing.

In particular, we make it more accessible and mobile-friendly.

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">&lt;a&gt;</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?