package web import ( "net/http" "net/url" "path" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // Handler returns a router with everything this package needs already // installed: panic recovery and the authn principal middleware, then the // routes. The daemon mounts it at "/". // // A caller that owns its own middleware stack — and has already applied // authn.Resolver.Middleware to it — uses Register instead. Installing the // principal middleware twice is harmless but pointless: it is idempotent. func (s *Server) Handler() http.Handler { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(s.resolver.Middleware()) s.Register(r) return r } // Register mounts every spec.sr.ht read-plane route onto r. It installs no // middleware of its own; the router it is handed must already resolve a // principal into the request context (authn.Resolver.Middleware), or every // viewer looks anonymous. // // The document route is a single wildcard because the format selector lives in // the *extension* and the document's address does not have one: ".md" and // ".json" are stripped from the tail by the handler, never routed on, so a // document called "notes/2026.json.md" is still reachable and a request for // "notes/2026.json" still means "the JSON of notes/2026". func (s *Server) Register(r chi.Router) { r.Get("/", s.handleIndex) r.Get("/healthz", s.handleHealthz) r.Get("/static/*", s.handleStatic) r.Get("/search", s.handleSearch) // The proposal routes are registered before the document wildcard. chi gives // the static "p" segment priority over the "*" catch-all regardless, but // keeping them adjacent makes the "/p/ is the proposal namespace" decision // visible in one place. r.Get("/~{owner}/{space}/p/{id}", s.handleProposal) r.Post("/~{owner}/{space}/p/{id}/approve", s.handleProposalApprove) r.Post("/~{owner}/{space}/p/{id}/reject", s.handleProposalReject) r.Get("/~{owner}/{space}", s.handleSpace) r.Get("/~{owner}/{space}/*", s.handleDocument) } // handleHealthz is a dependency-free liveness probe. func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok\n")) } // handleStatic serves the embedded assets, tagging the content-addressed // stylesheet as immutable: its name changes whenever its bytes do, so a browser // may keep it forever and a deploy still busts the cache. func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { name := path.Base(r.URL.Path) if hashedCSSRe.MatchString(name) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } s.staticFileServer.ServeHTTP(w, r) } // unescapePath decodes a chi wildcard back into a tree path. // // chi routes on r.URL.RawPath when the request had one, so the wildcard arrives // percent-encoded — which it must, since doc.Archive escapes every href segment // so spaces and Cyrillic survive. Decoding is per segment on purpose: a %2F // inside a segment is not a path separator and must not become one. func unescapePath(raw string) (string, bool) { if raw == "" { return "", true } segs := strings.Split(raw, "/") for i, seg := range segs { dec, err := url.PathUnescape(seg) if err != nil { return "", false } segs[i] = dec } return strings.Join(segs, "/"), true }