106 lines
3.7 KiB
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 }
|