first commit
This commit is contained in:
commit
581d3e6da5
11 changed files with 987 additions and 0 deletions
302
server.go
Normal file
302
server.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"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
|
||||
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,
|
||||
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
|
||||
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 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()
|
||||
w.Header().Set("Content-Disposition",
|
||||
mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||
http.ServeContent(w, r, name, info.ModTime(), f)
|
||||
}
|
||||
|
||||
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()
|
||||
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),
|
||||
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
|
||||
}{{"Name", "name"}, {"Type", "type"}, {"Size", "size"}, {"Modified", "modified"}}
|
||||
cols := make([]headerCol, len(headers))
|
||||
for i, h := range headers {
|
||||
cols[i] = headerCol{
|
||||
Label: h.Label,
|
||||
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])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue