~bigbes/sr-ht-ecore

ref: e8a9672594733218fd773bab9e9216ae1c2db6c2 sr-ht-ecore/chimw/chimw.go -rw-r--r-- 10.1 KiB
e8a96725 — Eugene Blikh bearer: mint the internal authorization through internalauth 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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
// Package chimw is the chi half of the shared HTTP middleware for the custom
// services of a self-hosted SourceHut instance (compare, spec, dolt, cover,
// bench, tokens).
//
// It is a second middleware package because the first one may not grow these.
// sr-ht-ecore/middleware imports nothing but net/http, on purpose: it is the
// package a handler, an API surface or a plain http.ServeMux can reach for
// without inheriting anybody's router. That rule is what kept the donors' getHead
// out of it — three lines, all three of them chi's — and the rule is right. It is
// not, however, an argument for leaving the helper copied into every router that
// wants it. Every custom service on this instance routes with chi; a package
// that says so in its name costs a service that already imports chi exactly
// nothing, and costs middleware's rule nothing either.
//
// The boundary between the two is one question: does the helper have to know
// what a route is? A cache header, a panic guard and a status constant do not,
// and live in middleware. Registering a pair of methods for one pattern,
// installing the two handlers chi calls when routing fails, and reading the
// request id chi's RequestID middleware put in the context all do, and live
// here.
//
// Three things, each of them either copied identically by the services that have
// it or absent from the ones that do not — which is the same finding twice over:
//
//   - GetHead, the read route registered under GET and HEAD both. Five copies:
//     cover, bench and tokens on their web surface, cover and bench again on
//     their API, where the same three lines carry a different name each time.
//   - RenderRefusals, chi's NotFound and MethodNotAllowed pointed at the
//     service's own error page. spec and dolt install neither, so an unrouted GET
//     there answers with net/http's plain-text 404 while every other refusal on
//     the instance is a rendered page.
//   - RequestLogger, the request line as a slog record instead of chi's
//     stdlib-log line on stdout.
//
// Usage, in the order a service installs them:
//
//	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, pages.InternalMessage)
//	}))
//	r.Use(middleware.PrivateCache)
//
//	chimw.RenderRefusals(r, s.renderError)
//	chimw.GetHead(r, "/healthz", s.handleHealthz)
//
// The daemons that adopt this will have to rename an import: every one of them
// already aliases chi's own middleware package as chimw, which is exactly the
// name this one takes by default. chimiddleware, as spelled above and in this
// package's own files, is the alias to move them to — the shorter name belongs
// to the package a service writes against.
//
// The request logger is the one middleware that belongs outside
// middleware.RecoverPanics rather than inside it. It has to observe the status
// that actually went out, and for a panicking handler that status is the 500
// RecoverPanics renders — a logger installed underneath would see the unwinding
// stack and nothing written, and would log the request that produced the error
// page as if it had produced nothing. chi's own Logger carries the same
// instruction for the same reason. Nothing is left uncovered by moving it out:
// this middleware builds a record and calls slog, and a panic in slog is not a
// failure any error page is going to survive either.
//
// Records go to slog's default logger unless one is named, exactly as
// middleware.RecoverPanics does and for the same reason: a library has no
// business choosing a handler. The service installs its own — scribe's tinted
// one on this instance — with slog.SetDefault at startup, and the request lines
// land in the same stream, with the same masking rules, as everything else it
// logs.
package chimw

import (
	"net/http"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
)

// GetHead registers one read route under GET and HEAD both.
//
// chi's Get registers GET alone, and net/http synthesizes nothing for a custom
// handler, so every read route of a donor answered `curl -I` — and every uptime
// probe, and every cache revalidating what it holds — with a 405 naming GET as
// the only method it would accept, plus a kilobyte of rendered error page from a
// path whose whole job is to say "yes" cheaply. RFC 9110 §9.3.2 makes HEAD
// mandatory for any resource that serves GET, and HEAD is the one method a
// monitor reaches for precisely because it costs no body.
//
// The two share the handler rather than getting one each, and that is the whole
// design: nothing in these services looks at r.Method on a read path, so a HEAD
// produces exactly the status line and the headers its GET would have, computed
// by the same code on the same query. There is no second path that could answer
// 200 where the GET answers 404, which on these pages would be a visibility leak
// (SPEC ch. 6.3). The body is dropped by net/http, which discards writes on a
// HEAD response after counting them, so the Content-Length a client gets is the
// real one.
//
// It is this and not chi's own middleware.GetHead, which registers nothing and
// instead rewrites, per request, the method the routing tree is walked with.
//
// The donors' comments say that rewrite lands on r.Method, so that every line
// written about the request — the log record of RequestLogger below, the panic
// report of middleware.RecoverPanics — names a method the viewer did not send.
// Read chi v5's source and it does not: it sets RouteMethod on the route context
// and leaves the request alone. The correction is worth keeping written down,
// because what is left is the half of the objection that no ordering or logging
// discipline can work around.
//
// The tree stops describing the service. A route registered for GET alone is a
// route that does not serve HEAD as far as anything reading the tree is
// concerned — a chi.Walk, which is how the donors' TestEveryGetRouteHasAHeadTwin
// asks the question at all, and chi's own 405 handler, which builds its Allow
// header out of the methods that were registered. The service answers HEAD
// anyway, from a middleware, and the two records disagree; the request context
// then says GET while the request says HEAD, so which one a handler or a later
// middleware believes depends on which it happened to read. Registering the pair
// leaves one record of what is served, and it is the tree.
//
// It is also two lines against a middleware that re-enters routing on every HEAD
// that missed.
//
// Mutating routes deliberately do not go through it. A HEAD that writes is not a
// HEAD, so a form's POST is registered with r.Post next to its GET, and a HEAD
// on that path resolves to the page.
func GetHead(r chi.Router, pattern string, h http.HandlerFunc) {
	r.Get(pattern, h)
	r.Head(pattern, h)
}

// An ErrorRenderer is a service's own error page, as every donor already spells
// it: the status it is answering with and the sentence the viewer can act on.
//
// It is this shape and not middleware.RecoverPanics's (w, r, recovered) because
// the two callbacks are answering different questions. A panic has one status
// and one message and hands over a value to classify; a refusal has no value and
// two statuses, and the renderer is the donors' existing renderError method,
// which is passed by name rather than wrapped in a closure per call site.
type ErrorRenderer func(w http.ResponseWriter, r *http.Request, status int, message string)

// RenderRefusals points chi's two routing failures at the service's error page.
//
// A path the router does not serve and a method it does not allow are answered
// by chi with net/http's Error: text/plain, no chrome, no nav, no way out for a
// viewer who mistyped a URL. Five of the donors' routers install a pair of
// closures to fix that and spell them identically, compare installs the 404
// alone and lives with chi's 405, and spec and dolt install neither — so a wrong
// address on those two is the only refusal on the instance that does not look
// like the service it came from.
//
// The messages are the shared ones — pages.NotFoundMessage and
// pages.MethodMessage — rather than the caller's. The 404 above all is not free
// prose: the visibility rules of these services require "somebody else's private
// thing" and "no such thing" to be indistinguishable (SPEC ch. 6.3), and a
// router's 404 that differed in its wording from a handler's would rebuild by
// hand the distinction the status code was chosen to erase. A service that wants
// other prose has the seam anyway, in the renderer it passes.
//
// One thing is lost by taking the 405 over from chi and is worth knowing rather
// than discovering: chi hands the list of methods that *would* have matched only
// to its own default handler, through unexported types, so a custom one cannot
// emit the Allow header RFC 9110 §15.5.6 asks a 405 for. Every donor already
// made that trade, silently. It is the right way round for these services, whose
// 405s are answered to browsers rather than to clients negotiating a method, and
// it is recorded here so that a service that does need Allow knows it has to
// walk the tree for it.
//
// It is a function taking a router, not a middleware, because chi's NotFound and
// MethodNotAllowed are registrations on the routing tree and not links in a
// chain: they run after routing has failed, so a chain has nothing left to hand
// them. That also means they are inherited by every sub-router mounted later —
// installing them once on the root is enough.
//
// A nil renderer is a wiring mistake and panics here, at construction, rather
// than at the first mistyped URL.
func RenderRefusals(r chi.Router, render ErrorRenderer) {
	if render == nil {
		panic("chimw: RenderRefusals needs a render callback")
	}
	r.NotFound(func(w http.ResponseWriter, r *http.Request) {
		render(w, r, http.StatusNotFound, pages.NotFoundMessage)
	})
	r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
		render(w, r, http.StatusMethodNotAllowed, pages.MethodMessage)
	})
}