mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
- Fixed /api/config/system endpoint to return all persisted settings fields including allowEmbedding, discoveryEnabled, and allowedEmbedOrigins - Added comprehensive input validation for all settings with proper min/max bounds - Fixed security headers to properly allow/deny iframe embedding based on user preference - Added real bug detection test suite that validates behavior, not just status codes The iframe embedding checkbox now properly persists its state and actually controls whether Pulse can be embedded in iframes. When enabled, removes X-Frame-Options header and sets CSP frame-ancestors to allow embedding.
387 lines
No EOL
12 KiB
Go
387 lines
No EOL
12 KiB
Go
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/websocket"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// SystemSettingsHandler handles system settings
|
|
type SystemSettingsHandler struct {
|
|
config *config.Config
|
|
persistence *config.ConfigPersistence
|
|
wsHub *websocket.Hub
|
|
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()
|
|
}) *SystemSettingsHandler {
|
|
return &SystemSettingsHandler{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
wsHub: wsHub,
|
|
monitor: monitor,
|
|
}
|
|
}
|
|
|
|
// validateSystemSettings validates settings before applying them
|
|
func validateSystemSettings(settings *config.SystemSettings, rawRequest map[string]interface{}) error {
|
|
// Validate polling intervals (min 1 second, max 1 hour)
|
|
if val, ok := rawRequest["pollingInterval"]; ok {
|
|
if interval, ok := val.(float64); ok {
|
|
if interval <= 0 {
|
|
return fmt.Errorf("polling interval must be positive (minimum 1 second)")
|
|
}
|
|
if interval < 1 {
|
|
return fmt.Errorf("polling interval must be at least 1 second")
|
|
}
|
|
if interval > 3600 {
|
|
return fmt.Errorf("polling interval cannot exceed 3600 seconds (1 hour)")
|
|
}
|
|
} else {
|
|
return fmt.Errorf("polling interval must be a number")
|
|
}
|
|
}
|
|
|
|
if val, ok := rawRequest["pvePollingInterval"]; ok {
|
|
if interval, ok := val.(float64); ok {
|
|
if interval <= 0 {
|
|
return fmt.Errorf("PVE polling interval must be positive (minimum 1 second)")
|
|
}
|
|
if interval < 1 {
|
|
return fmt.Errorf("PVE polling interval must be at least 1 second")
|
|
}
|
|
if interval > 3600 {
|
|
return fmt.Errorf("PVE polling interval cannot exceed 3600 seconds (1 hour)")
|
|
}
|
|
} else {
|
|
return fmt.Errorf("PVE polling interval must be a number")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
PollingInterval: 5,
|
|
}
|
|
}
|
|
|
|
// Log loaded settings for debugging
|
|
if settings != nil {
|
|
log.Debug().
|
|
Int("pollingInterval", settings.PollingInterval).
|
|
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,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(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
|
|
}
|
|
|
|
// 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
|
|
// Use rawRequest to check if field was provided (allows setting to 0 to clear)
|
|
if _, ok := rawRequest["pollingInterval"]; ok {
|
|
settings.PollingInterval = updates.PollingInterval
|
|
}
|
|
if _, ok := rawRequest["pvePollingInterval"]; ok {
|
|
settings.PVEPollingInterval = updates.PVEPollingInterval
|
|
}
|
|
if _, ok := rawRequest["pbsPollingInterval"]; ok {
|
|
settings.PBSPollingInterval = updates.PBSPollingInterval
|
|
}
|
|
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
|
|
if settings.PollingInterval > 0 {
|
|
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
|
|
}
|
|
if settings.AllowedOrigins != "" {
|
|
h.config.AllowedOrigins = settings.AllowedOrigins
|
|
}
|
|
if settings.ConnectionTimeout > 0 {
|
|
h.config.ConnectionTimeout = time.Duration(settings.ConnectionTimeout) * 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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
} |