serve.go
package main
import (
"fmt"
"io/fs"
"net/http"
"os"
"path"
"sort"
"strings"
)
// server holds one opened os.Root per configured tree.
// os.Root guarantees all path resolution stays inside the tree
// (it rejects ".." escapes and symlinks pointing outside).
type server struct {
trees map[string]*os.Root
}
func newServer(roots map[string]string) (*server, error) {
s := &server{trees: make(map[string]*os.Root)}
for name, dir := range roots {
root, err := os.OpenRoot(dir)
if err != nil {
return nil, fmt.Errorf("opening root %q: %w", name, err)
}
s.trees[name] = root
}
return s, nil
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
s.serveIndex(w, r)
return
}
name, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
root, ok := s.trees[name]
if !ok {
http.NotFound(w, r)
return
}
rel := path.Clean("/" + rest)[1:] // normalize; "" becomes "."
if rel == "" {
rel = "."
}
info, err := root.Stat(rel)
if err != nil {
http.NotFound(w, r)
return
}
switch {
case info.IsDir():
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
s.serveDir(w, r, root, name, rel)
case r.URL.Query().Get("fmt") == "raw":
serveRaw(w, r, root, rel)
default:
serveRendered(w, r, root, name, rel, info.Size())
}
}
// serveRaw sends the file bytes.
// Markdown gets text/plain so browsers display it instead of downloading;
// everything else gets stdlib content-type detection via ServeContent.
func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string) {
f, err := root.Open(rel)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if strings.HasSuffix(rel, ".md") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
// serveIndex lists the configured roots.
func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
names := make([]string, 0, len(s.trees))
for name := range s.trees {
names = append(names, name)
}
sort.Strings(names)
entries := make([]dirEntry, len(names))
for i, name := range names {
entries[i] = dirEntry{Name: name + "/", Href: "/" + name + "/"}
}
renderPage(w, pageData{Title: "repoview", Crumbs: crumbs("", ""), Listing: entries})
}
// serveDir renders a directory listing.
// The .git directory is omitted: it's not useful to browse and clutters the view.
func (s *server) serveDir(w http.ResponseWriter, r *http.Request, root *os.Root, name, rel string) {
ents, err := fs.ReadDir(root.FS(), rel)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var listing []dirEntry
if rel != "." {
listing = append(listing, dirEntry{Name: "../", Href: "../"})
}
for _, e := range ents {
if e.Name() == ".git" {
continue
}
n := e.Name()
if e.IsDir() {
n += "/"
}
listing = append(listing, dirEntry{Name: n, Href: n})
}
renderPage(w, pageData{
Title: name + "/" + strings.TrimSuffix(rel, "."),
Crumbs: crumbs(name, rel),
Listing: listing,
})
}
serve.go · 3.2 KiB · 0d0727cbf9