261101.1 · 0ad750f7e905ca3494043be38b127f94a06eb874
server.go splits by purpose
server.go held the server's identity, request guarding, the index, tree pages and page rendering. Each now has a file whose name says what is in it: about.go, guard.go, index.go, treepage.go and page.go. bugOff joins bug.go. server.go keeps the server and tree types, mounting, the embedded assets, the handler and the stop button.
Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms. Follow them with git log --follow or blame -C.
reading declarations
Changes to guard.go. All files
@@ -0,0 +1,176 @@+package main++import (+ "fmt"+ "net"+ "net/http"+ "net/netip"+ "net/url"+ "os"+ "slices"+ "strings"+ "time"+)++// logged records each request but those that come with every page: live+// reload streams, the stylesheet, fonts and the favicon. A shared server+// adds the visitor's address. A secret tree's token stays out of the log.+func (s *server) logged(next http.Handler) http.Handler {+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ p := r.URL.Path+ if p == "/favicon.ico" || strings.HasPrefix(p, ownPath) && p != ownPath+"stop" {+ next.ServeHTTP(w, r)+ return+ }+ uri := r.URL.RequestURI()+ // The token is cut from the decoded path, which the lookup matched;+ // the raw one may spell it in escapes, eg /%733cret/.+ if t := s.lookup(p); t != nil && t.secret {+ uri = "/<token>" + hideToken(r.URL, strings.TrimPrefix(p, t.prefix))+ } else if t := s.byPrefix(p + "/"); t != nil && t.secret {+ // The secret path without its slash, which redirects.+ uri = "/<token>" + hideToken(r.URL, "")+ }+ start := time.Now()+ sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}+ next.ServeHTTP(sw, r)+ msg := fmt.Sprintf("%s %s %d %dms", r.Method, uri, sw.status, time.Since(start).Milliseconds())+ if s.shared {+ msg += " from " + r.RemoteAddr+ }+ if sw.status >= 500 {+ logErr("%s", msg)+ } else {+ logInfo("%s", msg)+ }+ })+}++// hideToken is the request URI of u for the log, with rest, the decoded+// path below a secret tree's prefix, in place of its path.+func hideToken(u *url.URL, rest string) string {+ uri := (&url.URL{Path: "/" + rest}).EscapedPath()+ if rest == "" && !strings.HasSuffix(u.Path, "/") {+ uri = ""+ }+ if u.RawQuery != "" {+ uri += "?" + u.RawQuery+ }+ return uri+}++// statusWriter keeps the status a handler sent, for the log.+type statusWriter struct {+ http.ResponseWriter+ status int+ wrote bool+}++func (w *statusWriter) WriteHeader(code int) {+ if !w.wrote {+ w.status, w.wrote = code, true+ }+ w.ResponseWriter.WriteHeader(code)+}++func (w *statusWriter) Write(b []byte) (int, error) {+ w.wrote = true+ return w.ResponseWriter.Write(b)+}++func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }++// guard rejects requests for any host but this machine, which defeats DNS+// rebinding, and sets the headers every response needs. A shared server+// takes any host, since visitors use whatever name reaches it; a secret+// path is its guard against rebinding. So does a public server, which+// holds nothing a stranger may not read.+func guard(next http.Handler, anyHost bool) http.Handler {+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ host, _, err := net.SplitHostPort(r.Host)+ if err != nil {+ host = r.Host+ }+ if !anyHost && !localHost(host) {+ http.Error(w, progName+" answers only to this machine", http.StatusMisdirectedRequest)+ return+ }+ h := w.Header()+ h.Set("Content-Security-Policy", csp)+ h.Set("X-Content-Type-Options", "nosniff")+ // same-origin, not no-referrer: under no-referrer browsers send+ // Origin: null even on this server's own form posts.+ h.Set("Referrer-Policy", "same-origin")+ // Pages render from files that change while being read, so the+ // browser must ask again before reusing anything.+ h.Set("Cache-Control", "no-cache")+ next.ServeHTTP(w, r)+ })+}++// hostsFile maps names to addresses; tests point it elsewhere.+var hostsFile = "/etc/hosts"++// localHost accepts a loopback address, localhost, or a name that the hosts+// file maps to loopback, eg an www instance named there. DNS is never+// asked: a DNS name that resolves to loopback is the rebinding attack itself.+func localHost(host string) bool {+ if strings.EqualFold(host, "localhost") {+ return true+ }+ if ip := net.ParseIP(host); ip != nil {+ return ip.IsLoopback()+ }+ for _, e := range hostsEntries() {+ if e.ip.IsLoopback() && e.has(host) {+ return true+ }+ }+ return false+}++// hostsEntry is one line of the hosts file: an address and its names.+type hostsEntry struct {+ ip netip.Addr+ names []string+}++func (e hostsEntry) has(host string) bool {+ return slices.ContainsFunc(e.names, func(name string) bool { return strings.EqualFold(name, host) })+}++func hostsEntries() []hostsEntry {+ b, err := os.ReadFile(hostsFile)+ if err != nil {+ return nil+ }+ var out []hostsEntry+ for _, line := range strings.Split(string(b), "\n") {+ line, _, _ = strings.Cut(line, "#")+ f := strings.Fields(line)+ if len(f) < 2 {+ continue+ }+ ip, err := netip.ParseAddr(f[0])+ if err != nil {+ continue+ }+ out = append(out, hostsEntry{ip.Unmap(), f[1:]})+ }+ return out+}++// sameOrigin trusts Sec-Fetch-Site when present: browsers set it and pages+// cannot forge it. Origin is the fallback for clients that lack it; a+// client sending neither, such as curl, is not a browser a site can drive.+func sameOrigin(r *http.Request) bool {+ if site := r.Header.Get("Sec-Fetch-Site"); site != "" {+ return site == "same-origin"+ }+ o := r.Header.Get("Origin")+ if o == "" {+ return true+ }+ u, err := url.Parse(o)+ return err == nil && u.Host == r.Host+}