serve.go
package main
import (
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path"
"sort"
"strings"
)
// server holds one opened tree per configured exhibit.
type server struct {
trees map[string]tree
}
// tree is one exhibit as served: an opened os.Root plus its options.
// os.Root guarantees all path resolution stays inside the tree
// (it rejects ".." escapes and symlinks pointing outside).
type tree struct {
root *os.Root
exposeGit bool
}
func newServer(exhibits map[string]exhibit) (*server, error) {
s := &server{trees: make(map[string]tree)}
for name, ex := range exhibits {
root, err := os.OpenRoot(ex.Path)
if err != nil {
return nil, fmt.Errorf("opening exhibit %q: %w", name, err)
}
s.trees[name] = tree{root: root, exposeGit: ex.ExposeGit}
if ex.ExposeGit {
updateGitServerInfo(name, ex.Path)
}
}
return s, nil
}
// updateGitServerInfo runs `git update-server-info` in the exhibit directory,
// regenerating .git/info/refs and .git/objects/info/packs so that dumb-HTTP
// clone sees current refs (ordinary commits never maintain those files;
// without them a clone either fails or silently delivers a stale tip).
//
// This writes into the exhibit's .git directory: a deliberate, opt-in exception
// to vitrine's otherwise strictly read-only stance toward the trees it serves.
// Failure is logged, not fatal: the exhibit may not be a git repo,
// and plain file serving should keep working regardless.
// Executing git from PATH is a conscious choice; vitrine is glue,
// and reimplementing the two info files buys nothing but maintenance.
func updateGitServerInfo(name, dir string) {
cmd := exec.Command("git", "update-server-info")
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("exhibit %q: git update-server-info failed (dumb-HTTP clone may be stale or broken): %v: %s", name, err, strings.TrimSpace(string(out)))
}
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The "/-/" prefix is reserved for server-internal assets,
// keeping it distinct from any configured mount name.
if r.URL.Path == "/-/style.css" {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSS)
return
}
req := path.Clean("/" + r.URL.Path)[1:] // normalized, no leading slash; "" at root
// A request path inside a mount is served from that tree.
for mount, tr := range s.trees {
if req == mount || strings.HasPrefix(req, mount+"/") {
rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
if rel == "" {
rel = "."
}
s.serveFromTree(w, r, tr, mount, rel)
return
}
}
// A request path *above* a mount gets a synthetic listing
// showing only the exposed next-level names.
if kids := s.mountChildren(req); kids != nil {
if req != "" && !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
title := "vitrine"
if req != "" {
title = req + "/"
}
renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
return
}
http.NotFound(w, r)
}
// mountChildren returns the listing for a synthetic (above-mount) directory:
// the deduplicated next path segments of all mounts under the given prefix.
// Returns nil if no mount lives under this prefix.
func (s *server) mountChildren(prefix string) []dirEntry {
seen := make(map[string]bool)
for mount := range s.trees {
rest := mount
if prefix != "" {
if !strings.HasPrefix(mount, prefix+"/") {
continue
}
rest = strings.TrimPrefix(mount, prefix+"/")
}
seg, _, _ := strings.Cut(rest, "/")
seen[seg] = true
}
if len(seen) == 0 {
return nil
}
names := make([]string, 0, len(seen))
for seg := range seen {
names = append(names, seg)
}
sort.Strings(names)
entries := make([]dirEntry, 0, len(names)+1)
if prefix != "" {
entries = append(entries, dirEntry{Name: "../", Href: "../"})
}
for _, seg := range names {
entries = append(entries, dirEntry{Name: seg + "/", Href: seg + "/"})
}
return entries
}
// hasGitSegment reports whether any segment of a slash-separated relative path is ".git".
// Listings hide .git at every depth (nested repos included), so URL reachability matches.
func hasGitSegment(rel string) bool {
for _, seg := range strings.Split(rel, "/") {
if seg == ".git" {
return true
}
}
return false
}
// serveFromTree dispatches a request that resolved inside a mounted tree.
// Unless the exhibit opts in via expose-git, any path with a .git segment is rejected here,
// which covers direct URLs as well as listings (listings are filtered in serveDir).
func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
if !tr.exposeGit && hasGitSegment(rel) {
http.NotFound(w, r)
return
}
root := tr.root
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, tr, mount, rel)
case r.URL.Query().Get("fmt") == "raw":
serveRaw(w, r, root, rel)
default:
serveRendered(w, r, root, mount, 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)
}
// serveDir renders a directory listing.
// The .git directory is omitted unless the exhibit opts in via expose-git:
// it's not useful to browse and clutters the view.
func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
ents, err := fs.ReadDir(tr.root.FS(), rel)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var listing []dirEntry
listing = append(listing, dirEntry{Name: "../", Href: "../"})
for _, e := range ents {
if e.Name() == ".git" && !tr.exposeGit {
continue
}
n := e.Name()
if e.IsDir() {
n += "/"
}
listing = append(listing, dirEntry{Name: n, Href: n})
}
renderPage(w, pageData{
Title: mount + "/" + strings.TrimSuffix(rel, "."),
Crumbs: crumbs(joinURL(mount, rel)),
Listing: listing,
})
}
serve.go · 6.5 KiB · 8af31ec12b