textmachine/platform/internal/httpapi/middleware.go

106 lines
3.7 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/base32"
"log/slog"
"net/http"
"time"
)
type requestIDKey struct{}
// RequestID stamps every request. The id is ours, never the client's: an id echoed from a header
// lets a caller poison our logs and correlate other users' lines.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b [10]byte
// crypto/rand.Read never returns an error; it crashes the program instead.
rand.Read(b[:])
id := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
})
}
// RequestIDOf returns the id stamped by RequestID, or "".
func RequestIDOf(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
// SecurityHeaders applies the two product invariants to every response.
//
// PT-34: not one byte of a user's translation may reach an indexable URL — noindex is set here,
// once, rather than per handler, because a handler added later would not know the rule.
// Cache-Control: no-store is blanket for the same reason: the contract mandates it for responses
// carrying translated text, and a private API has nothing worth caching in a shared cache.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Robots-Tag", "noindex, nofollow")
h.Set("Cache-Control", "no-store")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// Recover turns a panic into a 500 instead of a dropped connection.
func Recover(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
log.ErrorContext(r.Context(), "panic in handler",
"panic", v, "path", r.URL.Path, "request_id", RequestIDOf(r.Context()))
WriteProblem(w, http.StatusInternalServerError, "Internal error", "")
}
}()
next.ServeHTTP(w, r)
})
}
}
// AccessLog writes one INFO line per request. Deliberately absent: money (D39.84 and the norm of
// the P0 prompt — costs do not reach INFO), request bodies and any user text.
func AccessLog(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.InfoContext(r.Context(), "request",
"method", r.Method,
// Pattern, not the raw path: the raw one carries book and run ids, which multiply
// log cardinality and identify a user's library in an operator's index. ServeMux
// fills Pattern in place, so it is readable here even though the mux ran inside.
"route", routeOf(r),
"status", rec.status,
"ms", time.Since(start).Milliseconds(),
"request_id", RequestIDOf(r.Context()))
})
}
}
func routeOf(r *http.Request) string {
if p := r.Pattern; p != "" {
return p
}
return "(unmatched)"
}
// statusRecorder captures the status code. Unwrap keeps http.ResponseController working through
// the wrapper — that is how a later SSE handler will reach Flush.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }