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]) }