current commit
261101.1 · server.go splits by purpose
package main

import (
	"context"
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"fmt"
	"html/template"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"time"
)

//go:embed assets/fonts.css
var fontsCSS []byte

//go:embed assets/style.css
var mainCSS []byte

// styleCSS is the stylesheet pages link: the font faces, which make fonts
// writes, then the program's own rules.
var styleCSS = append(append([]byte{}, fontsCSS...), mainCSS...)

// styleURL names the stylesheet by a hash of its content, so the browser
// keeps it for a year and a binary with a changed stylesheet links a new
// URL.
var styleURL = func() string {
	sum := sha256.Sum256(styleCSS)
	return ownPath + "style." + hex.EncodeToString(sum[:6]) + ".css"
}()

//go:embed assets/layout.html
var layoutHTML string

// fontFS holds subsets of STIX Two Text and Iosevka, which a page takes
// when the machine lacks the font. A changed font needs a new file name,
// since browsers keep these for a year.
//
//go:embed assets/fonts/*.woff2
var fontFS embed.FS

var layout = template.Must(template.New("layout").Funcs(template.FuncMap{
	"liveJS":   func() template.JS { return template.JS(liveJS) },
	"styleURL": func() string { return styleURL },
	"ownPath":  func() string { return ownPath },
	"progName": func() string { return progName },
}).Parse(layoutHTML))

// csp forbids every script but live reload's, which it names by hash.
// Pandoc passes raw HTML through, and a README in a cloned repo must not
// run code on this origin.
var csp = "default-src 'none'; script-src " + scriptSrc(liveJS) + "; connect-src 'self'; " +
	"img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; " +
	"form-action 'self'; base-uri 'none'; frame-ancestors 'none'"

// server answers for every tree served on one endpoint. A request goes to
// the tree with the longest prefix of its path.
type server struct {
	mu     sync.RWMutex
	trees  []*tree
	stop   func([]*tree, string) // withdraws trees for a cause; set by the hub
	about  about
	shared bool   // on an address beyond loopback: it shows the trees and nothing of the machine
	public bool   // the web address of --public, which strangers reach by any name
	live   *live  // nil in tests that do without live reload
	web    *web   // set on the web address, whose trees are repos in the cache
	webURL string // the web address's URL, for the index, or ""
	// homeURL is, on the web address, the default address's URL, which
	// its index links back to.
	homeURL string
}

func newServer() *server {
	return &server{stop: func([]*tree, string) {}}
}

// tree is one served directory, exposed with everything below it.
type tree struct {
	dir      string
	realDir  string // dir with its links resolved
	prefix   string // URL path of the tree, eg /scratch/notes/
	label    string // the tree as the first breadcrumb, eg ~/scratch/notes
	fsys     fs.FS
	root     *os.Root
	lock     *lock       // nil only in tests
	link     string      // registry link
	linkInfo os.FileInfo // the link this server knows, to tell it from a new join
	wd       int         // inotify watch on dir, or -1
	shared   bool        // served beyond loopback: only what other users could read
	secret   bool        // mounted under a token, and left off the index
	web      *webRepo    // the cached repo this tree is a worktree of, on the web address
	public   bool        // a stranger's repo on a public server: no link out, no path of the machine
	srv      *server     // the server that serves it, set when it joins
	linked   linkIndex   // the relative links of each doc, for ?backlinks
	at       *commit     // for a version, the commit it shows
	atLabel  string      // for a version, its commit's stardate, day and x, or hash
	base     *tree       // for a version, the tree it is a version of
	versions sync.Map    // the versions served so far, by full hash
}

func newTree(root *os.Root, dir, prefix, label string) *tree {
	real, err := filepath.EvalSymlinks(dir)
	if err != nil {
		real = dir
	}
	t := &tree{dir: dir, realDir: real, prefix: prefix, label: label, root: root, wd: -1}
	t.fsys = treeFS{t}
	return t
}

func (s *server) add(t *tree) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t.srv = s
	s.trees = append(s.trees, t)
	s.live.kick()
}

// remove reports whether t was served.
func (s *server) remove(t *tree) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	n := len(s.trees)
	s.trees = slices.DeleteFunc(s.trees, func(x *tree) bool { return x == t })
	s.live.kick()
	return len(s.trees) < n
}

// list returns the served trees sorted by prefix.
func (s *server) list() []*tree {
	s.mu.RLock()
	out := slices.Clone(s.trees)
	s.mu.RUnlock()
	slices.SortFunc(out, func(a, b *tree) int { return strings.Compare(a.prefix, b.prefix) })
	return out
}

func (s *server) byPrefix(prefix string) *tree {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, t := range s.trees {
		if t.prefix == prefix {
			return t
		}
	}
	return nil
}

// lookup returns the tree that serves path, or a version of, eg
// /x/www@261024.4/. A tree inside another wins, so it keeps its own label
// and lock.
func (s *server) lookup(path string) *tree {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var best *tree
	for _, t := range s.trees {
		_, _, version := t.versionIn(path)
		if (version || strings.HasPrefix(path, t.prefix)) && (best == nil || len(t.prefix) > len(best.prefix)) {
			best = t
		}
	}
	return best
}

// mount returns the URL prefix and label for dir. Inside home the prefix is
// the path relative to home, so the URL names the tree; outside home it is
// the absolute path.
func mount(dir, home string) (prefix, label string) {
	rel, err := filepath.Rel(home, dir)
	switch {
	case home == "" || err != nil || rel == ".." || strings.HasPrefix(rel, "../"):
		return strings.TrimSuffix(dir, "/") + "/", dir
	case rel == ".":
		return "/", "~"
	default:
		return "/" + filepath.ToSlash(rel) + "/", "~/" + filepath.ToSlash(rel)
	}
}

// shareMount returns the prefix and label of a shared tree: its base name,
// or the token under --secret. Neither tells a visitor where the tree lies.
func shareMount(dir, token string) (prefix, label string) {
	label = filepath.Base(dir)
	switch {
	case token != "":
		return "/" + token + "/", label
	case label == "/":
		return "/", label
	default:
		return "/" + label + "/", label
	}
}

func (t *tree) prefixURL() string {
	return (&url.URL{Path: t.prefix}).String()
}

func (s *server) handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET "+styleURL, func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/css; charset=utf-8")
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		w.Write(styleCSS)
	})
	mux.HandleFunc("GET "+ownPath+"fonts/{name}", func(w http.ResponseWriter, r *http.Request) {
		b, err := fontFS.ReadFile("assets/fonts/" + path.Base(r.PathValue("name")))
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "font/woff2")
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		w.Write(b)
	})
	// A shared server has no buttons: no visitor may stop it. The web
	// address has none either; its server stops with the default address.
	if !s.shared && s.web == nil {
		mux.HandleFunc("POST "+ownPath+"stop", s.handleStop)
	}
	// The bug button is on every page but a share's, unless --no-bug.
	if !bugOff && !s.shared {
		mux.HandleFunc("POST "+ownPath+"bug", s.handleBug)
	}
	mux.HandleFunc("GET "+ownPath+"live", s.handleLive)
	mux.HandleFunc("GET /", s.handlePath)
	return s.logged(guard(mux, s.shared || s.public))
}

// handleStop serves the stop buttons: a tree's button withdraws that tree,
// the index's withdraws every tree. Any site can submit a form to
// localhost, so the request must come from a page of this server.
func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
	if !sameOrigin(r) {
		http.Error(w, "stop must come from an "+progName+" page", http.StatusForbidden)
		return
	}
	trees, crumbs, why, kill := s.list(), []crumb{{Label: progName}}, "Kill server button", true
	if prefix := r.PostFormValue("tree"); prefix != "" {
		t := s.byPrefix(prefix)
		if t == nil {
			s.serveIndex(w, r, http.StatusNotFound, errorLine("Not served: "+prefix))
			return
		}
		trees, crumbs, why, kill = []*tree{t}, []crumb{t.rootCrumb()}, "Delete lock button", false
		// While other trees remain the server lives on: a button on the
		// index returns there, and a tree's page links it.
		if len(s.list()) > 1 {
			if r.PostFormValue("from") == "index" {
				s.stop(trees, why+" on the index")
				http.Redirect(w, r, "/", http.StatusSeeOther)
				return
			}
			crumbs = []crumb{indexCrumb, t.rootCrumb()}
		}
	}
	render(w, r, http.StatusGone, page{
		Title:  progName + " stopped",
		Crumbs: crumbs,
		Kind:   "stopped",
		// The server may close its listener before the browser asks for
		// the stylesheet, so the page carries it.
		Style:   template.CSS(styleCSS),
		Body:    template.HTML(coin("410") + `<table class="gone">` + "\n" + stoppedRows(trees, kill, len(s.list())-len(trees)) + "</table>\n"),
		Stopped: true,
	})
	// stop only withdraws; the hub shuts down on its own goroutine, since
	// Shutdown waits for this response to finish.
	s.stop(trees, why)
}

// stoppedRows say what a stop ended, in a table as a 404's: each tree,
// its lock, removed, or kept when Kill server leaves a persistent .LOCK
// for the next server, and then the server: stopped, or how many trees
// it still serves.
func stoppedRows(trees []*tree, kill bool, left int) string {
	var b strings.Builder
	for _, t := range trees {
		b.WriteString(goneRow("stopped", segments(t.label)))
		if t.lock == nil {
			continue
		}
		state := "removed"
		if kill && isPersist(t.lock.path) {
			state = "kept"
		}
		// A lock in its tree's directory goes by its name, since the row
		// above names the directory.
		where := tildePath(t.lock.path)
		if filepath.Dir(t.lock.path) == t.dir {
			where = filepath.Base(t.lock.path)
		}
		b.WriteString(goneRow(state, segments(where)))
	}
	switch left {
	case 0:
		b.WriteString(goneRow("server", "stopped; close this tab"))
	case 1:
		b.WriteString(goneRow("server", `<a href="/">serves 1 more tree</a>`))
	default:
		b.WriteString(goneRow("server", fmt.Sprintf(`<a href="/">serves %d more trees</a>`, left)))
	}
	return b.String()
}

// handlePath maps the URL path one to one onto the file tree, so relative
// links in the docs work unchanged.
func (s *server) handlePath(w http.ResponseWriter, r *http.Request) {
	r = r.WithContext(context.WithValue(r.Context(), startKey{}, time.Now()))
	if _, raw := r.URL.Query()["raw"]; !raw {
		r = s.withLive(r)
	}
	t := s.lookup(r.URL.Path)
	switch {
	case s.web != nil:
		s.web.serve(w, r, t)
	case t != nil:
		t.serve(w, r)
	case s.byPrefix(r.URL.Path+"/") != nil:
		// Temporary: the tree may be withdrawn and another served here.
		http.Redirect(w, r, s.byPrefix(r.URL.Path+"/").prefixURL(), http.StatusFound)
	case r.URL.Path == "/":
		s.serveIndex(w, r, http.StatusOK, "")
	default:
		s.serveStray(w, r)
	}
}