diff --git a/README.md b/README.md index e999cee..f92ab5e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ japanese for *stairs*. a minimal, fast file index server. single static binary. TOML config. sortable listings (name, type, size, modified). +browser-renderable files (images, audio, video, text, PDF and friends) are +served inline so they open in place; everything else is sent as a download. + ## build ```sh diff --git a/assets/index.html b/assets/index.html index 5169e12..306370e 100644 --- a/assets/index.html +++ b/assets/index.html @@ -166,7 +166,7 @@ footer a:hover { {{if .HasParent}}../-{{end}} {{range .Rows}} -{{.Name}} +{{.Name}} {{.Type}} {{if .IsDir}}-{{else}}{{.SizeS}}{{end}} {{.ModS}} diff --git a/config.go b/config.go index 8bd703c..4cf1d31 100644 --- a/config.go +++ b/config.go @@ -10,10 +10,11 @@ import ( ) type Config struct { - Listen string `toml:"listen"` - Root string `toml:"root"` - Hidden bool `toml:"hidden"` - Footer string `toml:"footer"` + Listen string `toml:"listen"` + Root string `toml:"root"` + Hidden bool `toml:"hidden"` + Footer string `toml:"footer"` + FollowSymlinks bool `toml:"follow_symlinks"` } func loadConfig(path string) (*Config, error) { @@ -22,9 +23,15 @@ func loadConfig(path string) (*Config, error) { return nil, err } cfg := &Config{} - if err := toml.Unmarshal(b, cfg); err != nil { + md, err := toml.Decode(string(b), cfg) + if err != nil { return nil, fmt.Errorf("%s: %w", path, err) } + // follow_symlinks defaults to true: symlinks are served as a window onto + // their target, even outside root. + if !md.IsDefined("follow_symlinks") { + cfg.FollowSymlinks = true + } if cfg.Root == "" { return nil, errors.New(`config: "root" is required`) } diff --git a/config.toml b/config.toml index 277bcda..9f146cc 100644 --- a/config.toml +++ b/config.toml @@ -11,3 +11,7 @@ hidden = true # footer shown at the bottom of every page footer = "kaidan 1.0" + +# follow symlinks even when their target lies outside root. set to false to +# keep the served tree confined to root (escaping symlinks then 404). +follow_symlinks = true diff --git a/server.go b/server.go index c9094bc..c7a7c02 100644 --- a/server.go +++ b/server.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "html/template" + "io" "mime" "net/http" "net/url" @@ -30,6 +31,7 @@ type server struct { 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 } @@ -43,6 +45,7 @@ func newServer(cfg *Config) *server { rootReal: rootReal, hidden: cfg.Hidden, footer: cfg.Footer, + follow: cfg.FollowSymlinks, tpl: template.Must(template.New("index").Parse(indexHTML)), } } @@ -111,7 +114,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if realPath != s.rootReal && !strings.HasPrefix(realPath, s.rootReal+string(os.PathSeparator)) { + if !s.follow && realPath != s.rootReal && !strings.HasPrefix(realPath, s.rootReal+string(os.PathSeparator)) { http.NotFound(w, r) // symlink escaping the served root return } @@ -147,11 +150,48 @@ func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, name st return } defer f.Close() + disp := "attachment" + if inlineFile(f, name) { + disp = "inline" + } w.Header().Set("Content-Disposition", - mime.FormatMediaType("attachment", map[string]string{"filename": name})) + 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 { @@ -176,20 +216,31 @@ func (s *server) serveListing(w http.ResponseWriter, r *http.Request, dirPath, u 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) } - var size int64 - var mod time.Time - if info, err := e.Info(); err == nil { - mod = info.ModTime() - if !isDir { - size = info.Size() - } - } rows = append(rows, row{ Name: name, URL: dirURL + url.PathEscape(name),