git » gofer » next » tree

[next] / server / fileserver.go

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) {
	// 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. Add initial / if missing, removes the .., and it
	// will end in a slash only if it is the root.
	if !strings.HasPrefix(req.URL.Path, "/") {
		req.URL.Path = "/" + req.URL.Path
	}
	cleanPath := path.Clean(req.URL.Path)

	// Open and stat the path.
	f, err := fsrv.root.Open(cleanPath)
	if err != nil {
		toHTTPError(w, err)
		return
	}
	defer f.Close()

	fi, err := f.Stat()
	if err != nil {
		toHTTPError(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 toHTTPError(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()
}