coverage~bigbes/sr-ht-spec64cae3afweb/router.go

Coverage
94.9% 37/39 statements
Δ
+0.0
Blob
46b9210
Uncovered L66-L67L157-L158
1 package web
2
3 import (
4 "net/http"
5 "net/url"
6 "strings"
7
8 "github.com/go-chi/chi/v5"
9 chimiddleware "github.com/go-chi/chi/v5/middleware"
10
11 "sourcecraft.dev/bigbes/sr-ht-ecore/assets"
12 "sourcecraft.dev/bigbes/sr-ht-ecore/chimw"
13 "sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
14 "sourcecraft.dev/bigbes/sr-ht-ecore/middleware"
15 )
16
17 // Handler returns a router with everything this package needs already
18 // installed: the request line, panic recovery, the private cache policy, the
19 // authn principal middleware and the same-origin guard, then the routes, then
20 // the two refusals chi answers when routing fails. The daemon mounts it at "/".
21 //
22 // The order is the one the shared packages ask for. RequestID and RealIP first,
23 // because chimw.RequestLogger reads both and can only carry an id and the
24 // viewer's address if they have already run. The logger is next and — the one
25 // piece of ordering that is not cosmetic — *outside* RecoverPanics rather than
26 // under it: it has to observe the status that actually went out, and for a
27 // panicking handler that status is the 500 RecoverPanics renders. Installed the
28 // other way round it would see an unwinding stack and nothing written, and would
29 // log the request that produced an error page as if it had produced nothing.
30 //
31 // Nothing is left uncovered by putting it outside: this middleware builds a
32 // record and calls slog, and a panic in slog is not a failure any error page was
33 // going to survive either.
34 //
35 // Until now this surface logged no request lines at all. core-go installs chi's
36 // own Logger, but only under -d and only on the groups it registers itself,
37 // which the router mounted at "/" is not one of — so a 500 here left a stack in
38 // the journal with no line saying which URL produced it.
39 //
40 // RecoverPanics then covers every later middleware as well as the handlers, and
41 // it is sr-ht-ecore's rather than chi's or our own for one behaviour: a panic
42 // that arrives *after* the response has started aborts the connection instead of
43 // appending an error page to a truncated one. PrivateCache sits inside it so
44 // that every answer — including the two refusals below — carries the same
45 // private, no-store a page rendered behind a login cookie needs.
46 //
47 // csrf.Require goes last of the four, and on the router rather than on the
48 // routes, which is the whole point of the change: the guard used to be a
49 // predicate that three handlers remembered to call, so a form added later went
50 // out unprotected by default. Here it covers the routes that are not written
51 // yet, and it runs before routing — a mutation aimed at an address this surface
52 // does not serve is refused rather than 404'd, which is the right way round,
53 // since an unrouted POST answering differently from a routed one would be a way
54 // to enumerate them without ever passing the check. It sits after the resolver
55 // so the refusal page names the viewer the way every other page does.
56 //
57 // A caller that owns its own middleware stack — and has already applied
58 // authn.Resolver.Middleware to it — uses Register instead, and owes its router
59 // this guard: Register installs no middleware of its own.
60 70 func (s *Server) Handler() http.Handler {
61 70 r := chi.NewRouter()
62 70 r.Use(chimiddleware.RequestID)
63 70 r.Use(chimiddleware.RealIP)
64 70 r.Use(chimw.RequestLogger(chimw.SlogFormatter{Skip: chimw.SkipPaths("/healthz")}))
65 70 r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
66 0 s.renderError(w, r, http.StatusInternalServerError, "")
67 0 }))
68 70 r.Use(middleware.PrivateCache)
69 70 r.Use(s.resolver.Middleware())
70 70 r.Use(csrf.Require(s.chromeSvc.SelfOrigin(), func(w http.ResponseWriter, r *http.Request) {
71 7 s.renderError(w, r, http.StatusForbidden, csrf.Message)
72 7 }))
73 70 s.Register(r)
74 70
75 70 // The two refusals chi answers when routing fails, pointed at this service's
76 70 // error page. Without them a mistyped URL — the one refusal a viewer is most
77 70 // likely to meet — came back as net/http's plain text, with no nav to get out
78 70 // of and nothing to say which service it came from, while every refusal a
79 70 // handler produced was a rendered page. It is registered here rather than in
80 70 // Register because it is not a route: a caller that owns its own router gets
81 70 // its own answer for an address it does not serve.
82 70 chimw.RenderRefusals(r, s.renderError)
83 70 return r
84 }
85
86 // Register mounts every spec.sr.ht read-plane route onto r. It installs no
87 // middleware of its own; the router it is handed must already resolve a
88 // principal into the request context (authn.Resolver.Middleware) and must
89 // already carry csrf.Require, or every viewer looks anonymous and every form is
90 // forgeable. Handler does both.
91 //
92 // The document route is a single wildcard because the format selector lives in
93 // the *extension* and the document's address does not have one: ".md" and
94 // ".json" are stripped from the tail by the handler, never routed on, so a
95 // document called "notes/2026.json.md" is still reachable and a request for
96 // "notes/2026.json" still means "the JSON of notes/2026".
97 76 func (s *Server) Register(r chi.Router) {
98 76 // Every read route is registered for GET and HEAD both, through
99 76 // chimw.GetHead. chi registers GET alone and net/http synthesizes nothing, so
100 76 // each of these answered `curl -I` — and every uptime probe, and every cache
101 76 // revalidating what it holds — with a 405 naming GET as the only method it
102 76 // takes, plus a page of rendered chrome from a path whose whole job is to be
103 76 // cheap. The pair shares one handler, so a HEAD produces exactly the status
104 76 // its GET would: there is no second path that could answer 200 where the GET
105 76 // answers 404, which on these pages would be a visibility leak.
106 76 //
107 76 // The mutating routes are deliberately not in it. A HEAD that writes is not a
108 76 // HEAD, so each POST stays a POST and a HEAD on that path resolves to the page.
109 76 chimw.GetHead(r, "/", s.handleIndex)
110 76 chimw.GetHead(r, "/healthz", s.handleHealthz)
111 76 r.Mount(assets.DefaultPrefix, s.static)
112 76 chimw.GetHead(r, "/search", s.handleSearch)
113 76 chimw.GetHead(r, "/inbox", s.handleInbox)
114 76 r.Post("/inbox/seen", s.handleInboxSeen)
115 76
116 76 // /tokens redirects to tokens.sr.ht, which issues every agent credential on
117 76 // the instance. The POST routes that minted and revoked here went with the
118 76 // table behind them; the GET stays so that a bookmark, the dashboard button
119 76 // and every doc that ever said "see /tokens" still land somewhere useful.
120 76 chimw.GetHead(r, "/tokens", s.handleTokens)
121 76
122 76 // The proposal routes are registered before the document wildcard. chi gives
123 76 // the static "p" segment priority over the "*" catch-all regardless, but
124 76 // keeping them adjacent makes the "/p/ is the proposal namespace" decision
125 76 // visible in one place.
126 76 chimw.GetHead(r, "/~{owner}/{space}/p/{id}", s.handleProposal)
127 76 r.Post("/~{owner}/{space}/p/{id}/approve", s.handleProposalApprove)
128 76 r.Post("/~{owner}/{space}/p/{id}/reject", s.handleProposalReject)
129 76 r.Post("/~{owner}/{space}/p/{id}/comment", s.handleProposalComment)
130 76 r.Post("/~{owner}/{space}/p/{id}/reply", s.handleProposalReply)
131 76 r.Post("/~{owner}/{space}/p/{id}/resolve", s.handleProposalResolve)
132 76
133 76 chimw.GetHead(r, "/~{owner}/{space}", s.handleSpace)
134 76 chimw.GetHead(r, "/~{owner}/{space}/*", s.handleDocument)
135 76 }
136
137 // handleHealthz is a dependency-free liveness probe.
138 2 func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
139 2 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
140 2 _, _ = w.Write([]byte("ok\n"))
141 2 }
142
143 // unescapePath decodes a chi wildcard back into a tree path.
144 //
145 // chi routes on r.URL.RawPath when the request had one, so the wildcard arrives
146 // percent-encoded — which it must, since doc.Archive escapes every href segment
147 // so spaces and Cyrillic survive. Decoding is per segment on purpose: a %2F
148 // inside a segment is not a path separator and must not become one.
149 39 func unescapePath(raw string) (string, bool) {
150 39 if raw == "" {
151 1 return "", true
152 1 }
153 38 segs := strings.Split(raw, "/")
154 75 for i, seg := range segs {
155 75 dec, err := url.PathUnescape(seg)
156 75 if err != nil {
157 0 return "", false
158 0 }
159 75 segs[i] = dec
160 }
161 38 return strings.Join(segs, "/"), true
162 }