| 1 |
|
// Package chimw is the chi half of the shared HTTP middleware for the custom |
| 2 |
|
// services of a self-hosted SourceHut instance (compare, spec, dolt, cover, |
| 3 |
|
// bench, tokens). |
| 4 |
|
// |
| 5 |
|
// It is a second middleware package because the first one may not grow these. |
| 6 |
|
// sr-ht-ecore/middleware imports nothing but net/http, on purpose: it is the |
| 7 |
|
// package a handler, an API surface or a plain http.ServeMux can reach for |
| 8 |
|
// without inheriting anybody's router. That rule is what kept the donors' getHead |
| 9 |
|
// out of it — three lines, all three of them chi's — and the rule is right. It is |
| 10 |
|
// not, however, an argument for leaving the helper copied into every router that |
| 11 |
|
// wants it. Every custom service on this instance routes with chi; a package |
| 12 |
|
// that says so in its name costs a service that already imports chi exactly |
| 13 |
|
// nothing, and costs middleware's rule nothing either. |
| 14 |
|
// |
| 15 |
|
// The boundary between the two is one question: does the helper have to know |
| 16 |
|
// what a route is? A cache header, a panic guard and a status constant do not, |
| 17 |
|
// and live in middleware. Registering a pair of methods for one pattern, |
| 18 |
|
// installing the two handlers chi calls when routing fails, and reading the |
| 19 |
|
// request id chi's RequestID middleware put in the context all do, and live |
| 20 |
|
// here. |
| 21 |
|
// |
| 22 |
|
// Three things, each of them either copied identically by the services that have |
| 23 |
|
// it or absent from the ones that do not — which is the same finding twice over: |
| 24 |
|
// |
| 25 |
|
// - GetHead, the read route registered under GET and HEAD both. Five copies: |
| 26 |
|
// cover, bench and tokens on their web surface, cover and bench again on |
| 27 |
|
// their API, where the same three lines carry a different name each time. |
| 28 |
|
// - RenderRefusals, chi's NotFound and MethodNotAllowed pointed at the |
| 29 |
|
// service's own error page. spec and dolt install neither, so an unrouted GET |
| 30 |
|
// there answers with net/http's plain-text 404 while every other refusal on |
| 31 |
|
// the instance is a rendered page. |
| 32 |
|
// - RequestLogger, the request line as a slog record instead of chi's |
| 33 |
|
// stdlib-log line on stdout. |
| 34 |
|
// |
| 35 |
|
// Usage, in the order a service installs them: |
| 36 |
|
// |
| 37 |
|
// r.Use(chimiddleware.RequestID) |
| 38 |
|
// r.Use(chimiddleware.RealIP) |
| 39 |
|
// r.Use(chimw.RequestLogger(chimw.SlogFormatter{Skip: chimw.SkipPaths("/healthz")})) |
| 40 |
|
// r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) { |
| 41 |
|
// s.renderError(w, r, http.StatusInternalServerError, pages.InternalMessage) |
| 42 |
|
// })) |
| 43 |
|
// r.Use(middleware.PrivateCache) |
| 44 |
|
// |
| 45 |
|
// chimw.RenderRefusals(r, s.renderError) |
| 46 |
|
// chimw.GetHead(r, "/healthz", s.handleHealthz) |
| 47 |
|
// |
| 48 |
|
// The daemons that adopt this will have to rename an import: every one of them |
| 49 |
|
// already aliases chi's own middleware package as chimw, which is exactly the |
| 50 |
|
// name this one takes by default. chimiddleware, as spelled above and in this |
| 51 |
|
// package's own files, is the alias to move them to — the shorter name belongs |
| 52 |
|
// to the package a service writes against. |
| 53 |
|
// |
| 54 |
|
// The request logger is the one middleware that belongs outside |
| 55 |
|
// middleware.RecoverPanics rather than inside it. It has to observe the status |
| 56 |
|
// that actually went out, and for a panicking handler that status is the 500 |
| 57 |
|
// RecoverPanics renders — a logger installed underneath would see the unwinding |
| 58 |
|
// stack and nothing written, and would log the request that produced the error |
| 59 |
|
// page as if it had produced nothing. chi's own Logger carries the same |
| 60 |
|
// instruction for the same reason. Nothing is left uncovered by moving it out: |
| 61 |
|
// this middleware builds a record and calls slog, and a panic in slog is not a |
| 62 |
|
// failure any error page is going to survive either. |
| 63 |
|
// |
| 64 |
|
// Records go to slog's default logger unless one is named, exactly as |
| 65 |
|
// middleware.RecoverPanics does and for the same reason: a library has no |
| 66 |
|
// business choosing a handler. The service installs its own — scribe's tinted |
| 67 |
|
// one on this instance — with slog.SetDefault at startup, and the request lines |
| 68 |
|
// land in the same stream, with the same masking rules, as everything else it |
| 69 |
|
// logs. |
| 70 |
|
package chimw |
| 71 |
|
|
| 72 |
|
import ( |
| 73 |
|
"net/http" |
| 74 |
|
|
| 75 |
|
"github.com/go-chi/chi/v5" |
| 76 |
|
|
| 77 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/pages" |
| 78 |
|
) |
| 79 |
|
|
| 80 |
|
// GetHead registers one read route under GET and HEAD both. |
| 81 |
|
// |
| 82 |
|
// chi's Get registers GET alone, and net/http synthesizes nothing for a custom |
| 83 |
|
// handler, so every read route of a donor answered `curl -I` — and every uptime |
| 84 |
|
// probe, and every cache revalidating what it holds — with a 405 naming GET as |
| 85 |
|
// the only method it would accept, plus a kilobyte of rendered error page from a |
| 86 |
|
// path whose whole job is to say "yes" cheaply. RFC 9110 §9.3.2 makes HEAD |
| 87 |
|
// mandatory for any resource that serves GET, and HEAD is the one method a |
| 88 |
|
// monitor reaches for precisely because it costs no body. |
| 89 |
|
// |
| 90 |
|
// The two share the handler rather than getting one each, and that is the whole |
| 91 |
|
// design: nothing in these services looks at r.Method on a read path, so a HEAD |
| 92 |
|
// produces exactly the status line and the headers its GET would have, computed |
| 93 |
|
// by the same code on the same query. There is no second path that could answer |
| 94 |
|
// 200 where the GET answers 404, which on these pages would be a visibility leak |
| 95 |
|
// (SPEC ch. 6.3). The body is dropped by net/http, which discards writes on a |
| 96 |
|
// HEAD response after counting them, so the Content-Length a client gets is the |
| 97 |
|
// real one. |
| 98 |
|
// |
| 99 |
|
// It is this and not chi's own middleware.GetHead, which registers nothing and |
| 100 |
|
// instead rewrites, per request, the method the routing tree is walked with. |
| 101 |
|
// |
| 102 |
|
// The donors' comments say that rewrite lands on r.Method, so that every line |
| 103 |
|
// written about the request — the log record of RequestLogger below, the panic |
| 104 |
|
// report of middleware.RecoverPanics — names a method the viewer did not send. |
| 105 |
|
// Read chi v5's source and it does not: it sets RouteMethod on the route context |
| 106 |
|
// and leaves the request alone. The correction is worth keeping written down, |
| 107 |
|
// because what is left is the half of the objection that no ordering or logging |
| 108 |
|
// discipline can work around. |
| 109 |
|
// |
| 110 |
|
// The tree stops describing the service. A route registered for GET alone is a |
| 111 |
|
// route that does not serve HEAD as far as anything reading the tree is |
| 112 |
|
// concerned — a chi.Walk, which is how the donors' TestEveryGetRouteHasAHeadTwin |
| 113 |
|
// asks the question at all, and chi's own 405 handler, which builds its Allow |
| 114 |
|
// header out of the methods that were registered. The service answers HEAD |
| 115 |
|
// anyway, from a middleware, and the two records disagree; the request context |
| 116 |
|
// then says GET while the request says HEAD, so which one a handler or a later |
| 117 |
|
// middleware believes depends on which it happened to read. Registering the pair |
| 118 |
|
// leaves one record of what is served, and it is the tree. |
| 119 |
|
// |
| 120 |
|
// It is also two lines against a middleware that re-enters routing on every HEAD |
| 121 |
|
// that missed. |
| 122 |
|
// |
| 123 |
|
// Mutating routes deliberately do not go through it. A HEAD that writes is not a |
| 124 |
|
// HEAD, so a form's POST is registered with r.Post next to its GET, and a HEAD |
| 125 |
|
// on that path resolves to the page. |
| 126 |
28 |
func GetHead(r chi.Router, pattern string, h http.HandlerFunc) { |
| 127 |
28 |
r.Get(pattern, h) |
| 128 |
28 |
r.Head(pattern, h) |
| 129 |
28 |
} |
| 130 |
|
|
| 131 |
|
// An ErrorRenderer is a service's own error page, as every donor already spells |
| 132 |
|
// it: the status it is answering with and the sentence the viewer can act on. |
| 133 |
|
// |
| 134 |
|
// It is this shape and not middleware.RecoverPanics's (w, r, recovered) because |
| 135 |
|
// the two callbacks are answering different questions. A panic has one status |
| 136 |
|
// and one message and hands over a value to classify; a refusal has no value and |
| 137 |
|
// two statuses, and the renderer is the donors' existing renderError method, |
| 138 |
|
// which is passed by name rather than wrapped in a closure per call site. |
| 139 |
|
type ErrorRenderer func(w http.ResponseWriter, r *http.Request, status int, message string) |
| 140 |
|
|
| 141 |
|
// RenderRefusals points chi's two routing failures at the service's error page. |
| 142 |
|
// |
| 143 |
|
// A path the router does not serve and a method it does not allow are answered |
| 144 |
|
// by chi with net/http's Error: text/plain, no chrome, no nav, no way out for a |
| 145 |
|
// viewer who mistyped a URL. Five of the donors' routers install a pair of |
| 146 |
|
// closures to fix that and spell them identically, compare installs the 404 |
| 147 |
|
// alone and lives with chi's 405, and spec and dolt install neither — so a wrong |
| 148 |
|
// address on those two is the only refusal on the instance that does not look |
| 149 |
|
// like the service it came from. |
| 150 |
|
// |
| 151 |
|
// The messages are the shared ones — pages.NotFoundMessage and |
| 152 |
|
// pages.MethodMessage — rather than the caller's. The 404 above all is not free |
| 153 |
|
// prose: the visibility rules of these services require "somebody else's private |
| 154 |
|
// thing" and "no such thing" to be indistinguishable (SPEC ch. 6.3), and a |
| 155 |
|
// router's 404 that differed in its wording from a handler's would rebuild by |
| 156 |
|
// hand the distinction the status code was chosen to erase. A service that wants |
| 157 |
|
// other prose has the seam anyway, in the renderer it passes. |
| 158 |
|
// |
| 159 |
|
// One thing is lost by taking the 405 over from chi and is worth knowing rather |
| 160 |
|
// than discovering: chi hands the list of methods that *would* have matched only |
| 161 |
|
// to its own default handler, through unexported types, so a custom one cannot |
| 162 |
|
// emit the Allow header RFC 9110 §15.5.6 asks a 405 for. Every donor already |
| 163 |
|
// made that trade, silently. It is the right way round for these services, whose |
| 164 |
|
// 405s are answered to browsers rather than to clients negotiating a method, and |
| 165 |
|
// it is recorded here so that a service that does need Allow knows it has to |
| 166 |
|
// walk the tree for it. |
| 167 |
|
// |
| 168 |
|
// It is a function taking a router, not a middleware, because chi's NotFound and |
| 169 |
|
// MethodNotAllowed are registrations on the routing tree and not links in a |
| 170 |
|
// chain: they run after routing has failed, so a chain has nothing left to hand |
| 171 |
|
// them. That also means they are inherited by every sub-router mounted later — |
| 172 |
|
// installing them once on the root is enough. |
| 173 |
|
// |
| 174 |
|
// A nil renderer is a wiring mistake and panics here, at construction, rather |
| 175 |
|
// than at the first mistyped URL. |
| 176 |
6 |
func RenderRefusals(r chi.Router, render ErrorRenderer) { |
| 177 |
6 |
if render == nil { |
| 178 |
1 |
panic("chimw: RenderRefusals needs a render callback") |
| 179 |
|
} |
| 180 |
5 |
r.NotFound(func(w http.ResponseWriter, r *http.Request) { |
| 181 |
4 |
render(w, r, http.StatusNotFound, pages.NotFoundMessage) |
| 182 |
4 |
}) |
| 183 |
5 |
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { |
| 184 |
1 |
render(w, r, http.StatusMethodNotAllowed, pages.MethodMessage) |
| 185 |
1 |
}) |
| 186 |
|
} |