From 605dc5352c6fd80d1989edcdf98a8c80f4662d75 Mon Sep 17 00:00:00 2001
From: Nixietab <75538775+nixietab@users.noreply.github.com>
Date: Thu, 15 Oct 2026 21:41:16 -0300
Subject: [PATCH] Implemented symlinks and mimetype for serve
---
README.md | 3 ++
assets/index.html | 2 +-
config.go | 17 ++++++++----
config.toml | 4 +++
server.go | 71 ++++++++++++++++++++++++++++++++++++++++-------
5 files changed, 81 insertions(+), 16 deletions(-)
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),