kaidan/server.go
2026-10-15 21:41:16 -03:00

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