package integrations import ( "context" "fmt" "log" "path/filepath" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // StackProvider abstracts stacks.Manager to break circular imports. type StackProvider interface { GetStack(name string) (*stacks.Stack, bool) GetStacks() []stacks.Stack RestartStack(name string) error } // Manager coordinates integration apply/revoke and lifecycle hooks. type Manager struct { sett *settings.Settings stacks StackProvider domain string stacksDir string encKey []byte logger *log.Logger handlers map[string]Handler // key: "provider:target" -> Handler mu sync.Mutex // serialize apply/revoke operations debug bool } // NewManager creates an integration manager and registers built-in handlers. func NewManager(sett *settings.Settings, sp StackProvider, domain, stacksDir string, encKey []byte, logger *log.Logger) *Manager { m := &Manager{ sett: sett, stacks: sp, domain: domain, stacksDir: stacksDir, encKey: encKey, logger: logger, handlers: make(map[string]Handler), } // Register built-in handlers m.RegisterHandler("onlyoffice:filebrowser", &OnlyOfficeFileBrowserHandler{}) m.RegisterHandler("onlyoffice:nextcloud", &OnlyOfficeNextcloudHandler{}) if m.isDebug() { keys := make([]string, 0, len(m.handlers)) for k := range m.handlers { keys = append(keys, k) } m.logger.Printf("[DEBUG] [integrations] NewManager: registered handlers: %v", keys) } return m } // SetDebug enables or disables debug logging. func (m *Manager) SetDebug(debug bool) { m.debug = debug } // isDebug returns whether debug logging is enabled. func (m *Manager) isDebug() bool { return m.debug } // RegisterHandler registers a handler for a provider:target integration key. func (m *Manager) RegisterHandler(key string, h Handler) { m.handlers[key] = h } // Toggle enables or disables an integration. func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bool) (settings.IntegrationState, error) { m.mu.Lock() defer m.mu.Unlock() key := IntegrationKey(provider, target) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: key=%s provider=%s target=%s enable=%v", key, provider, target, enable) } handler, ok := m.handlers[key] if !ok { return settings.IntegrationState{}, fmt.Errorf("nincs kezelő a(z) %s integrációhoz", key) } ac, err := m.buildApplyContext(provider, target) if err != nil { return settings.IntegrationState{}, err } var state settings.IntegrationState if enable { // Validate: provider must be deployed and running provStack, pOk := m.stacks.GetStack(provider) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: provider %s found=%v deployed=%v state=%v", provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState { if pOk { return provStack.State }; return "" }()) } if !pOk || !provStack.Deployed { return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider) } if provStack.State != stacks.StateRunning { return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nem fut", provider) } // Validate: target must be deployed and running (filebrowser is infra, always present) if target != "filebrowser" { tgtStack, tOk := m.stacks.GetStack(target) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: target %s found=%v deployed=%v state=%v", target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState { if tOk { return tgtStack.State }; return "" }()) } if !tOk || !tgtStack.Deployed { return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target) } if tgtStack.State != stacks.StateRunning { return state, fmt.Errorf("a célalkalmazás (%s) nem fut", target) } } start := time.Now() if err := handler.Apply(ac); err != nil { m.logger.Printf("[ERROR] [integrations] Integration %s apply failed: %v", key, err) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: Apply failed for %s in %v: %v", key, time.Since(start), err) } state.Enabled = true state.Status = "error" state.LastError = err.Error() state.EnabledAt = time.Now().UTC().Format(time.RFC3339) _ = m.sett.SetIntegrationState(key, state) return state, fmt.Errorf("integráció alkalmazása sikertelen: %w", err) } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: Apply succeeded for %s in %v", key, time.Since(start)) } state.Enabled = true state.Status = "active" state.EnabledAt = time.Now().UTC().Format(time.RFC3339) m.logger.Printf("[INFO] [integrations] Integration %s enabled", key) } else { start := time.Now() if err := handler.Revoke(ac); err != nil { m.logger.Printf("[WARN] [integrations] Integration revoke failed for %s: %v", key, err) state.LastError = err.Error() } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] Toggle: Revoke for %s completed in %v", key, time.Since(start)) } state.Enabled = false state.Status = "disabled" m.logger.Printf("[INFO] [integrations] Integration %s disabled", key) } if err := m.sett.SetIntegrationState(key, state); err != nil { return state, fmt.Errorf("integráció állapotának mentése sikertelen: %w", err) } return state, nil } // ListForProvider returns StatusInfo for all integrations defined in a provider's metadata. func (m *Manager) ListForProvider(providerSlug string) []StatusInfo { provStack, ok := m.stacks.GetStack(providerSlug) if !ok { return nil } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] ListForProvider: provider=%s integrationDefs=%d", providerSlug, len(provStack.Meta.Integrations)) } var result []StatusInfo for _, idef := range provStack.Meta.Integrations { key := IntegrationKey(providerSlug, idef.Target) state, _ := m.sett.GetIntegrationState(key) targetDeployed := false targetRunning := false if tgt, tOk := m.stacks.GetStack(idef.Target); tOk { // FileBrowser is infrastructure — if its stack exists, it's "deployed" if idef.Target == "filebrowser" { targetDeployed = true } else { targetDeployed = tgt.Deployed } targetRunning = tgt.State == stacks.StateRunning } result = append(result, StatusInfo{ Key: key, Provider: providerSlug, Target: idef.Target, Label: idef.Label, Description: idef.Description, Enabled: state.Enabled, Status: state.Status, LastError: state.LastError, TargetDeployed: targetDeployed, TargetRunning: targetRunning, }) } return result } // buildApplyContext gathers all data needed by a handler. func (m *Manager) buildApplyContext(provider, target string) (*ApplyContext, error) { provStack, ok := m.stacks.GetStack(provider) if !ok { m.logger.Printf("[WARN] [integrations] Failed to build context for %s:%s: provider stack not found", provider, target) return nil, fmt.Errorf("szolgáltató stack %q nem található", provider) } // Load decrypted env from provider's app.yaml provEnv := m.loadDecryptedEnv(provStack) if m.isDebug() { envKeyCount := len(provEnv) m.logger.Printf("[DEBUG] [integrations] buildApplyContext: provider=%s target=%s domain=%s envKeys=%d", provider, target, m.domain, envKeyCount) } provMeta := provStack.Meta return &ApplyContext{ ProviderName: provider, TargetName: target, Domain: m.domain, ProviderEnv: provEnv, ProviderMeta: &provMeta, StacksDir: m.stacksDir, Logger: m.logger, RestartStack: m.stacks.RestartStack, }, nil } // ReapplyConfigForTarget applies all active integrations targeting the given stack, // writing config changes only (no container restart). Use this when the caller // handles container restart separately (e.g. SyncFileBrowserMounts). func (m *Manager) ReapplyConfigForTarget(targetName string) { m.mu.Lock() defer m.mu.Unlock() all := m.sett.GetIntegrationsForTarget(targetName) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: target=%s integrations=%d", targetName, len(all)) } for key, state := range all { if !state.Enabled || state.Status == "disabled" { continue } provider, target, ok := ParseIntegrationKey(key) if !ok { continue } handler, hOk := m.handlers[key] if !hOk { continue } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: reapplying %s (status=%s)", key, state.Status) } ac, err := m.buildApplyContext(provider, target) if err != nil { m.logger.Printf("[WARN] [integrations] Cannot build context for integration %s reapply: %v", key, err) continue } // Override RestartStack to no-op — caller handles restart ac.RestartStack = func(string) error { return nil } if err := handler.Apply(ac); err != nil { m.logger.Printf("[WARN] [integrations] Integration config reapply failed for %s: %v", key, err) if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: %s failed: %v", key, err) } state.Status = "error" state.LastError = err.Error() _ = m.sett.SetIntegrationState(key, state) continue } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: %s succeeded", key) } state.Status = "active" state.LastError = "" _ = m.sett.SetIntegrationState(key, state) m.logger.Printf("[INFO] [integrations] Integration %s config reapplied for %s", key, targetName) } } // loadDecryptedEnv reads app.yaml for a stack and decrypts sensitive values. func (m *Manager) loadDecryptedEnv(s *stacks.Stack) map[string]string { stackDir := filepath.Dir(s.ComposePath) cfg := stacks.LoadAppConfig(stackDir) if cfg == nil { m.logger.Printf("[WARN] [integrations] Failed to load env for %s: app config is nil", s.Name) return nil } if m.encKey != nil { cfg.Env = crypto.DecryptMap(m.encKey, cfg.Env) } if m.isDebug() { m.logger.Printf("[DEBUG] [integrations] loadDecryptedEnv: stack=%s envKeys=%d", s.Name, len(cfg.Env)) } return cfg.Env }