package handler import ( "encoding/json" "io" "net/http" "net/http/pprof" "strconv" "strings" "sync" "time" "github.com/elfoundation/hatch/internal/face" "github.com/elfoundation/hatch/internal/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // Handler groups all HTTP handlers and their shared dependencies. type Handler struct { Repo store.Repository Debug bool FaceService *face.Service } // New creates a new Handler with the given store. func New(repo store.Repository) *Handler { return &Handler{Repo: repo} } // NewWithFaceService creates a new Handler with face search service. func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler { return &Handler{ Repo: repo, FaceService: faceService, } } // RegisterRoutes mounts all routes on the given chi router. func (h *Handler) RegisterRoutes(r chi.Router) { r.Use(middleware.Logger) r.Use(middleware.Recoverer) // Health check. r.Get("/healthz", Healthz) r.Get("/readyz", h.Readyz) // Debug endpoints (only when enabled). if h.Debug { r.Route("/debug/pprof", func(r chi.Router) { r.HandleFunc("/", pprof.Index) r.HandleFunc("/cmdline", pprof.Cmdline) r.HandleFunc("/profile", pprof.Profile) r.HandleFunc("/symbol", pprof.Symbol) r.HandleFunc("/trace", pprof.Trace) }) } // JSON API v1 routes. h.RegisterV1Routes(r) // Space management routes. h.RegisterSpaceRoutes(r) // Inspect page: server-rendered request list. r.Get("/e/{endpointID}", HandleInspect(h.Repo)) // SSE stream for live updates. r.Get("/e/{endpointID}/events", HandleSSE(h.Repo)) // Mock configuration. r.Put("/e/{endpointID}/mock", HandleMock(h.Repo)) // Replay: one-click re-send of a captured request. r.Post("/e/{endpointID}/requests/{requestID}/replay", HandleReplay(h.Repo)) // Capture webhook: any method on /{endpointID} (wildcard, must be last). r.HandleFunc("/{endpointID}", h.capture) r.HandleFunc("/{endpointID}/*", h.capture) } // capture handles incoming webhook captures on /{endpointID}. func (h *Handler) capture(w http.ResponseWriter, r *http.Request) { eid := r.PathValue("endpointID") if eid == "" { writeError(w, http.StatusBadRequest, "missing endpoint ID") return } ctx := r.Context() // Auto-create endpoint if it doesn't exist. if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil { h.Repo.CreateEndpoint(ctx, eid) } // Collect headers as JSON. hdr := map[string]string{} for k, v := range r.Header { hdr[k] = strings.Join(v, ", ") } hdrJSON, _ := json.Marshal(hdr) // Read body. var body []byte if r.Body != nil { body, _ = io.ReadAll(r.Body) r.Body.Close() } // Store the request. req := &store.Request{ Method: r.Method, Path: r.URL.Path, Headers: string(hdrJSON), Query: r.URL.RawQuery, Body: body, } if err := h.Repo.AppendRequest(ctx, eid, req); err != nil { writeError(w, http.StatusInternalServerError, "failed to store request") return } // Broadcast via SSE. broadcastRequest(eid, req) // Look up mock response for this endpoint. mock, err := h.Repo.GetMock(ctx, eid) if err == nil && mock != nil { for k, v := range mock.Headers { w.Header().Set(k, v) } w.WriteHeader(mock.Status) if mock.Body != nil { w.Write(mock.Body) } return } // Default: return 200 OK with empty JSON. w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) } // Healthz returns 200 OK for liveness probes. func Healthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("ok\n")) } // Readyz returns 200 OK if the service is ready to handle requests. // It checks database connectivity. func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) { if err := h.Repo.Ping(r.Context()); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{"error": "database not ready"}) return } w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("ok\n")) } // writeError writes a JSON error response. func writeError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // --------------------------------------------------------------------------- // Security Headers Middleware // --------------------------------------------------------------------------- // SecurityHeaders adds standard security headers to all responses. func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Content-Security-Policy", "default-src 'self'; "+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ "style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data: https:; "+ "font-src 'self' data:; "+ "connect-src 'self'; "+ "frame-ancestors 'none';") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") } next.ServeHTTP(w, r) }) } // --------------------------------------------------------------------------- // In-Memory Rate Limiter (sliding window) // --------------------------------------------------------------------------- // RateLimiter provides per-IP rate limiting without external dependencies. type RateLimiter struct { mu sync.Mutex windows map[string][]time.Time limit int window time.Duration } // NewRateLimiter creates a rate limiter with the given limit per window. func NewRateLimiter(limit int, window time.Duration) *RateLimiter { return &RateLimiter{ windows: make(map[string][]time.Time), limit: limit, window: window, } } // Limit returns chi-compatible middleware. func (rl *RateLimiter) Limit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := getClientIP(r) now := time.Now() cutoff := now.Add(-rl.window) rl.mu.Lock() slots := rl.windows[ip] fresh := slots[:0] for _, t := range slots { if t.After(cutoff) { fresh = append(fresh, t) } } if len(fresh) >= rl.limit { rl.windows[ip] = fresh rl.mu.Unlock() w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit)) w.Header().Set("X-RateLimit-Remaining", "0") http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } fresh = append(fresh, now) rl.windows[ip] = fresh rl.mu.Unlock() w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit)) w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(rl.limit-len(fresh))) next.ServeHTTP(w, r) }) } // --------------------------------------------------------------------------- // Input Validation // --------------------------------------------------------------------------- // ValidateSlug checks that a slug contains only lowercase alphanumeric and hyphens. func ValidateSlug(slug string) bool { if len(slug) < 3 || len(slug) > 100 { return false } for _, c := range slug { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { return false } } return true } // SanitizeSearchQuery strips characters that could cause injection issues. func SanitizeSearchQuery(q string) string { q = strings.ReplaceAll(q, "<", "") q = strings.ReplaceAll(q, ">", "") q = strings.ReplaceAll(q, "'", "") q = strings.ReplaceAll(q, "\"", "") q = strings.ReplaceAll(q, ";", "") q = strings.ReplaceAll(q, "--", "") return q }