package settings import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" ) // 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:"-"` debug bool `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"` // Storage paths registry StoragePaths []StoragePath `json:"storage_paths,omitempty"` // Cross-drive restic repo password (auto-generated on first use) CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` // Hub verification state HubVerified bool `json:"hub_verified,omitempty"` HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339 HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339 // Recovery credentials (saved from setup wizard input) RetrievalPassword string `json:"retrieval_password,omitempty"` // Pending events (queued for next Hub push) PendingEvents []PendingEvent `json:"pending_events,omitempty"` // Geo-restriction settings (Cloudflare WAF rules) GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"` // App-to-app integration state (e.g., "onlyoffice:filebrowser" → state) Integrations map[string]IntegrationState `json:"integrations,omitempty"` } // IntegrationState holds the state of a provider:target integration pair. type IntegrationState struct { Enabled bool `json:"enabled"` EnabledAt string `json:"enabled_at,omitempty"` // RFC3339 Status string `json:"status,omitempty"` // "active", "error", "disabled", "provider_stopped", "target_unavailable" LastError string `json:"last_error,omitempty"` } // AppBackupPrefs holds per-app backup toggle state. type AppBackupPrefs struct { // Existing: includes app data in nightly restic (same drive) Enabled bool `json:"enabled"` // Cross-drive backup to secondary storage CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. type CrossDriveBackup struct { Enabled bool `json:"enabled"` Method string `json:"method"` // "rsync" or "restic" DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1" Schedule string `json:"schedule"` // "daily", "weekly", "manual" // Runtime state (updated by backup runner, persisted for display) LastRun string `json:"last_run,omitempty"` // RFC3339 LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" LastError string `json:"last_error,omitempty"` LastDuration string `json:"last_duration,omitempty"` // "2m34s" LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" } // StoragePath represents a registered external storage location. type StoragePath struct { Path string `json:"path"` // e.g., "/mnt/hdd_1" Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB" IsDefault bool `json:"is_default,omitempty"` // new apps use this by default Schedulable bool `json:"schedulable"` // whether new apps can be deployed here AddedAt string `json:"added_at"` // RFC3339 Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp MigratedTo string `json:"migrated_to,omitempty"` // path of target drive } // 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{ "backup_failed", "db_dump_failed", "disk_warning", "disk_critical", "storage_disconnected", "node_down", "health_critical", "expected_backup_missed", "expected_dbdump_missed", } // PendingEvent is an event queued for the next Hub push cycle. type PendingEvent struct { EventType string `json:"event_type"` Severity string `json:"severity"` Message string `json:"message"` Details string `json:"details"` // JSON string CreatedAt string `json:"created_at"` // RFC3339 } // GeoRestriction holds global and per-app geo-restriction settings. type GeoRestriction struct { Enabled bool `json:"enabled"` AllowedCountries []string `json:"allowed_countries"` AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"` // Sync state (updated by geo sync manager) LastSync string `json:"last_sync,omitempty"` // RFC3339 LastSyncError string `json:"last_sync_error,omitempty"` ZoneID string `json:"zone_id,omitempty"` // cached Cloudflare zone ID RulesetID string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID } // AppGeoOverride holds per-app country override. type AppGeoOverride struct { AllowedCountries []string `json:"allowed_countries"` } // 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"` } // SetDebug enables or disables debug logging for settings operations. func (s *Settings) SetDebug(debug bool) { s.debug = debug } // 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] [settings] 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("[INFO] [settings] Loaded settings from %s", path) if s.debug { s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d", len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents)) } s.migrateResticToRsync() return s, nil } // migrateResticToRsync converts any cross-drive backup configs using restic to rsync. // Called once during Load() before the mutex is exposed. func (s *Settings) migrateResticToRsync() { changed := false for name, prefs := range s.AppBackup { if prefs.CrossDrive != nil && prefs.CrossDrive.Method == "restic" { prefs.CrossDrive.Method = "rsync" s.AppBackup[name] = prefs if s.log != nil { s.log.Printf("[INFO] [settings] Migrated cross-drive backup for %s from restic to rsync", name) } changed = true } } if changed { if err := s.save(); err != nil && s.log != nil { s.log.Printf("[ERROR] [settings] Failed to save restic→rsync migration: %v", err) } } } // 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 { if s.log != nil { s.log.Printf("[ERROR] [settings] Failed to save: %v", err) } return fmt.Errorf("marshaling settings: %w", err) } tmpPath := s.path + ".tmp" if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { if s.log != nil { s.log.Printf("[ERROR] [settings] Failed to save: %v", err) } return fmt.Errorf("creating settings dir: %w", err) } if err := os.WriteFile(tmpPath, data, 0644); err != nil { os.Remove(tmpPath) // clean up partial file if s.log != nil { s.log.Printf("[ERROR] [settings] Failed to save: %v", err) } return fmt.Errorf("writing tmp settings: %w", err) } if err := os.Rename(tmpPath, s.path); err != nil { os.Remove(tmpPath) if s.log != nil { s.log.Printf("[ERROR] [settings] Failed to save: %v", err) } return fmt.Errorf("renaming settings file: %w", err) } if s.debug { s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data)) } if s.log != nil { s.log.Printf("[INFO] [settings] Settings saved") } 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 { events := make([]string, len(DefaultEnabledEvents)) copy(events, DefaultEnabledEvents) return &NotificationPrefs{ EnabledEvents: events, 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. // H17: Deep-copies prefs so caller mutations after the call don't affect stored state. func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error { if prefs == nil { return fmt.Errorf("notification preferences cannot be nil") } s.mu.Lock() defer s.mu.Unlock() cp := *prefs if len(prefs.EnabledEvents) > 0 { cp.EnabledEvents = make([]string, len(prefs.EnabledEvents)) for i, e := range prefs.EnabledEvents { cp.EnabledEvents[i] = e } } s.Notifications = &cp return s.save() } // GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set). func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup { s.mu.RLock() defer s.mu.RUnlock() if s.AppBackup == nil { return nil } prefs, ok := s.AppBackup[stackName] if !ok || prefs.CrossDrive == nil { return nil } cp := *prefs.CrossDrive return &cp } // SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack. func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } existing := s.AppBackup[stackName] existing.CrossDrive = cfg s.AppBackup[stackName] = existing return s.save() } // UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place. // fn receives a pointer to the CrossDriveBackup and may mutate it. // If no cross-drive config exists for the stack, does nothing and returns nil. func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } existing := s.AppBackup[stackName] if existing.CrossDrive == nil { return nil // don't create config from thin air — just skip status update } fn(existing.CrossDrive) s.AppBackup[stackName] = existing return s.save() } // GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not). func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup { s.mu.RLock() defer s.mu.RUnlock() result := make(map[string]*CrossDriveBackup) for name, prefs := range s.AppBackup { if prefs.CrossDrive != nil { cp := *prefs.CrossDrive result[name] = &cp } } return result } // NOTE: GetCrossDriveResticPassword, SetCrossDriveResticPassword, and // GetOrCreateCrossDrivePassword were removed in the Tier 2 restic deprecation. // The CrossDriveResticPassword field is kept in the struct for backward-compat // JSON loading but is no longer used. // --- Storage Paths --- // GetStoragePaths returns a copy of all registered storage paths. func (s *Settings) GetStoragePaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() if len(s.StoragePaths) == 0 { return nil } result := make([]StoragePath, len(s.StoragePaths)) copy(result, s.StoragePaths) return result } // GetDefaultStoragePath returns the default storage path string, or "". func (s *Settings) GetDefaultStoragePath() string { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.IsDefault { return sp.Path } } return "" } // GetStorageLabel returns the label for a storage path, or the base name if not found. func (s *Settings) GetStorageLabel(path string) string { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path && sp.Label != "" { return sp.Label } } return filepath.Base(path) } // GetSchedulableStoragePaths returns paths available for new deployments. func (s *Settings) GetSchedulableStoragePaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if sp.Schedulable && !sp.Decommissioned { result = append(result, sp) } } return result } // AddStoragePath registers a new storage path. Validation is done by caller. func (s *Settings) AddStoragePath(sp StoragePath) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault) } for _, existing := range s.StoragePaths { if existing.Path == sp.Path { return fmt.Errorf("storage path %q already registered", sp.Path) } } if sp.IsDefault { for i := range s.StoragePaths { s.StoragePaths[i].IsDefault = false } } s.StoragePaths = append(s.StoragePaths, sp) if s.log != nil { s.log.Printf("[INFO] [settings] Added storage path: %s", sp.Path) } return s.save() } // RemoveStoragePath removes a path by its path string. func (s *Settings) RemoveStoragePath(path string) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path) } var kept []StoragePath for _, sp := range s.StoragePaths { if sp.Path != path { kept = append(kept, sp) } } s.StoragePaths = kept if s.log != nil { s.log.Printf("[INFO] [settings] Removed storage path: %s", path) } return s.save() } // SetDefaultStoragePath changes which path is the default. func (s *Settings) SetDefaultStoragePath(path string) error { s.mu.Lock() defer s.mu.Unlock() found := false for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].IsDefault = true found = true } else { s.StoragePaths[i].IsDefault = false } } if !found { return fmt.Errorf("storage path %q not found", path) } return s.save() } // SetSchedulable enables/disables a path for new deployments. func (s *Settings) SetSchedulable(path string, schedulable bool) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Schedulable = schedulable return s.save() } } return fmt.Errorf("storage path %q not found", path) } // SetStorageLabel updates the label for a storage path. func (s *Settings) SetStorageLabel(path, label string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Label = label return s.save() } } return fmt.Errorf("storage path %q not found", path) } // AutoDiscoverStoragePaths scans for HDD_PATH values and registers them if none exist. // discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml. // fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty). func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths)) } if len(s.StoragePaths) > 0 { return // already configured } seen := make(map[string]bool) var ordered []string for _, p := range discoveredPaths { cleaned := filepath.Clean(p) if cleaned != "" && !seen[cleaned] { seen[cleaned] = true ordered = append(ordered, cleaned) } } if fallbackHDDPath != "" { cleaned := filepath.Clean(fallbackHDDPath) if !seen[cleaned] { seen[cleaned] = true ordered = append(ordered, cleaned) } } for i, path := range ordered { sp := StoragePath{ Path: path, Label: InferStorageLabel(path), IsDefault: i == 0, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } s.StoragePaths = append(s.StoragePaths, sp) } if len(s.StoragePaths) > 0 { if err := s.save(); err != nil { logger.Printf("[ERROR] [settings] Failed to save auto-discovered storage paths: %v", err) return } logger.Printf("[INFO] [settings] Auto-discovered %d storage path(s)", len(s.StoragePaths)) for _, sp := range s.StoragePaths { logger.Printf("[INFO] [settings] %s (%s) default=%v", sp.Path, sp.Label, sp.IsDefault) } } } // InferStorageLabel generates a human-readable label for a storage path. func InferStorageLabel(path string) string { base := filepath.Base(path) if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") { return fmt.Sprintf("Külső tárhely (%s)", base) } return fmt.Sprintf("Tárhely (%s)", base) } // SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped. func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks)) } if s.log != nil { s.log.Printf("[INFO] [settings] Storage path %s disconnected=%v", path, disconnected) } for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Disconnected = disconnected if disconnected { s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339) s.StoragePaths[i].StoppedStacks = stoppedStacks } else { s.StoragePaths[i].DisconnectedAt = "" // Preserve StoppedStacks on reconnect so the UI can offer restart if stoppedStacks != nil { s.StoragePaths[i].StoppedStacks = stoppedStacks } } return s.save() } } return fmt.Errorf("storage path %q not found", path) } // ClearDisconnected marks a path as connected and clears all disconnect-related fields. func (s *Settings) ClearDisconnected(path string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Disconnected = false s.StoragePaths[i].DisconnectedAt = "" s.StoragePaths[i].StoppedStacks = nil return s.save() } } return fmt.Errorf("storage path %q not found", path) } // IsDisconnected returns whether a storage path is marked as disconnected. func (s *Settings) IsDisconnected(path string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path { return sp.Disconnected } } return false } // GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected. func (s *Settings) GetDisconnectedPaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if sp.Disconnected { result = append(result, sp) } } return result } // GetConnectedPaths returns a copy of all storage paths that are NOT disconnected and NOT decommissioned. func (s *Settings) GetConnectedPaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if !sp.Disconnected && !sp.Decommissioned { result = append(result, sp) } } return result } // IsStoragePathKnown returns whether a path belongs to any registered storage path // (connected, disconnected, or decommissioned). A path removed entirely from // storage_paths is NOT known. func (s *Settings) IsStoragePathKnown(path string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") { return true } } return false } // IsStoragePathSchedulable returns whether a path belongs to a registered, // schedulable (active) storage path. Returns false if the path is unknown, // disconnected, decommissioned, or inactive. func (s *Settings) IsStoragePathSchedulable(path string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") { return sp.Schedulable && !sp.Disconnected && !sp.Decommissioned } } return false } // GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path. func (s *Settings) GetStoppedStacks(path string) []string { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path { if len(sp.StoppedStacks) == 0 { return nil } result := make([]string, len(sp.StoppedStacks)) copy(result, sp.StoppedStacks) return result } } return nil } // ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart). func (s *Settings) ClearStoppedStacks(path string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].StoppedStacks = nil return s.save() } } return fmt.Errorf("storage path %q not found", path) } // SetDecommissioned marks a storage path as decommissioned with migration target. // Clears IsDefault and Schedulable. func (s *Settings) SetDecommissioned(path, migratedTo string) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo) } if s.log != nil { s.log.Printf("[INFO] [settings] Storage path %s decommissioned (migrated_to=%s)", path, migratedTo) } for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Decommissioned = true s.StoragePaths[i].DecommissionedAt = time.Now().UTC().Format(time.RFC3339) s.StoragePaths[i].MigratedTo = migratedTo s.StoragePaths[i].IsDefault = false s.StoragePaths[i].Schedulable = false return s.save() } } return fmt.Errorf("storage path %q not found", path) } // ClearDecommissioned removes the decommissioned state from a storage path. func (s *Settings) ClearDecommissioned(path string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Decommissioned = false s.StoragePaths[i].DecommissionedAt = "" s.StoragePaths[i].MigratedTo = "" return s.save() } } return fmt.Errorf("storage path %q not found", path) } // IsDecommissioned returns whether a storage path is marked as decommissioned. func (s *Settings) IsDecommissioned(path string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path { return sp.Decommissioned } } return false } // GetDecommissionedPaths returns a copy of all decommissioned storage paths. func (s *Settings) GetDecommissionedPaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if sp.Decommissioned { result = append(result, sp) } } return result } // --- Hub Verification --- // GetHubVerified returns the hub verification state. func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) { s.mu.RLock() defer s.mu.RUnlock() return s.HubVerified, s.HubVerifiedAt } // SetHubVerified updates the hub verification state and saves to disk. func (s *Settings) SetHubVerified(verified bool, at time.Time) error { s.mu.Lock() defer s.mu.Unlock() s.HubVerified = verified s.HubVerifiedAt = at.UTC().Format(time.RFC3339) s.HubLastCheck = at.UTC().Format(time.RFC3339) return s.save() } // SetHubLastCheck updates the last Hub check timestamp without changing verification status. func (s *Settings) SetHubLastCheck(at time.Time) error { s.mu.Lock() defer s.mu.Unlock() s.HubLastCheck = at.UTC().Format(time.RFC3339) return s.save() } // IsLimitedMode returns true if the controller should operate in limited mode // (new deployments blocked). This happens when: // - Never verified AND >7 days since controller started, OR // - Hub explicitly set customer as blocked (HubVerified=false after a successful check) func (s *Settings) IsLimitedMode() bool { s.mu.RLock() defer s.mu.RUnlock() if s.HubVerified { return false } // If we have a last check timestamp and it says not verified, limited mode if s.HubLastCheck != "" { return true } // Never checked yet — check if grace period (7 days) expired if s.HubVerifiedAt == "" { // No verification timestamp at all — not yet in limited mode (grace period from startup) return false } t, err := time.Parse(time.RFC3339, s.HubVerifiedAt) if err != nil { return false } return time.Since(t) > 7*24*time.Hour } // --- Retrieval Password --- // GetRetrievalPassword returns the stored retrieval password (thread-safe). func (s *Settings) GetRetrievalPassword() string { s.mu.RLock() defer s.mu.RUnlock() return s.RetrievalPassword } // SetRetrievalPassword updates the retrieval password and saves to disk. func (s *Settings) SetRetrievalPassword(password string) error { s.mu.Lock() defer s.mu.Unlock() s.RetrievalPassword = password return s.save() } // --- Pending Events --- // AddPendingEvent queues an event for the next Hub push cycle. func (s *Settings) AddPendingEvent(event PendingEvent) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity) } if s.log != nil { s.log.Printf("[INFO] [settings] Added pending event: %s", event.EventType) } s.PendingEvents = append(s.PendingEvents, event) return s.save() } // DrainPendingEvents returns and clears all pending events (thread-safe). func (s *Settings) DrainPendingEvents() []PendingEvent { s.mu.Lock() defer s.mu.Unlock() if len(s.PendingEvents) == 0 { return nil } if s.debug { s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents)) } events := make([]PendingEvent, len(s.PendingEvents)) copy(events, s.PendingEvents) s.PendingEvents = nil if err := s.save(); err != nil { s.log.Printf("[ERROR] [settings] Failed to save after draining pending events: %v — restoring events", err) s.PendingEvents = events return nil } return events } // --- Geo-Restriction --- // GetGeoRestriction returns a deep copy of the geo-restriction settings. func (s *Settings) GetGeoRestriction() *GeoRestriction { s.mu.RLock() defer s.mu.RUnlock() if s.GeoRestriction == nil { return nil } geo := *s.GeoRestriction if len(s.GeoRestriction.AllowedCountries) > 0 { geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries)) copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries) } if len(s.GeoRestriction.AppOverrides) > 0 { geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides)) for k, v := range s.GeoRestriction.AppOverrides { ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))} copy(ov.AllowedCountries, v.AllowedCountries) geo.AppOverrides[k] = ov } } return &geo } // SetGeoRestriction replaces the entire geo-restriction config and saves to disk. func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { if geo == nil { s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)") } else { s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries)) } } if geo == nil { s.GeoRestriction = nil return s.save() } cp := *geo if len(geo.AllowedCountries) > 0 { cp.AllowedCountries = make([]string, len(geo.AllowedCountries)) copy(cp.AllowedCountries, geo.AllowedCountries) } if len(geo.AppOverrides) > 0 { cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides)) for k, v := range geo.AppOverrides { ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))} copy(ov.AllowedCountries, v.AllowedCountries) cp.AppOverrides[k] = ov } } s.GeoRestriction = &cp return s.save() } // SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil. // Pass override=nil to remove the override (same as RemoveGeoAppOverride). func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error { s.mu.Lock() defer s.mu.Unlock() if override == nil { // nil override = remove (fall back to global) if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil { delete(s.GeoRestriction.AppOverrides, appName) } return s.save() } if s.GeoRestriction == nil { s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}} } if s.GeoRestriction.AppOverrides == nil { s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride) } ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))} copy(ov.AllowedCountries, override.AllowedCountries) s.GeoRestriction.AppOverrides[appName] = ov return s.save() } // RemoveGeoAppOverride removes a per-app override (app falls back to global). func (s *Settings) RemoveGeoAppOverride(appName string) error { s.mu.Lock() defer s.mu.Unlock() if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil { return nil } delete(s.GeoRestriction.AppOverrides, appName) return s.save() } // SetGeoSyncState updates the geo sync status fields. func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error { s.mu.Lock() defer s.mu.Unlock() if s.GeoRestriction == nil { return nil } s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339) s.GeoRestriction.LastSyncError = syncError if zoneID != "" { s.GeoRestriction.ZoneID = zoneID } if rulesetID != "" { s.GeoRestriction.RulesetID = rulesetID } return s.save() } // --- App-to-app integrations --- // GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser"). func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) { s.mu.RLock() defer s.mu.RUnlock() if s.Integrations == nil { return IntegrationState{}, false } state, ok := s.Integrations[key] return state, ok } // SetIntegrationState updates (or creates) the state for a single integration key. func (s *Settings) SetIntegrationState(key string, state IntegrationState) error { s.mu.Lock() defer s.mu.Unlock() if s.debug { s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled) } if s.Integrations == nil { s.Integrations = make(map[string]IntegrationState) } s.Integrations[key] = state return s.save() } // RemoveIntegrationState removes an integration key entirely. func (s *Settings) RemoveIntegrationState(key string) error { s.mu.Lock() defer s.mu.Unlock() if s.Integrations != nil { delete(s.Integrations, key) } return s.save() } // GetIntegrationsForProvider returns all integration states where key starts with "provider:". func (s *Settings) GetIntegrationsForProvider(provider string) map[string]IntegrationState { s.mu.RLock() defer s.mu.RUnlock() prefix := provider + ":" result := make(map[string]IntegrationState) for k, v := range s.Integrations { if strings.HasPrefix(k, prefix) { result[k] = v } } return result } // GetIntegrationsForTarget returns all integration states where key ends with ":target". func (s *Settings) GetIntegrationsForTarget(target string) map[string]IntegrationState { s.mu.RLock() defer s.mu.RUnlock() suffix := ":" + target result := make(map[string]IntegrationState) for k, v := range s.Integrations { if strings.HasSuffix(k, suffix) { result[k] = v } } return result }