~bigbes/sr-ht-spec

ref: 9cec0f542b65dc63883514cd54dc664f8c00157c sr-ht-spec/web/router.go -rw-r--r-- 7.9 KiB
9cec0f54 — Eugene Blikh deps: tidy after the third uplift 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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
}