package httpx import ( "context" "fmt" "net/http" "net/netip" "runtime/debug" "time" "bountyboard/internal/ulid" ) type ctxKey int const ( ctxKeyRequestID ctxKey = iota ctxKeyClient ) // RequestID returns the id assigned to this request by the middleware chain. func RequestID(ctx context.Context) string { id, _ := ctx.Value(ctxKeyRequestID).(string) return id } // Client returns the trusted-proxy-resolved client info for this request. func Client(ctx context.Context) clientInfo { c, _ := ctx.Value(ctxKeyClient).(clientInfo) return c } // ClientIP is a convenience accessor used by rate limiting and audit logging. func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP } // statusRecorder captures the response status for logging and metrics while // staying compatible with http.ResponseController (Unwrap). type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { if r.status == 0 { r.status = code } r.ResponseWriter.WriteHeader(code) } func (r *statusRecorder) Write(b []byte) (int, error) { if r.status == 0 { r.status = http.StatusOK } return r.ResponseWriter.Write(b) } func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } func (s *Server) withMiddleware(next http.Handler) http.Handler { return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next)))) } // recoverPanic guarantees no panic escapes a request path: it logs the stack // and returns the standard JSON error envelope. func (s *Server) recoverPanic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { if rec == http.ErrAbortHandler { // client went away mid-write panic(rec) } s.log.Error("panic in request handler", "err", fmt.Sprint(rec), "requestId", RequestID(r.Context()), "path", r.URL.Path, "stack", string(debug.Stack())) s.metrics.Inc("http_panics_total", 1) writeError(w, http.StatusInternalServerError, "internal", "internal server error") } }() next.ServeHTTP(w, r) }) } func (s *Server) assignRequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := ulid.New() w.Header().Set("X-Request-Id", id) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))) }) } func (s *Server) resolveClientInfo(next http.Handler) http.Handler { defaultProto := "http" if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" { defaultProto = "https" } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info))) }) } func (s *Server) accessLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w} next.ServeHTTP(rec, r) if rec.status == 0 { rec.status = http.StatusOK } s.metrics.Inc("http_requests_total", 1) s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1) s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "durationMs", time.Since(start).Milliseconds(), "clientIp", Client(r.Context()).IP.String(), "requestId", RequestID(r.Context())) }) }