package settings import ( "encoding/json" "fmt" "log" "os" "path/filepath" "sync" ) // Settings holds customer-modifiable overrides and cached state. // Persisted as a single JSON file (settings.json) in the data directory. type Settings struct { mu sync.RWMutex `json:"-"` path string `json:"-"` log *log.Logger `json:"-"` // Auth PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml // Notification preferences (Phase 2 — define struct now, leave empty) Notifications *NotificationPrefs `json:"notifications,omitempty"` // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` // Per-app backup preferences AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"` } // AppBackupPrefs holds per-app backup toggle state. type AppBackupPrefs struct { Enabled bool `json:"enabled"` } // NotificationPrefs holds customer notification preferences. type NotificationPrefs struct { Email string `json:"email,omitempty"` EnabledEvents []string `json:"enabled_events,omitempty"` CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6 } // DefaultEnabledEvents are the events enabled by default for new customers. var DefaultEnabledEvents = []string{ "disk_warning", "backup_failed", "update_available", } // DBValidationCache holds cached DB dump validation results. type DBValidationCache struct { ValidatedAt string `json:"validated_at"` // RFC3339 TableCount int `json:"table_count"` HasHeader bool `json:"has_header"` Error string `json:"error,omitempty"` } // Load reads settings from the given file path. // Returns empty Settings if the file doesn't exist (not an error). func Load(path string, logger *log.Logger) (*Settings, error) { s := &Settings{ path: path, log: logger, } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { logger.Printf("[INFO] No settings.json found, using defaults") return s, nil } return nil, fmt.Errorf("reading settings file: %w", err) } if err := json.Unmarshal(data, s); err != nil { return nil, fmt.Errorf("parsing settings file: %w", err) } logger.Printf("[DEBUG] Settings loaded from %s", path) return s, nil } // Save writes settings to disk atomically (write to .tmp, rename). // Caller must hold the write lock or call this from a method that does. func (s *Settings) save() error { data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("marshaling settings: %w", err) } tmpPath := s.path + ".tmp" if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { return fmt.Errorf("creating settings dir: %w", err) } if err := os.WriteFile(tmpPath, data, 0644); err != nil { return fmt.Errorf("writing tmp settings: %w", err) } if err := os.Rename(tmpPath, s.path); err != nil { os.Remove(tmpPath) return fmt.Errorf("renaming settings file: %w", err) } s.log.Printf("[DEBUG] Settings saved to %s", s.path) return nil } // GetPasswordHash returns the stored password hash (thread-safe). func (s *Settings) GetPasswordHash() string { s.mu.RLock() defer s.mu.RUnlock() return s.PasswordHash } // SetPasswordHash updates the password hash and saves to disk. func (s *Settings) SetPasswordHash(hash string) error { s.mu.Lock() defer s.mu.Unlock() s.PasswordHash = hash return s.save() } // GetDBValidations returns a copy of the cached DB validations. func (s *Settings) GetDBValidations() map[string]DBValidationCache { s.mu.RLock() defer s.mu.RUnlock() if s.DBValidations == nil { return nil } result := make(map[string]DBValidationCache, len(s.DBValidations)) for k, v := range s.DBValidations { result[k] = v } return result } // SetDBValidation saves a validation result for a dump file and persists to disk. func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error { s.mu.Lock() defer s.mu.Unlock() if s.DBValidations == nil { s.DBValidations = make(map[string]DBValidationCache) } s.DBValidations[filename] = cache return s.save() } // GetNotificationPrefs returns a copy of the notification preferences. func (s *Settings) GetNotificationPrefs() *NotificationPrefs { s.mu.RLock() defer s.mu.RUnlock() if s.Notifications == nil { return &NotificationPrefs{ EnabledEvents: DefaultEnabledEvents, CooldownHours: 6, } } prefs := *s.Notifications if prefs.CooldownHours == 0 { prefs.CooldownHours = 6 } if prefs.EnabledEvents == nil { prefs.EnabledEvents = DefaultEnabledEvents } // Return a copy of the slice events := make([]string, len(prefs.EnabledEvents)) copy(events, prefs.EnabledEvents) prefs.EnabledEvents = events return &prefs } // SetNotificationPrefs updates notification preferences and saves to disk. func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error { s.mu.Lock() defer s.mu.Unlock() s.Notifications = prefs return s.save() } // IsAppBackupEnabled returns whether backup is enabled for the given stack. func (s *Settings) IsAppBackupEnabled(stackName string) bool { s.mu.RLock() defer s.mu.RUnlock() if s.AppBackup == nil { return false } return s.AppBackup[stackName].Enabled } // SetAppBackup enables or disables backup for a stack and saves to disk. func (s *Settings) SetAppBackup(stackName string, enabled bool) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled} return s.save() } // GetAppBackupMap returns a map of stack_name -> enabled for all app backup prefs. func (s *Settings) GetAppBackupMap() map[string]bool { s.mu.RLock() defer s.mu.RUnlock() if s.AppBackup == nil { return nil } result := make(map[string]bool, len(s.AppBackup)) for k, v := range s.AppBackup { result[k] = v.Enabled } return result } // SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk. func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error { s.mu.Lock() defer s.mu.Unlock() s.AppBackup = make(map[string]AppBackupPrefs, len(prefs)) for name, enabled := range prefs { s.AppBackup[name] = AppBackupPrefs{Enabled: enabled} } return s.save() }