serve.go
package main
import (
"io/fs"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path"
"sort"
"strings"
"sync/atomic"
"vitrine/display"
)
// server holds the currently mounted exhibits and forwards behind an atomic pointer,
// so a config reload can swap the whole set at once while requests read it lock-free.
// Each request loads the pointer once and works with that immutable snapshot for its whole duration.
// (A RWMutex would also work, but there is nothing to lock *around*:
// the atomic pointer makes the publish-immutable-snapshots discipline explicit.)
type server struct {
mounts atomic.Pointer[mountSet]
theme display.Theme // fixed for the process lifetime; see display/theme.go
}
// mountSet holds everything served under mount names (URL prefixes):
// opened exhibit trees and reverse-proxy forwards.
// A mountSet is never mutated after being published to server.mounts.
type mountSet struct {
trees map[string]tree
// forwards is keyed like trees; the empty name is the root forward,
// which catches requests that no mount or synthetic listing claims.
forwards map[string]http.Handler
}
// 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
raw bool
}
func newServer(cfg *config, th display.Theme) *server {
s := &server{theme: th}
s.setMounts(cfg)
return s
}
// setMounts builds a fresh mount set from the given config and publishes it atomically.
// An exhibit whose directory cannot be opened (typically because it does not exist)
// is logged and skipped, not fatal: a config may legitimately name a tree
// that is absent on this particular host, and the rest should still serve.
// Malformed config is rejected earlier, in loadConfig; by here the config is well-formed
// and the only question is which of its directories are actually present.
// Forwards have no analogous liveness question at this point:
// their backends are contacted per-request, and an unreachable one answers 502 then.
//
// The previous mount set's os.Root handles are deliberately not closed here.
// An in-flight request may have loaded the old set just before the swap
// and still be reading through one of its roots; closing would break it mid-response.
// os.Root carries a runtime finalizer that closes its fd once unreferenced,
// so old handles are reclaimed exactly when the last such request drops them.
// That is the simplest scheme that is actually correct:
// refcounting would buy only promptness,
// and a few directory fds lingering until the next GC cycle cost nothing.
func (s *server) setMounts(cfg *config) {
mounts := &mountSet{
trees: make(map[string]tree, len(cfg.Exhibits)),
forwards: make(map[string]http.Handler, len(cfg.Forwards)),
}
for name, ex := range cfg.Exhibits {
root, err := os.OpenRoot(ex.Path)
if err != nil {
log.Printf("exhibit %q skipped: %v", name, err)
continue
}
mounts.trees[name] = tree{root: root, exposeGit: ex.ExposeGit, raw: ex.Raw}
}
// Run update-server-info only for exhibits that actually opened above.
// Re-running on every successful reload is deliberate and idempotent.
// (Watching the git repos themselves for new commits is possible future work;
// today a commit becomes clonable at the next config reload or restart.)
for name, tr := range mounts.trees {
if tr.exposeGit {
updateGitServerInfo(name, cfg.Exhibits[name].Path)
}
}
for name, f := range cfg.Forwards {
mounts.forwards[name] = newForwardProxy(name, f.url)
}
s.mounts.Store(mounts)
}
// newForwardProxy builds the reverse proxy for one forward.
// The mount prefix is stripped and the target's path (if any) prepended,
// so a request for /name/a/b against target http://h/base proxies to http://h/base/a/b.
// Websocket upgrades and streaming responses (SSE et al.) are handled by
// httputil.ReverseProxy itself: upgrades become a bidirectional byte copy,
// and unknown-length responses are flushed immediately as bytes arrive.
func newForwardProxy(name string, target *url.URL) http.Handler {
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target) // joins target.Path with the (pre-stripped) request path
pr.SetXForwarded()
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("forward %q: %v", name, err)
http.Error(w, "bad gateway: the service behind this forward did not answer", http.StatusBadGateway)
},
}
// The root forward proxies paths as-is; named forwards get their mount
// prefix stripped so backends see paths relative to their own root.
if name == "" {
return proxy
}
return http.StripPrefix("/"+name, proxy)
}
// 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")
// The theme's :root block follows the embedded stylesheet:
// at equal specificity the later declaration wins,
// so appending is what lets the theme's --accent override the stylesheet's default.
w.Write(display.StyleCSS)
w.Write(s.theme.CSS)
return
}
req := path.Clean("/" + r.URL.Path)[1:] // normalized, no leading slash; "" at root
// One snapshot of the mount set serves the whole request,
// so a concurrent config reload cannot pull a tree out from under us.
mounts := s.mounts.Load()
// A request path inside a mount is served by that mount, exhibit or forward.
// Longest prefix wins, so a mount nested under another mount's name
// (e.g. "docs/deep" under "docs") is dispatched deterministically
// to the more specific one, never by map iteration luck.
// The root forward is not matched here: it is the fallback for URL space
// nothing else claims, applied below the synthetic listings.
bestLen := -1
var bestTree tree
var bestFwd http.Handler
var bestMount string
for mount, tr := range mounts.trees {
if req == mount || strings.HasPrefix(req, mount+"/") {
if len(mount) > bestLen {
bestLen = len(mount)
bestMount, bestTree, bestFwd = mount, tr, nil
}
}
}
for mount, h := range mounts.forwards {
if mount != "" && (req == mount || strings.HasPrefix(req, mount+"/")) {
if len(mount) > bestLen {
bestLen = len(mount)
bestMount, bestFwd = mount, h
}
}
}
switch {
case bestFwd != nil:
// /name redirects to /name/, same as directories do, so that relative
// URLs in pages served by the backend resolve under the mount prefix.
if req == bestMount && !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
bestFwd.ServeHTTP(w, r)
return
case bestLen >= 0:
rel := strings.TrimPrefix(strings.TrimPrefix(req, bestMount), "/")
if rel == "" {
rel = "."
}
s.serveFromTree(w, r, bestTree, bestMount, rel)
return
}
// A request path strictly *above* a mount gets a synthetic listing
// showing only the exposed next-level names.
// The root listing is the one exception: it yields to a root forward
// (putting an app at "/" is the point of a root forward, and exhibits
// remain reachable at their own URLs), so it renders only without one.
rootFwd := mounts.forwards[""]
if kids := mountChildren(mounts, req); kids != nil && (req != "" || rootFwd == nil) {
if req != "" && !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
title := "vitrine"
if req != "" {
title = req + "/"
}
s.renderPage(w, display.PageData{Title: title, Crumbs: display.Crumbs("vitrine", req), Listing: kids})
return
}
// Everything unclaimed goes to the root forward, if one is configured.
if rootFwd != nil {
rootFwd.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
// mountChildren returns the listing for a synthetic (above-mount) directory:
// the deduplicated next path segments of all mounts (exhibits and named
// forwards alike) under the given prefix.
// Returns nil if no mount lives under this prefix.
// The root forward (empty name) never appears: it has no name to list.
func mountChildren(mounts *mountSet, prefix string) []display.DirEntry {
seen := make(map[string]bool)
note := func(mount string) {
rest := mount
if prefix != "" {
if !strings.HasPrefix(mount, prefix+"/") {
return
}
rest = strings.TrimPrefix(mount, prefix+"/")
}
seg, _, _ := strings.Cut(rest, "/")
seen[seg] = true
}
for mount := range mounts.trees {
note(mount)
}
for mount := range mounts.forwards {
if mount != "" {
note(mount)
}
}
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([]display.DirEntry, 0, len(names)+1)
if prefix != "" {
entries = append(entries, display.DirEntry{Name: "../", Href: "../"})
}
for _, seg := range names {
entries = append(entries, display.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)
case tr.raw:
// This exhibit opts out of the rendered frame entirely: every file
// serves as its raw bytes, as if ?fmt=raw were always present.
serveRaw(w, r, root, rel)
case hasGitSegment(rel):
// Files inside an exposed .git serve raw always: git's dumb-HTTP
// client fetches info/refs, HEAD, objects, etc. as plain GETs with
// no query string, and chokes on the HTML-wrapped rendered view.
// (Reaching here at all means the exhibit opted in via expose-git;
// the non-exposed case was rejected at the top of serveFromTree.)
// Rendering .git contents as pages was never meaningful anyway.
serveRaw(w, r, root, rel)
case serveRawByExtension(rel):
// Images and other binary formats that browsers render natively
// gain nothing from the HTML iframe wrapper; serve them directly.
serveRaw(w, r, root, rel)
default:
s.serveRendered(w, r, root, mount, rel, info.Size())
}
}
// serveRawByExtension reports whether a file should bypass the rendered
// HTML frame and serve its raw bytes by default. Images, fonts, and other
// binary formats that browsers render natively gain nothing from the
// iframe wrapper; serving them directly lets them display (or embed)
// without an extra layer of indirection.
func serveRawByExtension(rel string) bool {
switch strings.ToLower(path.Ext(rel)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico",
".woff", ".woff2", ".ttf", ".otf",
".pdf":
return true
}
return false
}
// 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 []display.DirEntry
listing = append(listing, display.DirEntry{Name: "../", Href: "../"})
for _, e := range ents {
if e.Name() == ".git" && !tr.exposeGit {
continue
}
n := e.Name()
if e.IsDir() {
n += "/"
}
listing = append(listing, display.DirEntry{Name: n, Href: n})
}
s.renderPage(w, display.PageData{
Title: mount + "/" + strings.TrimSuffix(rel, "."),
Crumbs: display.Crumbs("vitrine", display.JoinURL(mount, rel)),
Listing: listing,
})
}
serve.go · 14.1 KiB · 1f54fea43d