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)
r.Get("/inbox", s.handleInbox)
// 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
}