package api import ( "context" "encoding/json" "fmt" "net/http" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/discovery" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rs/zerolog/log" ) // SystemSettingsHandler handles system settings type SystemSettingsHandler struct { config *config.Config persistence *config.ConfigPersistence wsHub *websocket.Hub reloadSystemSettingsFunc func() // Function to reload cached system settings monitor interface { GetDiscoveryService() *discovery.Service StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) StopDiscoveryService() } } // NewSystemSettingsHandler creates a new system settings handler func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, wsHub *websocket.Hub, monitor interface { GetDiscoveryService() *discovery.Service StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) StopDiscoveryService() }, reloadSystemSettingsFunc func()) *SystemSettingsHandler { return &SystemSettingsHandler{ config: cfg, persistence: persistence, wsHub: wsHub, monitor: monitor, reloadSystemSettingsFunc: reloadSystemSettingsFunc, } } // SetMonitor updates the monitor reference used by the handler at runtime. func (h *SystemSettingsHandler) SetMonitor(m interface { GetDiscoveryService() *discovery.Service StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) StopDiscoveryService() }) { h.monitor = m } // validateSystemSettings validates settings before applying them func validateSystemSettings(settings *config.SystemSettings, rawRequest map[string]interface{}) error { // Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s // Legacy polling interval fields are ignored if provided if val, ok := rawRequest["pbsPollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval <= 0 { return fmt.Errorf("PBS polling interval must be positive (minimum 10 seconds)") } if interval < 10 { return fmt.Errorf("PBS polling interval must be at least 10 seconds") } if interval > 3600 { return fmt.Errorf("PBS polling interval cannot exceed 3600 seconds (1 hour)") } } else { return fmt.Errorf("PBS polling interval must be a number") } } if val, ok := rawRequest["pmgPollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval <= 0 { return fmt.Errorf("PMG polling interval must be positive (minimum 10 seconds)") } if interval < 10 { return fmt.Errorf("PMG polling interval must be at least 10 seconds") } if interval > 3600 { return fmt.Errorf("PMG polling interval cannot exceed 3600 seconds (1 hour)") } } else { return fmt.Errorf("PMG polling interval must be a number") } } // Validate boolean fields have correct type if val, ok := rawRequest["autoUpdateEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("autoUpdateEnabled must be a boolean") } } if val, ok := rawRequest["discoveryEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("discoveryEnabled must be a boolean") } } if val, ok := rawRequest["allowEmbedding"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("allowEmbedding must be a boolean") } } // Validate auto-update check interval (min 1 hour, max 7 days) if val, ok := rawRequest["autoUpdateCheckInterval"]; ok { if interval, ok := val.(float64); ok { if interval < 0 { return fmt.Errorf("auto-update check interval cannot be negative") } if interval > 0 && interval < 1 { return fmt.Errorf("auto-update check interval must be at least 1 hour") } if interval > 168 { return fmt.Errorf("auto-update check interval cannot exceed 168 hours (7 days)") } } else { return fmt.Errorf("auto-update check interval must be a number") } } // Validate connection timeout (min 1 second, max 5 minutes) if val, ok := rawRequest["connectionTimeout"]; ok { if timeout, ok := val.(float64); ok { if timeout < 0 { return fmt.Errorf("connection timeout cannot be negative") } if timeout > 0 && timeout < 1 { return fmt.Errorf("connection timeout must be at least 1 second") } if timeout > 300 { return fmt.Errorf("connection timeout cannot exceed 300 seconds (5 minutes)") } } else { return fmt.Errorf("connection timeout must be a number") } } // Validate theme if val, ok := rawRequest["theme"]; ok { if theme, ok := val.(string); ok { if theme != "" && theme != "light" && theme != "dark" { return fmt.Errorf("theme must be 'light', 'dark', or empty") } } else { return fmt.Errorf("theme must be a string") } } // Validate update channel if val, ok := rawRequest["updateChannel"]; ok { if channel, ok := val.(string); ok { if channel != "" && channel != "stable" && channel != "rc" { return fmt.Errorf("update channel must be 'stable' or 'rc'") } } else { return fmt.Errorf("update channel must be a string") } } return nil } // HandleGetSystemSettings returns the current system settings func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } settings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load system settings") settings = &config.SystemSettings{} } // Log loaded settings for debugging if settings != nil { log.Debug(). Str("theme", settings.Theme). Msg("Loaded system settings for API response") } // Include env override information response := struct { *config.SystemSettings EnvOverrides map[string]bool `json:"envOverrides,omitempty"` }{ SystemSettings: settings, EnvOverrides: h.config.EnvOverrides, } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write system settings response") } } // HandleUpdateSystemSettings updates the system settings func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.config, w, r) { return } // Check if using proxy auth and if so, verify admin status if h.config.ProxyAuthSecret != "" { if valid, username, isAdmin := CheckProxyAuth(h.config, r); valid { if !isAdmin { // User is authenticated but not an admin log.Warn(). Str("ip", r.RemoteAddr). Str("path", r.URL.Path). Str("method", r.Method). Str("username", username). Msg("Non-admin user attempted to update system settings") // Return forbidden error utils.WriteJSONError(w, "Admin privileges required", http.StatusForbidden) return } } } // Load existing settings first to preserve fields not in the request existingSettings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load existing settings") existingSettings = &config.SystemSettings{} } if existingSettings == nil { existingSettings = &config.SystemSettings{} } // Read the request body into a map to check which fields were provided var rawRequest map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&rawRequest); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Convert the map back to JSON for decoding into struct jsonBytes, err := json.Marshal(rawRequest) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Decode into updates struct var updates config.SystemSettings if err := json.Unmarshal(jsonBytes, &updates); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Validate the settings if err := validateSystemSettings(&updates, rawRequest); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Start with existing settings settings := *existingSettings // Only update fields that were provided in the request // Note: PVE polling is hardcoded to 10s, legacy polling fields are ignored if _, ok := rawRequest["pbsPollingInterval"]; ok { settings.PBSPollingInterval = updates.PBSPollingInterval } if _, ok := rawRequest["pmgPollingInterval"]; ok { settings.PMGPollingInterval = updates.PMGPollingInterval } if updates.AllowedOrigins != "" { settings.AllowedOrigins = updates.AllowedOrigins } if _, ok := rawRequest["connectionTimeout"]; ok { settings.ConnectionTimeout = updates.ConnectionTimeout } if updates.UpdateChannel != "" { settings.UpdateChannel = updates.UpdateChannel } if _, ok := rawRequest["autoUpdateCheckInterval"]; ok { settings.AutoUpdateCheckInterval = updates.AutoUpdateCheckInterval } if updates.AutoUpdateTime != "" { settings.AutoUpdateTime = updates.AutoUpdateTime } if updates.Theme != "" { settings.Theme = updates.Theme } if updates.DiscoverySubnet != "" { settings.DiscoverySubnet = updates.DiscoverySubnet } // Allow clearing of AllowedEmbedOrigins by setting to empty string if _, ok := rawRequest["allowedEmbedOrigins"]; ok { settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins } // Boolean fields need special handling since false is a valid value if _, ok := rawRequest["autoUpdateEnabled"]; ok { settings.AutoUpdateEnabled = updates.AutoUpdateEnabled } if _, ok := rawRequest["discoveryEnabled"]; ok { settings.DiscoveryEnabled = updates.DiscoveryEnabled } if _, ok := rawRequest["allowEmbedding"]; ok { settings.AllowEmbedding = updates.AllowEmbedding } // Update the config // Note: PVE polling is hardcoded to 10s if settings.AllowedOrigins != "" { h.config.AllowedOrigins = settings.AllowedOrigins } if settings.ConnectionTimeout > 0 { h.config.ConnectionTimeout = time.Duration(settings.ConnectionTimeout) * time.Second } if settings.PMGPollingInterval > 0 { h.config.PMGPollingInterval = time.Duration(settings.PMGPollingInterval) * time.Second } if settings.UpdateChannel != "" { h.config.UpdateChannel = settings.UpdateChannel } // Update auto-update settings h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled if settings.AutoUpdateCheckInterval > 0 { h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour } if settings.AutoUpdateTime != "" { h.config.AutoUpdateTime = settings.AutoUpdateTime } // Validate theme if provided if settings.Theme != "" && settings.Theme != "light" && settings.Theme != "dark" { http.Error(w, "Invalid theme value. Must be 'light', 'dark', or empty", http.StatusBadRequest) return } // Update discovery settings and manage the service prevDiscoveryEnabled := h.config.DiscoveryEnabled h.config.DiscoveryEnabled = settings.DiscoveryEnabled if settings.DiscoverySubnet != "" { h.config.DiscoverySubnet = settings.DiscoverySubnet } // Start or stop discovery service based on setting change if h.monitor != nil { if settings.DiscoveryEnabled && !prevDiscoveryEnabled { // Discovery was just enabled, start the service subnet := h.config.DiscoverySubnet if subnet == "" { subnet = "auto" } h.monitor.StartDiscoveryService(context.Background(), h.wsHub, subnet) log.Info().Msg("Discovery service started via settings update") } else if !settings.DiscoveryEnabled && prevDiscoveryEnabled { // Discovery was just disabled, stop the service h.monitor.StopDiscoveryService() log.Info().Msg("Discovery service stopped via settings update") } else if settings.DiscoveryEnabled && settings.DiscoverySubnet != "" { // Subnet changed while discovery is enabled, update it if svc := h.monitor.GetDiscoveryService(); svc != nil { svc.SetSubnet(settings.DiscoverySubnet) } } } // Save to persistence if err := h.persistence.SaveSystemSettings(settings); err != nil { log.Error().Err(err).Msg("Failed to save system settings") http.Error(w, "Failed to save settings", http.StatusInternalServerError) return } // Reload cached system settings after successful save if h.reloadSystemSettingsFunc != nil { h.reloadSystemSettingsFunc() } log.Info().Msg("System settings updated") // Broadcast theme change to all connected clients if theme was updated if settings.Theme != "" && h.wsHub != nil { h.wsHub.BroadcastMessage(websocket.Message{ Type: "settingsUpdate", Data: map[string]interface{}{ "theme": settings.Theme, }, }) log.Debug().Str("theme", settings.Theme).Msg("Broadcasting theme change to WebSocket clients") } if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil { log.Error().Err(err).Msg("Failed to write system settings update response") } }