package httpapi import ( "context" "crypto/rand" "encoding/hex" "log/slog" "net/http" "time" ) type ctxKey int const requestIDKey ctxKey = 0 // requestIDMW assigns a request id (for correlation in logs) and echoes it. // A client-supplied id is accepted only when it is short and well-formed; the // header ends up in logs, so accepting arbitrary bytes would be an injection // and log-flooding vector. func requestIDMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-Id") if !validRequestID(id) { id = newRequestID() } w.Header().Set("X-Request-Id", id) ctx := context.WithValue(r.Context(), requestIDKey, id) next.ServeHTTP(w, r.WithContext(ctx)) }) } const maxRequestIDLen = 128 func validRequestID(id string) bool { if id == "" || len(id) > maxRequestIDLen { return false } for _, r := range id { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': case r == '-', r == '_', r == '.': default: return false } } return true } // loggingMW logs one line per request with method, path, status and duration. // It records no addresses (PII) — only routing metadata. func loggingMW(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.Info("http", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration_ms", time.Since(start).Milliseconds(), "request_id", requestIDFrom(r.Context()), ) }) } } // timeoutMW bounds the whole request by setting a context deadline. Handlers // and adapters that talk to the network honour it; purely CPU-bound handlers // are additionally bounded by input limits and the HTTP server's read/write // timeouts in cmd/addrkitd. func timeoutMW(d time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), d) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) } } // recoverMW turns a panic into a 500 instead of crashing the process. func recoverMW(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 rec := recover(); rec != nil { log.Error("panic", "value", rec, "request_id", requestIDFrom(r.Context())) writeError(w, http.StatusInternalServerError, "internal error") } }() next.ServeHTTP(w, r) }) } } func requestIDFrom(ctx context.Context) string { if v, ok := ctx.Value(requestIDKey).(string); ok { return v } return "" } type statusRecorder struct { http.ResponseWriter status int } func (s *statusRecorder) WriteHeader(code int) { s.status = code s.ResponseWriter.WriteHeader(code) } // newRequestID returns a short random hex id. crypto/rand never fails in // practice; on the off chance it does we fall back to a constant so the // request still completes. func newRequestID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "unknown" } return hex.EncodeToString(b[:]) }