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 }