package v1 import ( "bufio" "fmt" "log/slog" "net" "net/http" "strings" "time" "github.com/gorilla/mux" ) // statusResponseWriter wraps http.ResponseWriter to capture the status code. // Implements http.Hijacker so WebSocket upgrades work through the proxy. type statusResponseWriter struct { http.ResponseWriter status int } func (w *statusResponseWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *statusResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := w.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() } return nil, nil, fmt.Errorf("upstream ResponseWriter does not implement http.Hijacker") } func (w *statusResponseWriter) Flush() { if fl, ok := w.ResponseWriter.(http.Flusher); ok { fl.Flush() } } // BearerAuthMiddleware returns middleware that requires a valid Bearer token // on all API endpoints except health checks and the SSE event stream. func BearerAuthMiddleware(token string) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Public endpoints — no auth required if path == "/api/v1/health" || path == "/api/v1/health/reconcile" || strings.HasSuffix(path, "/events") { next.ServeHTTP(w, r) return } // Non-API paths (frontend static files) — no auth required if !strings.HasPrefix(path, "/api/") { next.ServeHTTP(w, r) return } auth := r.Header.Get("Authorization") if auth == "" { http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized) return } const prefix = "Bearer " if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } } // RequestLoggingMiddleware logs method, path, status, and duration for each request. // Long-lived connections (SSE, WebSocket) are excluded. func RequestLoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Skip SSE and WebSocket endpoints (long-lived connections) if strings.HasSuffix(path, "/events") || strings.HasSuffix(path, "/ws") || strings.HasSuffix(path, "/stream") { next.ServeHTTP(w, r) return } // Security response headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Cache-Control", "no-store") start := time.Now() sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) attrs := []any{ "status", sw.status, "method", r.Method, "path", path, "duration", time.Since(start), } // Add route params if present vars := mux.Vars(r) if domain := vars["domain"]; domain != "" { attrs = append(attrs, "domain", domain) } if sw.status >= 400 { slog.Error("request", attrs...) } else { slog.Info("request", attrs...) } }) }