| 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 |
|
} |