361 lines
8 KiB
Go
361 lines
8 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
_ "embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed assets/index.html
|
|
var indexHTML string
|
|
|
|
//go:embed assets/about.html
|
|
var aboutHTML []byte
|
|
|
|
//go:embed assets/favicon.ico
|
|
var faviconICO []byte
|
|
|
|
type server struct {
|
|
root string // as configured, used for display paths
|
|
rootReal string // symlink-resolved root, used for containment checks
|
|
hidden bool
|
|
footer string
|
|
follow bool // serve symlinks even when they point outside root
|
|
tpl *template.Template
|
|
}
|
|
|
|
func newServer(cfg *Config) *server {
|
|
rootReal, err := filepath.EvalSymlinks(cfg.Root)
|
|
if err != nil {
|
|
rootReal = cfg.Root
|
|
}
|
|
return &server{
|
|
root: cfg.Root,
|
|
rootReal: rootReal,
|
|
hidden: cfg.Hidden,
|
|
footer: cfg.Footer,
|
|
follow: cfg.FollowSymlinks,
|
|
tpl: template.Must(template.New("index").Parse(indexHTML)),
|
|
}
|
|
}
|
|
|
|
type row struct {
|
|
Name string
|
|
URL string
|
|
IsDir bool
|
|
Size int64
|
|
ModTime time.Time
|
|
Type string
|
|
}
|
|
|
|
func (r row) SizeS() string { return humanSize(r.Size) }
|
|
func (r row) ModS() string { return r.ModTime.Format("2006-01-02 15:04") }
|
|
|
|
type headerCol struct {
|
|
Label string
|
|
Class string
|
|
URL string
|
|
Active bool
|
|
Desc bool
|
|
}
|
|
|
|
type crumb struct{ Name, URL string }
|
|
|
|
type listData struct {
|
|
Path string
|
|
Crumbs []crumb
|
|
Headers []headerCol
|
|
Rows []row
|
|
HasParent bool
|
|
Wget string
|
|
Footer string
|
|
}
|
|
|
|
var sortKeys = map[string]bool{"name": true, "size": true, "modified": true, "type": true}
|
|
|
|
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
upath := r.URL.Path
|
|
if !strings.HasPrefix(upath, "/") {
|
|
upath = "/" + upath
|
|
}
|
|
clean := path.Clean(upath)
|
|
switch clean {
|
|
case "/about":
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write(aboutHTML)
|
|
return
|
|
case "/favicon.ico":
|
|
w.Header().Set("Content-Type", "image/x-icon")
|
|
w.Header().Set("Cache-Control", "max-age=604800")
|
|
w.Write(faviconICO)
|
|
return
|
|
}
|
|
|
|
fsPath := filepath.Join(s.rootReal, filepath.FromSlash(strings.TrimPrefix(clean, "/")))
|
|
realPath, err := filepath.EvalSymlinks(fsPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !s.follow && realPath != s.rootReal && !strings.HasPrefix(realPath, s.rootReal+string(os.PathSeparator)) {
|
|
http.NotFound(w, r) // symlink escaping the served root
|
|
return
|
|
}
|
|
|
|
info, err := os.Stat(realPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if info.IsDir() && !strings.HasSuffix(upath, "/") {
|
|
localRedirect(w, r, path.Base(upath)+"/")
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
s.serveFile(w, r, realPath, filepath.Base(clean), info)
|
|
return
|
|
}
|
|
s.serveListing(w, r, realPath, clean)
|
|
}
|
|
|
|
func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
|
|
if q := r.URL.RawQuery; q != "" {
|
|
newPath += "?" + q
|
|
}
|
|
w.Header().Set("Location", newPath)
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
|
}
|
|
|
|
func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, name string, info os.FileInfo) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
http.Error(w, "cannot open file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
disp := "attachment"
|
|
if inlineFile(f, name) {
|
|
disp = "inline"
|
|
}
|
|
w.Header().Set("Content-Disposition",
|
|
mime.FormatMediaType(disp, map[string]string{"filename": name}))
|
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
|
}
|
|
|
|
func inlineFile(f *os.File, name string) bool {
|
|
if ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); ct != "" {
|
|
return inlineMIME(ct)
|
|
}
|
|
buf := make([]byte, 512)
|
|
n, err := f.Read(buf)
|
|
if _, serr := f.Seek(0, io.SeekStart); serr != nil {
|
|
return false
|
|
}
|
|
if err != nil && err != io.EOF {
|
|
return false
|
|
}
|
|
return inlineMIME(http.DetectContentType(buf[:n]))
|
|
}
|
|
|
|
func inlineMIME(ct string) bool {
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = strings.TrimSpace(ct[:i])
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(ct, "image/"),
|
|
strings.HasPrefix(ct, "audio/"),
|
|
strings.HasPrefix(ct, "video/"),
|
|
strings.HasPrefix(ct, "text/"):
|
|
return ct != "text/html"
|
|
case ct == "application/pdf",
|
|
ct == "application/xml",
|
|
ct == "application/json":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *server) serveListing(w http.ResponseWriter, r *http.Request, dirPath, urlPath string) {
|
|
entries, err := os.ReadDir(dirPath)
|
|
if err != nil {
|
|
http.Error(w, "cannot read directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sortKey := r.URL.Query().Get("sort")
|
|
if !sortKeys[sortKey] {
|
|
sortKey = "name"
|
|
}
|
|
desc := r.URL.Query().Get("dir") == "desc"
|
|
|
|
dirURL := urlPath
|
|
if !strings.HasSuffix(dirURL, "/") {
|
|
dirURL += "/"
|
|
}
|
|
rows := make([]row, 0, len(entries))
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !s.hidden && strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
isDir := e.IsDir()
|
|
var size int64
|
|
var mod time.Time
|
|
if info, err := e.Info(); err == nil { // lstat: does not follow symlinks
|
|
mod = info.ModTime()
|
|
if !isDir {
|
|
size = info.Size()
|
|
}
|
|
}
|
|
if e.Type()&os.ModeSymlink != 0 {
|
|
if ti, err := os.Stat(filepath.Join(dirPath, name)); err == nil {
|
|
isDir = ti.IsDir()
|
|
mod = ti.ModTime()
|
|
if isDir {
|
|
size = 0
|
|
} else {
|
|
size = ti.Size()
|
|
}
|
|
}
|
|
}
|
|
typ := "-"
|
|
if isDir {
|
|
typ = "dir"
|
|
} else if ext := strings.TrimPrefix(filepath.Ext(name), "."); ext != "" {
|
|
typ = strings.ToLower(ext)
|
|
}
|
|
rows = append(rows, row{
|
|
Name: name,
|
|
URL: dirURL + url.PathEscape(name),
|
|
IsDir: isDir,
|
|
Size: size,
|
|
ModTime: mod,
|
|
Type: typ,
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
a, b := rows[i], rows[j]
|
|
if a.IsDir != b.IsDir {
|
|
return a.IsDir
|
|
}
|
|
var c int
|
|
switch sortKey {
|
|
case "size":
|
|
c = cmp.Compare(a.Size, b.Size)
|
|
case "modified":
|
|
c = a.ModTime.Compare(b.ModTime)
|
|
case "type":
|
|
if c = strings.Compare(a.Type, b.Type); c == 0 {
|
|
c = strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
|
}
|
|
default:
|
|
c = strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
|
}
|
|
if desc {
|
|
return c > 0
|
|
}
|
|
return c < 0
|
|
})
|
|
|
|
headers := []struct {
|
|
Label string
|
|
Key string
|
|
Class string
|
|
}{
|
|
{"Name", "name", ""},
|
|
{"Type", "type", "ty"},
|
|
{"Size", "size", "sz"},
|
|
{"Modified", "modified", "mod"},
|
|
}
|
|
cols := make([]headerCol, len(headers))
|
|
for i, h := range headers {
|
|
cols[i] = headerCol{
|
|
Label: h.Label,
|
|
Class: h.Class,
|
|
Active: h.Key == sortKey,
|
|
Desc: h.Key == sortKey && desc,
|
|
}
|
|
d := "asc"
|
|
if h.Key == sortKey && !desc {
|
|
d = "desc"
|
|
}
|
|
cols[i].URL = dirURL + "?sort=" + h.Key + "&dir=" + d
|
|
}
|
|
|
|
crumbs := []crumb{{Name: "root", URL: "/"}}
|
|
cur := ""
|
|
for _, seg := range segments(urlPath) {
|
|
cur += "/" + seg
|
|
crumbs = append(crumbs, crumb{Name: seg, URL: cur + "/"})
|
|
}
|
|
|
|
s.tpl.Execute(w, listData{
|
|
Path: dirURL,
|
|
Crumbs: crumbs,
|
|
Headers: cols,
|
|
Rows: rows,
|
|
HasParent: urlPath != "/",
|
|
Wget: wgetCmd(r, dirURL),
|
|
Footer: s.footer,
|
|
})
|
|
}
|
|
|
|
func wgetCmd(r *http.Request, dirURL string) string {
|
|
cmd := "wget -r -np -nH"
|
|
if n := len(segments(dirURL)); n > 0 {
|
|
cmd += fmt.Sprintf(" --cut-dirs=%d", n)
|
|
}
|
|
return cmd + ` -R "index.html*" -e robots=off ` + shellQuote(baseURL(r)+dirURL)
|
|
}
|
|
|
|
// shellQuote quotes s so it is treated as a single literal argument by POSIX shells.
|
|
func shellQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
func baseURL(r *http.Request) string {
|
|
scheme := "http"
|
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
|
scheme = "https"
|
|
}
|
|
return scheme + "://" + r.Host
|
|
}
|
|
|
|
func segments(p string) []string {
|
|
p = strings.Trim(p, "/")
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(p, "/")
|
|
}
|
|
|
|
func humanSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|