// Package api provides HTTP handlers and middleware for the Silo API. package api import ( "net/http" "strings" "time" "github.com/go-chi/chi/v5/middleware" "github.com/kindredsystems/silo/internal/auth" "github.com/rs/zerolog" ) // RequestLogger returns a middleware that logs HTTP requests using zerolog. func RequestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) defer func() { logger.Info(). Str("method", r.Method). Str("path", r.URL.Path). Str("query", r.URL.RawQuery). Int("status", ww.Status()). Int("bytes", ww.BytesWritten()). Dur("duration", time.Since(start)). Str("remote", r.RemoteAddr). Str("user_agent", r.UserAgent()). Msg("request") }() next.ServeHTTP(ww, r) }) } } // Recoverer returns a middleware that recovers from panics and logs them. func Recoverer(logger zerolog.Logger) func(next 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 { logger.Error(). Interface("panic", rec). Str("method", r.Method). Str("path", r.URL.Path). Msg("panic recovered") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } } // RequireAuth extracts the user from a session cookie or API token and injects // it into the request context. If auth is disabled, injects a synthetic dev user. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Dev mode: inject synthetic admin user if s.authConfig == nil || !s.authConfig.Enabled { devUser := &auth.User{ ID: "00000000-0000-0000-0000-000000000000", Username: "dev", DisplayName: "Developer", Email: "dev@localhost", Role: auth.RoleAdmin, AuthSource: "local", } ctx := auth.ContextWithUser(r.Context(), devUser) next.ServeHTTP(w, r.WithContext(ctx)) return } // 1. Check for API token (Authorization: Bearer silo_...) if token := extractBearerToken(r); token != "" { user, err := s.auth.ValidateToken(r.Context(), token) if err != nil { writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired API token") return } ctx := auth.ContextWithUser(r.Context(), user) next.ServeHTTP(w, r.WithContext(ctx)) return } // 2. Check session if s.sessions != nil { userID := s.sessions.GetString(r.Context(), "user_id") if userID != "" { user, err := s.auth.GetUserByID(r.Context(), userID) if err == nil && user != nil { ctx := auth.ContextWithUser(r.Context(), user) next.ServeHTTP(w, r.WithContext(ctx)) return } // Invalid session — destroy it _ = s.sessions.Destroy(r.Context()) } } // 3. Not authenticated if isAPIRequest(r) { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") } else { next := r.URL.Path if r.URL.RawQuery != "" { next += "?" + r.URL.RawQuery } http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther) } }) } // RequireRole returns middleware that rejects users below the given role. // Role hierarchy: admin > editor > viewer. func (s *Server) RequireRole(minimum string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } if !auth.RoleSatisfies(user.Role, minimum) { writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions: requires "+minimum+" role") return } next.ServeHTTP(w, r) }) } } func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { return strings.TrimPrefix(h, "Bearer ") } return "" } func isAPIRequest(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/api/") }