Implemented symlinks and mimetype for serve

This commit is contained in:
Nixietab 2026-10-15 21:41:16 -03:00
parent 523e519a3b
commit 605dc5352c
5 changed files with 81 additions and 16 deletions

View file

@ -5,6 +5,9 @@ japanese for *stairs*. a minimal, fast file index server.
single static binary. TOML config. sortable listings (name, type, size, single static binary. TOML config. sortable listings (name, type, size,
modified). 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 ## build
```sh ```sh

View file

@ -166,7 +166,7 @@ footer a:hover {
{{if .HasParent}}<tr><td><a href="../">../</a></td><td class="num ty">-</td><td class="num sz"></td><td class="num mod"></td></tr>{{end}} {{if .HasParent}}<tr><td><a href="../">../</a></td><td class="num ty">-</td><td class="num sz"></td><td class="num mod"></td></tr>{{end}}
{{range .Rows}} {{range .Rows}}
<tr{{if .IsDir}} class="dir"{{end}}> <tr{{if .IsDir}} class="dir"{{end}}>
<td><a href="{{.URL}}"{{if not .IsDir}} download{{end}}>{{.Name}}</a></td> <td><a href="{{.URL}}">{{.Name}}</a></td>
<td class="num ty">{{.Type}}</td> <td class="num ty">{{.Type}}</td>
<td class="num sz">{{if .IsDir}}-{{else}}{{.SizeS}}{{end}}</td> <td class="num sz">{{if .IsDir}}-{{else}}{{.SizeS}}{{end}}</td>
<td class="num mod">{{.ModS}}</td> <td class="num mod">{{.ModS}}</td>

View file

@ -14,6 +14,7 @@ type Config struct {
Root string `toml:"root"` Root string `toml:"root"`
Hidden bool `toml:"hidden"` Hidden bool `toml:"hidden"`
Footer string `toml:"footer"` Footer string `toml:"footer"`
FollowSymlinks bool `toml:"follow_symlinks"`
} }
func loadConfig(path string) (*Config, error) { func loadConfig(path string) (*Config, error) {
@ -22,9 +23,15 @@ func loadConfig(path string) (*Config, error) {
return nil, err return nil, err
} }
cfg := &Config{} 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) 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 == "" { if cfg.Root == "" {
return nil, errors.New(`config: "root" is required`) return nil, errors.New(`config: "root" is required`)
} }

View file

@ -11,3 +11,7 @@ hidden = true
# footer shown at the bottom of every page # footer shown at the bottom of every page
footer = "kaidan 1.0" 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

View file

@ -5,6 +5,7 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"html/template" "html/template"
"io"
"mime" "mime"
"net/http" "net/http"
"net/url" "net/url"
@ -30,6 +31,7 @@ type server struct {
rootReal string // symlink-resolved root, used for containment checks rootReal string // symlink-resolved root, used for containment checks
hidden bool hidden bool
footer string footer string
follow bool // serve symlinks even when they point outside root
tpl *template.Template tpl *template.Template
} }
@ -43,6 +45,7 @@ func newServer(cfg *Config) *server {
rootReal: rootReal, rootReal: rootReal,
hidden: cfg.Hidden, hidden: cfg.Hidden,
footer: cfg.Footer, footer: cfg.Footer,
follow: cfg.FollowSymlinks,
tpl: template.Must(template.New("index").Parse(indexHTML)), 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) http.NotFound(w, r)
return 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 http.NotFound(w, r) // symlink escaping the served root
return return
} }
@ -147,11 +150,48 @@ func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, name st
return return
} }
defer f.Close() defer f.Close()
disp := "attachment"
if inlineFile(f, name) {
disp = "inline"
}
w.Header().Set("Content-Disposition", 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) 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) { func (s *server) serveListing(w http.ResponseWriter, r *http.Request, dirPath, urlPath string) {
entries, err := os.ReadDir(dirPath) entries, err := os.ReadDir(dirPath)
if err != nil { if err != nil {
@ -176,20 +216,31 @@ func (s *server) serveListing(w http.ResponseWriter, r *http.Request, dirPath, u
continue continue
} }
isDir := e.IsDir() 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 := "-" typ := "-"
if isDir { if isDir {
typ = "dir" typ = "dir"
} else if ext := strings.TrimPrefix(filepath.Ext(name), "."); ext != "" { } else if ext := strings.TrimPrefix(filepath.Ext(name), "."); ext != "" {
typ = strings.ToLower(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{ rows = append(rows, row{
Name: name, Name: name,
URL: dirURL + url.PathEscape(name), URL: dirURL + url.PathEscape(name),