// Package api provides HTTP handlers and middleware for the Silo API. package api import ( "crypto/sha256" "encoding/hex" "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) }) } } // RequireWritable rejects requests with 503 when the server is in read-only mode. func (s *Server) RequireWritable(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.serverState.IsReadOnly() { writeError(w, http.StatusServiceUnavailable, "server_read_only", "Server is in read-only mode") return } next.ServeHTTP(w, r) }) } // RequireRunnerAuth extracts and validates a runner token from the // Authorization header. On success, injects RunnerIdentity into context // and updates the runner's heartbeat. func (s *Server) RequireRunnerAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) if token == "" || !strings.HasPrefix(token, "silo_runner_") { writeError(w, http.StatusUnauthorized, "unauthorized", "Runner token required") return } hash := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(hash[:]) runner, err := s.jobs.GetRunnerByToken(r.Context(), tokenHash) if err != nil || runner == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid runner token") return } // Update heartbeat on every authenticated request _ = s.jobs.Heartbeat(r.Context(), runner.ID) identity := &auth.RunnerIdentity{ ID: runner.ID, Name: runner.Name, Tags: runner.Tags, } ctx := auth.ContextWithRunner(r.Context(), identity) next.ServeHTTP(w, r.WithContext(ctx)) }) } // RequireModule returns middleware that rejects requests with 404 when // the named module is not enabled. func (s *Server) RequireModule(id string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.modules.IsEnabled(id) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"error":"module '` + id + `' is not enabled"}`)) 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/") }