~bigbes/sr-ht-ecore

ref: b36a927213562090e100c8b43ab27ef9094b1de9 sr-ht-ecore/middleware/middleware.go -rw-r--r-- 14.4 KiB
b36a9272 — Eugene Blikh beads: ignore the JSONL exports a day 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
// Package middleware is the shared HTTP middleware for the custom services of a
// self-hosted SourceHut instance (compare, spec, dolt, cover, bench, ...).
//
// Three things every one of those services needs, and every one of them had
// copied byte for byte into its own web/router.go: the cache policy that keeps a
// page rendered behind a login cookie out of every cache that is not the
// viewer's own, the panic guard that turns a bug in a handler into the service's
// own error page instead of a dropped connection, and the 499 that separates a
// viewer who closed the tab from a server that broke. The copies were identical
// down to their comments, which is the sign that they were never three
// decisions — they were one, made once and pasted twice. This package is the one
// copy.
//
// Usage, in the order the donors install them:
//
//	r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, recovered any) {
//	    s.renderError(w, r, http.StatusInternalServerError, internalMessage)
//	}))
//	r.Use(middleware.PrivateCache)
//
// RecoverPanics goes outermost so that it covers every later middleware as well
// as the handlers; PrivateCache goes inside it so that the error page it renders
// carries the same headers as any other answer.
//
// What deliberately did not move here is the donors' getHead helper. It is three
// lines, and all three are chi's — it takes a chi.Router and calls Get and Head
// on it — so hoisting it would put a router dependency in a package whose whole
// point is that it needs nothing but net/http. It lives in the sibling package
// chimw instead, which is allowed the router dependency because knowing what a
// route is is what that package is for.
//
// Panics are reported through slog's default logger rather than a logger this
// package is handed. 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 everything logged here lands in the same
// stream, with the same masking rules, as the service's own lines. Handing a
// *slog.Logger to RecoverPanics would buy configurability nobody wants and cost
// every caller a parameter.
package middleware

import (
	"bufio"
	"errors"
	"io"
	"log/slog"
	"net"
	"net/http"
	"runtime/debug"
)

// StatusClientClosedRequest is nginx's 499: the caller went away — hung up, or
// ran out of its own deadline — before the answer was written.
//
// No RFC defines it, and that costs nothing, because the one certain thing about
// this response is that nobody reads it: the context it reports on is the
// request's own, and it ended before there was anything to send. So the code is
// chosen for the operator rather than for the client. What matters is that it is
// not in the 5xx range — that is the rate an alert is written against, and a
// browser navigating away mid-render, or a CI job that pressed ^C, must not page
// anybody — and 499 is the value the log pipelines in front of a SourceHut
// instance already understand, because the nginx that terminates TLS for one has
// been writing it for this exact event since long before any of these services
// existed.
//
// The alternatives are each worse in their own way. 500 is a false statement
// about the instance: nothing broke, and whoever is woken by it finds a healthy
// service. 408 is standard but means the other half of "nobody finished" — the
// server gave up waiting for a body still arriving, which is the server's
// problem and is safe to retry — and several clients do retry it automatically,
// which is the last thing to tell a caller that cancelled on purpose. 504 names
// a gateway timing out upstream, and there is no upstream here.
//
// It belongs in a middleware package rather than next to one service's status
// mapping because both surfaces of every service reach for it: the HTML side
// when a render's context is already cancelled, the API side in its
// error-to-status switch.
const StatusClientClosedRequest = 499

// PrivateCache marks every answer of a surface as one no cache may reuse for
// another viewer.
//
// These services render per-viewer documents at URLs that say nothing about the
// viewer: a token list, a repository page that is a page to its owner and a 404
// to everyone else, a dashboard of somebody's own runs. A cache with no
// instruction treats a 200 to a GET as reusable, so one proxy, one CDN or one
// browser on a shared machine is all it takes for somebody to be served another
// account's page — a disclosure arriving by a route no visibility check can
// stand in front of.
//
// The policy is "private, no-store" and not merely "no-cache" because the two
// answer different questions. no-cache still permits a *stored* copy, and only
// requires it to be revalidated before reuse; the copy sits in the shared proxy
// and on the disk of the shared machine either way, and a revalidation carries
// the next viewer's cookie, not the one the page was rendered for. private bars
// the shared caches from keeping it at all, no-store bars the private ones from
// writing it down, and for a page whose most sensitive form contains a live
// credential in plaintext, "do not write this to disk" is the instruction that
// was actually meant.
//
// Vary names Cookie and Authorization for the same reason: they are the two
// inputs that decide who the page is for, so any cache that does keep something
// must at least not hand it to a request that presented different ones.
//
// It is a middleware and not a line in the render path because render is not the
// only writer: /healthz is text/plain, static assets are bytes, a badge is an
// image, and a header this important must not depend on which write path a
// future page picks. The headers are set before the handler runs, so a handler
// that needs different ones — the static handler, which serves immutable
// hashed-name assets — overrides them with Set.
func PrivateCache(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		SetPrivateCache(w)
		next.ServeHTTP(w, r)
	})
}

// SetPrivateCache writes the two headers PrivateCache exists for.
//
// It is split out because a service also answers from places the router's
// middleware chain does not reach: the deny path called by the authentication
// middleware in front of the whole mount, before the router has seen the request
// at all, is the donors' example. Those answers are as viewer-specific as any
// page and must not be cached either.
func SetPrivateCache(w http.ResponseWriter) {
	w.Header().Set("Cache-Control", "private, no-store")
	w.Header().Set("Vary", "Cookie, Authorization")
}

// RecoverPanics turns a panicking handler into the error page the service would
// have written for any other bug.
//
// Without it a panic reaches net/http, which logs it and closes the connection
// without a response: the viewer sees a browser error page, not the service's,
// and an operator sees a stack with no request context around it. It is this and
// not chi's middleware.Recoverer because that one answers with plain text, and
// these surfaces answer with pages.
//
// render is the seam. Each service renders its own 500 — its own chrome, its own
// message, its own template set — so the middleware cannot write the page, only
// decide when one is owed. The callback takes the recovered value so that a
// service which wants to classify the panic can, and a service which does not
// ignores the parameter; a renderer with the donors' (w, r, status, message)
// shape is passed as a one-line closure rather than being reshaped:
//
//	middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
//	    s.renderError(w, r, http.StatusInternalServerError, internalMessage)
//	})
//
// A nil render is a wiring mistake and panics here, at construction, rather than
// at 3am inside a deferred function where the only thing left to do about it is
// drop the connection.
//
// The panic value and the stack are logged here and never handed to the viewer —
// an error from below names tables, queries and paths. Logging is the
// middleware's job and not the callback's because the stack is only reachable
// from inside the deferred function that recovered; a service left to log it
// would sooner or later log the value alone, and a panic without a stack is a
// bug report with the address torn off.
//
// Two panics are not this middleware's to answer.
//
// http.ErrAbortHandler is re-panicked, because the standard library defines it
// as "this handler is giving up on this connection on purpose": net/http expects
// to see it, drops the connection silently and logs nothing. Answering it with a
// page would resurrect a response somebody deliberately abandoned.
//
// A panic that happens *after* the response has started is answered by dropping
// the connection, not by rendering. This is where this package parts with its
// donors, which called their error renderer unconditionally: with bytes already
// on the wire that write is a superfluous WriteHeader the standard library logs
// and ignores, followed by an error page appended to the middle of a truncated
// one — a body that is neither document, with a 200 status line in front of it
// claiming both are fine. Panicking with http.ErrAbortHandler instead makes the
// failure legible: the connection dies mid-body, the client sees a short read
// against the Content-Length it was promised (or a chunked stream with no
// terminator) and reports a failed transfer, which is what happened. Detecting
// this is what the response writer is wrapped for.
func RecoverPanics(render func(w http.ResponseWriter, r *http.Request, recovered any)) func(http.Handler) http.Handler {
	if render == nil {
		panic("middleware: RecoverPanics needs a render callback")
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			tracked := &startTracker{ResponseWriter: w}
			defer func() {
				recovered := recover()
				if recovered == nil {
					return
				}
				if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
					panic(recovered)
				}
				slog.ErrorContext(r.Context(), "panic serving a request",
					"method", r.Method,
					"path", r.URL.Path,
					"panic", recovered,
					"stack", string(debug.Stack()))
				if tracked.started {
					// Half a page is already out. There is no status line left
					// to send and nothing useful to append; abandon the
					// connection instead of corrupting the body further.
					panic(http.ErrAbortHandler)
				}
				renderOnce(render, tracked, r, recovered)
			}()
			next.ServeHTTP(tracked, r)
		})
	}
}

// renderOnce calls render under a guard of its own, so that a panic while
// rendering the error page cannot be fed back into the path that renders error
// pages.
//
// The second failure is logged and the connection dropped. It is deliberately
// not a second attempt at a page: whatever is broken — a template, the chrome,
// the store the chrome reads a username from — is exactly what the retry would
// use, and the third try would be no different from the second. One log line
// naming both panics is what an operator needs; a loop is what they would
// otherwise get.
func renderOnce(
	render func(w http.ResponseWriter, r *http.Request, recovered any),
	w http.ResponseWriter,
	r *http.Request,
	recovered any,
) {
	defer func() {
		second := recover()
		if second == nil {
			return
		}
		if err, ok := second.(error); ok && errors.Is(err, http.ErrAbortHandler) {
			panic(second)
		}
		slog.ErrorContext(r.Context(), "panic rendering the error page",
			"method", r.Method,
			"path", r.URL.Path,
			"panic", second,
			"original_panic", recovered,
			"stack", string(debug.Stack()))
		panic(http.ErrAbortHandler)
	}()
	render(w, r, recovered)
}

// startTracker records whether anything has reached the wire yet.
//
// "The response has started" is the one fact RecoverPanics needs and net/http
// does not expose: by the time a panic is recovered, the only way to know
// whether a status line has already gone out is to have watched for it. Every
// method that commits the response — an explicit WriteHeader, the implicit one
// inside the first Write, a Flush, a Hijack — sets the flag before delegating.
//
// Wrapping a ResponseWriter costs the concrete type behind it, so the methods a
// handler may reasonably reach for are carried across:
//
//   - Unwrap is the net/http convention (Go 1.20+) that lets an
//     http.ResponseController find the real writer, which is how deadlines and
//     flushes are meant to be reached through wrappers like this one.
//   - Flush and Hijack are implemented directly as well, because plenty of code
//     still type-asserts for http.Flusher and http.Hijacker rather than going
//     through the controller. They delegate through the controller, which
//     returns http.ErrNotSupported if the writer underneath genuinely cannot do
//     it — the same outcome as the assertion having failed, minus the silence.
//   - ReadFrom keeps http.ServeContent and io.Copy on the fast path: net/http's
//     own writer implements io.ReaderFrom, and losing it would turn every static
//     asset into a buffered copy loop.
type startTracker struct {
	http.ResponseWriter
	started bool
}

func (t *startTracker) WriteHeader(status int) {
	t.started = true
	t.ResponseWriter.WriteHeader(status)
}

func (t *startTracker) Write(b []byte) (int, error) {
	t.started = true
	return t.ResponseWriter.Write(b)
}

// Unwrap gives http.ResponseController the writer this one wraps.
func (t *startTracker) Unwrap() http.ResponseWriter {
	return t.ResponseWriter
}

// Flush commits whatever is buffered, which starts the response.
func (t *startTracker) Flush() {
	t.started = true
	// The error is the writer saying it cannot flush, which is what an
	// unsatisfied http.Flusher assertion would have said by not existing.
	_ = http.NewResponseController(t.ResponseWriter).Flush()
}

// Hijack hands the connection to the caller, after which nothing here can write
// a status line — so the response counts as started.
func (t *startTracker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	t.started = true
	return http.NewResponseController(t.ResponseWriter).Hijack()
}

// ReadFrom preserves the io.ReaderFrom fast path of the writer underneath.
func (t *startTracker) ReadFrom(src io.Reader) (int64, error) {
	t.started = true
	if rf, ok := t.ResponseWriter.(io.ReaderFrom); ok {
		return rf.ReadFrom(src)
	}
	// Copy to the wrapped writer and not to t, or this is a recursion.
	return io.Copy(t.ResponseWriter, src)
}