package web import ( "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "sourcecraft.dev/bigbes/sr-ht-ecore/assets" "sourcecraft.dev/bigbes/sr-ht-ecore/chimw" "sourcecraft.dev/bigbes/sr-ht-ecore/csrf" "sourcecraft.dev/bigbes/sr-ht-ecore/middleware" ) // Handler returns a router with everything this package needs already // installed: the request line, panic recovery, the private cache policy, the // authn principal middleware and the same-origin guard, then the routes, then // the two refusals chi answers when routing fails. The daemon mounts it at "/". // // The order is the one the shared packages ask for. RequestID and RealIP first, // because chimw.RequestLogger reads both and can only carry an id and the // viewer's address if they have already run. The logger is next and — the one // piece of ordering that is not cosmetic — *outside* RecoverPanics rather than // under it: it has to observe the status that actually went out, and for a // panicking handler that status is the 500 RecoverPanics renders. Installed the // other way round it would see an unwinding stack and nothing written, and would // log the request that produced an error page as if it had produced nothing. // // Nothing is left uncovered by putting it outside: this middleware builds a // record and calls slog, and a panic in slog is not a failure any error page was // going to survive either. // // Until now this surface logged no request lines at all. core-go installs chi's // own Logger, but only under -d and only on the groups it registers itself, // which the router mounted at "/" is not one of — so a 500 here left a stack in // the journal with no line saying which URL produced it. // // RecoverPanics then covers every later middleware as well as the handlers, and // it is sr-ht-ecore's rather than chi's or our own for one behaviour: a panic // that arrives *after* the response has started aborts the connection instead of // appending an error page to a truncated one. PrivateCache sits inside it so // that every answer — including the two refusals below — carries the same // private, no-store a page rendered behind a login cookie needs. // // csrf.Require goes last of the four, and on the router rather than on the // routes, which is the whole point of the change: the guard used to be a // predicate that three handlers remembered to call, so a form added later went // out unprotected by default. Here it covers the routes that are not written // yet, and it runs before routing — a mutation aimed at an address this surface // does not serve is refused rather than 404'd, which is the right way round, // since an unrouted POST answering differently from a routed one would be a way // to enumerate them without ever passing the check. It sits after the resolver // so the refusal page names the viewer the way every other page does. // // A caller that owns its own middleware stack — and has already applied // authn.Resolver.Middleware to it — uses Register instead, and owes its router // this guard: Register installs no middleware of its own. func (s *Server) Handler() http.Handler { r := chi.NewRouter() r.Use(chimiddleware.RequestID) r.Use(chimiddleware.RealIP) r.Use(chimw.RequestLogger(chimw.SlogFormatter{Skip: chimw.SkipPaths("/healthz")})) r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) { s.renderError(w, r, http.StatusInternalServerError, "") })) r.Use(middleware.PrivateCache) r.Use(s.resolver.Middleware()) r.Use(csrf.Require(s.chromeSvc.SelfOrigin(), func(w http.ResponseWriter, r *http.Request) { s.renderError(w, r, http.StatusForbidden, csrf.Message) })) s.Register(r) // The two refusals chi answers when routing fails, pointed at this service's // error page. Without them a mistyped URL — the one refusal a viewer is most // likely to meet — came back as net/http's plain text, with no nav to get out // of and nothing to say which service it came from, while every refusal a // handler produced was a rendered page. It is registered here rather than in // Register because it is not a route: a caller that owns its own router gets // its own answer for an address it does not serve. chimw.RenderRefusals(r, s.renderError) 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) and must // already carry csrf.Require, or every viewer looks anonymous and every form is // forgeable. Handler does both. // // 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) { // Every read route is registered for GET and HEAD both, through // chimw.GetHead. chi registers GET alone and net/http synthesizes nothing, so // each of these answered `curl -I` — and every uptime probe, and every cache // revalidating what it holds — with a 405 naming GET as the only method it // takes, plus a page of rendered chrome from a path whose whole job is to be // cheap. The pair shares one handler, so a HEAD produces exactly the status // its GET would: there is no second path that could answer 200 where the GET // answers 404, which on these pages would be a visibility leak. // // The mutating routes are deliberately not in it. A HEAD that writes is not a // HEAD, so each POST stays a POST and a HEAD on that path resolves to the page. chimw.GetHead(r, "/", s.handleIndex) chimw.GetHead(r, "/healthz", s.handleHealthz) r.Mount(assets.DefaultPrefix, s.static) chimw.GetHead(r, "/search", s.handleSearch) chimw.GetHead(r, "/inbox", s.handleInbox) r.Post("/inbox/seen", s.handleInboxSeen) // /tokens redirects to tokens.sr.ht, which issues every agent credential on // the instance. The POST routes that minted and revoked here went with the // table behind them; the GET stays so that a bookmark, the dashboard button // and every doc that ever said "see /tokens" still land somewhere useful. chimw.GetHead(r, "/tokens", s.handleTokens) // 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. chimw.GetHead(r, "/~{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.Post("/~{owner}/{space}/p/{id}/comment", s.handleProposalComment) r.Post("/~{owner}/{space}/p/{id}/reply", s.handleProposalReply) r.Post("/~{owner}/{space}/p/{id}/resolve", s.handleProposalResolve) chimw.GetHead(r, "/~{owner}/{space}", s.handleSpace) chimw.GetHead(r, "/~{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")) } // 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 }